Skip to content

fix(notify): use https:// in the ntfy notify command - #218

Merged
robercano merged 4 commits into
mainfrom
fix/notify-https-scheme
Aug 5, 2026
Merged

fix(notify): use https:// in the ntfy notify command#218
robercano merged 4 commits into
mainfrom
fix/notify-https-scheme

Conversation

@robercano

Copy link
Copy Markdown
Owner

The self-adapter's notify command passed a scheme-less ntfy.sh/$topic to curl, which defaults a bare host to http://. On a box with a kernel egress fence permitting only DNS and TCP 443, every loop notification hit port 80 and was dropped — silently. The loop believed it had notified; nothing arrived.

Found only because the egress alarm reported the blocked attempt (TCP -> 159.203.148.75:80). Without that alarm, the loop would have run unattended with a dead feedback channel and no indication anything was wrong — on a box whose operator was about to leave it alone.

notify.sh's own usage example carried the same scheme-less form, so anyone copying it inherits the bug on any egress-filtered host. Both fixed.

The general shape recurred throughout this provisioning run: a layer that looks correct in its configuration and does nothing in practice. Inert Write() deny rules, is-active on a crash-looping unit, an alarm dying on a missing PTR, claude-rc parked on a prompt while reporting active — and now curl's http:// default, invisible until something blocks port 80.

🤖 Generated with Claude Code

robercano and others added 4 commits August 4, 2026 12:50
`meta skuid != <agent> accept` never matches kernel-generated packets --
ICMPv6 MLD/ND and DHCP renewal carry no owning socket -- so they fell through
to the drop rule. Observed live: MLD listener reports (TYPE=131) from the
box's own link-local address were dropped and alarmed on. Restructure to jump
on a positive UID match, so only packets the agent actually owns are filtered.

Also from the same run: drop link-local multicast WITHOUT the log prefix
(mDNS/MLD is discovery noise, and an alarm that cries wolf gets ignored);
add the v6 set and the lo/established rules the sketch omitted; and record
that statsig.anthropic.com has no A record -- Claude Code's telemetry goes to
api.statsig.com / statsigapi.net / events.statsigapi.net / featureassets.org.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Working files from a real provisioning run on bare-metal Ubuntu 26.04, so the
next box does not rebuild them from the sketches in HARDENING.md: nftables
egress fence, egress alarm, auditd rules, divergence tripwire, and the
user-unit-safe systemd drop-in.

The README records the reasoning, which is the part that cost time:

- The fence restricts protocol/port, not destination. IP-set allowlisting was
  built, tested and abandoned -- GitHub and Google LBs rotate faster than a
  DNS refresh can track, and it dropped real work. HARDENING.md already lists
  GitHub as a sanctioned exfil channel, so an allowlist that must include
  GitHub could never close that path anyway.
- Jump on a positive skuid match; `skuid != <uid> accept` never matches
  kernel-generated packets and silently filters the host's own stack.
- The alarm runs as root, keys its cooldown on destination AND port, and
  deliberately avoids `set -e`/`pipefail` -- an address with no PTR made
  getent fail and killed the follower, losing events silently.
- ufw is left alone; nftables.service stays disabled so it cannot flush it.
- The systemd drop-in is the user-unit-safe subset; the full block crash-loops
  a --user unit with 218/CAPABILITIES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cloudflare retired the per-application SSH CA the runbook depends on. On a
current account, Access -> Service auth -> SSH -> Generate certificate is
disabled and `cloudflared access ssh-gen` returns "Bad request, please create
CA for application" -- the client never gets a certificate, so no server-side
change can fix it. Verified with the correct account CA trusted, ruling out a
CA mismatch first.

Steps 1-3 of the runbook are unaffected and still correct: loopback-only
hardened sshd, the dedicated outbound-only tunnel, the Access application.

Documents the successor and its real cost: Access for Infrastructure requires
the WARP client on every device in Traffic + DNS mode, uses a private network
route rather than a public hostname ingress, and offers no browser-rendered
SSH terminal. Also records the self-managed-keys alternative and why it is a
worse trade -- it needs WARP too while reintroducing long-lived authorized_keys,
reversing the "nothing long-lived to steal" property the design exists for.

Two field notes worth keeping:
- Verify the installed CA is the one Cloudflare signs with. On a live run the
  dashboard-pasted key did not match the account gateway CA (SHA256:UdAX7Pben
  vs SHA256:K3EDSsU0olUb), and a cert signed by an untrusted CA fails exactly
  like a client-side fault.
- If sshd is loopback-only, register the target as 127.0.0.1 so cloudflared
  dials its own loopback and the property survives; the LAN-IP fallback needs
  a second ListenAddress and exposes sshd to the LAN.

provision.md Phase 8 rewritten accordingly, plus: run cloudflared as a system
unit (the admin account often has Linger=no, and remote access must not depend
on a login session), watch for an orphaned DNS record on the chosen hostname,
and audit the account's other tunnels as standing ingress paths.

Phase 9 additions: trigger the notification rather than assuming it, check for
non-loopback listeners, and the claude-rc trap -- arm-loop.sh passes --spawn
but the server still prompts "Enable Remote Control? (y/n)" inside tmux, so the
unit reports active while parked on a prompt. Also: state the unattended
posture out loud before signing off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The self-adapter's notify command passed a scheme-less "ntfy.sh/$topic" to
curl, which defaults a bare host to http://. On a box with a kernel egress
fence permitting only DNS and TCP 443, every loop notification therefore hit
port 80 and was dropped -- silently. The loop believed it had notified; the
operator received nothing.

Found only because the egress alarm reported the blocked attempt: "TCP ->
159.203.148.75:80". Without that alarm the loop would have run unattended with
a dead feedback channel and no indication.

notify.sh's own usage example carried the same scheme-less form, so anyone
copying it inherits the bug on any egress-filtered host. Both fixed.

Worth noting the general shape, which recurred repeatedly on this run: a layer
that looks correct in its configuration and does nothing in practice. curl's
http:// default is invisible until something blocks port 80.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@robercano-ghbot robercano-ghbot added the needs-human Loop escalated: attempt budget exhausted, manual triage required label Aug 5, 2026
@robercano-ghbot

Copy link
Copy Markdown
Collaborator

fix(notify): use https:// in the ntfy notify command (not yet reviewed)

@robercano
robercano merged commit 373ce16 into main Aug 5, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-human Loop escalated: attempt budget exhausted, manual triage required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants