Replit Agent commited on
Commit
bc3314b
Β·
1 Parent(s): 8a804bd

feat: migrate to Cloudflare Named Tunnel (remove Quick Tunnel)

Browse files

- Replace 'cloudflared tunnel --url ssh://localhost:22' (Quick Tunnel)
with 'cloudflared tunnel run --token $CF_TUNNEL_TOKEN' (Named Tunnel)
- Add sshd health check via ss -tlnp before starting tunnel
- Add iproute2 to Dockerfile for ss(8) command
- Gracefully skip tunnel if CF_TUNNEL_TOKEN is not set (warn + continue)
- Log connector ID, tunnel name, edge location, protocol on connect
- Detect and diagnose: auth failures, reconnect loops, network errors,
invalid tokens, duplicate connectors
- Never print or log the tunnel token
- Remove all Quick Tunnel code: trycloudflare.com URL parsing,
tunnel-url.txt generation, 60s URL wait loop
- Update .env.example with CF_TUNNEL_TOKEN and SSH_PASSWORD docs

Files changed (3) hide show
  1. .env.example +19 -2
  2. Dockerfile +1 -1
  3. entrypoint.sh +158 -33
.env.example CHANGED
@@ -1,7 +1,24 @@
1
- # API Keys (at least one required)
2
  ANTHROPIC_API_KEY=
3
  OPENAI_API_KEY=
4
 
5
- # Optional: HTTP Basic Auth for the server
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  OPENCODE_SERVER_USERNAME=
7
  OPENCODE_SERVER_PASSWORD=
 
1
+ # API Keys (at least one required for AI features)
2
  ANTHROPIC_API_KEY=
3
  OPENAI_API_KEY=
4
 
5
+ # SSH access via Cloudflare Named Tunnel
6
+ # Required for Termius connections.
7
+ # Steps:
8
+ # 1. Log in to https://one.dash.cloudflare.com
9
+ # 2. Go to Zero Trust β†’ Networks β†’ Tunnels
10
+ # 3. Create a tunnel (or select an existing one)
11
+ # 4. Under "Public Hostname", add a hostname routed to:
12
+ # Service type: SSH
13
+ # URL: localhost:22
14
+ # 5. On the tunnel's Overview tab, copy the tunnel token
15
+ # 6. Paste it here (or add as a Hugging Face Space Secret)
16
+ CF_TUNNEL_TOKEN=
17
+
18
+ # SSH password for the root user inside the container.
19
+ # If omitted, a random password is generated and printed to Space logs on startup.
20
+ SSH_PASSWORD=
21
+
22
+ # Optional: HTTP Basic Auth for the OpenCode server
23
  OPENCODE_SERVER_USERNAME=
24
  OPENCODE_SERVER_PASSWORD=
Dockerfile CHANGED
@@ -20,7 +20,7 @@ ARG TTYD_VERSION=1.7.7
20
 
21
  RUN apt-get update && apt-get install -y --no-install-recommends \
22
  ca-certificates curl git gnupg python3 python3-pip nginx \
23
- openssh-server \
24
  && curl -fsSL "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz" \
25
  | tar -xz -C /usr/local/bin opencode \
26
  && chmod +x /usr/local/bin/opencode \
 
20
 
21
  RUN apt-get update && apt-get install -y --no-install-recommends \
22
  ca-certificates curl git gnupg python3 python3-pip nginx \
23
+ openssh-server iproute2 \
24
  && curl -fsSL "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz" \
25
  | tar -xz -C /usr/local/bin opencode \
26
  && chmod +x /usr/local/bin/opencode \
entrypoint.sh CHANGED
@@ -4,7 +4,7 @@
4
  # nginx :7860 (HF exposed)
5
  # /terminal β†’ ttyd :7681 β€” real PTY bash, mobile-optimised
6
  # / β†’ opencode :8080 β€” chat UI + REST API + SSE
7
- # sshd :22 (Cloudflare tunnel β†’ Termius)
8
  #
9
  set -u
10
 
@@ -131,7 +131,7 @@ nginx
131
  echo "[NGINX] Started."
132
 
133
  # ─── SSH server ──────────────────────────────────────────────────────
134
- # Listens on port 22 (internal only β€” Cloudflare tunnel exposes it).
135
  # Password is set via SSH_PASSWORD Space Secret.
136
  # Falls back to a generated password logged to Space logs if not set.
137
  echo "[SSH] Configuring SSH server..."
@@ -172,42 +172,167 @@ ssh-keygen -A 2>/dev/null || true
172
  /usr/sbin/sshd
173
  echo "[SSH] sshd started on port 22."
174
 
175
- # ─── Cloudflare Tunnel ───────────────────────────────────────────────
176
- # Creates a free public tunnel: abc-def-xyz.trycloudflare.com β†’ localhost:22
177
- # The URL is stable as long as the Space stays alive (use UptimeRobot to
178
- # prevent sleeping). URL is saved to /data/logs/tunnel-url.txt.
179
- echo "[TUNNEL] Starting Cloudflare tunnel for SSH..."
180
- CF_LOG=/data/logs/cloudflared.log
181
-
182
- cloudflared tunnel --url ssh://localhost:22 \
183
- --no-autoupdate \
184
- --logfile "${CF_LOG}" \
185
- --loglevel info \
186
- > /dev/null 2>&1 &
187
-
188
- # Wait up to 60 s for the tunnel URL to appear in the log
189
- # cloudflared logs the URL at INFO level β€” must use --loglevel info (not warn)
190
- TUNNEL_URL=""
191
- for i in $(seq 1 60); do
192
  sleep 1
193
- TUNNEL_URL=$(grep -o 'https://[^ |]*\.trycloudflare\.com' "${CF_LOG}" 2>/dev/null | tr -d ' ' | head -1)
194
- [ -n "${TUNNEL_URL}" ] && break
195
  done
196
 
197
- if [ -n "${TUNNEL_URL}" ]; then
198
- # Strip https:// β€” Termius only needs the hostname
199
- TUNNEL_HOST=$(echo "${TUNNEL_URL}" | sed 's|https://||')
200
- echo "${TUNNEL_HOST}" > /data/logs/tunnel-url.txt
201
- echo "[TUNNEL] ============================================"
202
- echo "[TUNNEL] Cloudflare tunnel is UP"
203
- echo "[TUNNEL] Termius hostname : ${TUNNEL_HOST}"
204
- echo "[TUNNEL] Termius port : 22"
205
- echo "[TUNNEL] Termius user : root"
206
- echo "[TUNNEL] URL saved to : /data/logs/tunnel-url.txt"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  echo "[TUNNEL] ============================================"
208
  else
209
- echo "[TUNNEL] WARNING: Could not get tunnel URL within 30s."
210
- echo "[TUNNEL] Check /data/logs/cloudflared.log for details."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  fi
212
 
213
  # ─── Start DB self-healing daemon ────────────────────────────────────
 
4
  # nginx :7860 (HF exposed)
5
  # /terminal β†’ ttyd :7681 β€” real PTY bash, mobile-optimised
6
  # / β†’ opencode :8080 β€” chat UI + REST API + SSE
7
+ # sshd :22 (Cloudflare Named Tunnel β†’ Termius)
8
  #
9
  set -u
10
 
 
131
  echo "[NGINX] Started."
132
 
133
  # ─── SSH server ──────────────────────────────────────────────────────
134
+ # Listens on port 22 (internal only β€” Cloudflare Named Tunnel exposes it).
135
  # Password is set via SSH_PASSWORD Space Secret.
136
  # Falls back to a generated password logged to Space logs if not set.
137
  echo "[SSH] Configuring SSH server..."
 
172
  /usr/sbin/sshd
173
  echo "[SSH] sshd started on port 22."
174
 
175
+ # ─── Verify sshd is actually listening on port 22 ────────────────────
176
+ # Uses ss(8) from iproute2. Fails hard if sshd did not bind β€” a tunnel
177
+ # to a port nobody is listening on is worse than no tunnel at all.
178
+ echo "[SSH] Verifying sshd is listening on port 22..."
179
+ SSHD_LISTEN=""
180
+ for _i in $(seq 1 10); do
181
+ SSHD_LISTEN=$(ss -tlnp 2>/dev/null | grep ' 0\.0\.0\.0:22 \|:22 ' || true)
182
+ [ -n "$SSHD_LISTEN" ] && break
 
 
 
 
 
 
 
 
 
183
  sleep 1
 
 
184
  done
185
 
186
+ if [ -n "$SSHD_LISTEN" ]; then
187
+ echo "[SSH] Confirmed: sshd is listening on port 22."
188
+ echo "[SSH] $SSHD_LISTEN"
189
+ else
190
+ echo "[SSH] ============================================"
191
+ echo "[SSH] FATAL: sshd is NOT listening on port 22."
192
+ echo "[SSH] Active TCP listeners:"
193
+ ss -tlnp 2>/dev/null || true
194
+ echo "[SSH] ============================================"
195
+ echo "[SSH] Cannot start Cloudflare tunnel β€” no SSH service to tunnel to."
196
+ # Do not exit β€” OpenCode and the terminal should still work.
197
+ # The operator needs to investigate why sshd failed to bind.
198
+ fi
199
+
200
+ # ─── Cloudflare Named Tunnel ──────────────────────────────────────────
201
+ # Requires CF_TUNNEL_TOKEN set in Space Secrets.
202
+ # The token is obtained from the Cloudflare Zero Trust dashboard:
203
+ # Zero Trust β†’ Networks β†’ Tunnels β†’ select/create tunnel β†’ Overview tab β†’ copy token
204
+ #
205
+ # The named tunnel must be configured in Cloudflare Zero Trust to route
206
+ # the public hostname to ssh://localhost:22 (service: SSH, URL: localhost:22).
207
+ #
208
+ # NOTE: The token is NEVER printed or logged.
209
+ echo "[TUNNEL] ============================================"
210
+ CF_LOG=/data/logs/cloudflared.log
211
+
212
+ if [ -z "${CF_TUNNEL_TOKEN:-}" ]; then
213
+ echo "[TUNNEL] WARNING: CF_TUNNEL_TOKEN secret is not set."
214
+ echo "[TUNNEL] SSH access via Cloudflare Named Tunnel is DISABLED."
215
+ echo "[TUNNEL] To enable:"
216
+ echo "[TUNNEL] 1. Create a Named Tunnel in Cloudflare Zero Trust"
217
+ echo "[TUNNEL] (Zero Trust β†’ Networks β†’ Tunnels β†’ Add a tunnel)"
218
+ echo "[TUNNEL] 2. Configure the public hostname to route to ssh://localhost:22"
219
+ echo "[TUNNEL] 3. Copy the tunnel token from the Cloudflare dashboard"
220
+ echo "[TUNNEL] 4. Add CF_TUNNEL_TOKEN to Space Secrets"
221
+ echo "[TUNNEL] OpenCode and terminal will start normally without it."
222
  echo "[TUNNEL] ============================================"
223
  else
224
+ echo "[TUNNEL] Named tunnel starting..."
225
+ # Use 'run --token' β€” the only correct form for Named Tunnels.
226
+ # Do NOT use 'tunnel --url' (Quick Tunnel) β€” it creates ephemeral
227
+ # trycloudflare.com addresses that SSH clients cannot reliably use.
228
+ cloudflared tunnel run \
229
+ --token "${CF_TUNNEL_TOKEN}" \
230
+ --no-autoupdate \
231
+ --logfile "${CF_LOG}" \
232
+ --loglevel info \
233
+ > /dev/null 2>&1 &
234
+ CF_PID=$!
235
+
236
+ # ── Wait up to 40 s for the tunnel to register ───────────────────
237
+ TUNNEL_CONNECTED=0
238
+ for _i in $(seq 1 40); do
239
+ sleep 1
240
+ # cloudflared Named Tunnel logs "Registered tunnel connection" on success
241
+ if grep -qi \
242
+ "Registered tunnel connection\|registered tunnel connection\|Connection registered" \
243
+ "${CF_LOG}" 2>/dev/null; then
244
+ TUNNEL_CONNECTED=1
245
+ break
246
+ fi
247
+ # Detect fatal auth errors early β€” no point waiting the full 40 s
248
+ if grep -qi \
249
+ "Invalid.*token\|token.*invalid\|failed to authenticate\|authentication failed\|ERR_FAILED_TO_AUTHENTICATE\|Invalid tunnel credentials\|failed to validate token" \
250
+ "${CF_LOG}" 2>/dev/null; then
251
+ echo "[TUNNEL] ERROR: Cloudflare authentication failed β€” aborting wait."
252
+ break
253
+ fi
254
+ done
255
+
256
+ if [ "$TUNNEL_CONNECTED" = "1" ]; then
257
+ # ── Extract metadata from JSON log lines ──────────────────────
258
+ # cloudflared Named Tunnel writes structured JSON; field values
259
+ # are extracted with portable sed without exposing the token.
260
+ CONNECTOR_ID=$(grep -o '"connectorID":"[^"]*"' "${CF_LOG}" 2>/dev/null \
261
+ | head -1 | sed 's/"connectorID":"//;s/"//')
262
+ TUNNEL_NAME=$(grep -o '"tunnelName":"[^"]*"' "${CF_LOG}" 2>/dev/null \
263
+ | head -1 | sed 's/"tunnelName":"//;s/"//')
264
+ TUNNEL_ID=$(grep -o '"tunnelID":"[^"]*"' "${CF_LOG}" 2>/dev/null \
265
+ | head -1 | sed 's/"tunnelID":"//;s/"//')
266
+ CONN_LOCATION=$(grep -o '"location":"[^"]*"' "${CF_LOG}" 2>/dev/null \
267
+ | head -1 | sed 's/"location":"//;s/"//')
268
+ CONN_PROTOCOL=$(grep -o '"protocol":"[^"]*"' "${CF_LOG}" 2>/dev/null \
269
+ | head -1 | sed 's/"protocol":"//;s/"//')
270
+
271
+ echo "[TUNNEL] Named tunnel connected"
272
+ echo "[TUNNEL] ============================================"
273
+ [ -n "$CONNECTOR_ID" ] && echo "[TUNNEL] Connector ID : ${CONNECTOR_ID}"
274
+ [ -n "$TUNNEL_NAME" ] && echo "[TUNNEL] Tunnel name : ${TUNNEL_NAME}"
275
+ [ -n "$TUNNEL_ID" ] && echo "[TUNNEL] Tunnel ID : ${TUNNEL_ID}"
276
+ [ -n "$CONN_LOCATION" ] && echo "[TUNNEL] Edge location : ${CONN_LOCATION}"
277
+ [ -n "$CONN_PROTOCOL" ] && echo "[TUNNEL] Protocol : ${CONN_PROTOCOL}"
278
+ echo "[TUNNEL] SSH port : 22"
279
+ echo "[TUNNEL] SSH user : root"
280
+ echo "[TUNNEL] Connect via : the public hostname configured in"
281
+ echo "[TUNNEL] Cloudflare Zero Trust for this tunnel."
282
+ echo "[TUNNEL] Logs : ${CF_LOG}"
283
+ echo "[TUNNEL] ============================================"
284
+ else
285
+ # ── Diagnose the failure ──────────────────────────────────────
286
+ echo "[TUNNEL] ============================================"
287
+ echo "[TUNNEL] WARNING: Named tunnel did NOT confirm connection within 40 s."
288
+ echo "[TUNNEL] Connection status: FAILED"
289
+ echo "[TUNNEL] ----"
290
+
291
+ # Show recent log lines β€” strip any line containing the token pattern
292
+ # (tokens are long base64 strings; we never echo the CF_TUNNEL_TOKEN var).
293
+ echo "[TUNNEL] Recent cloudflared output:"
294
+ tail -25 "${CF_LOG}" 2>/dev/null \
295
+ | grep -v -i "token\|credential\|secret\|password" \
296
+ || echo "[TUNNEL] (log not yet written)"
297
+ echo "[TUNNEL] ----"
298
+
299
+ # Specific diagnoses
300
+ if grep -qi \
301
+ "Invalid.*token\|token.*invalid\|failed to validate token\|Invalid tunnel credentials\|ERR_FAILED_TO_AUTHENTICATE\|authentication failed\|failed to authenticate" \
302
+ "${CF_LOG}" 2>/dev/null; then
303
+ echo "[TUNNEL] DIAGNOSIS : Invalid or expired tunnel token."
304
+ echo "[TUNNEL] ACTION : Regenerate the token in Cloudflare Zero Trust:"
305
+ echo "[TUNNEL] Zero Trust β†’ Networks β†’ Tunnels β†’ select tunnel"
306
+ echo "[TUNNEL] β†’ Overview tab β†’ copy new token"
307
+ echo "[TUNNEL] Update CF_TUNNEL_TOKEN in Space Secrets."
308
+
309
+ elif grep -qi "reconnect\|retrying connection\|retry connection" \
310
+ "${CF_LOG}" 2>/dev/null; then
311
+ echo "[TUNNEL] DIAGNOSIS : Tunnel is in a reconnect loop."
312
+ echo "[TUNNEL] ACTION : Check Cloudflare service status and Space outbound"
313
+ echo "[TUNNEL] network connectivity."
314
+
315
+ elif grep -qi \
316
+ "dial tcp\|connection refused\|no such host\|network unreachable\|i/o timeout\|TLS handshake" \
317
+ "${CF_LOG}" 2>/dev/null; then
318
+ echo "[TUNNEL] DIAGNOSIS : Network error β€” cannot reach the Cloudflare edge."
319
+ echo "[TUNNEL] ACTION : Verify outbound HTTPS/QUIC is not blocked from the Space."
320
+
321
+ elif grep -qi "already running\|tunnel already" \
322
+ "${CF_LOG}" 2>/dev/null; then
323
+ echo "[TUNNEL] DIAGNOSIS : A duplicate tunnel connector may already be running."
324
+ echo "[TUNNEL] ACTION : Check your Cloudflare Zero Trust Tunnels list for"
325
+ echo "[TUNNEL] stale connectors and remove them."
326
+
327
+ else
328
+ echo "[TUNNEL] DIAGNOSIS : Unknown β€” inspect full log for details."
329
+ echo "[TUNNEL] Log path : ${CF_LOG}"
330
+ fi
331
+
332
+ echo "[TUNNEL] ============================================"
333
+ echo "[TUNNEL] OpenCode and terminal continue normally."
334
+ echo "[TUNNEL] SSH access via Cloudflare will be unavailable until resolved."
335
+ fi
336
  fi
337
 
338
  # ─── Start DB self-healing daemon ────────────────────────────────────