Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/usage.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,9 @@ bash /workspace/.dots-tailscale/start.sh
bash /workspace/.dots-tailscale/start.sh check
```

일반 기동에서는 인증을 시작하지 않습니다. `start`·`login`·`auth`에서 Tailscale backend가 `Running`이고 로컬 SSH 배너를 확인하면 종료코드 `0`, 인증 필요는 `10`, 그 밖의 BackendState는 `11`을 반환합니다. 기타 구성·기동 실패도 비영 종료코드입니다. 필요할 때만 `start.sh login`을 실행합니다.
일반 기동에서는 인증을 시작하지 않습니다. `start`·`login`·`auth`에서 backend가 `Running`이고 로컬 SSH 배너를 확인하면 종료코드 `0`입니다. 인증 필요는 `10`, backend 준비 시간 초과나 그 밖의 준비되지 않은 상태는 `11`, 기한 내 유효한 backend 상태를 한 번도 받지 못하면 `12`, 관리자 장치 승인 필요는 `13`입니다. 기타 구성·기동 실패도 비영 종료코드입니다. 필요할 때만 `start.sh login`을 실행합니다.

backend 준비 단계마다 단조 시계 기준 총 30초 동안 조회와 재시도 대기를 수행합니다. 각 조회는 최대 3초이며 남은 예산을 넘지 않습니다. `NoState`·`Starting`은 재조회하고 CLI가 응답했다는 이유만으로 준비 완료로 판단하지 않습니다. 유효한 준비 중 상태를 확인했다면 이후 조회가 실패해도 기한 만료는 `11`로 유지합니다. 이 한도는 backend 단계에 적용되며 전체 구성요소 초기화나 명시적인 인증 시간을 모두 합친 한도가 아닙니다. `login`·`auth`는 먼저 최대 30초 동안 로컬 API의 유효한 응답을 기다립니다. 이 사전 확인은 `NoState`·`Starting`도 허용하므로 새 데몬에 아직 인증이 없어도 명시적인 `up`을 실행할 수 있습니다. 로그인 명령은 별도 180초, Auth Key 인증 명령은 별도 60초 한도를 유지하며, 이후 최종 backend 준비 단계를 수행합니다. 이미 관리자 승인을 기다리는 상태에서는 `up`을 생략합니다. 인증 명령 실패·시간 초과 후에도 backend를 확인해 승인 필요는 `13`, 로그인 필요는 `10`으로 구분합니다. backend가 Running이어도 인증 명령 실패를 성공으로 숨기지 않고 원래 실패 코드를 반환합니다. 새 데몬 기동 여부를 확인하는 상태 조회는 최대 3초입니다.

설치 위치는 `DOTS_TS_DIR`, 장비 이름은 `NODE_HOSTNAME`, 업스트림 프록시는 `UPSTREAM_PROXY`로 변경합니다. 설치된 래퍼는 자기 디렉터리를 `DOTS_TS_DIR`로 사용합니다. 프록시 URL은 인증 없는 HTTP CONNECT 주소를 지원합니다.

Expand Down
7 changes: 6 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,14 @@ Replace the example username if your cloud-computer user differs. Use the addres
| --- | --- |
| Running and local SSH ready | `0` |
| Authentication required (`needs_auth`) | `10` |
| Another backend state (`unavailable`) | `11` |
| Backend readiness deadline or another unavailable state | `11` |
| No valid backend state received by the deadline | `12` |
| Administrator machine approval required (`needs_machine_auth`) | `13` |
| Explicit authentication command fails while backend is Running | Original command failure code |
| Setup or component failure | Nonzero, normally `1` |

Backend readiness uses one 30-second monotonic deadline per phase, including retries and sleeps; each status query has at most three seconds and no more than the remaining budget. `NoState` and `Starting` are retried. If a valid transient state was observed, expiry remains exit `11` even if a later query fails; exit `12` means no valid state was obtained during the phase. Successful CLI communication alone is not readiness. The deadline covers the backend phase, not all component initialization or explicit authentication: `login` and `auth` first wait for a valid local API response (up to 30 seconds). This precheck accepts NoState/Starting without requiring Running, so a fresh unauthenticated daemon can reach explicit `up`. Login retains its separate 180-second command limit; auth retains its separate 60-second authentication limit; both then perform the final backend-readiness phase. An already pending machine approval skips `up`. If `up` fails or times out, backend inspection still runs: NeedsMachineAuth maps to `13`, NeedsLogin to `10`; a Running backend does not hide the command failure. A new-launch status probe is capped at three seconds.

Ordinary `start` does not invoke login. Previously authenticated state disappearing causes a failure for manual review rather than silent identity replacement. Existing conflicting files, partial CA installations, and missing established host keys also require review. Do not delete the installation directory to update a script: it holds node state and private keys.

The custom TLS/WebSocket shim implements the configured local HTTP CONNECT bridge. Local tests cover initial payload preservation and transport boundaries using isolated sockets. The revised shim was deployed on 2026-10-14 and passed live DERP ping and strict public-key SSH checks through the proxy path. With the previous shim, the maintainer observed retained installation files and successful manual restart after an environment replacement. The revised version has not yet been verified across a later replacement. Changes to the platform proxy can break it.
Expand Down
74 changes: 59 additions & 15 deletions tailscale-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ PY
fi
running "$D/shim/shim.py" || fail 'shim did not start'
export HTTPS_PROXY=http://127.0.0.1:18089 HTTP_PROXY=http://127.0.0.1:18089 SSL_CERT_FILE="$D/shim/bundle.crt"
if ! TS status --json >/dev/null 2>&1 && launch_needed "$D/tailscaled"; then
if launch_needed "$D/tailscaled" && ! timeout 3 "$D/tailscale" --socket="$SOCK" status --json >/dev/null 2>&1; then
Comment thread
abruption marked this conversation as resolved.
[[ ! -L "$SOCK" ]] || fail 'socket symlink refused'
if [[ -e "$SOCK" ]]; then
fail "socket exists but daemon cannot be identified: $SOCK; inspect before removing it"
Expand All @@ -297,12 +297,6 @@ PY
rotate "$R/tailscaled.log"
nohup "$D/tailscaled" --tun=userspace-networking --statedir="$D/state" --socket="$SOCK" --port=0 "${outbound_args[@]}" >> "$R/tailscaled.log" 2>&1 9>&- &
fi
local ready=0
for _ in {1..20}; do
if timeout 3 "$D/tailscale" --socket="$SOCK" status --json > "$R/status.json"; then ready=1; break; fi
sleep 0.5
done
[[ "$ready" = 1 ]] || fail 'tailscaled CLI unavailable'
if launch_needed "$S/sshd_config"; then
rotate "$R/sshd.log"
/usr/sbin/sshd -f "$S/sshd_config" -E "$R/sshd.log" 9>&-
Expand All @@ -314,12 +308,51 @@ with socket.create_connection(('127.0.0.1',22), timeout=5) as s:
PY
}
backend() {
TS status --json | python3 -c 'import json,sys; print(json.load(sys.stdin)["BackendState"])'
# One deadline covers all status queries and retry sleeps in this phase.
# Optional duration is for isolated tests; normal startup always uses 30s.
python3 - "$D/tailscale" "$SOCK" "${1:-30}" "${2:-backend}" <<'READY'
import json, subprocess, sys, time

deadline = time.monotonic() + float(sys.argv[3])
last_state = None
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
try:
reply = subprocess.run(
[sys.argv[1], '--socket=' + sys.argv[2], 'status', '--json'],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
timeout=min(3.0, remaining), check=True)
state = json.loads(reply.stdout)['BackendState']
if not isinstance(state, str):
raise ValueError('invalid backend state')
except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError):
pass
else:
last_state = state
if (state in ('Running', 'NeedsLogin', 'NeedsMachineAuth')
or sys.argv[4] == 'api' and state in ('NoState', 'Starting', 'Stopped')):
print(state)
sys.exit(0)
if state not in ('NoState', 'Starting'):
print('unavailable: backend is not ready; inspect dots-ts doctor', file=sys.stderr)
sys.exit(11)
remaining = deadline - time.monotonic()
if remaining > 0:
time.sleep(min(0.5, remaining))

if last_state is None:
print('query_failed: backend status unavailable within 30s; inspect dots-ts doctor and runtime logs', file=sys.stderr)
sys.exit(12)
print('readiness_timeout: BackendState=' + last_state + '; inspect dots-ts doctor before retrying start', file=sys.stderr)
sys.exit(11)
READY
}
authenticate_with_key() {
local state key_file
state="$(backend)"
[[ "$state" != Running ]] || return 0
state="$(backend 30 api)"
[[ "$state" != Running && "$state" != NeedsMachineAuth ]] || return 0
key_file="${DOTS_TS_AUTH_KEY_FILE:-}"
[[ -n "$key_file" ]] || fail 'auth mode requires DOTS_TS_AUTH_KEY_FILE'
python3 - "$key_file" <<'AUTH'
Expand All @@ -333,7 +366,7 @@ if stat.S_IMODE(st.st_mode) not in (0o600, 0o400): sys.exit('Auth key permission
if not st.st_size: sys.exit('Auth key file is empty')
AUTH
timeout 60 "$D/tailscale" --socket="$SOCK" up --auth-key="file:$key_file" \
--hostname="$NODE_HOSTNAME" --ssh=false --timeout=45s
--hostname="$NODE_HOSTNAME" --ssh=false --timeout=45s || AUTH_COMMAND_STATUS=$?
}
case "$MODE" in check|install|start|login|auth) ;; *) fail 'usage: tailscale-setup.sh [check|install|start|login|auth]';; esac
if [[ "$MODE" = check ]]; then
Expand All @@ -350,15 +383,26 @@ private_dir "$D"; private_dir "$R"
exec 9> "$D/.operation.lock"
flock -n 9 || fail 'another operation is running'
if [[ "$MODE" = install ]]; then install_all; exit 0; fi
AUTH_COMMAND_STATUS=0
start_all
if [[ "$MODE" = login ]]; then
# Explicit interactive action only; no auth key, no forced reauthentication.
timeout 180 "$D/tailscale" --socket="$SOCK" up --hostname="$NODE_HOSTNAME" --ssh=false
# Wait for the daemon's local API before starting an interactive command.
# NeedsLogin is ready for explicit up; machine approval needs no new login.
state="$(backend 30 api)"
if [[ "$state" != NeedsMachineAuth ]]; then
timeout 180 "$D/tailscale" --socket="$SOCK" up --hostname="$NODE_HOSTNAME" --ssh=false || AUTH_COMMAND_STATUS=$?
fi
fi
if [[ "$MODE" = auth ]]; then authenticate_with_key; fi
state="$(backend)"
state="$(backend 30)"
case "$state" in
Running) : > "$D/authenticated"; printf 'running: Tailscale and local SSH ready\n';;
Running)
if [[ "${AUTH_COMMAND_STATUS:-0}" != 0 ]]; then
printf 'authentication_command_failed: backend is running but the explicit authentication command failed\n' >&2
exit "$AUTH_COMMAND_STATUS"
fi
: > "$D/authenticated"; printf 'running: Tailscale and local SSH ready\n';;
NeedsLogin) printf 'needs_auth: run bash %s/setup.sh login\n' "$D"; exit 10;;
NeedsMachineAuth) printf 'needs_machine_auth: approve this existing device in the Tailscale admin console\n'; exit 13;;
Comment thread
abruption marked this conversation as resolved.
*) printf 'unavailable: BackendState=%s\n' "$state"; exit 11;;
esac
97 changes: 97 additions & 0 deletions tests/test_auth_readiness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Exercise real explicit-auth orchestration with delayed local API fixtures."""
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
import tempfile
import unittest

SOURCE = (Path(__file__).resolve().parents[1] / 'tailscale-setup.sh').read_text()

def function(name):
return re.search(r'^' + name + r'\(\) \{\n.*?^}\n', SOURCE, re.M | re.S).group()

class AuthReadinessTests(unittest.TestCase):
def flow(self, mode, before, after='Running', up_exit=0, key=True, budget=2):
with tempfile.TemporaryDirectory(prefix='codex-auth-ready-') as tmp:
root = Path(tmp)
(root / 'fixture.json').write_text(json.dumps({'before': before, 'after': after, 'up_exit': up_exit}))
dummy = root / 'key'
dummy.write_text('dummy-never-a-real-credential\n');dummy.chmod(0o600)
program = root / 'cli.py'
program.write_text('''import json,pathlib,sys
r=pathlib.Path(__file__).parent
f=json.loads((r/'fixture.json').read_text())
if sys.argv[2]=='status':
n=int((r/'calls').read_text()) if (r/'calls').exists() else 0
(r/'calls').write_text(str(n+1))
value=f['after'] if (r/'up').exists() else f['before'][min(n,len(f['before'])-1)]
if value=='unavailable':sys.exit(1)
print(json.dumps({'BackendState':value,'AuthURL':'PRIVATE_AUTH_URL'}))
else:
assert sys.argv[2]=='up'
assert (r/'calls').exists(), 'up raced the local API'
n=int((r/'calls').read_text())
assert n>=len(f['before']), 'up ran before successful status'
(r/'up').write_text(str(n))
sys.exit(f['up_exit'])
''')
cli = root / 'tailscale'
cli.write_text('#!/bin/sh\nexec '+shlex.quote(sys.executable)+' '+shlex.quote(str(program))+' "$@"\n');cli.chmod(0o700)
# Only initialization and the outer timeout executable are fixtures.
# The actual backend/auth functions and main auth/case flow execute.
script='set -euo pipefail\n'
script+='D='+shlex.quote(str(root))+'\nSOCK=fixture.sock\nNODE_HOSTNAME=fixture\nMODE='+mode+'\n'
script+='fail() { printf "%s\\n" "$*" >&2; exit 1; }\nstart_all() { :; }\ntimeout() { shift; "$@"; }\n'
script+=function('backend')+function('authenticate_with_key')
script+=SOURCE[SOURCE.index('AUTH_COMMAND_STATUS=0\nstart_all\n'):]
script=script.replace('backend 30','backend '+str(budget))
env=dict(os.environ);env.pop('DOTS_TS_AUTH_KEY_FILE',None)
if key:env['DOTS_TS_AUTH_KEY_FILE']=str(dummy)
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,env=env,timeout=8)
self.assertNotIn('PRIVATE_AUTH_URL', result.stdout+result.stderr)
self.assertEqual(dummy.read_text(),'dummy-never-a-real-credential\n')
result.up_called=(root/'up').exists()
result.authenticated=(root/'authenticated').exists()
return result

def test_login_waits_for_api_and_allows_no_state_before_up(self):
result=self.flow('login',['unavailable','unavailable','NoState'])
self.assertEqual(result.returncode,0,result.stderr)
self.assertTrue(result.up_called)
self.assertTrue(result.authenticated)

def test_failed_up_is_followed_by_machine_approval_mapping(self):
for mode in ('login','auth'):
for failure in (2,124):
with self.subTest(mode=mode,failure=failure):
result=self.flow(mode,['NeedsLogin'],after='NeedsMachineAuth',up_exit=failure)
self.assertEqual(result.returncode,13,result.stderr)
self.assertIn('needs_machine_auth:',result.stdout)
self.assertTrue(result.up_called)
self.assertFalse(result.authenticated)

def test_existing_approval_skips_up_and_key_read(self):
for mode in ('login','auth'):
with self.subTest(mode=mode):
result=self.flow(mode,['NeedsMachineAuth'],key=False)
self.assertEqual(result.returncode,13,result.stderr)
self.assertFalse(result.up_called)

def test_failed_up_with_running_backend_is_not_reported_successful(self):
for mode in ('login','auth'):
with self.subTest(mode=mode):
result=self.flow(mode,['NoState'],after='Running',up_exit=42)
self.assertEqual(result.returncode,42,result.stderr)
self.assertIn('authentication_command_failed:',result.stderr)
self.assertFalse(result.authenticated)

def test_inaccessible_api_does_not_start_interactive_up(self):
result=self.flow('login',['unavailable'],budget=.4)
self.assertEqual(result.returncode,12,result.stderr)
self.assertFalse(result.up_called)

if __name__=='__main__':unittest.main()
Loading
Loading