diff --git a/docs/usage.ko.md b/docs/usage.ko.md index 1f2e7a3..3bd5f0d 100644 --- a/docs/usage.ko.md +++ b/docs/usage.ko.md @@ -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 주소를 지원합니다. diff --git a/docs/usage.md b/docs/usage.md index bff1bab..dde742f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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. diff --git a/tailscale-setup.sh b/tailscale-setup.sh index 6c2de2e..78ad66b 100755 --- a/tailscale-setup.sh +++ b/tailscale-setup.sh @@ -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 [[ ! -L "$SOCK" ]] || fail 'socket symlink refused' if [[ -e "$SOCK" ]]; then fail "socket exists but daemon cannot be identified: $SOCK; inspect before removing it" @@ -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>&- @@ -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' @@ -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 @@ -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;; *) printf 'unavailable: BackendState=%s\n' "$state"; exit 11;; esac diff --git a/tests/test_auth_readiness.py b/tests/test_auth_readiness.py new file mode 100644 index 0000000..8a407d6 --- /dev/null +++ b/tests/test_auth_readiness.py @@ -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() diff --git a/tests/test_startup_readiness.py b/tests/test_startup_readiness.py new file mode 100644 index 0000000..24aa551 --- /dev/null +++ b/tests/test_startup_readiness.py @@ -0,0 +1,146 @@ +"""Exercise the real startup readiness loop with an isolated status CLI.""" +import json +from pathlib import Path +import re +import shlex +import subprocess +import sys +import tempfile +import time +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 StartupReadinessTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='codex-readiness-') + self.addCleanup(self.temp.cleanup) + self.directory = Path(self.temp.name) + self.cli = self.directory / 'tailscale' + self.program = self.directory / 'fake_cli.py' + self.program.write_text('''import json, pathlib, sys, time +d = pathlib.Path(__file__).parent +assert sys.argv[1:] == ['--socket=fixture.sock', 'status', '--json'] +counter = d / 'calls' +n = int(counter.read_text()) if counter.exists() else 0 +counter.write_text(str(n + 1)) +sequence = json.loads((d / 'sequence.json').read_text()) +item = sequence[min(n, len(sequence) - 1)] +if item == 'hang': + time.sleep(10) +elif item == 'error': + print('PRIVATE_AUTH_URL', file=sys.stderr) + sys.exit(1) +elif item == 'invalid': + print('PRIVATE_INVALID_JSON') +else: + print(json.dumps({'BackendState': item, 'AuthURL': 'PRIVATE_AUTH_URL'})) +''') + self.cli.write_text('#!/bin/sh\nexec ' + shlex.quote(sys.executable) + ' ' + + shlex.quote(str(self.program)) + ' "$@"\n') + self.cli.chmod(0o700) + # Identity fixtures must remain byte-for-byte unchanged during waits. + self.state = self.directory / 'tailscaled.state' + self.key = self.directory / 'ssh_host_ed25519_key' + self.state.write_bytes(b'fixture-retained-device-identity') + self.key.write_bytes(b'fixture-retained-ssh-identity') + + def run_backend(self, sequence, seconds=2, final=False): + (self.directory / 'sequence.json').write_text(json.dumps(sequence)) + script = 'set -euo pipefail\nD=' + shlex.quote(str(self.directory)) + '\nSOCK=fixture.sock\n' + script += function('backend') + script += 'state="$(backend ' + str(seconds) + ')"\n' + if final: + script += SOURCE[SOURCE.rindex('case "$state" in'):] + else: + script += 'printf "%s\\n" "$state"\n' + started = time.monotonic() + result = subprocess.run(['bash', '-c', script], capture_output=True, + text=True, timeout=seconds + 3) + result.elapsed = time.monotonic() - started + self.assertNotIn('PRIVATE_', result.stdout + result.stderr) + self.assertEqual(self.state.read_bytes(), b'fixture-retained-device-identity') + self.assertEqual(self.key.read_bytes(), b'fixture-retained-ssh-identity') + return result + + def test_no_state_starting_running_without_self_id_is_ready(self): + result = self.run_backend(['NoState', 'Starting', 'Running']) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, 'Running\n') + self.assertEqual((self.directory / 'calls').read_text(), '3') + + def test_query_errors_and_invalid_json_retry_then_running(self): + result = self.run_backend(['error', 'invalid', 'Running']) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, 'Running\n') + + def test_auth_states_return_to_explicit_auth_caller(self): + for state in ('NeedsLogin', 'NeedsMachineAuth'): + with self.subTest(state=state): + result = self.run_backend([state]) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), state) + + def test_final_auth_states_have_distinct_codes(self): + for state, code, message in (('NeedsLogin', 10, 'needs_auth:'), + ('NeedsMachineAuth', 13, 'needs_machine_auth:')): + with self.subTest(state=state): + result = self.run_backend([state], final=True) + self.assertEqual(result.returncode, code, result.stderr) + self.assertIn(message, result.stdout) + + def test_persistent_no_state_has_bounded_deadline(self): + for state in ('NoState', 'Starting'): + with self.subTest(state=state): + result = self.run_backend([state], seconds=0.7) + self.assertEqual(result.returncode, 11) + self.assertIn('readiness_timeout: BackendState=' + state, result.stderr) + self.assertLess(result.elapsed, 1.7) + + def test_slow_query_uses_remaining_total_budget(self): + result = self.run_backend(['NoState', 'hang'], seconds=2) + self.assertEqual(result.returncode, 11) + self.assertIn('readiness_timeout: BackendState=NoState', result.stderr) + self.assertLess(result.elapsed, 3) + self.assertEqual((self.directory / 'calls').read_text(), '2') + + def test_query_failure_and_invalid_json_have_query_error_code(self): + for state in ('error', 'invalid', 'hang', None): + with self.subTest(state=state): + result = self.run_backend([state], seconds=0.2) + self.assertEqual(result.returncode, 12) + self.assertIn('query_failed:', result.stderr) + + def test_other_backend_state_is_sanitized_not_auto_reauthenticated(self): + result = self.run_backend(['PRIVATE_UNEXPECTED_BACKEND']) + self.assertEqual(result.returncode, 11) + self.assertIn('unavailable: backend is not ready', result.stderr) + + def test_previously_authenticated_missing_state_stops_before_start(self): + (self.directory / 'installed').touch() + (self.directory / 'tailscaled').touch(mode=0o700) + (self.directory / 'authenticated').touch() + sshd = self.directory / 'sshd' + sshd.mkdir() + retained_key = sshd / 'ssh_host_ed25519_key' + retained_key.write_bytes(b'existing-host-key') + script = 'set -euo pipefail\nD=' + shlex.quote(str(self.directory)) + '\nS="$D/sshd"\n' + script += "fail() { printf '%s\\n' \"$*\" >&2; exit 1; }\n" + script += function('start_all') + '\nstart_all\n' + result = subprocess.run(['bash', '-c', script], capture_output=True, + text=True, timeout=3) + self.assertEqual(result.returncode, 1) + self.assertIn('previously authenticated state is missing', result.stderr) + self.assertFalse((self.directory / 'calls').exists()) + self.assertFalse((self.directory / 'state').exists()) + self.assertEqual(retained_key.read_bytes(), b'existing-host-key') + + +if __name__ == '__main__': + unittest.main()