guest@6admin.io:~$

Driving opencode behind an SSH tunnel, with direnv

I told elsewhere how I keep my ssh and git config in a versioned repository, with a ~/.ssh/config that carries the identities, the jump hosts and the hardening (myflec ). This post is its logical sequel, one notch further, where access is no longer just opening a session but keeping a tunnel alive continuously so that a coding agent and a browser can work behind it. The binder, this time, is direnv .

The principle: direnv loads the environment per directory

direnv evaluates a .envrc file every time you enter a directory and cleans the environment when you leave. The bootstrap fits in four lines, done once.

sudo apt install direnv
echo 'eval "$(direnv hook bash)"' >> ~/.bashrc   # after the other prompt hooks
exec $SHELL
direnv allow                                     # trust this .envrc

From there, every cd into the project loads the secrets, the gateway variables and the safety rails in one go, with nothing to source by hand. The secrets live in a .env.secrets in mode 600, gitignored, loaded by direnv’s dotenv_if_exists function, never committed.

dotenv_if_exists .env.secrets

A self-healing SSH tunnel

The heart of the setup is that direnv evaluates the .envrc synchronously on every prompt. A tunnel that blocks, or a script that fails, would then freeze the shell on every cd, which is unacceptable. Three rules follow and they are not negotiable: the probe must be fast, the repair must run detached in the background and the script must always end with exit 0 so the export set survives even with the gateway down.

Tunnel health is measured in two steps, a local listener present and a real HTTP answer coming through it. Any return code counts, a 401 already proves the path is alive.

gw_port=4443
gw_url="https://gateway.internal:${gw_port}"
gw_fwd="-L 0.0.0.0:${gw_port}:gateway.internal:443"

_gw_healthy() {
  [[ -n $(ss -ltnH "sport = :${gw_port}") ]] || return 1
  curl -ksS --connect-timeout 1 --max-time 3 \
       -o /dev/null "${gw_url}/v1/models" 2>/dev/null
}

_gw_repair() {
  pkill -f -- "${gw_fwd}" 2>/dev/null || true
  ssh -f -N -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes \
      -o ConnectTimeout=5 "${gw_fwd}" jump >/dev/null 2>&1 || true
}

The repair is throttled to one attempt per minute per tunnel, through a timestamp file in direnv’s layout directory. Without that rail, a dead gateway would relaunch an ssh on every cd. And the detachment is a double fork with every file descriptor closed, because a background child keeping a direnv pipe open would be enough to freeze the prompt.

_repair_throttled() {
  local name=$1; shift
  local stamp now last
  stamp="$(direnv_layout_dir)/${name}-repair.stamp"
  now=$(date +%s)
  last=$(cat "$stamp" 2>/dev/null || echo 0)

  (( now - last < 60 )) && return 1

  date +%s > "$stamp" 2>/dev/null || true
  ( ( "$@" ) </dev/null >/dev/null 2>&1 & ) &
}

if ! _gw_healthy && _repair_throttled gw _gw_repair; then
  for _ in {1..4}; do _gw_healthy && break; sleep 0.25; done
fi

exit 0

The final exit 0 is not a detail, it is the invariant. Whether the gateway answers or not, the exports must go through and the shell must return control instantly.

SOCKS5, for the browser and the CLIs

The same principle serves a dynamic SOCKS5 tunnel, declared once in the ssh config and reused everywhere, to avoid the port sprawl that never ends up lining back up.

Host socks5
    DynamicForward 9050
    SessionType none
    ForkAfterAuthentication yes
    ServerAliveInterval 60

On the browser side, a small script brings the tunnel up if it is not already listening, waits for the port to actually open through a bounded poll rather than a blind sleep, then launches the browser on it.

port=9050
listening() { [[ -n $(ss -ltnH "sport = :$port") ]]; }

if ! listening; then
  ssh -o ConnectTimeout=5 socks5
  for _ in {1..20}; do listening && break; sleep 0.25; done
fi
listening || { echo "error: socks tunnel down on :$port" >&2; exit 1; }

chromium --proxy-server="socks5://127.0.0.1:$port" &

On the command-line side, proxychains-ng points at exactly the same port, which stays the single source of truth.

strict_chain
proxy_dns
[ProxyList]
socks5 127.0.0.1 9050
proxychains4 -f my_proxy.conf glab ci lint

The SSH config that holds it all together

None of this would work without the ssh config I describe in myflec , which does the heavy lifting upstream. The jump host is declarative (ProxyJump ), multiplexing avoids reopening a session for every command and the hardening is there so a dead jump host is detected instead of hanging.

Host *.internal
    ProxyJump jump
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m
    Compression yes

# default hardening
ServerAliveInterval 60
ServerAliveCountMax 3
TCPKeepAlive no
StrictHostKeyChecking ask

The TCPKeepAlive no choice is not an oversight. TCP keepalives survive a dead IP path and lie, where SSH-level keepalives tell the truth and three misses in a row cut the connection cleanly. I developed that tunneling angle, usage side, in SSH tunneling .

opencode and Claude Code routed through the gateway

The practical point of this whole setup is that both agents I use, opencode and Claude Code alike, launched from this directory, talk to a remote gateway without knowing it. Claude Code makes do with the environment variables it reads natively, exported by the .envrc, without the slightest wrapper.

export ANTHROPIC_BASE_URL="${gw_url}/proxy/anthropic"
export ANTHROPIC_API_KEY="${GATEWAY_API_KEY}"
export ANTHROPIC_AUTH_TOKEN="anyvalueworks"   # the gateway authenticates at the network layer
export ANTHROPIC_DEFAULT_OPUS_MODEL="anthropic/claude-opus-latest"
export ANTHROPIC_DEFAULT_SONNET_MODEL="anthropic/claude-sonnet-latest"

opencode, for its part, reads an opencode.json that points the baseURL at the tunnelled endpoint and carries the key in a header, referenced from the environment and never written in clear.

{
  "provider": {
    "anthropic": {
      "options": {
        "baseURL": "https://gateway.internal:4443/proxy/anthropic/v1",
        "headers": { "x-api-key": "{env:GATEWAY_API_KEY}" },
        "setCacheKey": true,
        "timeout": 600000
      }
    }
  }
}

The honest part

One point deserves to be named rather than hidden. I export NODE_TLS_REJECT_UNAUTHORIZED=0 for this setup and it is a deliberate choice, not an oversight. The gateway lives behind my own SSH tunnel and an upstream firewall, its certificate chain is validated out of band and the end-to-end path is already encrypted by SSH. It would be unacceptable on an exposed endpoint, it holds up behind a tunnel I control end to end. Naming the trade-off beats dressing it up.

Wired, hidden, logged

  • direnv turns a directory into a full context, the environment, the secrets and the tunnel come up together on every cd and fall on exit.
  • A tunnel evaluated synchronously imposes three rules, fast probe, detached repair and always exit 0, so the shell never freezes and the exports are never lost.
  • Timestamp-file throttling keeps a dead gateway from relaunching an ssh on every prompt and the double fork stops the background child from freezing the prompt.
  • A single SOCKS port, declared once in the ssh config, serves the browser and the CLIs through proxychains alike, one source of truth.
  • All of it sits on the ssh config from myflec , the one that carries the identities, the jump host and the hardening.

[edit: 2026] The Ansible implementation of all this: the ssh role .