hush/docs/OPERATIONS.md
jx12n 62c95f8c2f docs, ops scripts, and the MCP install
Written after the service was live, so every command and every number here was
run against the real deployment rather than assumed:

  * DEPLOY.md records the things a rebuild needs and git does not hold — the
    Redis ACL user (and why +getdel is the one to notice), the GCP Secret
    Manager entry, the DNS record, and the three coordinated edits vmalert
    needs because it has no ConfigMap auto-discovery.
  * It also records two blockers rather than hiding them: Woodpecker is NOT
    activated (the token in rdev-credentials returns 401), so pushes do not
    deploy yet and the Kaniko Job is the interim path; and the host is
    hush.threesix.ai rather than hush.orchard9.ai because orchard9.ai is on
    GoDaddy and no GoDaddy credential exists anywhere I can reach.
  * OPERATIONS.md is one section per alert, plus the failure modes that are not
    alerts — chiefly that "gone" cannot distinguish already-revealed from
    expired from LRU-evicted, on purpose, so the operator's default reading of
    an unexpected "gone" is that the secret is compromised and should be
    rotated.
  * scripts/logs.sh and alerts-check.sh verify rather than assert:
    alerts-check asks vmalert what it actually loaded AND checks each rule's
    series exists, because a rule reading a metric nothing exports can never
    fire and looks exactly like a healthy service.
  * scripts/smoke.sh is a real client — it generates a key, encrypts, posts only
    ciphertext, reveals, decrypts, then asserts the second reveal is 410, that
    three GETs did not consume the secret, that missing and malformed ids are
    indistinguishable, and that a plaintext field is refused.

install-mcp.sh proves the MCP handshake before writing any config, backs up
mcp.json, and rewrites only hush's entry — a config pointing at a broken server
surfaces as an opaque host-side connect failure, which is worth one extra check
to avoid.
2026-09-03 00:18:53 -06:00

179 lines
7.4 KiB
Markdown

# Operating hush
hush is a stateless Go process in front of TTL'd Redis keys. There is no
schema, no queue, no background worker and no durable state of its own, so
almost every incident is one of: Redis is unreachable, the pod is not being
scraped, or someone is abusing anonymous create.
## Reading the logs
```bash
make logs # hush, last hour
./scripts/logs.sh 'service:hush level:error'
./scripts/logs.sh 'service:hush category:secret' # the create/reveal/gone lifecycle
./scripts/logs.sh 'service:hush sid:fb26b024452a' # one secret, end to end
```
Vector collects pod stdout cluster-wide with no annotation, so hush's JSON lands
in VictoriaLogs automatically. Indexed stream fields are `service`, `level`,
`host`, `unit` — everything else (`request_id`, `sid`, `category`, `error_type`)
is exact-match queryable and deliberately not indexed.
`level` is lowercase in the corpus. `{level="ERROR"}` matches nothing.
### `sid`, and why no id is ever logged
The secret id is the capability: anyone holding it can reveal the secret. It is
never logged. The correlation handle is `sid = sha256(id)[:12]`, which follows
one secret across `secret.created``secret.revealed``secret.gone` and is
useless for opening it.
Verified rather than asserted: creating a secret and searching the whole corpus
for its raw id returns zero hits, while its `sid` returns the lifecycle. If you
ever see a 43-character base64url string in a hush log line, that is a **P1
capability leak** — the id type is built so it cannot happen (see
`internal/secret/id.go`) and a regression means someone added a `Value()` call
at a log site.
## Alerts
Routing (`alertmanager.yaml`): `critical` and `high` reach Discord **and** open a
Pantheon incident; `warning` is Discord only.
### HushRedisUnreachable — critical
`hush_store_up == 0` for 2m. hush can neither store nor deliver a secret;
readiness fails and the pod has left the Service.
Nothing is lost — Redis owns the secrets and hush fails closed — but the URL is
down. In order:
```bash
kubectl -n databases get pod -l app=redis # is Redis up?
kubectl -n projects logs -l app=hush --tail=50 # what does hushd say?
kubectl -n databases exec redis-0 -- redis-cli --no-auth-warning -a "$PW" ACL LIST | grep hush
```
That last check matters more than it looks: the Redis pod's init container
reconciles the `default` ACL user on every start. If a change ever dropped the
`hush` user, or dropped `+getdel` from it, the symptom is identical to an
outage — and a missing `+getdel` specifically breaks *only* reveal while create
keeps succeeding, so the service looks half-healthy.
### HushMetricsAbsent — high
`absent(hush_store_up)` for 10m. Every other rule reads a hush metric, so
absence silently disables the whole group.
Most likely cause is not a dead pod but a **dropped scrape target**. vmagent
gates on `prometheus.io/scrape=true` AND a `prometheus.io/port` that *equals* a
declared `containerPort` — via `keepequal`, which drops a mismatch **silently**:
no error, no `up=0`, the target simply never appears.
```bash
kubectl -n projects get pod -l app=hush -o jsonpath='{.items[0].metadata.annotations}'
kubectl -n projects get pod -l app=hush -o jsonpath='{.items[0].spec.containers[0].ports}'
# the annotation value and the containerPort number must be identical strings
```
Then confirm the NetworkPolicy still admits `observability` on 18500; without
it vmagent discovers the target and every scrape is connection-refused.
### Hush5xxRateHigh — warning
>5% 5xx over 15m on non-trivial traffic. A 5xx means a secret was
accepted-but-not-stored, or a reveal failed **without** destroying the secret.
Neither loses data, but a caller who saw a 500 on create does not know whether
their link exists. Check `error_type` in the logs.
### HushRateLimitSustained — warning
Steady create refusals for 30m. hush is anonymous-create by design, so this is
how bulk automation shows up; a single user retrying cannot sustain it, because
the limit is per IP.
If it is abuse rather than a busy NAT:
```bash
kubectl -n projects set env deployment/hush HUSH_REQUIRE_AUTH=true HUSH_CREATE_TOKEN=<token>
```
Reveal stays anonymous either way — the recipient is external and holds no
credential. That asymmetry is the design, not an oversight.
### HushCreateRejectionsHigh — warning
More than half of creates failing validation. Break down by reason:
```bash
./scripts/logs.sh 'service:hush level:warn'
# or in Grafana: hush_secrets_rejected_total by (reason)
```
`ciphertext_invalid` in bulk means a client is posting something that is not
base64url — either a broken page deploy or someone treating hush as a plaintext
API. `ciphertext_too_large` means someone is trying to use it as a file host.
## Failure modes that are not alerts
### A user says "the link says gone" and swears they never opened it
Three possible causes and hush deliberately cannot tell them apart, because
distinguishing them would leak whether a given link was real:
1. Someone else opened it — **treat the secret as compromised and rotate it.**
2. It expired.
3. Redis evicted it early (below).
Assume (1) unless the TTL clearly elapsed. That is the conservative reading and
it is cheap: rotating a credential costs less than a leaked one.
### Redis evicted a secret before its TTL
The shared Redis runs `maxmemory-policy allkeys-lru` at 256 MiB, so under memory
pressure it can drop a hush key **before** its TTL fires.
This is an **availability** risk and never a confidentiality one: eviction only
deletes. A secret can become unavailable early; it can never outlive its TTL and
can never be read twice. For a secret courier that is the correct direction to
fail, which is why `410 gone` does not distinguish it — the user-visible
contract is identical.
If it starts happening, the fix is upstream (Redis memory, or `volatile-lru`,
which is a cluster-wide change affecting every tenant) rather than anything in
hush.
### The pod restarts
Nothing is lost. All state is in Redis. In-flight requests get the two-phase
drain: readiness flips to 503, the load balancer stops sending traffic, then the
process shuts down. `terminationGracePeriodSeconds: 45` exceeds the chassis's
5s drain + 25s shutdown, so the kubelet does not SIGKILL mid-drain.
### Someone reports a link that "lost its #"
Chat and email clients truncate URL fragments. The secret is **intact and
unopened** — the fragment never reaches the server, so nothing was consumed. The
reveal page and the MCP tool both say this explicitly rather than reporting a
generic failure. Ask the sender to re-send the whole link.
## Routine checks
```bash
make deploy-status # rollout, pods, ingress, certificate
BASE=https://hush.threesix.ai make smoke # end-to-end with real crypto
make alerts-check # rules loaded AND their series exist
```
`make smoke` creates and burns a real secret against production. It is safe to
run any time; it touches nothing but its own secret.
## What has no runbook because it cannot happen
- **Reading a stored secret as an operator.** There is no key. `kubectl exec` into
Redis and you get ciphertext.
- **Restoring a revealed secret.** `GETDEL` is atomic and there is no backup of
a value that existed for one read.
- **Listing outstanding secrets.** The store contract has no `List` and no
`Exists`. `--scan` in Redis yields opaque keys and opaque values.