A small helpdesk system: an office Pixel running a patched GrapheneOS Dialer answers technician calls, records both call legs as separate channels, and a Ruby backend transcribes them through Whisper and files an AI summary against the caller. Squashed to a single commit for sharing. No credentials are included; secrets live outside the repo in /etc/helpdesk/env on the server or a gitignored .claude/env.local locally. See .claude/env.local.example for the shape. Start at README.md, then docs/architecture.md.
11 KiB
Operations
The production instance runs on moje.al.army. Here's how to deploy it, roll it back, read its logs,
back it up, and what CI does.
Where things are on the server
- Code:
/srv/helpdesk(a checkout; the deploy script syncs into it). - Service:
helpdesk.service(systemd). Runs as thehelpdeskuser, binds127.0.0.1:4000, reads/etc/helpdesk/env. - Data:
/var/lib/helpdesk(recordings, and the wiki index). Deploys never touch it. - Web: nginx serves the vmin mTLS vhost for
moje.al.army(console, 443) and the certless device door on 8443 (sites-available/helpdesk-device.conf- the phone's HMAC plane). Both proxy to127.0.0.1:4000. Reference copies of all of it are indeploy/. - Secrets:
/etc/helpdesk/env(root-only). The full variable list is below. - Firewall:
ufw, default deny inbound, allowing only 22, 80, 443 and 8443 (v4 and v6), enabled at boot. Port 80 is not optional - it carries the ACME challenge that renews the server certificate. The backend on 4000 and PostgreSQL are loopback-only and were never exposed; the firewall is the layer that keeps a future service from binding publicly by accident.
Deploy
Deploys go through CI. Push to master, let the pipeline pass, then click the manual
deploy:production job. It runs deploy/helpdesk-deploy.sh, which syncs the checkout into
/srv/helpdesk, runs the migration, restarts the service, and polls /healthz. If health doesn't come
back, it rolls back to the previous release by itself.
To deploy by hand on the box:
sudo /usr/local/sbin/helpdesk-deploy /path/to/checkout
Roll back
The deploy keeps the previous release at /srv/helpdesk.prev and reverts on a failed health check. To
roll back manually:
sudo systemctl stop helpdesk
sudo rsync -a --delete /srv/helpdesk.prev/ /srv/helpdesk/
sudo systemctl start helpdesk
Logs and health
journalctl -u helpdesk -f # service logs
curl -s http://127.0.0.1:4000/healthz # {"ok":true,...}
From outside the box, health needs a client cert: curl --cert your.pem https://moje.al.army/healthz.
Without one you get a 400, because mTLS is required.
Backups
helpdesk-pg-backup.timer dumps the database nightly at 03:30 to /var/backups/helpdesk and keeps 14
days. To prove a dump restores, load it into a scratch database and check it:
sudo -u postgres createdb helpdesk_restore_test
sudo -u postgres pg_restore --no-owner -d helpdesk_restore_test /var/backups/helpdesk/helpdesk-<ts>.dump
sudo -u postgres psql -c '\dt' helpdesk_restore_test
sudo -u postgres dropdb helpdesk_restore_test
Secrets and configuration
Everything lives in /etc/helpdesk/env (root-only; the systemd unit loads it). Names only - the values
stay on the server and in the password manager.
| Variable | What it does |
|---|---|
HELPDESK_DEV=0 |
production posture: operator auth on, no dev endpoints, no demo seed |
HELPDESK_OP_AUTH=1, HELPDESK_PROXY_SECRET |
operator plane: require the mTLS proxy's injected headers |
HELPDESK_OPERATOR_CNS |
comma-separated cert CNs allowed on the console, e.g. lucy,petr. Unset = accept any cert the vmin CA verified. Also the revocation mechanism: no CRL is configured, so removing a CN here is how a lost operator certificate is turned off |
HELPDESK_AUTH=1, HELPDESK_DEVICE_TOKEN, HELPDESK_DEVICE_SECRET |
device plane: the phone's HMAC credentials |
HELPDESK_DATABASE_URL |
PostgreSQL as the helpdesk role (never postgres) |
HELPDESK_BIND=127.0.0.1 |
never listen on a public interface; nginx is the only entry |
HELPDESK_SEED=0 |
boot empty instead of seeding demo contacts |
HELPDESK_REC_DIR |
where uploaded recordings land (under /var/lib/helpdesk) |
WHISPER_URL |
hosted Whisper (https://whisper.cajk.org/app); selects the remote transcriber |
WHISPER_MTLS_P12, WHISPER_MTLS_PASS |
the vmin client cert the backend presents to Whisper (p12 at /etc/helpdesk/, pass from the password manager). Leave WHISPER_CA unset - the server cert is public. |
OPENROUTER_API_KEY |
all AI calls (summaries, dossier, embeddings) |
SUMMARISER_MODEL, MAINTAINER_MODEL, MAINTAINER_REBUILD_MODEL |
optional model overrides; the code defaults to google/gemini-3.6-flash for all three |
WIKI_INDEX_PATH |
the wiki RAG index (/var/lib/helpdesk/wiki_index.json); unset or missing file just means summaries run ungrounded |
HELPDESK_GLOSSARY_PATH |
override the domain vocabulary applied to transcripts; defaults to lib/helpdesk/glossary_terms.txt in the checkout. Only distinctive terms belong in it - see docs/architecture.md |
HELPDESK_CERT_WATCH_TLS |
host:port to read the served certificate from, e.g. moje.al.army:8443. Use the device door, not 443: it serves the same certificate without demanding a client cert. Unset = not checked |
HELPDESK_CERT_WATCH_CA |
the operator CA bundle to watch (/etc/ssl/certs/ca-combined.crt). Unset = not checked |
HELPDESK_CERT_WARN_DAYS |
when the console starts warning; default 30. The Whisper cert is picked up automatically from WHISPER_MTLS_P12 |
Certificates
Three certificates matter, and only one of them renews itself.
| Certificate | Where | Expires | Renewal |
|---|---|---|---|
Server cert for moje.al.army |
/etc/ssl/moje.al.army/{fullchain,privkey}.pem, shared by the console on :443 and the device door on :8443 |
2026-10-21 | automatic |
vmin CA (ca.vmin.cz) |
/etc/ssl/certs/ca-combined.crt |
2030-09-09 | manual, but see below before assuming a reissue |
| Operator + Whisper client certs | p12s with the operators; /etc/helpdesk/vmin-whisper.p12 |
leaf 2036-05-02, bound by the CA above | manual |
The server cert renews through acme.sh (not certbot), installed at /root/.acme.sh and run from
root's crontab four times a day. It was set up by /root/07-acme-cert.sh. It writes straight to the
paths nginx reads and then runs systemctl reload nginx.service, so a renewal needs no hand-holding;
next renewal is 2026-09-21, thirty days before expiry. Two things it depends on: port 80 must stay open
so the HTTP-01 challenge at /.well-known/acme-challenge/ is reachable, and that location must keep its
own block in the vhost rather than being swept into the https redirect. Note that cron sends its output
to /dev/null and no log file is kept, so a renewal that fails repeatedly is invisible until the cert
actually expires - if you touch the firewall or DNS, check the cert date afterwards.
Before treating a vmin expiry as a reissue job, check whether the server's CA copy is simply stale.
Virtualmaster re-signs ca.vmin.cz keeping the same key, and the newer copy ships inside the operator
p12s - so the fix is often just installing a file you already have, with every existing client
certificate still working. This is not hypothetical: on 2026-07-27 the server trusted a copy expiring
2026-09-30 while the p12 already held one expiring 2030-09-09, same key and serial. Swapping that one
file moved the deadline out by four years and no certificate had to be reissued. docs/runbooks/cert-renewal.md
has the commands to tell the two situations apart, and the verification order that catches the
easily-forgotten half (transcription fails silently hours later, not at renewal time).
If a genuine reissue is ever needed, it means refreshing /etc/ssl/certs/ca-combined.crt, reissuing the
operator p12s and replacing the Whisper p12 - otherwise the console stops authenticating operators and
transcription stops at the same moment. Start about three weeks out, since it depends on Virtualmaster
replying.
The console warns about all of this by itself: a badge appears in the navbar when any certificate is
inside HELPDESK_CERT_WARN_DAYS (default 30) and within 30 days, and stays hidden otherwise. The
console applies that 30-day cap itself, so setting the variable higher makes the backend report earlier
without the badge shouting about certificates months away; setting it lower still tightens the warning
as you would expect. That includes each
operator's own certificate, which the server holds no copy of - nginx reports the expiry of whichever
one was presented, so people are warned about theirs individually. It needs these two headers on the
console vhost, and warns about nothing personal without them:
proxy_set_header X-Operator-Cert-Days $ssl_client_v_remain;
proxy_set_header X-Operator-Cert-Expires $ssl_client_v_end;
Checking any of this without logging in:
# server cert dates, either port
openssl s_client -4 -connect moje.al.army:443 -servername moje.al.army </dev/null 2>/dev/null \
| openssl x509 -noout -dates
# which CA the console will accept client certs from
openssl s_client -4 -connect moje.al.army:443 </dev/null 2>&1 | grep -A2 "Acceptable client"
On the dev box, the same keys live outside the repo: the OpenRouter key in
~/Downloads/openrouterApiKlic2 (the sk-or-v1-… line), the wiki JWT in
~/Downloads/apikeyvodafonewiki (the eyJ… line), local env in .claude/env.local (gitignored).
The wiki index
Summaries are grounded in the Vodafone wiki through a small cosine index. It is built offline and is not in git (32 MB of embeddings), so a fresh server needs it placed once:
cd components/backend/wiki
WIKI_JWT=… OPENROUTER_API_KEY=… ruby build_index.rb # fetches ~170 pages, embeds, writes wiki_index.json
scp wiki_index.json skyyf@moje.al.army:/tmp/ && ssh skyyf@moje.al.army \
'sudo install -o helpdesk -g helpdesk /tmp/wiki_index.json /var/lib/helpdesk/wiki_index.json'
The service picks it up on restart via WIKI_INDEX_PATH. Rebuild it the same way when the wiki content
changes enough to matter; nothing breaks in between, retrieval just returns older text.
The CI pipeline
.gitlab-ci.yml, running on the project's own runner on moje.al.army (jobs are tagged helpdesk so
they land there, not on the shared vmin runners). Four stages:
- lint -
ruby -con all Ruby, shellcheck on the scripts, a parse check on the patch files. - test - the unit suites plus the JSON-vs-PostgreSQL parity harness, against a throwaway
helpdesk_testdatabase (the CI variableHELPDESK_TEST_DATABASE_URL, a low-privilegehelpdesk_cirole). - e2e - boots the backend on the throwaway database and an alternate port, then drives it with
sim/simulator.rbover HTTP. - deploy - manual,
masteronly. Runs the deploy script above.
CI runs on the system Ruby (/usr/bin/ruby), not the Passenger Ruby that's also installed; the
before_script forces PATH and clears GEM_HOME/GEM_PATH to make that happen. If a job dies at
"prepare environment: exit status 1", that's the gitlab-runner user's shell profile, not your code.
To reproduce a failing job, run the same command from the job's script inside components/backend.