diff --git a/docs/deployment/CT_REUSE_HANDOFF.ko.md b/docs/deployment/CT_REUSE_HANDOFF.ko.md index 1eee52b..33924bd 100644 --- a/docs/deployment/CT_REUSE_HANDOFF.ko.md +++ b/docs/deployment/CT_REUSE_HANDOFF.ko.md @@ -4,6 +4,12 @@ 기준 source: `6c621870ad17d3775685165afb9237755b79d55a`, `task/runtime-lifecycle-refactor`. 범위: 현장 도구와 확인 사실 보존. **test 설치·범용화 구현·네트워크 전환은 하지 않았다.** +후속 상태(2026-09-30 17:43 KST): 포트 !override 수정 후 apply/start/WSS/camera3 시각화 성공, +Web signer/Redis override 문제 해소. 현장 CA·계정 준비와 ct 안내 보완 후 재부팅 대기다. +[후속 검증](../profiles/ltb1021/topics/runtime-lifecycle/validation/20260930T174310+0900-validation-ct-preboot.md)을 +먼저 읽는다. 아래 r01 수치32/66과 미준비/실패 상태는 초기 보관 시점이며 후속 source는67파일이다. +새 영구 snapshot은 `2026-09-30_ct-preboot_r01`이며 기존 r01 archive는 덮어쓰지 않는다. + ## 1. 다음 작업자가 먼저 읽을 것 1. 이 문서: 현재 경계, 구현 위치, test 이식 차이와 다음 행동. diff --git a/docs/deployment/ICERTI_OFFLINE_MAINTENANCE.ko.md b/docs/deployment/ICERTI_OFFLINE_MAINTENANCE.ko.md index f73b0c4..f89c20e 100644 --- a/docs/deployment/ICERTI_OFFLINE_MAINTENANCE.ko.md +++ b/docs/deployment/ICERTI_OFFLINE_MAINTENANCE.ko.md @@ -3,8 +3,9 @@ Last reviewed: `2026-09-30`, 연결 버전·오류 구분·USB 해제·터미널 언어·자원 보호 보완. 연결 단절 후 test 재현·범용화 준비는 [ct 재사용 인계](CT_REUSE_HANDOFF.ko.md)를 우선한다. -최신 수동시험은 로그인/version2 갱신까지 성공하고 Web signer 설정 누락으로 인증서 발급에서 대기 중이다. -아래의 이전401/400·미로그인 상태는 해당 시점의 기록이며 최신 상태와 구분한다. +최신 수동시험은 Web signer 설정·Redis override 문제와 ct 포트 병합 수정 뒤 발급/apply/start/WSS와 +camera3 시각화까지 통과했다. field CA·계정은 준비됐고 office를 유지한 수동 재부팅 시험 대기다. +아래의 이전401/400/500·미로그인 상태는 해당 시점의 기록이며 최신 상태와 구분한다. 기존 군중 배포를 보존하면서 현장 긴급 분석·Python APP 패치와 작업 기록을 준비했다. 설치 준비 완료와 새 현장 네트워크·브라우저 수용 완료는 구분한다. @@ -81,6 +82,8 @@ Last reviewed: `2026-09-30`, 연결 버전·오류 구분·USB 해제·터미널 `ct config cancel`로 보존·선택 해제한 뒤 `ct config version 번호` → `ct config prepare`로 새로 준비한다. 원격 결과 미확정·적용 중에는 무조건 취소/초기화하지 않는다. 준비 파일의 SHA를 손으로 고치지 않는다. - 새 준비본에는 실제 송신 번호가 표시된다. 이후 사용자 명시 `ct config connect` 한 번으로 진행한다. +- 버전 저장 명령은 저장 후 파일을 다시 읽어 실제값을 출력한다. 발급 실패의 허용된 details.reason은 + 결과 파일과 안내에 보존한다. Web CA 서명 설정 누락을 버전 증가·새 CA 생성으로 우회하지 않는다. 서비스·카메라는 자동 Start하지 않는다. 성공한 동일 설정은 재부팅/재로그인 때 번호를 올리지 않는다. - 오류는 LOGIN/CERTIFICATE 단계, HTTP 상태, 허용된 Web 오류 code/field로 표시한다. 비밀정보와 응답 원문은 출력하지 않는다. 시도 수는 해당 준비본의 명시적 helper 실행 수이며 이전 미계측은 별도 표시한다. diff --git a/docs/profiles/ltb1021/topics/runtime-lifecycle/README.ko.md b/docs/profiles/ltb1021/topics/runtime-lifecycle/README.ko.md index 0f05a8b..2174f53 100644 --- a/docs/profiles/ltb1021/topics/runtime-lifecycle/README.ko.md +++ b/docs/profiles/ltb1021/topics/runtime-lifecycle/README.ko.md @@ -1,9 +1,20 @@ # Runtime Lifecycle -Status: `CT REUSE EVIDENCE PRESERVED · WEB CERTIFICATE CONFIG WAIT · TEST PORT NOT IMPLEMENTED` +Status: `FIELD PROFILE PREPARED · CT PREBOOT CHECKS PASS · USER REBOOT WAIT` Owner: `(test, ltb1021, work-session)` on `task/runtime-lifecycle-refactor` +## 수동 재부팅 직전 준비 + +Last reviewed: `2026-09-30 17:43:10 KST`. [사전 준비·검증](validation/20260930T174310+0900-validation-ct-preboot.md). +Web 발급 설정·bbox Redis 고정값 문제 해결 뒤 camera3 시각화/수동Stop을 사용자 확인했다. +side 포트 override 수정과 apply/start/WSS 성공을 유지하고, version 저장후표시·발급 reason 안내를 보완했다. +현장 CA·계정을 field 전용 파일에 준비했으며 office·NIC·현재 실행 설정은 유지했다. +표적26건+manifest4건/source67파일, 도구5파일 설치와 부팅 보호·데이터 mount 확인 PASS. +서비스/카메라 조작·새로그인·재부팅은 하지 않았다. 다음은 사용자 sudo reboot 후 수동 Start 전 자동 기동 관측. +회사망 ARP는 임시 예외이며 필요시 수동복구한다. 실제 현장 적용·PDF·test용 범용화는 후속이다. +아래는 이전 시점 기록이며 Web500 대기 상태로 되돌아가지 않는다. + ## 연결 단절 전 ct 재사용 보존 Last reviewed: `2026-09-30 16:36:24 KST`. 기준 HEAD `6c621870ad17d3775685165afb9237755b79d55a`. diff --git a/tools/icerti_offline/maintenance/connection_versions.py b/tools/icerti_offline/maintenance/connection_versions.py index d927d70..b7c15fd 100644 --- a/tools/icerti_offline/maintenance/connection_versions.py +++ b/tools/icerti_offline/maintenance/connection_versions.py @@ -73,19 +73,20 @@ def show(profile): value = read(identity) print('Web:', identity['manager'], 'AI ID:', identity['server_id']) print('connection_version override:', web.get('connection_version', 'auto')) - for key in ('prepared', 'successful'): - print('Local', key, 'version:', value.get(key, {}).get('version', 'unknown')) + for key, label in (('prepared', 'Local prepared version:'), ('successful', 'Local successful version:')): + print(label, value.get(key, {}).get('version', 'unknown')) print('Web current version: UNKNOWN (no remote query or login).') print('Profile version=1 is its file format. It is NOT connection_version.') def configure(args): p = profiles.load() - show(p) if args == ['status']: + show(p) print('Set: ct config version NUMBER | auto (no login)') return if not args: + show(p) value=input('Connection version NUMBER / auto [Enter=keep current]: ').strip() if not value:return args=[value] @@ -102,4 +103,5 @@ def configure(args): else: profiles.web(p)['connection_version'] = version profiles.save(p) + show(profiles.load(p['id'])) print('Saved locally. No remote request. Next: ct config prepare') diff --git a/tools/icerti_offline/maintenance/field_diagnostics.py b/tools/icerti_offline/maintenance/field_diagnostics.py index 01bd6f8..823fa8b 100644 --- a/tools/icerti_offline/maintenance/field_diagnostics.py +++ b/tools/icerti_offline/maintenance/field_diagnostics.py @@ -14,7 +14,14 @@ def explain_result(result): print('HTTP:', result.get('http_status', 'unknown'), 'error:', result.get('error', 'unknown')) code = result.get('remote_code') print('Web code:', code or 'not recorded', 'field:', result.get('remote_field', '-')) - if code in ('CONNECTION_VERSION_CONFLICT', 'STALE_CONNECTION_VERSION'): + reason = result.get('remote_reason') + if result.get('phase') == 'CERTIFICATE' and reason == 'CERTIFICATE_SIGNING_NOT_CONFIGURED': + print('Web certificate signing is not configured. Ask the Web operator to configure its existing CA certificate/key paths and reload the application.') + print('Preserve this stage and session. Do not cancel, change the connection version or create a new CA. Retry once after the Web fix.') + elif result.get('phase') == 'CERTIFICATE' and reason == 'CERTIFICATE_SIGNING_FAILED': + print('Web certificate signing failed. Ask the Web operator to check CA/key validity, matching and file permissions.') + print('Preserve this stage and session. Do not cancel, change the connection version or create a new CA. Retry once after the Web fix.') + elif code in ('CONNECTION_VERSION_CONFLICT', 'STALE_CONNECTION_VERSION'): print('Connection revision rejected. Not a password error. Web current version is unknown.') print('Preserve failed stage: ct config cancel; ct config version NUMBER; ct config prepare') elif result.get('http_status') == 401: diff --git a/tools/icerti_offline/maintenance/field_ui.py b/tools/icerti_offline/maintenance/field_ui.py index da40068..cb40696 100644 --- a/tools/icerti_offline/maintenance/field_ui.py +++ b/tools/icerti_offline/maintenance/field_ui.py @@ -150,6 +150,15 @@ MESSAGES = { } KOREAN_MESSAGES = { + 'Local prepared version:':'로컬 마지막 준비 버전:', + 'Local successful version:':'로컬 마지막 발급 성공 버전:', + 'Editable source: network/webs/ports/redis_allow/media in the profile. Format version stays 1.':'직접 편집: 프로필의 network/webs/포트/redis_allow/media. 파일 형식 version은 1을 유지합니다.', + 'Connection revision: webs[].connection_version or ct config version NUMBER; then prepare.':'연결 버전: webs[].connection_version 또는 ct config version 번호로 지정 후 prepare합니다.', + 'Runtime site/Compose/env: supported imports only via ct emergency; do not edit active hashes or prepared files.':'실행 site/Compose/env: 지원 변경만 ct emergency로 수용합니다. active 지문/준비본은 직접 고치지 않습니다.', + 'Credentials and CA: use ct profile credentials / ct ca; never publish private files.':'계정/CA: ct profile credentials / ct ca를 사용하고 비공개 파일은 공유하지 않습니다.', + 'Web certificate signing is not configured. Ask the Web operator to configure its existing CA certificate/key paths and reload the application.':'Web 인증서 발급 설정이 없습니다. Web 담당자에게 기존 CA 인증서/서명키 경로 설정과 앱 반영을 요청하세요.', + 'Web certificate signing failed. Ask the Web operator to check CA/key validity, matching and file permissions.':'Web 인증서 서명 실패입니다. Web 담당자에게 CA/키 유효성·일치·읽기 권한 확인을 요청하세요.', + 'Preserve this stage and session. Do not cancel, change the connection version or create a new CA. Retry once after the Web fix.':'준비본과 세션을 보존하세요. cancel·버전 변경·새 CA 생성을 하지 말고 Web 조치 후 한 번 재시도하세요.', '26 Connection version 27 CPU/RAM resources 28 Language 29 ARP status':'26 연결 설정 버전 27 CPU/RAM 진단 28 언어 29 ARP 상태', '30 VSCode safety status (settings only; no automatic restart)':'30 VSCode 안전 설정 상태 (자동 재시작 없음)', '31 USB status (ct usb mount / ct usb unmount)':'31 USB 상태 (ct usb mount / ct usb unmount)', diff --git a/tools/icerti_offline/maintenance/network_prepare.py b/tools/icerti_offline/maintenance/network_prepare.py index 9335c3a..ecd598d 100644 --- a/tools/icerti_offline/maintenance/network_prepare.py +++ b/tools/icerti_offline/maintenance/network_prepare.py @@ -172,7 +172,7 @@ def enroll(stage): code=item.get('error') if isinstance(code,str) and __import__('re').fullmatch('[A-Z0-9_]{1,80}',code):summary['error']=code if type(item.get('status')) is int:summary['http_status']=item['status'] - for key in ('phase','remote_code','remote_field'): + for key in ('phase','remote_code','remote_field','remote_reason'): val=item.get(key) if isinstance(val,str) and __import__('re').fullmatch('[A-Za-z0-9_.]{1,80}',val):summary[key]=val except ValueError:pass diff --git a/tools/icerti_offline/maintenance/test_field_recovery.py b/tools/icerti_offline/maintenance/test_field_recovery.py index dfc4dce..7f85651 100644 --- a/tools/icerti_offline/maintenance/test_field_recovery.py +++ b/tools/icerti_offline/maintenance/test_field_recovery.py @@ -83,6 +83,56 @@ class RecoveryTests(unittest.TestCase): self.assertNotIn('remote_code',safe_error(b'{"error":{"code":"leaked-secret"}}','LOGIN',400)) self.assertNotIn('remote_code',safe_error(b'{"error":{"code":{}}}','LOGIN',400)) + def test_certificate_reason_is_allowlisted_and_phase_scoped(self): + raw=json.dumps({'error':{'code':'INTERNAL_ERROR','details':{ + 'reason':'CERTIFICATE_SIGNING_NOT_CONFIGURED','key':'private-value'}}}).encode() + result=safe_error(raw,'CERTIFICATE',500) + self.assertEqual(result['remote_reason'],'CERTIFICATE_SIGNING_NOT_CONFIGURED') + self.assertNotIn('private-value',json.dumps(result)) + self.assertNotIn('remote_reason',safe_error(raw,'LOGIN',500)) + for value in ('secret-value', 'UNREVIEWED_REASON', {}, ['CERTIFICATE_SIGNING_FAILED']): + raw=json.dumps({'error':{'code':'INTERNAL_ERROR','details':{'reason':value}}}).encode() + self.assertNotIn('remote_reason',safe_error(raw,'CERTIFICATE',500)) + + def test_saved_version_output_reads_new_value(self): + with tempfile.TemporaryDirectory() as root,patch.object(fp,'STATE',Path(root)),patch.dict(os.environ,{'CONTEXTRA_CLI_LANG':'en'}): + p=fp.create('office','fixture','eno1','192.0.2.1/24','192.0.2.42') + output=io.StringIO() + with patch.object(fp,'load',return_value=p) as load,patch.object(fp,'save') as save,patch('sys.stdout',output): + versions.configure(['2']) + self.assertEqual(p['webs'][0]['connection_version'],2) + save.assert_called_once_with(p);load.assert_called_with('office') + self.assertIn('connection_version override: 2',output.getvalue()) + self.assertNotIn('connection_version override: auto',output.getvalue()) + + def test_certificate_guidance_preserves_session_and_version(self): + import field_diagnostics + output=io.StringIO() + with patch.dict(os.environ,{'CONTEXTRA_CLI_LANG':'ko'}),patch('sys.stdout',output): + field_diagnostics.explain_result({'phase':'CERTIFICATE','http_status':500, + 'remote_code':'INTERNAL_ERROR','remote_reason':'CERTIFICATE_SIGNING_NOT_CONFIGURED'}) + self.assertIn('Web 인증서 발급 설정이 없습니다',output.getvalue()) + self.assertIn('준비본과 세션을 보존',output.getvalue()) + self.assertNotIn('ct config cancel;',output.getvalue()) + + def test_enrollment_summary_preserves_safe_reason(self): + import network_prepare + with tempfile.TemporaryDirectory() as root: + data=Path(root);stage=data/'site'/'fixture' + for name in ('tls','bridge-session'):(stage/name).mkdir(parents=True) + fp.atomic(stage/'record.json',{'base_active_sha256':'baseline','desired':{'ai_ip':'192.0.2.1'}}) + fp.atomic(stage/'host-enrollment.json',{}) + detail=safe_error(json.dumps({'error':{'code':'INTERNAL_ERROR','details':{ + 'reason':'CERTIFICATE_SIGNING_NOT_CONFIGURED','private':'must-not-log'}}}).encode(),'CERTIFICATE',500) + failed=Mock(returncode=2,stdout=json.dumps(detail),stderr='') + output=io.StringIO() + with patch.object(network_prepare,'DATA',data),patch.object(network_prepare,'sha',return_value='baseline'),patch.object(network_prepare.subprocess,'run',return_value=Mock(stdout='')),patch.object(maintenance_process,'run',return_value=failed),patch('sys.stdout',output): + with self.assertRaises(RuntimeError):network_prepare.enroll(stage) + result=fp.read_json(stage/'enroll-result.json') + self.assertEqual(result['remote_reason'],'CERTIFICATE_SIGNING_NOT_CONFIGURED') + self.assertEqual(result['attempt_count'],1) + self.assertNotIn('must-not-log',output.getvalue()) + def test_retry_cancel_does_not_archive_intent(self): with tempfile.TemporaryDirectory() as root,patch.object(fp,'STATE',Path(root)): stage=Path(root)/'stage';(stage/'bridge').mkdir(parents=True) diff --git a/tools/icerti_offline/source-manifest.json b/tools/icerti_offline/source-manifest.json index 9414836..c0205b8 100644 --- a/tools/icerti_offline/source-manifest.json +++ b/tools/icerti_offline/source-manifest.json @@ -1,8 +1,8 @@ { "schema_version": 1, - "baseline": "icerti-field-recovery-20260930", - "source_head": "f5b8b978e08311e8c5410e62320851ed5fb28c7e", - "source_evidence": "/tmp/codex/ltb1021/2026-09-30_icerti-field-fixes_r01", + "baseline": "icerti-preboot-20260930", + "source_head": "bc93be6fc42e9c2bb5196b980b015ba2e81a7869", + "source_evidence": "/tmp/codex/ltb1021/2026-09-30_icerti-preboot_r01", "files": [ { "path": "USB_README.ko.md", @@ -107,7 +107,8 @@ }, { "path": "maintenance/connection_versions.py", - "sha256": "c8bb2df181e53e4ae9a2c94d506b68c29f9f3890d9bbd6efb6ff20b5e3673a6c" + "sha256": "4fb9a5b2b78a7379d510894c82ccef02da0caa6da8c7b4c6a8ad3be1a8311461", + "previous_sha256": "c8bb2df181e53e4ae9a2c94d506b68c29f9f3890d9bbd6efb6ff20b5e3673a6c" }, { "path": "maintenance/ct", @@ -128,7 +129,8 @@ }, { "path": "maintenance/field_diagnostics.py", - "sha256": "0d1c70888dc2cd7bac51e902d917ab79b013c66b18fa28938fffeb9ca4e94102" + "sha256": "d752d785cb1e15364fb12afc2038ca0e0f1bac256869ef63512582f32e73c662", + "previous_sha256": "0d1c70888dc2cd7bac51e902d917ab79b013c66b18fa28938fffeb9ca4e94102" }, { "path": "maintenance/field_network.py", @@ -148,7 +150,8 @@ }, { "path": "maintenance/field_ui.py", - "sha256": "352a7a1eb52eb10ac4a4517b7c7af4be8aa386498fbf21a5039342aaff406b16" + "sha256": "a5f3ccc88c205908a035d3ef93c4a2194b9b8b9784899d6288c6ddee1f9fa613", + "previous_sha256": "352a7a1eb52eb10ac4a4517b7c7af4be8aa386498fbf21a5039342aaff406b16" }, { "path": "maintenance/field_usb.py", @@ -173,7 +176,8 @@ }, { "path": "maintenance/network_prepare.py", - "sha256": "99c9d62ccc0c0dff68a43ec8d7aa38c0c07e9961af28ac71ea3771468b24fd4f" + "sha256": "05dbf5a1eacbfd8b471e89b5609a72d2afee02fb26cfa0e854050f38308e074b", + "previous_sha256": "99c9d62ccc0c0dff68a43ec8d7aa38c0c07e9961af28ac71ea3771468b24fd4f" }, { "path": "maintenance/observability.py", @@ -193,7 +197,8 @@ }, { "path": "maintenance/test_field_recovery.py", - "sha256": "7a4891be6de6f5dc2d4154dcd44a2bb538eb06fba768c8023c94211c9892f148" + "sha256": "16ef49e4e4164b49755d000eb0f7ca4ac4a56cfc758545d61dca5b9cbcd38c5e", + "previous_sha256": "7a4891be6de6f5dc2d4154dcd44a2bb538eb06fba768c8023c94211c9892f148" }, { "path": "maintenance/test_network_overlay.py", @@ -279,7 +284,8 @@ { "path": "maintenance/bridge_tools/tools/secure_bridge_enroll.py", "repository_path": "tools/secure_bridge_enroll.py", - "sha256": "6be2927b3258e8414a327d7dd84341541e47f08a637ddaf8e997890d3020a1c7" + "sha256": "b17f42e2a296450cd8dd9e8ae64e5f8328d5202bc834287b7cd047a5a0e0562f", + "previous_sha256": "6be2927b3258e8414a327d7dd84341541e47f08a637ddaf8e997890d3020a1c7" }, { "path": "maintenance/bridge_tools/tools/secure_bridge_provision.py", diff --git a/tools/secure_bridge_enroll.py b/tools/secure_bridge_enroll.py index 1cf4fda..aa171c6 100644 --- a/tools/secure_bridge_enroll.py +++ b/tools/secure_bridge_enroll.py @@ -23,6 +23,10 @@ ERROR_CODES = frozenset({'AUTH_FAILED','TOKEN_INVALID','PASSWORD_CHANGE_REQUIRED 'STALE_CONNECTION_VERSION','INVALID_AI_CONNECTION'}) ERROR_FIELDS = frozenset({'advertisedIp','controlPort','redis','redis.host','redis.port','redis.db', 'redis.username','redis.password','redis.tls','bboxStreamKey','connectionConfigVersion','aiControlCa'}) +CERTIFICATE_REASONS = frozenset({'CERTIFICATE_SIGNING_NOT_CONFIGURED','CERTIFICATE_SIGNING_FAILED', + 'AI_CONNECTION_NOT_REGISTERED','ADVERTISED_IP_NOT_REGISTERED','CSR_REQUIRED', + 'CSR_MUST_NOT_CONTAIN_PRIVATE_KEY','CSR_INVALID','CSR_SIGNATURE_INVALID','CSR_KEY_TOO_WEAK', + 'CSR_KEY_ALGORITHM_UNSUPPORTED','CSR_KEY_UNREADABLE','AI_ACTOR_REQUIRED','SERVER_ID_REQUIRED'}) def safe_error(raw, phase, status): @@ -35,6 +39,9 @@ def safe_error(raw, phase, status): if error.get('code') in ERROR_CODES: result['remote_code'] = error['code'] details = error.get('details') + reason = details.get('reason') if isinstance(details, dict) else None + if phase == 'CERTIFICATE' and isinstance(reason, str) and reason in CERTIFICATE_REASONS: + result['remote_reason'] = reason ai = details.get('aiConnection') if isinstance(details, dict) else None if isinstance(ai, dict): if ai.get('code') in ERROR_CODES: