1. Purpose
Ping(at)Work is a web application for defining templates for network PING and running them manually or on a schedule. Results are stored with a status (OK, warning, critical, error) calculated from latency and packet loss thresholds, and status changes can be notified by e-mail or webhook.
The solution is delivered as an offline package for Rocky Linux 9 (x86_64) and runs the same way on a physical server, VMware and Proxmox VE.
2. Architecture
Browser ──HTTPS:443──▶ Caddy (pingforge-caddy)
(HTTP:80 → 301 HTTPS) │ reverse_proxy, TLS, HSTS
▼
gunicorn + Flask (pingforge-web) 127.0.0.1:8000
│ templates, users, queue, views
▼
SQLite /var/lib/pingforge/pingforge.db (WAL)
▲
│ reads the queue, schedules, stores results
Worker (pingforge-worker) ──▶ /usr/bin/ping (CAP_NET_RAW)
│
└──▶ SMTP / webhook on status change
| Component | Technology | Responsibility |
|---|---|---|
|
Caddy 2.11.7 (static Go binary) |
TLS termination, HTTP→HTTPS, security headers, compression, proxy to gunicorn |
|
Python 3.9, Flask 3.1, gunicorn 23 (2 processes × 4 threads) |
User interface, login, template CRUD, queues runs, shows results, documents |
|
Python 3.9, one process with threads |
Executes the queue, schedules templates, runs |
Database |
SQLite 3 in WAL mode |
Templates, targets, runs, results, users, login failures, key/value (worker heartbeat) |
The split between web and worker is deliberate: the web process never starts subprocesses and needs
no extra privileges. Only the worker has CAP_NET_RAW.
3. Run flow
-
The user presses Run or Save and run. The web process creates a row in
runswithstate = queuedand sends the user to the run page. -
The worker reads the queue every 2 seconds and starts one thread per run.
statebecomesrunning. -
For each target and IP version an argument list is built (see Command building), and
pingruns in parallel with up tomax_parallelprocesses at once. -
The output is parsed (sent, received, loss, min/avg/max/mdev). Each result gets a status from the thresholds. The run’s status is the worst result.
-
The results are stored in one transaction. The template gets
last_statusandlast_run_at. -
If the status has changed since the previous run, a notification is sent.
-
The run page polls
/api/runs/<id>every 1.5 seconds and reloads when the run is finished.
Scheduled templates: every 15 seconds the worker queues the enabled templates where
last_run_at + schedule_minutes has passed and that have no run queued or running already.
3.1. Command building
/usr/bin/ping -<4|6> -n -c <count> -i <interval> -s <size> -W <timeout> -w <deadline>
[-t <ttl>] [-M do] [-Q <dscp<<2>] [-I <interface>] -- <host>
-
deadline = ceil(count × interval + timeout) + 1seconds. The process is killed afterdeadline + 10seconds. -
-Qgets the full TOS byte, i.e. the DSCP value shifted two bits (EF 46 → 184). -
The command runs with
subprocess.run(list), without a shell, withLC_ALL=Cand a minimalPATH.
3.2. Status rules
| Status | Rule (first match wins) |
|---|---|
Error |
|
Critical |
0 replies, or loss ≥ critical loss threshold, or average latency ≥ critical latency threshold |
Warning |
loss ≥ warning loss threshold, or average latency ≥ warning latency threshold |
OK |
otherwise |
A notification is sent when the status changes. The very first run only notifies if it is not OK.
4. Validation
| Field | Rule |
|---|---|
Target |
IPv4/IPv6 ( |
Count |
1–1000 |
Interval |
0.2–60 s (comma and point accepted) |
Size |
0–65507 bytes |
Timeout |
1–60 s |
TTL |
1–255 or empty |
Interface |
|
DSCP |
0, 8, 10, 18, 26, 34, 40, 46, 48 |
Schedule |
0, 5, 15, 60 or custom 1–10080 min |
Thresholds |
Numbers ≥ 0 (loss ≤ 100). Warning ≤ critical |
Notify |
E-mail addresses |
5. Data model
users(id, username UNIQUE, pw_hash, created_at)
templates(id, name, description, ip_version, count, interval, size, timeout, ttl,
interface, dscp, df, schedule_minutes, warn_latency, crit_latency,
warn_loss, crit_loss, notify, enabled, last_status, last_run_at,
created_at, updated_at)
targets(id, template_id → templates ON DELETE CASCADE, host, label, position)
runs(id, template_id → templates ON DELETE SET NULL, template_name, trigger,
requested_by, state, status, queued_at, started_at, finished_at, message)
results(id, run_id → runs ON DELETE CASCADE, host, label, ip_version, command,
sent, received, loss, rtt_min, rtt_avg, rtt_max, rtt_mdev, status,
output, exit_code)
login_failures(key, ts)
kv(key PRIMARY KEY, value) -- worker_heartbeat
Timestamps are stored as ISO 8601 in UTC and shown in the server’s local time zone.
PRAGMA user_version holds the schema version (1).
6. HTTP interface
| Method | Path | Function |
|---|---|---|
GET/POST |
|
Login |
POST |
|
Logout |
GET |
|
Template overview |
GET |
|
New / edit template, with the last run |
POST |
|
Save ( |
POST |
|
Run, duplicate, delete |
GET |
|
History and details |
GET |
|
Results as CSV |
GET |
|
JSON |
GET |
|
Settings and system status |
POST |
|
Change password, import templates |
GET |
|
Export templates |
GET |
|
Switch language (cookie |
GET |
|
Documents (public) |
GET |
|
Health check |
Every POST requires a CSRF token. All pages except login, documents and the health check require login.
6.1. Template export format
{
"format": "pingforge-templates",
"version": 1,
"exported": "2026-10-07T17:43:00+00:00",
"templates": [
{
"name": "Core routers – latency", "description": "", "ip_version": "4",
"count": 10, "interval": 0.2, "size": 56, "timeout": 1, "ttl": 64,
"interface": "", "dscp": 46, "df": true, "schedule_minutes": 5,
"warn_latency": 30.0, "crit_latency": 80.0, "warn_loss": 1.0, "crit_loss": 5.0,
"notify": "noc@example.com", "enabled": true,
"targets": [ { "host": "10.10.0.1", "label": "core-osl-01" } ]
}
]
}
6.2. Webhook
POST with Content-Type: application/json:
{ "text": "[PingForge] CRITICAL: WAN links ...", "template": "WAN links", "template_id": 3,
"run_id": 42, "status": "critical", "previous_status": "ok",
"results": [ { "host": "192.0.2.10", "label": "wan-b", "ip_version": "4", "status": "critical",
"loss": 100.0, "rtt_min": null, "rtt_avg": null, "rtt_max": null, "rtt_mdev": null } ] }
The text field makes incoming webhooks in Slack, Mattermost and Rocket.Chat show the message
directly.
7. User interface
-
Server-rendered HTML (Jinja2) that works without JavaScript.
static/js/app.jsadds dynamic target rows, a live command preview, copying, search, theme switching and refreshing of the run page. -
Language: all strings live in
i18n.pyas pairs (English, Norwegian). The choice is stored in thelangcookie.default_languagesets the default. The user manual and the licenses page follow the chosen language. -
Dark/light mode: CSS variables under
[data-theme="dark"]and[data-theme="light"]. The choice is stored in thethemecookie so the page renders in the right theme without flashing. -
Fonts: IBM Plex Sans and Mono, served locally (woff2). No external resources are loaded.
-
Accessibility: semantic buttons and form controls, visible focus, "skip to content", status as text (not only colour), contrast ≥ 4.5:1, works on mobile from 360 px.
8. Security
| Measure | Implementation |
|---|---|
Transport |
Caddy with TLS 1.2/1.3, HSTS, HTTP→HTTPS. Self-signed RSA 3072 certificate at installation |
Login |
Werkzeug scrypt hash. Lockout after 5 failures per user / 20 per IP for 15 min. Session 12 h |
Session |
Signed cookie (itsdangerous) with |
CSRF |
Per-session token on every POST |
Headers |
CSP |
Commands |
No shell, validated arguments, |
Processes |
Dedicated system users. systemd hardening: |
Network |
Only Caddy listens externally. Caddy’s admin API is turned off |
9. Files and permissions
| Path | Owner / mode | Content |
|---|---|---|
|
root, 0644/0755 |
The |
|
root |
Python environment (from wheels) |
|
root, 0755 |
Caddy |
|
root |
Documentation (shown in the app) |
|
root:pingforge 0640 |
Configuration and secrets |
|
root 0644 |
Proxy configuration |
|
root:pingforge-caddy 0750 |
Certificate (0644) and key (0640) |
|
pingforge 0750 |
Database |
|
pingforge-caddy 0750 |
Caddy’s working data |
|
root 0644 |
Services |
|
root 0755 |
Administration |
10. Capacity
-
One run uses at most
max_parallel(16)pingprocesses. Several templates can run at once. -
Duration per target ≈
count × intervalseconds (5 × 1 s = 5 s). 256 targets with 16 in parallel take about 16 × the duration of one target. -
Database growth ≈ 1–3 kB per result (including raw output). 50 targets every 5 minutes for 90 days is about 1.3 million results, in the order of 2–4 GB. Lower
retention_daysfor large volumes.
11. Building and packaging
build.sh assembles the package from the source code and vendor/:
-
vendor/wheels/– wheels for CPython 3.9 manylinux x86_64 (pinned inrequirements.txt) and pip. -
vendor/caddy/– the Caddy binary and its license (SHA-512 verified on download). -
vendor/fonts/– the OFL licenses for IBM Plex.
The result is dist/pingforge-<version>-el9-x86_64.tar.gz with SHA256SUMS inside and a .sha256
file next to it. scripts/gen_notices.py builds the third-party notices and scripts/gen_inventory.py
builds third_party_inventory.json from the same files; build.sh runs both.
pingforge-1.0.0-el9-x86_64/
├── install.sh uninstall.sh VERSION LICENSE README.md requirements.txt SHA256SUMS
├── third_party_inventory.json
├── app/pingforge/ the Python package (app, worker, cli, templates, static)
├── wheels/ 13 wheel files
├── caddy/ caddy, LICENSE
├── config/ Caddyfile, pingforge.conf.example
├── systemd/ pingforge-web/-worker/-caddy.service
└── Dokumenter/ all documentation
12. Source code
| File | Content |
|---|---|
|
Flask application: routes, login, CSRF, headers, document view |
|
Queue, scheduling, execution, cleanup |
|
Building the |
|
Validation of forms and imports, CIDR expansion |
|
Schema and database functions |
|
E-mail and webhook |
|
English and Norwegian strings |
|
|
|
Reading |
|
Jinja2 templates |
|
CSS, JavaScript, fonts, icon |