Skip to content

fix(devcontainer): unbreak ttyd and restore npm-global bin on PATH - #4674

Merged
ttraenkler merged 2 commits into
loopdive:mainfrom
ttraenkler:fix-devcontainer-ttyd-path
Aug 20, 2026
Merged

fix(devcontainer): unbreak ttyd and restore npm-global bin on PATH#4674
ttraenkler merged 2 commits into
loopdive:mainfrom
ttraenkler:fix-devcontainer-ttyd-path

Conversation

@ttraenkler

Copy link
Copy Markdown
Contributor

Stacked on #4673do not enqueue until that lands. Both issues surfaced only once init-firewall.sh started applying again.

ttyd was unreachable from everywhere

The rule meant to restrict ttyd to Tailscale cannot work under Docker Desktop. Published-port traffic arrives through a userland proxy that rewrites the source, so every client — loopback, LAN and Tailscale alike — appears as one fixed address (measured: 172.66.144.201). The source match never fires and the following DROP takes everyone.

Native Linux Docker DNATs and preserves the client address, where the filter does work, so it is kept behind TTYD_SOURCE_FILTER=1 rather than deleted.

…which moves enforcement to the port binding

ttyd is an unauthenticated writable web terminal, and appPort published it on 0.0.0.0 — a shell offered to the entire LAN. The host binding is the only layer that sees the real client, so that is where the restriction now lives:

"appPort": ["2222:2222", "${localEnv:TTYD_BIND_IP:127.0.0.1}:7681:7681"]

Loopback by default; set TTYD_BIND_IP to a Tailscale address for remote access. sshd on 2222 stays on all interfaces — key-only, no passwords, no root login.

⚠️ Port bindings are creation-time, so this takes effect on the next container recreate, not on restart.

claude / pnpm were "command not found" in login shells

Debian's /etc/profile hardcodes PATH for non-root login shells, clobbering the ENV PATH set in the Dockerfile and dropping /usr/local/share/npm-global/bin. That hits every login shell — every ssh session, and anything tmux or ttyd starts that way — while a plain docker exec kept working, which is what made it look like Claude had been uninstalled. A profile.d snippet re-adds the directory after /etc/profile has run, plus an explicit zsh hook since zsh doesn't read profile.d itself.

Verification

In the running container:

  • Firewall applies twice consecutively at exit 0, both built-in checks passing.
  • ttyd answers 200 over loopback, Tailscale and LAN (measured pre-binding).
  • api.anthropic.com reachable, example.com still blocked.
  • claude -p works from a bash -l login shell.
  • The Dockerfile RUN block was executed against a deliberately stripped PATH to confirm it appends correctly.

🤖 Generated with Claude Code

ttraenkler and others added 2 commits August 20, 2026 23:20
The firewall aborted before reaching its DROP policies, so the container ran
with no egress restriction at all while looking perfectly healthy. Three
independent faults, all inherited from the upstream reference script
(anthropics/claude-code .devcontainer/init-firewall.sh), which still has all
three today:

1. Chain policies were never reset after the flush. `iptables -F` clears rules
   but not policy, and postStartCommand re-runs this script on every container
   start/attach. On a re-run the policies from the bottom of this file were
   still DROP while every ACCEPT rule had just been flushed away, so the
   GitHub-meta fetch failed, `set -e` aborted, and the container was left with
   DROP policies and zero rules - no network at all until recreated.

2. `ipset add` without `-exist` made a duplicate address fatal. DNS answers
   legitimately repeat an address (auth.openai.com returns 104.18.41.241
   twice), which aborted the run.

3. A domain that no longer resolves was fatal. statsig.anthropic.com - a local
   addition, not in the upstream list - lost its A record upstream, so the
   script died there on every single run. Non-required domains now warn and are
   skipped, and are named in a summary line; only api.anthropic.com and
   registry.npmjs.org remain fatal.

Faults 2 and 3 abort before the DROP policies are set, which is exactly why
this stayed invisible: the container kept working, just unfirewalled, and
nobody reads postStartCommand output on a healthy-looking box.

Verified in the running dev container: three consecutive runs, exit 0 each
time, both built-in verifications passing (example.com blocked, api.github.com
reachable), 81 allowlist entries, and `claude -p` reaching the API from inside
the container.

No issue id: infrastructure fix found while diagnosing a live container outage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two follow-ups to the init-firewall.sh repair, both surfaced only once that
script started applying again.

ttyd (7681) was unreachable from everywhere. The rule intended to restrict it
to Tailscale cannot work under Docker Desktop: published-port traffic arrives
through a userland proxy that REWRITES the source, so every client - loopback,
LAN, Tailscale alike - appears as one fixed address (measured: 172.66.144.201).
The source match never fires and the following DROP takes everyone. Native
Linux Docker DNATs and preserves the source, where the filter does work, so it
is kept behind TTYD_SOURCE_FILTER=1 rather than deleted.

That moves enforcement to the only layer that sees the real client: the host
port binding. ttyd is an UNAUTHENTICATED writable web terminal and appPort
published it on 0.0.0.0, i.e. a shell offered to the whole LAN. It now binds
${localEnv:TTYD_BIND_IP:127.0.0.1} - loopback by default, set TTYD_BIND_IP to
a Tailscale address to reach it remotely. sshd on 2222 stays on all interfaces:
key-only, no passwords, no root login.

Separately, `claude` and `pnpm` were "command not found" in any LOGIN shell -
every ssh session, and anything tmux or ttyd starts that way. Debian's
/etc/profile hardcodes PATH for non-root login shells, clobbering the ENV PATH
from this Dockerfile and dropping /usr/local/share/npm-global/bin. A
profile.d snippet re-adds it after /etc/profile has run, plus an explicit zsh
hook since zsh does not read profile.d itself.

Verified in the running container: firewall applies twice consecutively at
exit 0 with both built-in checks passing, ttyd answers 200 over loopback,
Tailscale and LAN (pre-binding), api.anthropic.com reachable, example.com
still blocked, and `claude -p` works from a bash login shell. The Dockerfile
RUN block was executed against a stripped PATH to confirm it appends correctly.

Stacked on fix-devcontainer-firewall; enqueue only after that lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ttraenkler ttraenkler added hold Do not auto-enqueue to merge queue and removed hold Do not auto-enqueue to merge queue labels Aug 20, 2026
@ttraenkler
ttraenkler added this pull request to the merge queue Aug 20, 2026
Merged via the queue into loopdive:main with commit 2832986 Aug 20, 2026
17 checks passed
ttraenkler added a commit to ttraenkler/js2 that referenced this pull request Aug 21, 2026
Another lane pushed loopdive#4673/loopdive#4674 (init-firewall re-run safety, ttyd PATH) onto this branch. Everything else it carried was already in via the earlier upstream/main catch-up, so the effective diff is .devcontainer/ only — no source, no gate surface. Merged, never rebased.

Pre-commit checklist done. ✓

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant