Browse Source

Publish startup readiness, UI performance, logging and demo sources

main
gudae 2 months ago
parent
commit
d7e63d6a9e
  1. 390
      DEMO_AUTH_MAIL_LOG_20260909.md
  2. 290
      DEMO_RUNBOOK_20260909.md
  3. 49
      FACTORY_RESET.md
  4. 68
      LOGD_DECISION_REVIEW_20260908.md
  5. 23
      WEB_LOG_FEATURES.md
  6. 27
      backend/BROWSER_ANIMATION_DIAGNOSIS_20260909.md
  7. 75
      backend/REPLACEMENT_UI_PERFORMANCE_20260909.md
  8. 61
      backend/STARTUP_TIMING.md
  9. 29
      backend/WCH_BOOT_STALL_20260909.md
  10. 1
      backend/cmd/tera-accessd/main.go
  11. 49
      backend/cmd/tera-api/factory_reset_cleanup.go
  12. 32
      backend/cmd/tera-api/factory_reset_cleanup_test.go
  13. 171
      backend/cmd/tera-api/main.go
  14. 2
      backend/cmd/tera-api/main_test.go
  15. 46
      backend/cmd/tera-api/startup_observation_test.go
  16. 33
      backend/cmd/tera-api/startup_retry_test.go
  17. 130
      backend/cmd/tera-api/startup_timing.go
  18. 43
      backend/deploy/buildroot/etc/init.d/S59snmpd
  19. 5
      backend/deploy/etc/tera/tera-accessd.env
  20. 1
      backend/deploy/etc/tera/tera-clone-demo-web.env
  21. 11
      backend/internal/adapters/mock/factory_auth.go
  22. 132
      backend/internal/adapters/mock/factory_reset.go
  23. 164
      backend/internal/adapters/mock/factory_reset_test.go
  24. 90
      backend/internal/adapters/mock/log_export_formats.go
  25. 18
      backend/internal/adapters/mock/log_port_scope.go
  26. 45
      backend/internal/adapters/mock/log_web_status.go
  27. 55
      backend/internal/adapters/mock/log_web_test.go
  28. 83
      backend/internal/adapters/mock/login_time_test.go
  29. 140
      backend/internal/adapters/mock/startup_runtime.go
  30. 42
      backend/internal/adapters/mock/startup_runtime_test.go
  31. 95
      backend/internal/adapters/mock/startup_test.go
  32. 205
      backend/internal/adapters/mock/store.go
  33. 14
      backend/internal/adapters/mock/store_test.go
  34. 25
      backend/internal/alarmdelivery/alarmsocket/socket.go
  35. 12
      backend/internal/alarmdelivery/alarmsocket/socket_test.go
  36. 4
      backend/internal/alarmdelivery/controller.go
  37. 13
      backend/internal/alarmdelivery/dispatcher.go
  38. 27
      backend/internal/alarmdelivery/dispatcher_test.go
  39. 37
      backend/internal/api/bootstrap_time_test.go
  40. 12
      backend/internal/api/captcha_test.go
  41. 37
      backend/internal/api/log_web_status.go
  42. 42
      backend/internal/api/log_web_test.go
  43. 64
      backend/internal/api/port_config_read_test.go
  44. 88
      backend/internal/api/router.go
  45. 112
      backend/internal/api/router_test.go
  46. 2
      backend/internal/api/serial_runtime_test.go
  47. 45
      backend/internal/api/startup.go
  48. 4
      backend/internal/authcaptcha/service.go
  49. 10
      backend/internal/authcaptcha/service_test.go
  50. 9
      backend/internal/domain/auth.go
  51. 1
      backend/internal/domain/models.go
  52. 15
      backend/internal/logservice/socket.go
  53. 30
      backend/internal/logservice/socket_test.go
  54. 44
      backend/internal/logstorage/factory_reset.go
  55. 50
      backend/internal/logstorage/factory_reset_test.go
  56. 16
      backend/internal/networkconfig/netsocket/socket.go
  57. 9
      backend/internal/networkconfig/netsocket/socket_test.go
  58. 20
      backend/internal/networkconfig/network.go
  59. 28
      backend/internal/networkconfig/os_controller.go
  60. 26
      backend/internal/networkconfig/os_controller_test.go
  61. 13
      backend/internal/secretstore/store.go
  62. 126
      backend/internal/snmpagent/device_test.go
  63. 46
      backend/internal/snmpagent/os_controller.go
  64. 6
      backend/internal/snmpagent/os_controller_test.go
  65. 63
      backend/internal/startup/clock.go
  66. 49
      backend/internal/startup/clock_test.go
  67. 54
      backend/internal/startup/retry_test.go
  68. 157
      backend/internal/startup/status.go
  69. 46
      backend/internal/startup/status_test.go
  70. 22
      backend/internal/supportbundle/manager.go
  71. 2
      backend/internal/systemtime/os_ntp_applier.go
  72. 152
      backend/internal/systemtime/settings.go
  73. 75
      backend/internal/systemtime/settings_test.go
  74. 51
      backend/internal/systemtime/timesocket/socket.go
  75. 13
      backend/internal/systemtime/timesocket/socket_test.go
  76. 54
      backend/internal/tlscert/bootstrap_test.go
  77. 83
      backend/internal/tlscert/repository.go
  78. 28
      backend/internal/update/controller.go
  79. 36
      backend/internal/update/manager.go
  80. 57
      backend/internal/update/manager_test.go
  81. 16
      backend/internal/update/updatesocket/socket.go
  82. 22
      backend/internal/webgateway/gateway.go
  83. 29
      backend/internal/webgateway/gateway_test.go
  84. 26
      demo-services/compose.yaml
  85. 10
      demo-services/syslog/Dockerfile
  86. 7
      demo-services/syslog/rsyslog.conf
  87. 17
      frontend/cmd/server/main.go
  88. 11
      frontend/internal/handlers/account_roles_test.go
  89. 15
      frontend/internal/handlers/admin_controls_test.go
  90. 84
      frontend/internal/handlers/device_handler.go
  91. 4
      frontend/internal/handlers/device_handler_integration_test.go
  92. 8
      frontend/internal/handlers/log_view_test.go
  93. 27
      frontend/internal/handlers/proxy_metadata_test.go
  94. 71
      frontend/internal/handlers/startup_status.go
  95. 51
      frontend/internal/handlers/startup_status_test.go
  96. 51
      frontend/internal/mock/mock_data.go
  97. 1
      frontend/internal/routes/routes.go
  98. 43
      frontend/internal/service/app_service.go
  99. 23
      frontend/internal/service/stateless_test.go
  100. 34
      frontend/web/static/css/style.css

390
DEMO_AUTH_MAIL_LOG_20260909.md

@ -0,0 +1,390 @@
# RADIUS·TACACS·메일·시스템 로그 실습 시연
이 문서는 2026-09-09 현재 PC의 기존 AAA 실습 환경과 장비 소스를 확인해서 작성했다. 전체 메뉴 안내는 `DEMO_RUNBOOK_20260909.md`를 참고한다.
## 1. 지금 준비된 것과 이번에 추가한 것
| 항목 | 주소 | 현재 확인 범위 |
|---|---|---|
| 장비 관리 UI | http://192.168.0.223/ | 관리 포트80 기준 |
| 기존 RADIUS 컨테이너 | 192.168.0.100:1645/UDP → 컨테이너1812 | Docker 실행 상태·포트 매핑 확인 |
| 기존 TACACS+ 컨테이너 | 192.168.0.100:49/TCP | Docker 실행 상태·포트 매핑 확인 |
| 새 Mailpit 수신 서버 | PC 1025/TCP, 수신함 http://127.0.0.1:8025/ | 구성 파일 추가, 이번 작업에서 실행·장비 수신 시험은 하지 않음 |
| 새 Syslog 수신 서버 | PC 1514/UDP 또는 TCP → 컨테이너514 | 구성 파일 추가, 이번 작업에서 실행·장비 수신 시험은 하지 않음 |
RADIUS와 TACACS는 이미 **별도 이미지·별도 컨테이너**다. 두 서버를 바꾸어 켤 필요 없이 함께 실행하고, 장비에서 인증 방식을 선택해 시연하면 된다. 메일·Syslog까지 총 네 컨테이너 구성이다.
**동시 실행과 동시 인증의 구분**
- 웹 로그인: `primary_method` 하나를 사용한다. RADIUS → TACACS 순서로 기본 방식을 바꿔서 시험한다. 한 번의 웹 로그인에서 두 서버를 모두 통과하는 구조가 아니다.
- Reverse SSH/Telnet: 포트의 동작 설정별로 RADIUS 또는 TACACS를 지정할 수 있다. 서로 다른 시험 포트/Listen Port로 나누면 나란히 시험할 수 있다.
- 장비 웹의 외부 인증은 Local fallback을 유지한다. 외부 인증 거부 뒤에도 같은 이름·암호의 로컬 계정이 있으면 로그인할 수 있다. 따라서 `labadmin`을 장비 로컬 사용자에 중복 등록하지 않는다.
- 포트 Reverse 외부 인증 경로는 실패 시 로컬 암호로 우회하지 않는다.
- 현재 구현은 인증 결과와 장비에 설정한 기본 역할을 사용한다. RADIUS 속성별 역할 매핑, TACACS 명령별 허가·Accounting, RADIUS Challenge/EAP/MFA 시연으로 확대해 설명하지 않는다.
- 시리얼로 연결한 상대 장비의 자체 로그인은 또 다른 인증이다. 통합콘솔의 Reverse 인증 성공 뒤 상대 장비가 아이디를 요구하는 것은 이중 오류가 아니라 별도 로그인일 수 있다.
## 2. PC 서버 준비: 복사해서 실행할 명령
### 2-1. 주소·컨테이너 확인
Docker Desktop을 Linux 컨테이너 모드로 실행하고 PowerShell을 연다.
```powershell
Set-Location C:\Users\temp\Desktop\tera_Clone_Demo
Get-NetIPAddress -AddressFamily IPv4
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
```
아래 예시는 PC LAN 주소가 `192.168.0.100`일 때다. 다른 주소면 모든 PC 서버 주소를 그 값으로 바꾼다. **장비의 서버 주소에 localhost, 127.0.0.1, Docker 내부 서비스 이름을 입력하면 안 된다.**
### 2-2. RADIUS·TACACS
현재 두 컨테이너가 정상 실행 중이면 시작 명령은 생략한다. 중지됐거나 새로 구성할 때:
```powershell
.\aaa-lab\start.ps1 -BindAddress 192.168.0.100
docker compose -f .\aaa-lab\docker-compose.yml ps
```
실행 파일은 각각 `aaa-lab/radius/Dockerfile`, `aaa-lab/tacacs/Dockerfile`, 묶음 구성은 `aaa-lab/docker-compose.yml`이다. 서버 계정은 각각 `radius/authorize`, `tacacs/tac_plus.conf`에 있다. 이 구성은 파일을 이미지에 복사하므로 계정을 바꿨다면 다시 빌드해야 반영된다.
기존 실습 전용 값:
| 입력 | 값 |
|---|---|
| 사용자 이름 | `labadmin` |
| 암호 | `LabAdminPassword-2026` |
| 서버 공유 Secret | `LabAaaSharedSecret-2026` |
이는 공개된 폐쇄망 실습 값이다. 실제 운영 자격증명이 아니다. Shared Secret은 사용자 암호와 다른 값이며, 장비와 서버가 동일하게 설정해야 한다.
### 2-3. 메일·Syslog 수신기
다음 명령은 새 이미지 다운로드/빌드와 컨테이너 시작을 수행한다. 인터넷이 없는 시연장에서는 미리 빌드해 둔다.
```powershell
$env:DEMO_BIND_ADDRESS = '192.168.0.100'
docker compose -f .\demo-services\compose.yaml config --quiet
docker compose -f .\demo-services\compose.yaml up -d --build
docker compose -f .\demo-services\compose.yaml ps
```
두 서비스가 실행된 뒤 PC에서 `http://127.0.0.1:8025/`를 열면 시험 메일함이 보인다. Mailpit의 메일함은 이 PC에만 공개했고 SMTP1025는 장비가 접근하는 PC LAN 주소에 공개했다.
### 2-4. 필요한 방화벽만 확인
필요 경로는 **장비192.168.0.223 → PC192.168.0.100**의 UDP1645, TCP49, TCP1025, UDP/TCP1514다. 브라우저용8025는 PC 내부에서만 사용한다.
차단될 경우 관리자 PowerShell에서 해당 장비만 허용하는 규칙을 추가할 수 있다. 다음은 규칙이 아직 없을 때의 예시다.
```powershell
New-NetFirewallRule -DisplayName 'TERA Demo TCP' -Direction Inbound -Action Allow -Protocol TCP -LocalAddress 192.168.0.100 -LocalPort 49,1025,1514 -RemoteAddress 192.168.0.223 -Profile Any
New-NetFirewallRule -DisplayName 'TERA Demo UDP' -Direction Inbound -Action Allow -Protocol UDP -LocalAddress 192.168.0.100 -LocalPort 1645,1514 -RemoteAddress 192.168.0.223 -Profile Any
```
이 PC에서는 과거 Docker의 명시적 차단 규칙이 허용 규칙보다 우선했던 기록이 있다. 추가 후에도 안 되면 기존 차단 규칙을 확인한다. 방화벽 전체를 끄지 않는다. 기존 `aaa-lab/fix-radius-firewall.ps1`은 과거 특정 규칙을 수정하는 스크립트이므로 현재 규칙·주소를 확인한 뒤 사용한다. `Test-NetConnection -Port`는 TCP 시험이라 RADIUS UDP 정상 여부를 판정할 수 없다.
## 3. RADIUS 웹 로그인 시연
### 화면 준비
- 관리자 브라우저: 현재 관리자 로그인 유지.
- 시험 브라우저: 별도 프로필/시크릿 창. 동일 시크릿 세션의 여러 창은 쿠키를 공유할 수 있으므로 각각 독립 세션인지 확인.
- PowerShell: 아래 서버 로그 표시.
```powershell
docker compose -f .\aaa-lab\docker-compose.yml logs --since 1m --follow radius
```
### 장비 입력값
관리자 운영 → RADIUS:
| 항목 | 입력 |
|---|---|
| 사용 | 켬 |
| 서버 | 192.168.0.100 |
| 포트 | **1645** |
| 공유 Secret | LabAaaSharedSecret-2026 |
| 타임아웃 | 5초 |
| 기본 역할 | 포트 운영자 |
적용 후 재조회한다. 이 실습은 호스트1645를 컨테이너1812로 연결했으므로 장비에1812를 입력하면 다른 경로를 시험하게 된다.
### 시연 A: 성공
1. 장비 로컬 사용자에 `labadmin`이 중복으로 없는지 확인한다.
2. 시험 브라우저에서 `http://192.168.0.223/`에 접속한다.
3. `labadmin` / `LabAdminPassword-2026`과 올바른 CAPTCHA로 로그인한다.
4. 장비 화면의 로그인 성공·포트 운영자 메뉴를 보여 준다.
5. 같은 시각의 RADIUS 로그에서 사용자 요청과 `Access-Accept`를 보여 준다.
**합격:** 장비 로그인 성공 + 실제 서버의 해당 요청 승인. 화면에 들어갔다는 사실만으로 합격하지 않는다.
### 시연 B: 잘못된 암호 거부
1. 시험 계정 로그아웃 후 새 CAPTCHA를 입력한다.
2. 암호만 `Wrong-Lab-Password`로 바꿔 1회 로그인한다.
3. 장비 로그인 거부와 서버 `Access-Reject`를 확인한다.
4. CAPTCHA 오류로 거부되면 외부 서버 요청 자체가 없을 수 있으므로 암호 거부 시험과 구분한다.
### 시연 C: 서버 중단과 Local fallback
다른 사람이 이 시험 서버를 사용하지 않는 시간에만 한다. 기존 세션은 서버를 끈다고 즉시 로그아웃되지 않으므로 **새 로그인**으로 검사한다.
```powershell
docker compose -f .\aaa-lab\docker-compose.yml stop radius
```
- 외부 전용 `labadmin`의 새 로그인은 실패해야 한다.
- 실제 로컬 관리자 계정의 새 로그인은 fallback으로 가능해야 한다. 관리자 이름·암호는 현재 장비 값을 사용한다.
- 기존 관리자 세션은 유지하고, 임계 횟수에 도달할 정도로 외부 계정 실패를 반복하지 않는다.
끝나면 즉시 복원한다.
```powershell
docker compose -f .\aaa-lab\docker-compose.yml start radius
```
복원 후 새 CAPTCHA로 외부 로그인 성공을 다시 확인한다. 타임아웃과 계정 잠금 상태를 구분한다.
## 4. TACACS+ 웹 로그인 시연
두 서버는 계속 함께 실행한다. 관리자 운영 → TACACS에서 다음을 적용한다.
| 항목 | 입력 |
|---|---|
| 사용 | 켬 |
| 서버 | 192.168.0.100 |
| 포트 | **49** |
| 공유 Secret | LabAaaSharedSecret-2026 |
| 타임아웃 | 5초 |
| 기본 역할 | 포트 운영자 |
```powershell
docker compose -f .\aaa-lab\docker-compose.yml logs --since 1m --follow tacacs
```
1. 새 시험 세션에서 동일 실습 계정과 올바른 CAPTCHA로 로그인한다.
2. TACACS 서버 로그의 같은 시각 인증 성공 기록과 장비 로그인 성공을 함께 확인한다. 서버 로그 문구는 버전마다 달라 RADIUS의 `Access-Accept`라는 문구를 찾는 것이 아니다.
3. 로그아웃 후 틀린 암호 1회 → 서버의 인증 거부 + 장비 로그인 거부를 확인한다.
4. 필요 시 `docker compose -f .\aaa-lab\docker-compose.yml stop tacacs`로 중단 → 외부 계정 새 로그인 실패 → 로컬 관리자 로그인 가능을 확인한다.
5. `docker compose -f .\aaa-lab\docker-compose.yml start tacacs`로 복원하고 외부 로그인을 재확인한다.
**주의:** TACACS를 기본으로 적용하면 웹 로그인은 TACACS를 사용한다. RADIUS 화면의 ‘사용’ 표시도 기본 방식 여부를 반영하므로 꺼져 보일 수 있다. 이것만 보고 RADIUS 컨테이너가 종료됐다고 판단하지 않는다. 두 서버 목록을 보존한 채 기본 방식만 바뀌는 흐름이다.
## 5. 두 인증 방식을 한 화면에서 나란히 시연하기
웹 로그인은 위처럼 순서대로 보여 주고, 동시 비교는 Reverse 접속으로 한다.
1. RADIUS를 먼저 사용·적용하고 TACACS도 사용·적용해 두 서버 정보를 저장한다. 마지막 기본 방식은 TACACS여도 된다. 이후 RADIUS를 명시적으로 사용 해제·저장하지 않는다.
2. 실제 연결된 시험 포트 A의 동작 설정1을 Reverse SSH, 인증 RADIUS로 지정한다.
3. 다른 시험 포트 B의 동작 설정1을 Reverse SSH, 인증 TACACS로 지정한다.
4. 서로 다른 미사용 Listen Port를 선택한다. 예시는 A=22001, B=22002이며 실제 사용 가능 여부를 확인한 후 적용한다. 웹80은 변경하지 않는다.
5. 데이터 포트는 상대와 동일한9600/8/N/1로 맞춘다.
6. PowerShell 창 두 개에서 각각 접속한다. 암호는 프롬프트에서 입력한다.
```powershell
ssh -p 22001 labadmin@192.168.0.223
```
```powershell
ssh -p 22002 labadmin@192.168.0.223
```
7. 최초 SSH 호스트 키 확인 시 대상 장비의 키인지 확인한다. 장비 변경 뒤 키 불일치가 나면 검증 없이 호스트 키 검사를 끄지 않는다.
8. RADIUS 창 접속 시 radius 로그, TACACS 창 접속 시 tacacs 로그에 인증 기록이 생기는지 보여 준다.
9. 각각 상대 장비의 무해한 조회 명령을 실행하고 실제 응답을 확인한다.
10. 잘못된 암호는 접속을 거부해야 한다. 인증 성공 후 응답이 없는 경우는 시리얼 배선·설정·IRQ 문제로 따로 조사한다.
**합격:** 각 경로에서 의도한 외부 서버 승인 → Reverse 세션 생성 → 실제 상대 응답. 상대 장비가 한 대뿐이면 순서대로 시험하고 ‘동시 양방향 검증’이라고 설명하지 않는다.
TACACS 서버 설정에 `priv-lvl=15`가 있어도 현재 장비가 그 값을 사용해 관리 권한을 부여하는 것은 아니다. 장비의 외부 인증 기본 역할을 기준으로 설명한다. 브라우저 포트 디버그만으로 Reverse 인증 방식 검증을 대신하지 않는다.
## 6. 메일 알림: 실제 수신함까지 보여 주기
### 장비 입력값
기타 설정 → 알림 → 메일 설정에서 다음을 저장한다.
| 항목 | 입력 |
|---|---|
| 메일 사용 | 켬 |
| SMTP 서버 | 192.168.0.100 |
| SMTP 포트 | **1025** |
| STARTTLS | 끔 |
| SMTP 인증 | 끔 |
| 발신자 | tera@example.test |
| 수신자 | demo@example.test |
| 참조 | 비워 둠 |
| 제목 | TERA 시연 알림 |
| 본문 | 장비 알림 시험: {{message}} |
Mailpit은 이 시험망에서 메일을 받아 PC 웹 화면에 보여 주는 수신기다. 주소의 실제 사서함이나 Gmail 계정이 필요하지 않으며 외부 수신함으로 자동 배달하지 않는다. 기본 SMTP는 인증·암호화 없이1025를 사용한다. [Mailpit SMTP 공식 문서](https://mailpit.axllent.org/docs/configuration/smtp/)
### 시연 A: 테스트 발송
1. PC에서 `http://127.0.0.1:8025/`를 연다.
2. 장비에서 설정을 적용하고 **테스트 발송**을 누른다.
3. 새 메일을 열어 수신 시각, 발신자, 수신자, 제목, 본문을 보여 준다.
4. 예전 메일을 성공으로 착각하지 않도록 현재 시각과 비교한다.
**합격:** 버튼 처리 성공 + Mailpit에 새 메일 도착. 이것은 SMTP 발송 경로 시험이며 자동 이벤트 알림 시험은 다음 단계다.
### 시연 B: 실제 이벤트에 의한 자동 메일
다음 중 한 가지를 먼저 리허설한다.
**방법 1 — 시리얼 수신 키워드(일반 시연 권장)**
1. 시험 포트 고급 설정에 `DEMO_ALARM_0909`를 저장한다.
2. 기타 설정 → 알림 → 시리얼 이벤트에서 같은 채널을 선택한다.
3. 수신 키워드 감지와 메일 전달을 켜서 적용한다.
4. 상대 장비에서 `DEMO_ALARM_0909`가 **장비 RX로 들어오도록** 전송한다. 터미널 TX로 보내기만 해서는 수신 조건을 입증하지 못한다. 에코 상대라면 돌아온 RX를 확인한다.
5. 포트 로그의 수신 문자열과 이벤트, Mailpit의 새 메일을 같은 시각으로 대조한다.
**방법 2 — 로그인 실패 임계값(시스템 이벤트 연계)**
1. 시스템 이벤트에서 로그인 실패 임계값 알림과 메일 전달을 켠다.
2. 장비의 현재 로그인 보호 횟수·감지 시간을 확인한다. 값을 모르면 임의로 3회라고 가정하지 않는다.
3. 관리자와 다른 전용 실패 시험 이름으로 올바른 CAPTCHA + 틀린 암호를 설정 임계 횟수만큼 시도한다.
4. 임시 잠금 발생 기록과 새 메일을 확인한다. 실제 관리자 계정으로 시험하지 않는다.
5. 해당 시험 계정의 잠금 해제 시간을 기록하고 이후 정상 인증 시연에는 사용하지 않는다.
정상 로그인이나 설정 변경이 모든 경우 메일로 발송되는 것은 아니다. 알림 규칙에 연결된 이벤트를 만들어야 한다.
### 실제 회사 메일로 전환할 때
회사 SMTP 서버가 제공하는 호스트·포트·인증·발신 허용 조건을 입력하고 외부 사서함에서 도착을 확인한다. 현재 발송 코드는 평문 연결 후 선택적으로 STARTTLS와 사용자/암호 인증을 한다. **465의 암묵적 TLS나 OAuth 전용 서버를 지원한다고 가정하지 않는다.** Mailpit 성공만으로 회사 메일 서버 인증까지 검증된 것은 아니다.
## 7. 시스템 로그: 사건을 만들고 찾아내는 시연
### 7-1. 먼저 기록 설정
기타 설정 → 시스템 로그 → 로그 이벤트에서 로컬 시스템·설정 변경·운영 기록을 켜고 적용한다. 시작 시각을 기록한다. 장비와 PC 시각을 맞춰 놓는다.
### 7-2. 세 가지 사건 생성
| 사건 | 실행 | 확인할 내용 |
|---|---|---|
| 인증 성공·실패 | 별도 시험 계정 정상 로그인 후 로그아웃, 틀린 암호로1회 시도 | 사용자, 시각, `auth.login`, succeeded/authentication_failed 등 결과 |
| 설정 변경 | 시험 포트 별칭을 `DEMO_LOG_0909`로 변경·적용 | 설정 변경 분류, 작업자, 포트/대상, 처리 결과 |
| 포트 운영 | 시험 포트 접속·무해한 문자열 송수신·해제 | 포트 운영/포트 로그의 채널·방향·원문 |
로그 메뉴 → 시스템 로그에서 시작 시각 이후를 검색한다. 종류 필터를 시스템/설정 변경/운영에 맞게 바꾼다. 별칭 문자열이 감사 메시지에 반드시 포함되는 것은 아니므로 안 나오면 사용자·대상 포트·시각으로 찾는다. RX/TX 원문은 포트 로그에서 확인한다.
### 7-3. 필터·다운로드·권한
1. 사용자 또는 시험 시각으로 검색하고 기대한 성공·실패 두 기록을 보여 준다.
2. 페이지 크기와 이전/다음 이동을 보여 준다.
3. 같은 검색 조건으로 CSV/JSON/JSONL을 내려받아 시각·행 내용이 일치하는지 확인한다. 내보내기 상한 때문에 전체 이력과 건수가 다를 수 있다.
4. 포트 운영자 계정으로 시스템 로그 접근이 거부되는지 확인한다. 허용 포트의 포트 로그 접근은 가능해야 한다.
5. 재부팅 후 시험 시각의 기록을 다시 찾아 보관 여부를 확인한다.
**설명 문장:** “메뉴를 보여 주는 데서 끝내지 않고, 방금 만든 성공·실패·설정 변경이 누가 언제 무엇을 했는지 기록되는지 확인합니다.”
## 8. 원격 Syslog: 장비 밖에 저장되는 것 확인
### 수신 화면 준비
```powershell
docker compose -f .\demo-services\compose.yaml exec syslog tail -n 30 -F /var/log/tera/remote.log
```
이 창을 시연 화면 옆에 둔다. `docker compose logs syslog`는 데몬 시작 오류 확인용이며 수신 메시지는 위 파일에서 확인한다.
### 장비 설정
기타 설정 → 시스템 로그 → 로그 서버:
| 항목 | 입력 |
|---|---|
| 원격 사용 | 켬 |
| 로그 서버 | **192.168.0.100:1514** |
| 프로토콜 | 우선 TCP |
| 원격 전송할 로그 | 시스템·설정 변경·운영·상태 중 시험 항목 켬 |
1. 적용 후 **새로운** 실패 로그인1회 또는 별칭 변경을 수행한다. 과거 이력이 자동 재전송된다고 가정하지 않는다.
2. 장비 로컬 기록과 PC 수신 파일에 같은 시각·사용자·대상 사건이 있는지 비교한다.
3. 장비의 전송 상태 새로고침에서 최근 상태·시도 횟수·오류를 확인한다.
4. UDP도 시연하려면 프로토콜을 UDP로 변경·적용하고 새 사건을 다시 만든다. 동일1514로 받는다.
**합격:** 실제 수신 파일에 해당 사건이 기록됨. 특히 UDP의 ‘전송 완료’는 수신 증거가 아니다. Docker NAT로 `from=`이 내부 주소로 보일 수 있으므로 메시지 내용·사용자·시각도 대조한다.
### 선택: 실패·복구 시연
TCP 모드에서 수신기를 중단한다.
```powershell
docker compose -f .\demo-services\compose.yaml stop syslog
```
새 시험 사건을 만들고 전송 실패/재시도 상태를 확인한다. 전송 처리 시간에 따라 즉시 바뀌지 않을 수 있다. 다시 시작한다.
```powershell
docker compose -f .\demo-services\compose.yaml start syslog
```
새 사건의 수신을 확인한다. 중단 중 사건까지 모두 복구됐다고 설명하려면 해당 사건도 파일에서 하나씩 확인한다. 무제한 재전송·유실 없음은 보장하지 않는다.
수신 기록 파일 보관:
```powershell
docker compose -f .\demo-services\compose.yaml cp syslog:/var/log/tera/remote.log .\demo-services\received-syslog.log
```
## 9. 실패했을 때 확인 순서
| 증상 | 우선 확인 |
|---|---|
| RADIUS 서버에 요청이 없음 | PC주소, UDP1645 매핑·방화벽, 웹 기본 인증 방식, CAPTCHA에서 먼저 거부됐는지 |
| RADIUS 요청은 있으나 응답 없음 | Shared Secret, 클라이언트 허용, Message-Authenticator 정책. 현재 클라이언트는 PAP이며 Challenge 미지원 |
| Access-Reject | 실제 서버 계정·암호, 로컬 계정 암호와 혼동 여부 |
| 서버가 승인했는데 웹 실패 | 장비 로그·응답 검증·세션 처리 확인. 서버 승인만으로 웹까지 합격하지 않음 |
| TACACS 로그 없음 | TCP49, 기본 방식, 서버 실행, 방화벽 |
| 외부 서버를 꺼도 로그인됨 | 기존 세션인지, 같은 이름의 로컬 계정으로 fallback 됐는지 |
| 포트 인증은 성공하나 RX 없음 | 상대 로그인·9600·배선·실제 응답·현재 IRQ95 상태 |
| 메일 테스트 실패 | SMTP1025와 웹8025 혼동, STARTTLS/인증 체크, 방화벽 |
| 테스트 메일만 오고 자동 메일 안 옴 | 해당 규칙 활성·메일 전달 선택, RX 키워드 또는 실제 임계 조건 충족 |
| Syslog 전송 완료인데 수신 없음 | UDP 결과 의미, 1514포트, 선택 분류, 사건이 설정 이후 생성됐는지 |
| 시스템 로그에서 데이터 원문 안 보임 | 포트 로그를 선택했는지, 해당 포트 RX/TX 기록 옵션 |
기존 RADIUS 구성은 Docker NAT 실습용 catch-all client와 Message-Authenticator 미요구 설정이다. 현행 장비 클라이언트의 호환 시험용이며 운영망 보안 구성을 충족한다고 소개하지 않는다. 운영 서버가 Message-Authenticator를 필수로 요구하면 클라이언트 개선이 필요할 수 있다.
## 10. 시연 순서·종료
**권장 진행:** 네 서버 준비 → RADIUS 성공/실패 → TACACS 성공/실패 → 두 Reverse 접속 비교 → Mailpit 테스트 수신 → 키워드 자동 메일 → 로그 검색·내보내기 → 원격 Syslog 수신 → 설정 원복.
- 서버 중단·복구는 기본 시연이 성공한 뒤 선택해서 한다.
- 장비의 외부 기본 인증·기본 역할·포트 인증·메일·로그 목적지·알림 규칙을 시연 전 값으로 복원한다. 무조건 Local로 바꾸는 것이 아니라 기존 상태를 기준으로 한다.
- 수신 증거를 먼저 보관한다. 아래 stop은 컨테이너를 중지하며 볼륨 데이터를 삭제하지 않는다.
```powershell
docker compose -f .\demo-services\compose.yaml stop
```
AAA도 종료하려면 다른 사용자가 없는지 확인한 뒤:
```powershell
docker compose -f .\aaa-lab\docker-compose.yml stop
```
이번 시연용으로 만든 방화벽 규칙만 제거하려면 관리자 PowerShell에서:
```powershell
Get-NetFirewallRule -DisplayName 'TERA Demo TCP','TERA Demo UDP' | Remove-NetFirewallRule
```
### 이전 문서·스크립트와의 차이
`aaa-lab/README.md`와 예전 원격 시험 스크립트에는 `:8083` 주소와 CAPTCHA 없이 로그인하는 흐름이 남아 있다. 이번 시연은 **HTTP80 + 실제 CAPTCHA 수동 로그인** 기준이며, 그 스크립트를 그대로 실행하지 않는다. 이 PC에 실행 중인 인증 컨테이너가 있다는 사실은 현재 교체 장비의 인증 성공을 이번에 재검증했다는 뜻이 아니다.
### 근거
- 로컬: `aaa-lab/docker-compose.yml`, `radius/authorize`, `radius/clients.conf`, `tacacs/tac_plus.conf` 및 실제 Docker 포트 조회.
- 구현: `backend/internal/externalauth/client.go`, `backend/internal/adapters/mock/store.go`, `backend/internal/alarmdelivery/transport.go`, `frontend/web/static/js/integration.js`.
- [FreeRADIUS 구성 파일](https://wiki.freeradius.org/config/Configuration-files)
- [현재 실습 TACACS 구현의 설정 문법](https://github.com/facebook/tac_plus/blob/main/tacacs-F4.0.4.28/tac_plus.conf.5.in)
- [Mailpit Docker 구성](https://mailpit.axllent.org/docs/install/docker/)
- [Mailpit SMTP 기본 동작](https://mailpit.axllent.org/docs/configuration/smtp/)
- [rsyslog UDP 입력](https://docs.rsyslog.com/doc/configuration/modules/imudp.html), [TCP 입력](https://docs.rsyslog.com/doc/configuration/modules/imtcp.html)

290
DEMO_RUNBOOK_20260909.md

@ -0,0 +1,290 @@
# TERA 장비 전체 기능 시연·검증 가이드
작성 기준: 2026-09-09, 현재 저장소의 화면·처리 코드 및 기존 장비 확인 기록.
RADIUS·TACACS 서버 실행, 장비 입력값, 성공·실패 시험, 메일 수신함, 로컬/원격 시스템 로그의 구체적인 실습은 [인증·메일·로그 상세 시연](DEMO_AUTH_MAIL_LOG_20260909.md)을 참고한다.
> 이 문서는 시연 절차다. 모든 기능이 현재 교체 장비에서 실측 검증됐다는 뜻은 아니다. 특히 외부 인증·메일·SNMP·DDNS, 전체 48포트 통신, 펌웨어 갱신·공장 초기화는 별도 환경과 실장비 검증이 필요하다.
## 1. 시연 구성과 합격 기준
권장 구성은 **핵심 시연 약 40분 + 외부 연동·전체 포트 검수 별도**다. 시간은 진행 계획이며 부팅·응답 시간 보장이 아니다.
| 순서 | 내용 | 계획 시간 | 핵심 증거 |
|---|---|---:|---|
| 1 | 장비 구성 소개, 부팅·시간·로그인 | 5분 | 준비 완료, CAPTCHA, 실제 시각 |
| 2 | 대시보드·포트 상태 | 3분 | 연결한 채널과 표시 일치 |
| 3 | 9600 설정·CR 감지·실제 양방향 통신 | 10분 | 상대 장비 수신 + 장비 RX/TX |
| 4 | 포트 설정·별칭·Reverse 접속 | 5분 | 저장 후 재조회, 실제 접속 |
| 5 | 사용자·권한·로그 | 7분 | 제한 계정 거부, 로그 검색·내보내기 |
| 6 | 준비한 외부 연동 1~2개 | 5분 | 수신 서버의 실제 결과 |
| 7 | 재부팅·설정 유지·초기 응답 재확인 | 5분 이상 | 유지된 설정, 재접속·재통신 |
**판정 원칙**
- 설정 저장 성공: 다시 조회했을 때 값이 유지된다.
- 기능 성공: 실제 상대 장비·외부 서버에서 결과를 확인한다.
- 영구 저장 성공: 재부팅 후에도 값과 동작이 유지된다.
- 준비 완료: 백엔드의 준비 조건을 충족한 상태다. 모든 케이블·상대 장비·외부 서비스의 정상 동작까지 보증하는 표시로 설명하지 않는다.
- 미연결 포트가 DOWN/미감지인 것은 정상일 수 있다. 48포트 모두 UP일 필요는 없다.
- 환경이 없거나 실제 확인하지 않은 기능은 **미검증**으로 기록한다. 성공 표시만 보고 합격 처리하지 않는다.
## 2. 시연 전 준비
### 기본값과 준비물
| 항목 | 준비·확인 |
|---|---|
| 관리 접속 | `http://192.168.0.223/`, HTTP 기본 포트 **80** 유지 |
| PC 네트워크 | 장비에 도달 가능한 주소·케이블 확인. 이전 PC 주소는 192.168.0.100이었으나 현재 값은 다시 확인 |
| 복구 콘솔 | 현재 구성 COM4 / 115200. USB 연결 변경 시 COM 번호 재확인 |
| 데이터 포트 | 시연 대상 **9600 / 8 / None / 1 / 흐름 제어 None**, 상대 장비와 동일하게 설정 |
| 시리얼 상대 | 실제 명령에 응답하는 시험 장비 또는 검증된 에코 시험기, 맞는 RS232 배선 |
| 계정 | 관리자 계정, 임시 port_operator 계정, 서로 다른 브라우저 프로필 |
| 자료 | 기존 설정 백업, 시연 대상 채널·배선표, 결과 기록표 |
| 선택 연동 | SMTP 수신함, SNMP 매니저·Trap 수신기, Syslog 서버, RADIUS/TACACS 서버, DDNS 계정 |
**관리 콘솔 115200과 데이터 포트 9600은 서로 다른 설정이다.** CR 감지 때문에 COM4 콘솔 속도까지 9600으로 바꾸지 않는다.
### 시작 전 반드시 한 번 리허설
1. 아래 표에 시험 채널을 확정한다. 이전에 동작한 채널 번호만 믿지 말고 현재 케이블을 확인한다.
2. 가능하면 CH01~CH24 중 한 포트, CH25~CH48 중 한 포트에서 각각 실제 통신한다.
3. 일반 시연은 확인된 포트를 사용한다. 전체 포트 검수는 48개 모두 별도 기록한다.
4. 포트 설정의 **적용 대상 추가/선택 목록**을 확인한다. 선택이 브라우저에 유지될 수 있으므로 실수로 여러 포트에 적용하지 않는다.
5. 설정 백업 파일과 복원 암호를 준비하고 현재 관리 IP·계정·배선 정보를 기록한다.
6. 외부 서버를 사용하는 기능은 시연 전에 해당 서버의 수신 화면까지 열어 둔다.
| 용도 | 실제 채널 | 상대 장비 | 연결 방식·응답 명령 |
|---|---|---|---|
| 주 시연 | CH____ | ____ | ____ |
| 반대 그룹 확인 | CH____ | ____ | ____ |
| 권한 거부 시험 | CH____ | ____ | 접근 금지 대상으로 지정 |
## 3. 부팅 → 시간 → 로그인
**설명 문장:** “장비가 설정을 적용하는 동안 진행 상태를 보여 주고, 준비가 완료되면 로그인할 수 있습니다.”
1. 시연 장비를 재부팅하고 시간을 잰다. PC 브라우저에서 관리 주소를 연다.
2. 설정 중 표시·경과 시간·진행률과 로그인 버튼 상태를 확인한다.
3. 준비 완료 시점을 기록한다. 매번 같은 초에 끝나야 한다고 설명하지 않는다.
4. CAPTCHA가 표시되는지 확인한다. 갱신 조작이 제공되면 새 이미지로 바뀌는지도 확인한다.
5. 임시 시험 계정으로 CAPTCHA 오입력을 한 번 시험하고 로그인이 거부되는지 확인한다. 무작정 반복해 잠금·알림을 유발하지 않는다.
6. 올바른 계정·암호·CAPTCHA로 로그인한다. 실패 후에는 새 CAPTCHA가 필요할 수 있다.
7. 날짜와 시간 메뉴에서 PC와 장비의 실제 시각을 비교한다.
**시간 확인:** 현재 설계에는 장비 시각이 초기화되지 않았을 때 로그인 전 PC 시각을 전달하는 흐름이 있다. 브라우저를 열지 않은 상태에서도 항상 PC 시간으로 맞춰진다고 설명하면 안 된다. NTP 사용 체크와 서버 주소 저장도 실제 동기화 성공과 다르다. 재부팅 후 실제 시각을 확인한다.
**합격:** 준비 전 로그인 제한, 완료 후 정상 로그인, CAPTCHA 확인, 실제 시각 정상. 이미지가 없거나 준비가 계속 멈추면 시연을 진행하기 전에 원인을 확인한다.
## 4. 대시보드·공통 UI·포트 상태
1. 대시보드에서 CPU·메모리 등 표시되는 자원 정보와 포트 상태를 확인한다.
2. 케이블 연결표와 포트 상태를 대조한다. CR 응답에 따른 상태는 단순 케이블 삽입만으로 즉시 UP이 되지 않을 수 있다.
3. 대시보드 → 포트 설정 → 로그 → 포트 상태를 이동한다. 메뉴 내용과 마지막 선택 메뉴가 일치하는지 확인한다.
4. 작업 탭 생성·전환·닫기, 사이드바 접기, 한국어/English 변경을 확인하고 한국어로 복원한다.
5. 화면에 표시된 초기값이 실제 설정 조회 후 다른 값으로 바뀌는 동안 성급하게 적용하지 않는다.
**성능 기록:** 부팅 직후, 로그인 직후, 로그인 1분 후 같은 메뉴를 이동해 클릭부터 실제 데이터 표시까지 각각 기록한다. 예를 들어 ‘3초 이내’ 같은 기준을 쓸 경우 시연 전에 합의한 목표로 명시한다. 현재 제품의 보장 수치로 소개하지 않는다.
## 5. 포트 매개변수·CR 감지·실제 통신
**설명 문장:** “통신 속도를 상대 장비와 맞추고, 상태 표시뿐 아니라 실제 데이터를 주고받는 것까지 확인하겠습니다.”
1. 포트 설정에서 시험 채널을 **개별 보기**로 선택한다.
2. 적용 대상이 시험 채널만인지 확인하고 `9600 / 8 / None / 1 / None`으로 적용한다.
3. 다른 메뉴에 갔다 돌아와 같은 값을 다시 확인한다. 일반 설정 폼은 적용·영구 저장 흐름을 사용하지만 오류 메시지가 나오면 저장 완료로 판단하지 않는다.
4. CR에 응답하는 상대 장비를 연결하고 포트 상태 갱신을 기다린다. 감지까지 걸린 시간을 기록한다.
5. **포트 디버그 → 채널 선택 → 접속**을 누른다.
6. 상대 장비에 맞는 무해한 조회 명령을 입력하고 Enter로 CR을 전송한다. 명령을 모르면 임의 명령 대신 시험용 에코 장치를 사용한다.
7. 상대 장비가 실제로 명령을 수신했는지, 응답이 터미널에 돌아오는지 확인한다.
8. 고급 기능에서 ASCII/HEX·끝 문자 없음/CR/LF/CRLF를 시험한다. 시험 문자열 예시는 `DEMO_20260909_A`, HEX 대응 예시는 `44 45 4D 4F`다. 실제 상대가 허용하는 입력만 보낸다.
9. 터미널의 로그 저장으로 파일을 내려받아 수신 내용이 있는지 확인한다.
10. 접속을 해제하고 다시 접속해 동일하게 통신한다. 두 번째 포트 그룹도 같은 절차로 확인한다.
**합격:** 상대 수신 + 응답 수신 + 해당 채널 로그의 방향·내용 일치. 터미널에 입력한 문자가 보이는 것만으로 RX 성공으로 판정하지 않는다.
**추가 확인:** ‘화면 지우기’와 터미널 고급 기능의 ‘로그 지우기’는 각각 화면/터미널 수집 내용의 정리 기능으로 확인한다. 중앙 로그 메뉴의 보관 데이터 삭제와 동일하다고 설명하지 않는다. Ctrl+C·Break 등은 상대 장비 작업을 중단시킬 수 있으므로 일반 시연에서는 사용하지 않는다.
## 6. 나머지 포트 기능
| 기능 | 시연 방법 | 합격 증거 |
|---|---|---|
| 별칭 | 진단 도구의 포트 별칭 또는 포트 매개변수에서 시험 별칭 저장 | 재조회한 별칭 일치 |
| 다중 적용 | 비어 있는 시험 포트 2개만 선택해 동일 설정 적용 | 두 포트 재조회, 비선택 포트 유지 |
| 매개변수 | 별도 상대에서 속도·패리티·흐름 제어 등을 쌍으로 변경 | 상대 설정과 일치할 때 송수신, 종료 후 9600 기준 복원 |
| 동작 설정 1·2 | 각각 화면에 표시된 Listen Port와 Reverse SSH/Telnet 모드 확인·설정 | PC 클라이언트에서 해당 주소·포트 접속 및 실제 시리얼 응답 |
| 모드 비활성화 | 시험 접속 모드를 disabled로 적용 | 해당 서비스 접속 차단, 원복 후 재접속 |
| 인증 방식 | 우선 Local로 시험, 외부 인증은 준비된 서버로 별도 시험 | 정상 계정 성공·잘못된 계정 거부 |
| 최대 클라이언트 | 시험 포트의 제한을 정하고 여러 독립 세션 접속 | 설정된 한도와 실제 접속 결과 일치 |
| 데이터 로그 | 읽기/송신 기록을 켜고 고유 문자열 송수신 | RX/TX 기록, 시간·방향·표시 옵션 확인 |
| 고급 키워드 | `DEMO_ALARM` 등록, 시리얼 이벤트의 키워드 알림도 활성화 | 실제 수신 데이터와 일치하는 이벤트 발생 |
Reverse 접속용 포트는 화면에 저장된 실제 값을 사용한다. 관리 웹 포트 80과 혼동하지 않는다. SSH/Telnet 접속 성공만으로 실제 시리얼 응답 성공을 대신하지 않는다. 키워드는 대소문자를 무시하는 문자열 포함 방식이며 정규식으로 소개하지 않는다.
## 7. 사용자·권한·비밀번호
1. 관리자 운영 → 사용자에서 임시 `port_operator` 계정을 만든다. 실제 비밀번호는 시연 자료에 적지 않는다.
2. 시험 포트 A만 허용하고 포트 B는 허용하지 않는다. 그룹 권한과 개별 권한을 함께 확인한다.
3. 별도 브라우저 프로필에서 임시 계정으로 로그인한다.
4. 허용 포트의 조회·디버그·로그 접근을 확인한다.
5. 비허용 포트 접근은 거부되는지 확인한다. 메뉴가 안 보이는 것만 확인하지 말고 해당 화면 직접 접근도 시험한다.
6. 관리자 운영, 시스템 로그 등 관리자 전용 기능에 접근할 수 없는지 확인한다.
7. 임시 계정의 비밀번호를 변경하고 로그아웃 후 새 비밀번호로 로그인한다. 이전 비밀번호는 거부되어야 한다.
8. 관리자에서 임시 계정을 비활성화하고 **새 로그인**이 거부되는지 확인한다. 기존 세션 종료 정책은 별도로 관찰해 기록한다.
9. 시연 종료 후 임시 계정·그룹을 정리한다.
현재 UI의 역할은 administrator와 port_operator를 기준으로 설명한다. 별도 Viewer 역할이 있다고 소개하지 않는다.
## 8. 로그 전체 확인
1. 시스템 로그, 포트 로그, 포트 상태 이벤트 화면을 각각 연다.
2. 앞서 생성한 로그인·설정 변경·송수신·연결 상태 변화 중 실제 제공되는 기록을 찾는다.
3. 시간·포트·사용자·방향·종류·키워드 필터를 각각 적용한다. `DEMO_20260909_A`로 송수신 기록을 식별한다.
4. 페이지 크기와 이전/다음 이동을 확인한다.
5. 동일 조건으로 CSV·JSON·JSONL을 내려받아 시험 문자열·시각·채널·방향을 확인한다. 내보내기 건수 제한 안에서 화면 필터와 일치하는지 비교한다.
6. 운영자 계정에는 허용 포트 로그만 보이는지 확인한다.
7. 보존 설정과 저장소 사용량을 확인한다. 보존 기간은 설정값이며 실제 그 기간의 기록이 모두 존재한다는 증거는 아니다.
8. 삭제는 필요한 경우 마지막에 시험 포트의 폐기 가능한 로그로만 한다. 보호된 감사 로그는 일반 삭제 대상으로 소개하지 않는다.
보존 기간을 줄이면 오래된 기록이 지워질 수 있으므로 일반 시연에서는 현재 값을 유지한다. 저장소 용량·발생량에 따른 실제 보존 가능 기간도 설정 기간과 구분한다.
## 9. 기타 설정·외부 알림
| 메뉴 | 준비·실행 | 성공 판정 |
|---|---|---|
| 날짜와 시간 | Asia/Seoul, NTP 설정 확인. 필요 시 ‘현재 PC 시간으로 동기화’ | 실제 시각 일치 및 재부팅 후 재확인 |
| NTP | 도달 가능한 외부 또는 내부 서버 사용 | 설정 저장 외에 실제 동기화 상태·시각 확인 |
| 메일 | SMTP 주소·포트·TLS·인증·수신자 설정 후 테스트 발송 | 실제 수신함 도착·제목·내용 일치 |
| SNMP Trap | 수신기 주소·버전·인증 설정 후 테스트 전송 | 수신기의 Trap 확인 |
| 원격 Syslog | 서버·UDP/TCP·대상 이벤트 설정 후 시험 이벤트 생성 | 원격 서버의 해당 이벤트 확인 |
| 시스템 이벤트 | 준비된 이벤트 하나를 안전하게 발생 | 로컬 기록과 선택한 알림 채널의 실제 수신 |
| 시리얼 키워드 이벤트 | 시험 포트 키워드·전달 채널 설정 후 상대에서 문자열 전송 | 해당 포트 키워드 이벤트 및 실제 수신 |
| DCD/DSR 이벤트 | 지원·배선이 확인된 시험 장비로 신호 변경 | 신호 변화와 이벤트 일치 |
UDP 전송 성공 표시는 수신 서버 도착을 보장하지 않는다. 로컬/원격 기록 대상 선택, 메일/Trap/Syslog 전달 선택을 모두 확인한다. DCD/DSR은 현재 배선·보드 지원 확인 없이는 성공을 약속하지 않는다.
## 10. 관리자 운영의 외부 연동·인증서
| 기능 | 실행 순서 | 완료 증거·주의점 |
|---|---|---|
| RADIUS | 준비된 서버 주소·포트·공유 비밀·역할·Local fallback 저장 → 시험 계정 인증 | 실제 서버 인증 기록 + 정상/오류 계정 결과. 로컬 관리자 접근 경로 유지 |
| TACACS | 서버 설정 → 실제 서버 계정 인증 | 서버 기록 + 허용 역할·접속 범위 일치 |
| 주소 필터 | 현재 관리자 PC를 고려해 시험 규칙 구성 → 두 번째 PC로 차단 검증 | 허용 PC 접근 가능, 차단 PC 접근 거부. 원복·콘솔 경로 확보 |
| SNMP Agent | v1/v2c/v3 중 준비한 방식 설정 → 매니저에서 조회 | 장비 식별/가동 시간 등 실제 응답. Trap 수신과 별도 시험 |
| SSL 자체 서명 | 장비 시각 확인 → 시험 인증서 생성 → 목록·활성 상태 확인 | HTTPS에서 실제 제공되는 인증서 확인. HTTP80 화면만으로 TLS 검증 불가 |
| SSL 가져오기 | 일치하는 PEM 인증서·개인키 가져오기 | 정상 등록·활성화·HTTPS 인증서 일치 |
| CSR | 입력값으로 CSR 생성·다운로드 | 파일 존재·입력 정보 확인. 발급 완료 인증서와 구분 |
주 시연은 HTTP80을 유지한다. HTTPS 시험은 활성화된 별도 접속 주소와 인증서 신뢰 조건을 먼저 확인한 뒤 수행한다. 자체 서명 인증서는 브라우저 신뢰 경고가 발생할 수 있다. 외부 인증 서버 장애 시 fallback 동작은 실제 설정·정책대로 시험하며 무조건 로그인 가능하다고 설명하지 않는다.
## 11. 네트워크·진단 도구
일반 시연에서는 **현재 관리 주소와 포트 80을 유지하고 조회·설명 위주**로 진행한다. 실제 네트워크 전환은 별도 검수 시간에 한다.
| 메뉴 | 확인 방법 | 실제 변경 시 판정 |
|---|---|---|
| 인터페이스 1·2 | DHCP/정적 IP·마스크·게이트웨이·DNS 확인 | 적용 후 의도한 주소로 재접속, 재부팅 후 유지 |
| IPv6 | Auto/Disable/Static과 주소·prefix 확인 | IPv6 시험 PC에서 실제 도달 확인 |
| 호스트 | 시험 호스트명 적용·재조회 | 저장된 이름 확인, 사용하는 이름 해석 경로가 있으면 별도 확인 |
| Single/OOB | 주·대체 인터페이스, 감시 대상·주기·실패 횟수 확인 | 별도 회선에서 주 경로 단절 → 대체 경로 → 복구 실측 |
| Bonding | 모드·링크 감지 설정, 대응 스위치·배선 준비 | 링크 장애 시 설정 모드에 맞는 통신 유지·복구 |
| Bridge | 연결 토폴로지·STP 설정 검토 | 시험망에서 실제 브리지 전달·복구 확인 |
| DDNS | 제공자 계정·도메인·IPv4/6 조건 설정 | 실제 DNS 조회 결과와 업데이트 대상 주소 일치 |
| Ping | 응답 가능한 PC/시험 서버 지정, 횟수 설정 | 실제 응답·손실률 확인. 대상 방화벽의 ICMP 허용 확인 |
| 포트 별칭 | 시험 채널 이름 수정 | 해당 포트 재조회 시 같은 이름 |
DHCP·IP·OOB·Bonding·Bridge·주소 필터는 현재 세션을 끊을 수 있다. 변경 전 새 접속 주소와 복구 콘솔을 준비한다. 단순 저장 성공은 회선 전환·DNS 갱신 성공의 증거가 아니다.
## 12. 백업·복원·재부팅
### 일반 시연에 적합한 백업·복원
1. 기준 상태의 시험 포트 별칭을 기록한다.
2. 백업 내보내기에서 암호를 지정해 설정 파일을 내려받는다. 암호는 별도 보관한다.
3. 시험 별칭을 다른 이름으로 변경한다.
4. 백업 가져오기/복원에서 파일과 암호를 지정한다. 일반 시연은 **네트워크 포함을 끈 상태**로 진행한다.
5. 복원·적용 후 기준 별칭이 돌아왔는지 확인한다. 계정 등 다른 설정도 복원 범위에 영향을 받을 수 있으므로 시험용 백업을 사용한다.
설정 백업은 펌웨어 전체 이미지나 모든 로그의 백업과 동일하지 않다.
### 저장 후 재시작 및 영구 저장 확인
1. 시험 별칭·9600·사용자 권한·로그 옵션 등 확인할 값을 기록한다.
2. 관리자 상단의 저장 후 재시작을 실행한다.
3. 같은 HTTP80 주소에 다시 접속하고 준비 상태·경과 시간을 확인한다.
4. CAPTCHA로 로그인하고 기록한 값을 다시 조회한다.
5. 실제 송수신을 다시 수행한다. 메뉴 응답 시간·CPU도 같은 조건에서 기록한다.
6. 로그아웃 후 보호된 화면 재접근 시 로그인 요구를 확인한다.
## 13. 펌웨어·공장 초기화는 별도 마지막 시험
### 펌웨어
- 현재 장비와 호환되는 **서명된 `.pkg`**가 있을 때만 시험한다.
- `build/linux-arm64`의 실행 파일은 웹 펌웨어 업로드용 패키지와 같지 않다.
- 현재 버전·복구 방법·백업 확보 → 패키지 업로드 → 검증/적용 완료 확인 → 재접속 → 실제 버전 및 핵심 통신 재확인 순서다.
- 업로드 100%는 업데이트 완료가 아니다. 교체 장비의 업데이트 경로는 이번 문서 작성으로 검증된 것이 아니다.
### 공장 초기화
- 사용자·설정·로그·인증서 등을 지우고 재부팅하는 시험이므로 주 시연 종료 후 별도 장비에서 수행한다.
- 현재 구현 계약상 확인 문구는 `공장 초기화`다. 실행 전 백업, 초기 계정 정책, 초기 네트워크 주소, 콘솔 복구를 확인한다.
- 초기화 후 `192.168.0.223`에 그대로 접속된다고 가정하지 않는다.
- 초기 설정·로그인·사용자 데이터 제거·핵심 통신을 확인한다. 현재 교체 장비의 전체 초기화 연동은 별도 검증 대상으로 둔다.
## 14. 현재 성능·커널 이슈를 설명하는 방법
“웹 로그인 진행 표시의 불필요한 애니메이션 부하는 개선했습니다. 기존 기록에서 CPU가 약 41~44%였던 조건이 수정 후 짧은 측정에서는 약 11~14%였습니다. 다만 재부팅 중 RCU stall과 시리얼 IRQ95 차단 기록은 별도 커널 문제로 남아 있어, 실제 포트 통신과 부팅 직후 응답은 계속 확인해야 합니다.”
- 위 수치는 과거 특정 조건의 측정이며 현재 모든 상황의 보장값이 아니다.
- IRQ95 차단이 발생한 상태에서 통신이 실패하면 웹 설정 완료 표시만 보고 정상이라고 설명하지 않는다.
- 문제 발생 시 시각·채널·요청·증상과 전체 콘솔 로그를 함께 기록한다. 웹 변경만으로 커널 문제가 해결됐다고 단정하지 않는다.
- 정상 시연 전 재부팅 후 실제 송수신 리허설을 통과해야 한다.
## 15. 결과 기록표
결과는 `합격 / 실패 / 미검증 / 해당 없음` 중 하나로 작성한다.
| 시험 | 결과 | 증거·측정값 |
|---|---|---|
| 부팅 준비·시간·CAPTCHA·로그인 | | 준비 __초, 시각 차이 __초 |
| 부팅 직후/1분 후 UI 응답 | | 각각 __초 / __초, CPU __% |
| 대시보드·상태와 배선 일치 | | 채널 __ |
| 9600 적용·재조회 | | 채널 __ |
| CH01~24 그룹 실제 RX/TX | | 채널 __, 로그 파일 __ |
| CH25~48 그룹 실제 RX/TX | | 채널 __, 로그 파일 __ |
| ASCII/HEX·CR/CRLF | | 상대 수신 __ |
| Reverse SSH/Telnet·접속 한도 | | 모드 __, Listen Port __ |
| 별칭·다중 적용 | | 대상 __ |
| 계정·권한·비밀번호·로그아웃 | | 허용 __, 거부 __ |
| 로그 필터·내보내기·권한 | | 파일 __ |
| 시간 동기화 | | 서버/PC __, 실제 시각 __ |
| SMTP·Trap·Syslog | | 실제 수신 기록 __ |
| 키워드·시스템·신호 이벤트 | | 이벤트 __ |
| RADIUS/TACACS | | 서버 기록 __ |
| SNMP 조회·SSL/CSR | | 응답/인증서 __ |
| 네트워크·DDNS·Ping | | 실제 도달/조회 __ |
| 백업·복원 | | 복원 전후 값 __ |
| 재부팅 후 유지·실제 재통신 | | 결과 __ |
| 펌웨어·공장 초기화 | | 별도 시험 여부 __ |
전체 48포트 검수는 아래 한 줄을 채널마다 복제해 작성한다. 두 그룹에서 한 개씩 성공한 것만으로 전체 48개 합격으로 처리하지 않는다.
`CH__ | 상대 장비 __ | 9600/8/N/1 __ | CR 응답 __ | TX __ | RX __ | Reverse __ | 로그 __ | 재부팅 후 __ | 판정 __`
## 16. 종료 전 원복
- 시험 포트 설정을 합의한 기본값으로 복원하고 다중 적용 선택을 해제한다.
- 임시 계정·알림 수신처·키워드·접속 세션을 정리한다.
- 관리 주소·HTTP80, 기준 데이터 속도9600을 다시 확인한다.
- 결과표와 로그·내보내기 파일을 보관한다. 비밀번호·공유 비밀·개인키는 시연 자료에서 제외한다.
- 미검증 항목은 그대로 남겨 후속 검수 대상으로 전달한다.
### 근거 문서
- `backend/BROWSER_ANIMATION_DIAGNOSIS_20260909.md`: 브라우저 부하 비교·수정 기록.
- `backend/REPLACEMENT_UI_PERFORMANCE_20260909.md`: 교체 장비 성능·배포 조사 기록.
- `WEB_LOG_FEATURES.md`: 로그 기능·권한·검증 범위.
- `FACTORY_RESET.md`: 초기화 동작 계약·관련 범위.
- `frontend/internal/mock/mock_data.go`, `frontend/web/static/js/integration.js`, `frontend/web/templates/partials/block_serial-terminal.html`: 메뉴·설정 처리·터미널 조작의 소스 기준. 화면 기본 예제 값은 실장비 값과 구분했다.

49
FACTORY_RESET.md

@ -0,0 +1,49 @@
# 테라클론 공장초기화 계약
웹 `POST /api/v1/system/factory-reset`과 SW2 모니터는 동일한 `Store.FactoryReset`을 호출한다.
초기화 후 기본 계정은 `terauser / terauser` 하나다. 고객 계정과 사용자 정의 그룹, 로그인 세션/토큰/실패 기록, 주소 필터, 장비 설정과 보류 변경, 저장 인증정보, 고객 TLS 키와 인증서, 과거 로그와 내보내기, 진단 파일, 알림 전송/재시도 기록, 업로드된 펌웨어 패키지를 삭제한다. TLS 기본 키는 새로 생성한다.
설정 저장을 마친 후 기존 시스템 제어 서비스에 재부팅을 요청한다. 설치된 펌웨어와 제조사 업데이트 검증 키는 유지한다. 공장초기화 자체 및 다음 부팅에서 새로 발생한 시스템/감사 이벤트는 새 기록이다. 파일시스템 상의 삭제이며 저장 매체의 포렌식 보안 소거 기능은 아니다.
## 구현
- frontend 서버는 SQLite를 열지 않고 사용자·장비 데이터를 tera-api로 전달한다. 기존 로컬 계정 API와 터미널용 legacy 경로는 서버에 등록하지 않는다.
- 이전 설치의 `/var/lib/teraweb/frontend.db` 및 `/var/lib/tera-clone-demo-web/teraclone.db`와 `-wal`, `-shm`, `-journal`은 공통 초기화에서 정리한다.
- 인증 파일 로딩은 초기 계정 목록에 병합하지 않고 저장된 목록으로 교체한다. 재기동 시 admin 등 샘플 계정이 재생성되지 않는다.
- 일반 로그 삭제의 감사 로그 보호 정책과 공장초기화는 별도다. `preserve_on_factory_reset`은 false이며 true 설정은 거부한다.
- reset은 진행 중인 HTTP 변경을 기다리고, 완료 후 재부팅 전 추가 변경을 거부한다. 진단·알림 작업을 종료한 뒤 결과 파일과 재시도 상태를 지운다.
- 네트워크 기본 설정은 재부팅 시 적용되도록 저장한다. 기존 rollback snapshot/timer가 기본값을 다시 덮어쓰지 못하도록 제거한다.
- 초기화 중 삭제/저장/서비스 오류가 있으면 성공을 반환하지 않는다. 펌웨어 업그레이드 진행 중에는 초기화를 거부한다. 저장 장치 오류 시 이미 삭제한 데이터는 자동 복구되지 않는다.
## 함께 배포할 구성
`build/factory-reset-linux-arm64/`에 다음 ARM64 실행 파일을 빌드했다.
- tera-api (테라클론 API 설치 이름은 tera-clone-demo-api)
- tera-clone-demo-web
- tera-logd, tera-netd, tera-alarmd, tera-updated, tera-snmpd
API만 교체하면 이전 보조 서비스가 새로운 factory_reset 명령을 지원하지 않을 수 있으므로 함께 교체한다. clone 전용 alarmd 인스턴스에도 같은 alarmd 빌드를 사용한다. 웹 env의 TERACLONE_DB_PATH는 제거했다.
80 포트의 별도 teraWeb/8082 API가 계속 설치되어 있으면 해당 서비스에도 동일 소스의 변경을 통합해야 한다. 수정 대상은 tera_Clone_Demo 소스다. 2026-09-08 해당 웹/API와 보조 서비스 8개를 장비에 배포했다. 기존 별도 tera-web/tera-api 실행 파일은 교체하지 않았다. 장비의 수동 배포 백업 디렉터리나 다른 프로그램의 파일을 임의로 재귀 삭제하지 않는다.
## 검증
- 임시 저장소에 계정·세션·그룹·로그·비밀정보·고객 인증서·진단 파일을 넣고 초기화한 후 재개방: terauser/terauser만 로그인 성공, 이전 세션/계정 거부.
- 로그 네 종류(audit/system/port/link): 로그 서비스 소켓을 통한 삭제, 디스크 원문 제거, 재개방 후 빈 기록 검증.
- 네트워크 pending transaction/rollback 제거 및 재부팅용 파일 저장 검증.
- 알림 전송 기록과 재시도 상태: 재기동 후 복구되지 않음 검증.
- 업로드된 서명 펌웨어 패키지 삭제 및 현재 설치 버전 유지 검증.
- SQLite sidecar 삭제, 비관련 파일 보존, stateless frontend의 로컬 계정 생성 거부 검증.
- API 권한, reset 완료 후 쓰기 차단, SW2 모니터 호출 경로, 한국어/영어 안내와 프론트엔드 회귀 테스트 통과.
- ARM64 실행 파일 7개 빌드 완료. 실장비 서비스 배포·재시작 및 HTTPS/해시 검증을 완료했다. 공장초기화와 장비 재부팅 자체는 실행하지 않았다.
배포 백업: `/userdata/tera-backups/factory-reset-deploy-20260908-104450`. 배포 중 발견된 기존 updater 상태 파일의 `apply_policy` 호환성을 수정하고 회귀 테스트를 추가했다.
## Factory defaults correction (2026-09-08)
CAPTCHA defaults to enabled, including after factory reset and state reload. The 48 serial ports default to 9600 8N1; the COM5 management console stays at 115200. On-device checking after setting 9600 confirmed CR response on CH18, the only connected port. Login autofill uses username/current-password.
## PC clock synchronization (2026-09-08)
The first successful browser login with time.sync permission after API service startup submits PC UTC time through the existing tera-timed boundary. Synchronization precedes session issuance and retains the configured device timezone and NTP settings. Later logins do not override the clock again. Failed synchronization is audited and retried at the next authorized login. Browser access is required; no PC clock is available to the device while the PC/browser is disconnected. No public unauthenticated clock setter is exposed.

68
LOGD_DECISION_REVIEW_20260908.md

@ -0,0 +1,68 @@
# tera-logd 의사결정 질문서 구현 대조
**후속 사용자 결정 반영:** 원문 저장 유지(D-01), 관리자 보존기간 변경 허용(D-02), 포트 운영자에게 허용 포트 조회·다운로드만 허용(D-10), 공장초기화 전체 삭제 유지(D-11)가 승인됐습니다. 아래 표는 결정 이전의 질문서 대비 차이 기록이며, 현재 웹 구현은 WEB_LOG_FEATURES.md를 참조하세요.
2026-09-08 · 대상: 2026-07-24 질문서와 현재 tera_Clone_Demo 소스
**판정: 질문서의 권고 기준을 모두 충족하지 않는다.** 질문서에는 선택·승인란이 비어 있다. 권고안을 확정 요구사항으로 간주하지 않았으며, 실제 코드에서 확인한 일치·차이와 시험 증거 부족을 구분했다. 첨부 원문은 수정하지 않았다.
공장초기화는 이후 사용자의 명시 지시인 **기존 데이터 삭제, 기본 계정 terauser/terauser만 재생성**이 우선한다. 질문서 D-11의 로그 보존을 되살리면 최신 지시를 위반한다. 초기화·부팅 이후 새로 발생하는 감사 로그는 생성된다.
## 우선 확인할 차이
1. **원문 저장(D-01)**: 시리얼 RX/TX payload를 base64로 실제 저장한다. metadata-only 권고와 다르며, base64는 암호화가 아니다. `backend/cmd/tera-seriald/main.go:267`의 `recordPayload`, `backend/internal/adapters/mock/store.go`의 `RawUARTPayloadCapture: true`로 확인했다.
2. **365일 보장(D-02, D-13)**: 기본값은 365일이나 API가 1~365일 변경을 허용한다. 용량 부족 시 system/port의 오래된 세그먼트를 기간 만료 전에도 회수할 수 있다. `logstorage.ensureCapacityLocked`, `reclaimOldestLocked`, `validateRetentionDays`가 근거다. 기본값 365일은 최소 365일 보장과 다르다.
3. **쓰기 정책(D-05)**: `logstorage.Store.Append`는 각 레코드에 `file.Sync()`를 호출한다. 질문서의 일반 레코드 배치 원칙과 다르다. 또한 `recordAuditEventLocked`가 `persistAuditLogLocked`의 오류를 무시하므로 모든 변경 명령에 durable audit 성공을 보장한다고 할 수 없다.
4. **검색 인덱스(D-14)**: 실제 인덱스는 `index/log-index-v1.json`이다. 재구축 가능한 인덱스와 checksummed segment는 구현됐으나 질문서의 SQLite WAL 구조와 다르다.
5. **납품 검증(D-21~23)**: 이번 확인 범위에는 승인된 48채널 장기 부하·실제 전원 차단·eMMC 수명 측정 결과가 없다. 호스트 테스트 통과로 이를 대신할 수 없다.
## 23개 항목 대조
| ID | 항목 | 판정 및 확인 내용 |
|---|---|---|
| D-01 | Port Log 원문 | 불일치. RX/TX 원문 저장 기능이 켜진 기본 프로파일과 payload 저장 경로가 있다. metadata-only로 바꿀지는 별도 제품 결정 사항이다. |
| D-02 | 365일 보존 | 불일치. 기본 365일, 설정 범위 1~365일. system/port 용량 회수는 보호기간 고정 보장이 아니다. |
| D-03 | 이벤트율·집계 | 부분 구현. seriald 보고 주기 기본 10초가 있으나 payload는 별도 이벤트로 기록한다. 승인된 events/sec·48채널 용량 산정은 확인하지 못했다. |
| D-04 | 저장 암호화·삭제 | 권고의 제한적 logical wipe 방향과 일부 일치. 로그 JSONL은 평문이며 파일 삭제가 forensic erase를 보장하지 않는다. 보안 책임자 승인 기록은 없다. |
| D-05 | 전원 장애 손실 | 차이 있음. 레코드마다 Sync하며 일반 batching이 아니다. audit 저장 실패를 호출자에게 강제 전파하지 않는 경로가 있다. 실제 power-cut 손실 한계는 미검증이다. |
| D-06 | 분류·schema | 부분 구현. audit/system/port/link, version/sequence/UTC/checksum과 크기 제한 존재. 원문 필드가 포함되며 최종 승인 catalog·migration 계약은 미확인이다. |
| D-07 | 포트별 로그 설정 | 부분 구현. per-port/bulk Read/Send 및 capture 설정과 세션·감사 기록 경로가 존재한다. 모든 설정 조합에서 필수 감사 이벤트가 유지되는지는 별도 시험이 필요하다. |
| D-08 | keyword | 부분 구현. 대소문자를 무시한 literal match와 rule ID가 있다. 원문 저장이 별도로 존재하여 metadata-only 저장 목표를 충족하지 않는다. 청크 경계·장기 부하 기준은 미검증이다. |
| D-09 | 시각·보정 | 부분 구현 및 이번 개선. UTC와 클래스별 증가 sequence가 있다. 첫 time.sync 권한 사용자 로그인 때 PC 시간 자동 적용, 변경 전후 감사 메시지, 이후 세션 발급을 추가했다. trusted-time retention 및 역행·미동기화 전체 정책은 별도 검증 대상이다. |
| D-10 | 권한 | 부분 구현. log.export 등 서버 권한 검사가 존재한다. system/port clear 기능도 있으므로 질문서의 wipe 전용 역할·일반 관리자 삭제 금지 기준과 별도 대조가 필요하다. |
| D-11 | 공장초기화 | 최신 지시에 따라 의도적으로 다름. 예전 로그·인덱스 등 초기화 대상 데이터를 삭제하고 기본 계정만 재생성한다. 질문서 로그 보존은 현재 기준으로 채택하지 않는다. |
| D-12 | cap·floor | 구현 존재. logd가 공통 capacity policy의 클래스 cap과 free-space floor를 사용한다. 실제 이벤트율·365일·업데이트 동시 사용 용량 보장 근거는 부족하다. |
| D-13 | pressure·회수 | 불일치 부분 존재. cap/floor와 오류는 있으나 system/port의 보호기간 이전 회수 경로 및 audit 오류 무시 경로가 있어 권고한 보존·fail-closed 보장과 다르다. |
| D-14 | segment·index | 일부 일치. 4 MiB 기본 segment, 체크섬, 회전, 잘린 active tail 복구, 인덱스 재구축 테스트 존재. SQLite WAL 대신 JSON index를 사용한다. |
| D-15 | IPC·실패 처리 | 부분 구현. 제한 크기의 Unix socket request/response와 timeout 존재. producer 전체의 durable ack·queue overflow·drop metric 보장은 확인하지 못했고 audit 오류 전파 차이가 있다. |
| D-16 | 검색·pagination | 부분 구현. sequence 기반 page와 sparse checkpoint 존재. API/UI 필터 존재. 승인된 365일 데이터셋 검색 SLA·전체 범위 완전성 증거는 부족하다. |
| D-17 | export | 부분 구현. 제한된 행 수·필터와 NDJSON 출력 경로가 있다. 먼저 행 배열을 구성하므로 완전한 저장소 직결 streaming은 아니다. 질문서의 manifest/checksum 패키지 보장은 확인하지 못했다. 원문 제외 기준도 충족하지 않는다. |
| D-18 | Remote Syslog | 부분 구현. UDP/TCP 대상과 alarm delivery 경로가 있다. 현재 대상 검증은 TLS protocol을 지원하지 않는다. 승인된 전송 범위·장애/중복 시험 결과는 별도 확인해야 한다. |
| D-19 | 무결성 | 일부 일치. SHA-256 checksum 및 증가 sequence, 손상 검출이 있다. root 공격에 대한 서명·HMAC·외부 anchor/rollback 보장은 확인하지 못했다. |
| D-20 | packaging·health | 구현 및 일부 실기 확인. 별도 tera-logd 서비스·socket·기동 스크립트, 장비 health 응답 존재. 전체 펌웨어 upgrade/rollback matrix 시험은 미검증이다. |
| D-21 | eMMC 수명 | 미검증. 이번 확인에서 승인된 write/day·write amplification·PRE_EOL/LIFE_TIME 결과를 확보하지 못했다. |
| D-22 | SLA·soak | 미검증. CH18 1포트 CR 응답 확인은 48채널 장기 동시 부하 시험을 대체하지 않는다. |
| D-23 | 납품 증거 | 부분 확보. 호스트 테스트·이번 두 실행파일 해시·실기 설정 확인은 있다. 질문서가 요구하는 전원 차단·장기 soak·전체 traceability 승인 묶음은 없다. |
## 주요 코드 근거
- 원문 수집: `backend/cmd/tera-seriald/main.go`의 `reportStreamEvents`, `recordPayload`; `backend/internal/serial/stream.go`
- 보존·감사·export: `backend/internal/adapters/mock/store.go`의 `validateRetentionDays`, `recordAuditEventLocked`, `StreamLogExport`
- 저장·복구: `backend/internal/logstorage/store.go`의 `Append`, `ensureCapacityLocked`, `writeIndexLocked`, `inspectSegment`
- IPC: `backend/internal/logservice/socket.go`
- 용량: `backend/internal/storage/capacity/governor.go`, `backend/cmd/tera-logd/main.go`
- Syslog: `backend/internal/alarmdelivery/controller.go`의 `normalizeSyslogTarget`
- 초기화: `backend/internal/adapters/mock/factory_reset.go`, `backend/internal/logstorage/factory_reset.go`
- 이번 시간 변경: `backend/internal/adapters/mock/store.go`의 `Login`, `backend/internal/adapters/mock/login_time_test.go`
## 이번 변경과 확인 범위
- 주소 필터 IPv6 탭·추가 화면 제거. 기존 IPv6 URL은 IPv4 화면으로 이동. 네트워크 인터페이스 자체의 IPv6 설정·기존 API 필터 엔진은 변경하지 않았다.
- 첫 관리자 브라우저 로그인 시 PC 시간 동기화. 엄밀히는 API 프로세스 시작 후 최초 1회이며 API 서비스 재시작 후에도 적용한다. 로그인 실패·time.sync 권한 없는 사용자는 시간을 변경하지 못한다. 실패 시 로그인을 막지 않고 다음 관리자 로그인에서 재시도한다.
- PC/브라우저 미접속 상태에서는 PC 시각을 얻을 수 없다. 기존 장비 시간대·NTP 설정은 유지한다.
- 호스트 테스트: API, mock store, systemtime/timesocket, logstorage/logservice, frontend handlers/service 통과.
- 실기: 성공한 관리자 로그인으로 1970년 장비 시각이 현재 PC UTC 시각으로 변경됨; 세션 유지; IPv6 탭 미노출·이전 URL redirect 확인; 12개 서비스 health ok; CH18 CR up 확인.
- 실기 재부팅·공장초기화·전원 차단은 이번 확인에서 실행하지 않았다. 자동 시간 동기화의 새 프로세스 경로를 확인했으며 초기화 상태는 기존 호스트 테스트로 검증했다.
- 배포 API SHA-256: e05b45136b5ea6765289a6aedd6ec6752aca7169329d2e0bf35dd228183ee540
- 배포 Web SHA-256: ace1cada6dc20f38ae91f19b235d72265ed0f2cb1980990cd404149333bf00d6
- release/latest의 두 바이너리도 같은 해시로 갱신했다. 질문서 평가를 이유로 로그 원문·보존 정책을 이번에 임의 변경하지 않았다.

23
WEB_LOG_FEATURES.md

@ -0,0 +1,23 @@
# 웹 로그 기능 적용 기록
2026-09-08 · release 20260908-log-web-2
사용자 승인: 명령·응답 원문 저장 유지, 보존기간 관리자 변경 허용, 포트 운영자는 자신에게 허용된 포트만 조회·다운로드, 초기화는 기존 데이터 전체 삭제.
| 항목 | 적용 내용 |
|---|---|
| 1 원문 로그 | 기존 RX/TX 원문·명령·응답 기록 및 표시 유지. 검색에 payload 원문도 포함. |
| 2 보존기간 | 관리자 로그 화면에 시스템·감사·포트 보존일 설정 추가. 서버 허용 범위 1~365일, 기본 365일. 적용과 영구 저장 수행. 기간 축소·용량 회수에 따른 삭제 가능성을 표시. |
| 3 포트별 기록 | 기존 수신/송신·표시 형식·일괄 적용 기능 유지. savePort가 선택된 포트를 bulk API로 전달. |
| 4 키워드 알림 | 기존 포트 고급 설정의 키워드와 알림 설정의 수신 키워드 감지를 유지. 문자열·대소문자 무시 방식 안내 추가. 알림 이벤트는 기존 포트 로그에 표시. |
| 5 검색·목록 | 기존 기간·포트·사용자·방향·유형·검색어 유지. 50/100/200/500건 페이지 단위 조회와 이전/다음 버튼 추가. |
| 6 다운로드 | CSV, JSON 배열, JSONL 지원. UI 100/500/1000/5000건 상한 선택, 서버 최대 10000건 유지. 필터를 서버에 전달. CSV 인용부호·쉼표·줄바꿈 처리, 수식 주입 방지, UTF-8 BOM, no-store 응답. |
| 7 권한 | 포트 운영자 포트 로그 메뉴 허용. API에서 허용 포트만 pagination·export 이전 필터링. 명시적으로 거부된 포트 요청은 403. 시스템 로그·삭제·보존 설정 변경은 금지. 기존 그룹+사용자별 유효 권한 계산 유지. |
| 8 저장 공간 | 실제 logd snapshot 기반 클래스별 사용량/상한·남은 공간·경고·최고령 기록 표시. 30초 순증가량에서 클래스 상한 도달까지의 추가 저장 가능 일수 추정. 데이터가 없거나 증가하지 않으면 추정 불가로 표시. 이는 보존기간 보장이 아님. |
| 9 원격 Syslog | 기존 서버·포트·UDP/TCP 설정에 전송할 이벤트 분류 선택 추가. 최근 관측한 전송의 대기/성공/실패·시도 횟수·오류 상태를 조회. API 재시작 전 이력 전체 목록은 제공하지 않음. |
CAPTCHA는 4자리이며 기본 활성화·2분 만료·1회 사용·접속자 결합 검증을 유지한다. 대시보드 저장장치 사용량의 “로그·백업 등이 함께 사용하는 데이터 파티션” 문구를 제거했다.
검증: authcaptcha/API/mock/프론트엔드 handlers·service 테스트 통과. 권한 테스트는 읽기 허용 CH18, 명시 거부 CH01, 시스템 로그·삭제·retention 차단, CSV/JSON 원문 보존과 다른 포트 누출 방지를 확인한다. 실제 장비에서 4자리 CAPTCHA 로그인, 저장 공간/보존 설정/Syslog 상태 GET, CSV·JSON·JSONL 다운로드 및 파싱을 확인했다. 원격 수신 서버로의 전송 시험, 48채널 장기 부하, 전원 차단·공장초기화는 이번에 실행하지 않았다. 대화에서 보류한 커널/드라이버 문제는 해결됐다고 주장하지 않는다.
장비 배포 파일과 release/latest/rootfs/usr/bin의 2개 파일 SHA-256을 일치시켰다. 모든 변경은 기존 tera-clone-demo 서비스 대상이며 legacy tera-api/tera-web는 교체하지 않았다.

27
backend/BROWSER_ANIMATION_DIAGNOSIS_20260909.md

@ -0,0 +1,27 @@
# 내부 브라우저 CPU 비교
장비의 동일한 matrix-browser 옵션(-no-sandbox --disable-gpu), 동일한 백엔드/CR 설정을 유지하고 표시 콘텐츠만 바꿨다. 아래 수치는 vmstat 1의 첫 누적 행을 제외한 구간별 user+system이다. 짧은 비교 실험이며 장기 평균은 아니다.
| 표시 콘텐츠 | 전체 CPU 사용 |
| --- | --- |
| 기존 장비 UI | 41~42% |
| about:blank | 4~5% (반올림된 idle 기준 약 4~6%) |
| 원래 URL로 재실행 | 41~44% |
| 현재 로그인 HTML/CSS 복사본, JavaScript 제외, 애니메이션 유지 | 43~47% |
| 같은 복사본, CSS animation/transition만 비활성화 | 4~9% |
로그인 CSS의 login-progress-slide는 무한 반복 애니메이션이다. JS를 제거한 비교 화면에서 이 애니메이션을 포함한 CSS 동작만으로 높은 부하가 재현됐다. 원래 실행 페이지의 JS 상태나 실제 DOM을 직접 확인한 것은 아니므로 원래 페이지가 왜 계속 애니메이션 중인지까지 확정하지 않는다.
웹에서 가능한 개선은 로그인 준비 막대의 무한 이동 효과를 제거하고 실제 진행률 변경만 표시하는 것이다. 상태 조회/인증/CAPTCHA/CR 검사를 끄는 변경은 필요하지 않다. login.js가 AbortSignal.timeout을 직접 사용하므로 장비 Qt 브라우저의 지원 여부 확인과 구형 브라우저용 타임아웃 대체 구현도 검토해야 한다. 이 API의 미지원은 아직 런타임에서 확인하지 않았다.
실험 후 /etc/init.d/S99matrix-browser start로 원래 URL을 복구했다. 영구 설정과 배포 파일은 바꾸지 않았다. 이 결과는 별도로 확인된 RCU stall/IRQ95 차단의 해결을 의미하지 않는다.
원시 기록: ../runtime/browser-blank-comparison.log 및 ../runtime/browser-animation-comparison.log.
## 수정 및 장비 배포 확인
로그인 준비 막대의 무한 애니메이션과 관련 keyframes를 제거하고 CSS 캐시 버전을 갱신했다. 준비 상태에 따른 막대 폭 및 메시지 갱신 코드는 유지했다. 프런트엔드 핸들러 테스트 및 ARM64 빌드가 통과했다.
웹만 배포했고 해시 `1d6b8ba457e702a70fab9b03156d80a012ead37440fc8d93e5914ddd9c70c3f6`, HTTP80, 준비 상태12/12, CAPTCHA true를 확인했다. 내부 브라우저를 원래 URL로 재실행한 후 실측 CPU는 첫 누적 행 제외 11~14% 구간이었다. 수정 전 동일 URL의 41~44%보다 낮다. CR 서비스는 변경하지 않았다. 커널 IRQ 장애 해결이나 장시간/재부팅 후 성능 보장을 의미하지 않는다.
배포 기록: ../runtime/static-progress-transfer.log. CPU 측정: ../runtime/static-progress-cpu.log.

75
backend/REPLACEMENT_UI_PERFORMANCE_20260909.md

@ -0,0 +1,75 @@
# 교체 장비 UI 지연 진단 — 2026-09-09
대상: 192.168.0.223, HTTP 80, COM4, 커널 5.10.160 #23. 사용자가 보고한 화면은 PC 브라우저이다.
## 확인 결과
1. 초기 설정은 11:35:45에 완료됐다(`runtime synchronization completed elapsed_ms=35358`).
2. 이후 11:55:19부터 11:58:17까지 시리얼 상태 조회, 로그 기록 및 저장 공간 조회에서 응답 실패가 발생했다. API 로그에 degraded 메시지가 10개 있었다. 11:58:36에는 시리얼 재조정 복구가 기록됐다.
3. 커널은 부팅 2700.237766초에 CPU0 RCU stall, 2832.568711초에 `irq 95: nobody cared`, 2832.732864초에 `Disabling IRQ #95`를 기록했다. IRQ95 핸들러는 `wch_interrupt`이고 /proc/interrupts의 카운터는 1500000이었다. API 응답 실패와 시간대가 겹친다.
4. 이 장비의 stall 스택에는 네트워크 수신/softirq 및 readlink 경로가 포함된다. task 이름이 tera-seriald라는 사실만으로 사용자 공간 시리얼 코드가 직접 원인이라고 단정하지 않는다. 이전 장비의 #22 스택과도 구분해야 한다.
5. 내부 브라우저는 `matrix-browser -no-sandbox --disable-gpu 127.0.0.1`로 실행된다. matrix-browser와 렌더러를 몇 초간 SIGSTOP한 비교에서, vmstat의 구간별 전체 CPU 사용량은 약 43%에서 12~16%로 감소했다. SIGCONT와 자동 복구용 watchdog으로 원상 복구했고 두 프로세스의 실행 상태를 확인했다. 서비스 시작 설정은 변경하지 않았다.
6. 장애 기록 이후 시점의 설정 읽기 및 두 시리얼 상태 요청을 포함한 진단 helper는 52ms에 완료됐다. 이 수치는 장애가 발생한 시간대의 API 지연이나 로그인된 PC 화면의 응답 시간을 대체하지 않는다.
## 판단과 한계
- 설정 완료 이후에도 운영 중 장비 측 정체가 발생한다. 로그인 대기 연장으로 이후 장애를 예방할 수 없다.
- 내부 브라우저의 지속적인 CPU 부하는 별도로 확인됐으나, 이것만으로 PC 화면 지연의 단독 원인이라고 단정할 수 없다.
- API의 복구 메시지는 WCH IRQ 또는 실제 시리얼 송수신 복구를 보장하지 않는다.
- 커널/드라이버의 최초 장애 유발 조건과 개별 PC 메뉴의 지연 시간은 아직 확정하지 않았다. 로그인된 진단 브라우저가 필요하다.
- 이번 확인에서는 커널, IRQ 설정, HTTP 포트, 9600bps 설정, 배포 파일을 변경하지 않았다.
## 후속 확인
- 로그인된 PC 화면에서 메뉴별 응답 및 표시 지연을 기록하고 장비 로그와 비교한다.
- 커널 담당자에게 #23의 RCU stall 및 WCH IRQ95 차단 로그를 전달해 인터럽트 처리 원인을 확인한다.
- 내부 화면이 필요한 운영 조건을 유지하면서 불필요한 렌더링 부하를 줄인다.
## 로그인 후 PC 브라우저 확인
사용자가 진단 브라우저에 로그인한 뒤, 설정을 적용하거나 저장하지 않고 메뉴를 전환했다. 시간은 자동 클릭 시작부터 접근성 화면 확인까지의 관측 시간이며, 브라우저 자동화 왕복/관측 비용을 포함한다. API 단독 소요 시간이나 정밀 렌더링 프로파일은 아니다.
| 화면 | 관측 시간 |
| --- | --- |
| 네트워크 설정 | 390ms, 재확인 251ms |
| 포트 설정 | 388ms, 재확인 350ms |
| 포트 상태 | 447ms, 재확인 384ms |
| 대시보드 | 336ms, 재확인 318ms |
| 시스템 로그 | 100건 표시 확인 813ms |
확인 중 수집된 브라우저 error/warn 로그는 없었고, 수초 단위의 심한 지연은 재현하지 못했다. 포트 설정 화면의 9600bps도 확인했다. 진단 후 대시보드로 돌아왔다. 부팅 직후나 앞서 기록된 장비 장애 중의 동작은 이 결과로 검증되지 않는다. 현재의 정상 전환을 근거로 장애 해결을 선언하지 않는다.
## 사용자 승인 재부팅 후 확인 (12:17경 재부팅)
- COM4에서 정상 종료 및 새 커널 부팅, 로그인 프롬프트를 수집했다. 원시 기록은 `../runtime/reboot-ui-20260909.log`에 있다.
- 사용자의 콘솔 로그인 후 12:22:47(가동 약 5분)에 vmstat를 수집했다. 구간별 user+system은 52%, 62%, iowait는 16%, 13%였다. 이 시점은 전원 인가 직후 수십 초와는 구분한다.
- `/api/v1/auth/startup` 응답은 ready=true, 12/12, elapsed_ms=327015였다. 로그인 화면도 준비 시간 327초를 표시했다.
- 현재 부팅의 dmesg 검색에서는 RCU stall/IRQ 차단 메시지를 찾지 못했다. 이전 부팅에서 발생한 장애를 이번 부팅에 발생했다고 간주하지 않는다.
- uptime 409.27초에서 411.32초 사이 IRQ95는 398728에서 400840으로 증가했다(약 1030회/초). IRQ99는 1772에서 1782로 증가했다. 발생 횟수만으로 IRQ 장애라고 판정하지 않는다.
- tera-api의 stdout/stderr는 /dev/null이다. 완료된 startup 응답에는 단계별 소요 시간이 없어, 이번 준비 327초의 지배 단계나 PC 시간 동기화 대기 비중을 사후 확정할 수 없다.
- 기존 진단 탭은 종료되어 사용자 로그인 후 UI 측정이 이어지지 않았다. 새 진단 탭을 열었고 로그인 입력을 요청했다. 이 구간의 로그인 후 메뉴 지연을 측정했다고 보고하면 안 된다.
다음 부팅 검증에는 부팅 전에 단조 시간 기준의 단계별 준비 상태·CPU·API 소요 시간 수집을 마련해야 한다. 현재 결과로 로그인 준비 단계 누락 또는 커널 장애 하나를 원인으로 확정하지 않는다.
## 이번 부팅의 로그인 후 지연 재현 (12:25경, uptime 약 8분)
- 새 진단 탭 로그인 후 메뉴 전환을 실행했다. 클릭 후 첫 접근성 관측(약 250~350ms)은 이전 화면을 표시했다. 따라서 이 관측 시간을 화면 전환 완료 시간으로 사용하면 안 된다.
- 포트 설정 이동에서 URL 변경과 화면을 기다린 관측은 2773ms였다. 그 시점에는 9600 설정값이 아직 표시되지 않았고, 다음 관측에서는 표시됐다. 별도 네트워크 메뉴 전환 대기도 시간 초과 후 다음 관측에서 완료돼 지연이 확인됐다.
- integration.js의 navigateWithinConsole는 navigating=true이면 새 요청을 반환해 무시하고, initializeCurrentPage의 데이터 로딩을 기다린 다음에야 navigating=false로 바꾼다. 연속 메뉴 클릭이 반영되지 않는 체감에 기여하는 구현이다. HTML/API/브라우저 각각의 지배적 지연 시간은 아직 분리하지 못했다.
- 이 시점 vmstat는 user+system 약 54%, iowait 0%였다. dmesg에는 새 RCU stall/IRQ 차단 검색 결과가 없었다. 앞선 iowait 13~16%가 계속 유지된다고 보고하지 않는다.
- 장비 내부 loopback에서 비인증 로그인 HTML을 wget으로 읽는 시간은 7ms, 10ms, 6ms였다. 인증된 화면/API 및 PC 네트워크의 지연을 이 측정으로 배제하지 않는다.
- 브라우저 error/warn 로그는 없었다. 설정 변경이나 커널 수정은 하지 않았다.
## 적용 수정
- 메뉴 초기화 중 마지막 이동 요청을 보존한다. 초기화를 겹쳐 실행하지 않으므로 이전 화면 요청이 새 폼에 값을 기록하는 경쟁을 피한다. 인증 만료 시 이동 대기는 폐기한다.
- 포트 설정 화면은 `GET /api/v1/ports?limit=48&runtime=false`로 저장된 실행 설정을 먼저 읽는다. 이 선택적 조회에서는 시리얼/세션 런타임 수집을 호출하지 않는다. 권한 검사와 포트별 필터는 동일하며, 기본 API 응답은 기존 실시간 동작을 유지한다.
- 선택적 설정 응답은 미관측 링크를 unknown으로 취급한다. 설정 폼을 채운 뒤 기존 런타임 폴링을 별도로 시작한다. PATCH/적용·로그인 준비 판정·9600bps·HTTP80 설정은 수정하지 않는다.
- 검증: JavaScript 49개 통과, backend internal/api·adapters/mock·startup 통과, frontend internal/handlers·service 통과. ARM64 API/웹 빌드 성공.
- 이 수정은 포트 설정의 런타임 대기와 메뉴 클릭 무시를 줄이는 범위다. 모든 메뉴의 장비 측 지연 또는 커널 장애가 해결됐다는 뜻은 아니다.
## 배포 시도 및 복구
API/웹 두 파일만 교체하는 배포를 시도했다. 12:36:28 백업 위치는 `/userdata/tera-backups/config-read-20260909-123628`이다. 준비 상태가 7/12에서 지연돼 자동 복구했고 12:40:39 복구 완료 로그를 확인했다. 이 과정에서 새 부팅의 커널에 uptime 1308초 RCU stall, 1411.077981초 `Disabling IRQ #95`, GPU dvfs timeout이 기록됐다. 수정된 설정 조회가 이 장애를 촉발했다는 증거는 없으며, 기존 부팅에서도 같은 유형의 IRQ 차단이 있었다.
복구 후 API SHA256은 `ef466f00788868617d8528a10ee597e2119ca6965eb81ed5e5e89bfc8a87536f`, 웹은 `2b7001dd33ad07c9a1886712926384e065c0c09c0715873f30b107969bc23eb9`로 이전 파일과 일치했다. 준비 상태는 12/12, 8383ms, CAPTCHA true였다. 이는 IRQ 또는 실물 포트 송수신 복구의 증거가 아니다. 수정 빌드는 `build/linux-arm64`에 있으며 장비에는 적용되어 있지 않다. 전송 및 커널 원시 기록은 `../runtime/config-read-transfer.log`에 있다.

61
backend/STARTUP_TIMING.md

@ -0,0 +1,61 @@
# 부팅 초기화 시간 확인
## 로그인 준비 상태
`GET /api/v1/auth/startup`은 인증 없이 준비 단계와 경과 시간을 제공합니다. 로그인 화면의 `/login/backend-status`는 이 상태가 `ready`인 경우에만 로그인 정책을 조회하고 로그인을 허용합니다. API의 로그인·CAPTCHA POST도 초기화 중에는 503으로 차단합니다.
설정 복원, 인증서, SNMP, 접속 설정, 시리얼 설정, 활성화된 포트 서비스 설정이 성공해야 합니다. 마지막으로 48개 시리얼 채널의 가용성과 운영 환경의 세션 서비스 정상 응답을 확인합니다. 부팅 알람 전송은 필수 성공 조건이 아닙니다.
게이지는 완료 단계 수/전체 단계 수이며 예상 남은 시간 비율이 아닙니다. 표시되는 경과 시간은 API 프로세스 시작 기준입니다. API가 응답하기 전에는 단계와 시간을 알 수 없어 대기 표시를 사용합니다. 준비 완료 후 시간은 고정됩니다.
필수 단계가 오류를 반환하면 3초 간격으로 실제 재시도합니다. 25초 이상 완료되지 않은 단계는 `delayed`로 표시하며 로그인은 차단됩니다. 응답하지 않는 호출과 새 시도를 중첩하지 않고, 해당 호출이 반환된 뒤에만 다음 시도를 진행합니다. 재시도가 성공하면 자동으로 다음 단계로 진행합니다. API 로그에는 상세 오류가 기록되지만 인증 전 화면에는 단계 이름만 표시합니다. 이 준비 확인은 전체 시리얼 송수신 시험이나 이후 발생하는 WCH 커널 장애의 방지를 의미하지 않습니다.
프론트엔드와 API를 함께 배포해야 합니다. 이전 API에 새 프론트엔드만 연결하면 명시적 준비 상태를 확인할 수 없어 로그인이 차단됩니다.
API는 시작 시 표준 오류 로그에 `tera-api startup stage=... elapsed_ms=...`를 출력합니다.
| 단계 | 측정 대상 |
| --- | --- |
| `config.load`, `config.persist` | 설정 읽기와 저장 |
| `auth.load`, `auth.bootstrap`, `auth.persist` | 계정 복원, 기본 계정 생성, 저장 |
| `sessions.*`, `login_failures.*`, `operations.*` | 세션, 로그인 실패, 작업 기록 복원과 저장 |
| `logs.retention`, `logs.journal.load` | 로그 보존 정책 적용, 기존 로그 읽기 |
| `store.total` | 저장소 초기화 전체 시간 (위 단계 포함) |
| `runtime.tls`, `runtime.snmp`, `runtime.port_services`, `runtime.management`, `runtime.serial`, `runtime.cold_start_alarm` | 각 서비스 초기 동기화 |
`runtime synchronization completed elapsed_ms=...`는 API 프로세스 시작부터 병렬 동기화 작업 종료까지의 시간입니다. OS 부팅 전체 시간이나 브라우저 렌더링 시간은 아닙니다. 각 runtime 단계는 병렬 실행되므로 시간을 합산하지 않습니다. 단계 종료는 성공을 보장하지 않으며, 기존 `degraded` 및 `error` 로그도 함께 확인합니다.
systemd 환경에서는 `journalctl -b -u tera-api.service`로 확인합니다. Buildroot의 `start-stop-daemon -b` 실행 경로에서는 표준 오류가 버려질 수 있으므로, 실제 장비의 서비스 실행 설정에서 표준 오류 수집을 먼저 구성해야 합니다. UI의 시스템 로그 목록에 자동 저장되는 항목은 아닙니다.
변경 후 실제 전원 부팅에서 로그를 수집하고, 동일한 설정·로그량으로 변경 전후의 첫 UI 요청 시간을 비교합니다. `auth.bootstrap`은 저장된 계정 목록이 있는 재시작에서는 나타나지 않아야 합니다. 공장 초기 상태에서는 나타날 수 있습니다.
이번 개선은 저장된 계정이 있을 때 불필요한 기본 계정 비밀번호 해시 생성을 생략하고, 계정·세션·로그인 실패 기록의 중복 저장을 제거합니다. 설정 마이그레이션, 로그 복원, 서비스 준비 상태 검사는 유지합니다. 실제 장비의 지연 원인과 개선 시간은 부팅 측정 후 판단합니다.
CAPTCHA는 준비 중에도 안내 영역을 표시합니다. 실제 이미지 발급은 준비 완료 및 활성화 정책 확인 후 수행하므로 초기화 대기 중 이미지가 만료되지 않습니다.
## 로그인 전 PC 시간 복원
장비 시각이 2000년 이전인 경우 로그인 준비 응답에 프로세스별 임의 토큰을 제공합니다. 로그인 페이지는 이 토큰과 현재 브라우저 UTC 시간을 `/login/bootstrap-time`으로 전송합니다. 프론트엔드는 JSON/전용 헤더와 Origin을 검사하고, API는 토큰·장비 시각·입력 범위를 검사합니다. 시간 변경은 기존 tera-timed 경계를 사용하며 설정된 시간대를 유지합니다.
성공 후 같은 API 프로세스에서는 다시 적용하지 않습니다. 장비 시간이 이미 정상 범위라면 이 비로그인 경로로 변경할 수 없습니다. 실패 요청은 3초 간격으로 제한하며 정상 시간을 변경하려면 기존 관리자 시간 설정 기능을 사용해야 합니다. 최초 복원 시 접속 PC의 시각을 신뢰하므로 PC 시간을 정확하게 유지해야 합니다. 이 기능은 NTP 또는 RTC를 설정하거나 하드웨어 시간을 영구 저장하는 기능이 아닙니다.
시간 복원 후에도 필수 설정 준비 확인이 끝나야 CAPTCHA 발급과 로그인이 허용됩니다. 케이블 연결만으로는 복원되지 않으며 브라우저로 로그인 페이지를 열어야 합니다.
## 전체 설정 준비 판정 (2026-09-09)
로그인은 다음 단계가 모두 성공한 뒤 허용한다. 시간 초과나 재시도 중인 단계는 완료로 계산하지 않는다.
- 저장소: 계정·권한·접근 제한 및 모든 running 설정 복원/검증.
- 시간: tera-timed가 시간대와 NTP 사용 여부/서버를 적용하고 NTP 프로세스 상태를 확인. 2000년 이전이면 로그인 화면의 기존 PC 시간 동기화 또는 NTP를 기다림. 이미 유효한 시각은 재설정하지 않음.
- 네트워크: tera-netd의 저장 설정과 실제 인터페이스/정적 IPv4 주소 비교. 불일치하면 running 설정 적용, 실제 주소 확인 후 트랜잭션 확정. pending 설정은 적용하지 않음.
- TLS·SNMP·관리 접속·포트 서비스: 각 담당 서비스의 설정 적용 성공 응답.
- 알림/인증 정책: 실행 중인 정책 검증, 활성 SMTP·SNMP trap·RADIUS/TACACS 자격 증명 참조 확인, 알림 데몬의 읽기 전용 health 응답. 검증을 위한 알림 전송이나 외부 로그인은 하지 않음.
- 로그: 저장된 로그 보존 정책 적용 응답.
- 시리얼: 48개 회선 설정 및 포트별 캡처/패키징/로깅 설정 적용. 마지막 서비스 검사에서 모든 채널의 available/profile_applied 확인.
- 초기 상태: 위 설정 적용이 끝난 뒤 새로 수집한 48개 CR 관측을 기다림. pending/confirming/error는 완료가 아님. no_response는 관측 완료로 인정하며 모든 포트에 연결 장비가 있을 것을 요구하지 않음.
- 마지막 서비스 검사: 시리얼과 세션 서비스 확인.
NTP 서버 응답, 외부 SMTP 수신, 외부 인증 서버 로그인 성공은 설정 적용과 별개의 외부 가용성이다. 인터넷이 없는 환경에서도 PC 시각으로 부팅할 수 있다. 네트워크의 DHCP 임대 획득 및 외부 DDNS 갱신 성공까지 보장하는 검사는 아니다.
배포 스크립트: `scripts/deploy-full-readiness.ps1`. API/web/timed/clone alarmd 네 파일을 SHA256 검증 후 교체하고, 120회 준비 상태 확인에서 성공하지 않으면 네 파일을 함께 복구한다. 하드웨어 커널/드라이버 코드는 변경하지 않는다.

29
backend/WCH_BOOT_STALL_20260909.md

@ -0,0 +1,29 @@
# WCH 부팅 지연 진단 — 2026-09-09
## 장비에서 확인한 사실
- 커널: `5.10.160 #22`, ARM64, TERA CORA-X48 PRO K FET3568J-C.
- 부팅 150.52초와 279.04초에 CPU0 RCU stall 발생.
- 279초 덤프의 CPU0 PC가 `READ_UART_IIR.isra.0`, `wch_ser_interrupt`에 반복적으로 위치함.
- 319.47초 `irq 95: nobody cared`, 핸들러 `wch_interrupt`, 이어서 `Disabling IRQ #95`.
- PCI `0000:01:00.0`, vendor/device `1c00:4353`, IRQ95, 드라이버 `wchpciserial`.
- 장비 매핑에서 이 PCI 장치는 CH01–CH24에 해당함. 다른 장치 `0002:21:00.0`은 IRQ99.
- `modules.builtin`에 `drivers/tty/serial/wch/wch.ko`, `wch_pre.ko`가 등록되어 있음. 현재 드라이버는 커널 내장 구성.
- CR 자동 탐색: 부팅 30초 뒤 시작, 5초 주기, 응답 제한 150ms, 채널 간 20ms. 모뎀 링크 스캔은 꺼져 있음.
- 측정 시 가용 메모리 약 1.5GB. 웹이 실제 사용하는 API는 `127.0.0.1:8084`; 장애 후 로그인 정책 요청은 약 14ms.
## 판단과 한계
WCH 인터럽트 처리 경로의 커널 정체가 확인됐다. UI 지연과 관련될 가능성이 높지만, 자동 탐색·포트 초기 설정·외부 입력 중 어떤 동작이 최초 촉발 조건인지는 아직 분리 검증하지 않았다. 150초 스택 자체는 파일 조회/블록 I/O 경로이므로 모든 stall을 동일한 원인으로 단정하지 않는다.
IRQ 비활성화 후 UI 응답이 빨라져도 시리얼 통신 복구를 의미하지 않는다. 초기 계정 해시 생성을 줄인 API 변경은 이 커널 장애를 해결하지 않는다.
## 다음 수정에 필요한 것
실제 커널 #22의 BSP 소스, 설정, 빌드 도구 및 WCH 드라이버 패치가 필요하다. 현재 작업 공간과 검색한 사용자 폴더에서는 해당 소스를 찾지 못했고 장비에도 `/usr/src`가 없다. 매핑 파일은 `target-bsp/docs/snapshots/20260721-wch-legacy-intx-loopback/manifest.json`을 근거로 참조한다.
소스 확보 후 `wch_ser_interrupt`의 반복 종료 조건, IIR 읽기 및 인터럽트 원인 제거, IRQ 반환값과 PCI INTx 처리, 포트 open/close/termios 적용 시 인터럽트 상태 변경을 확인한다. 코드 검토 전 `irqpoll` 추가, IRQ 강제 재활성화, 드라이버 교체는 적용하지 않았다.
수정 검증은 부팅 초기부터 커널 로그를 연속 수집하며 UI 응답과 CH01–CH48 실제 송수신을 함께 확인해야 한다. 자동 탐색을 일시 중지하는 비교 실험은 기능 변화와 복원 절차를 명시하고 수행한다.
원시 로그: `../runtime/perf-console-20260909.log`.

1
backend/cmd/tera-accessd/main.go

@ -19,6 +19,7 @@ import (
func main() {
socketPath := accessGetenv("TERA_ACCESSD_SOCKET", managementaccess.DefaultSocketPath)
handler, err := webgateway.NewHandler(webgateway.Options{
WebUpstream: os.Getenv("TERA_ACCESSD_WEB_UPSTREAM"),
StaticRoot: accessGetenv("TERA_ACCESSD_STATIC_ROOT", "/usr/share/tera-web"),
APIUpstream: accessGetenv("TERA_ACCESSD_API_UPSTREAM", "http://127.0.0.1:8082"),
SessionUpstream: accessGetenv("TERA_ACCESSD_SESSION_UPSTREAM", "http://127.0.0.1:8081"),

49
backend/cmd/tera-api/factory_reset_cleanup.go

@ -0,0 +1,49 @@
package main
import (
"fmt"
"os"
"path/filepath"
)
// Compatibility cleanup belongs to the shared reset path, never the UI.
// Current frontends are stateless. Keep this for upgrades from SQLite builds.
func cleanupLegacyFrontendState() error {
for _, path := range []string{"/var/lib/teraweb/frontend.db", "/var/lib/tera-clone-demo-web/teraclone.db"} {
if err := removeLegacyDatabase(path); err != nil {
return err
}
}
return nil
}
func removeLegacyDatabase(path string) error {
parent := filepath.Dir(path)
resolved, err := filepath.EvalSymlinks(parent)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if resolved != parent {
return fmt.Errorf("refusing redirected frontend state directory: %s", parent)
}
for _, suffix := range []string{"", "-wal", "-shm", "-journal"} {
target := path + suffix
info, err := os.Lstat(target)
if os.IsNotExist(err) {
continue
}
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("refusing non-regular frontend state: %s", target)
}
if err := os.Remove(target); err != nil {
return err
}
}
return nil
}

32
backend/cmd/tera-api/factory_reset_cleanup_test.go

@ -0,0 +1,32 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestLegacyFrontendDatabaseResetIncludesSQLiteSidecars(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "frontend.db")
for _, suffix := range []string{"", "-wal", "-shm", "-journal"} {
if err := os.WriteFile(path+suffix, []byte("customer-data"), 0600); err != nil {
t.Fatal(err)
}
}
other := filepath.Join(root, "unrelated")
if err := os.WriteFile(other, []byte("keep"), 0600); err != nil {
t.Fatal(err)
}
if err := removeLegacyDatabase(path); err != nil {
t.Fatal(err)
}
for _, suffix := range []string{"", "-wal", "-shm", "-journal"} {
if _, err := os.Stat(path + suffix); !os.IsNotExist(err) {
t.Fatal("SQLite state survived")
}
}
if _, err := os.Stat(other); err != nil {
t.Fatal("unrelated file removed")
}
}

171
backend/cmd/tera-api/main.go

@ -12,8 +12,8 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"tera-clone/services/tera-api/internal/startup"
"time"
"tera-clone/services/tera-api/internal/adapters/mock"
@ -47,6 +47,22 @@ import (
)
func main() {
startupStarted := time.Now()
startupContext, cancelStartup := context.WithCancel(context.Background())
defer cancelStartup()
boot := startup.New(startupStarted)
boot.Add("store", "설정 복원")
boot.Add("tls", "인증서 설정")
boot.Add("snmp", "SNMP 설정")
boot.Add("management", "접속 설정")
boot.Add("serial", "시리얼 설정")
boot.Add("health", "서비스 상태 확인")
boot.Add("time", "시간 및 NTP 설정")
boot.Add("network", "네트워크 설정")
boot.Add("policies", "알림 및 인증 정책")
boot.Add("logs", "로그 보존 설정")
boot.Add("port_observation", "포트 초기 상태 수집")
boot.Set("store", "running")
addr := listenAddrConfig()
readHeaderTimeout := getenvDurationSeconds("TERA_API_READ_HEADER_TIMEOUT_SECONDS", 5*time.Second)
readTimeout := getenvDurationSeconds("TERA_API_READ_TIMEOUT_SECONDS", 15*time.Second)
@ -153,6 +169,7 @@ func main() {
}
store, err := mock.NewStoreWithOptions(time.Now, mock.StoreOptions{
StartupTiming: logStartupTiming,
LogJournal: logJournal,
AllowSampleLogs: allowMockActors,
AuditPath: auditPath,
@ -189,15 +206,93 @@ func main() {
DiagnosticsWorkers: getenvInt("TERA_API_DIAGNOSTICS_WORKERS", 2),
DiagnosticsQueue: getenvInt("TERA_API_DIAGNOSTICS_QUEUE", 16),
RemoveLegacyAuthUsers: true,
FactoryResetCleanup: func() error {
if resetter, ok := firmwareController.(update.FactoryResetter); ok {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := resetter.FactoryReset(ctx); err != nil {
return err
}
}
return cleanupLegacyFrontendState()
},
})
if err != nil {
log.Fatalf("tera-api store setup failed: %v", err)
}
defer store.Close()
startupTasks := make([]func(), 0, 6)
boot.Set("store", "ready")
clockBootstrap, err := startup.NewClockBootstrap(time.Now, func(ctx context.Context, stamp time.Time) error {
settings, err := store.TimeSettings(ctx)
if err != nil {
return err
}
_, err = timeController.Sync(ctx, systemtime.Request{BrowserTime: stamp, Timezone: settings.Timezone})
if err == nil {
log.Printf("tera-api restored uninitialized clock from login browser")
}
return err
})
if err != nil {
log.Fatalf("clock bootstrap setup: %v", err)
}
startupTasks := make([]func(), 0, 12)
timeSettingsApplied := false
for _, item := range []struct {
id string
work func(context.Context) error
}{
{"time", func(ctx context.Context) error {
settings, err := store.TimeSettings(ctx)
if err != nil {
return err
}
controller, ok := timeController.(systemtime.SettingsController)
if !ok {
return fmt.Errorf("time settings controller unavailable")
}
if !timeSettingsApplied {
if err := controller.Configure(ctx, systemtime.Settings{Timezone: settings.Timezone, NTPEnabled: settings.Mode == "ntp", Servers: settings.NTPServers}); err != nil {
return err
}
timeSettingsApplied = true
}
if time.Now().Year() < 2000 {
return fmt.Errorf("waiting for initial clock synchronization")
}
return nil
}},
{"network", func(ctx context.Context) error {
if allowMockActors {
return nil
}
return store.ReconcileStartupNetwork(ctx)
}},
{"logs", store.ReconcileStartupLogs},
{"policies", func(ctx context.Context) error {
if err := store.VerifyStartupPolicies(ctx); err != nil {
return err
}
if allowMockActors {
return nil
}
if health, ok := alarmDeliveryController.(interface{ Health(context.Context) error }); ok {
return health.Health(ctx)
}
return fmt.Errorf("alarm service health check unavailable")
}},
} {
item := item
startupTasks = append(startupTasks, requiredStartupTask(startupContext, boot, item.id, func() error {
ctx, cancel := context.WithTimeout(startupContext, 20*time.Second)
defer cancel()
return item.work(ctx)
}))
}
bootIDPath := getenv("TERA_API_BOOT_ID_PATH", "/proc/sys/kernel/random/boot_id")
bootMarkerPath := getenv("TERA_API_BOOT_MARKER_PATH", "/var/lib/tera-api/last-notified-boot-id")
startupTasks = append(startupTasks, func() {
startupTasks = append(startupTasks, timedStartupTask("cold_start_alarm", func() {
if bootID, unnotified, bootErr := unnotifiedKernelBoot(bootIDPath, bootMarkerPath); bootErr != nil {
log.Printf("tera-api cold-start detection degraded: %v", bootErr)
} else if unnotified {
@ -210,25 +305,28 @@ func main() {
log.Printf("tera-api cold-start marker degraded: %v", markerErr)
}
}
})
startupTasks = append(startupTasks, func() {
}))
startupTasks = append(startupTasks, requiredStartupTask(startupContext, boot, "tls", func() error {
reconcileContext, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
reconcileErr := store.ReconcileTLSRuntime(reconcileContext)
if reconcileErr != nil {
log.Printf("tera-api TLS certificate reconciliation degraded: %v", reconcileErr)
}
})
startupTasks = append(startupTasks, func() {
return reconcileErr
}))
startupTasks = append(startupTasks, requiredStartupTask(startupContext, boot, "snmp", func() error {
reconcileContext, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
reconcileErr := store.ReconcileSNMPAgentRuntime(reconcileContext)
if reconcileErr != nil {
log.Printf("tera-api SNMP agent reconciliation degraded: %v", reconcileErr)
}
})
return reconcileErr
}))
var portServiceController portservice.Controller = portservice.NoopController{}
if getenvBool("TERA_API_PORT_SERVICES_ENABLED", !allowMockActors) {
boot.Add("port_services", "포트 서비스 설정")
signer, signerErr := portservice.LoadOrCreateSSHSigner(getenv("TERA_API_PORT_SSH_HOST_KEY", "/var/lib/tera-api/port-service-ed25519"))
if signerErr != nil {
log.Fatalf("tera-api channel SSH host key setup failed: %v", signerErr)
@ -252,38 +350,43 @@ func main() {
}
defer gateway.Close()
portServiceController = gateway
startupTasks = append(startupTasks, func() {
startupTasks = append(startupTasks, requiredStartupTask(startupContext, boot, "port_services", func() error {
reconcileContext, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
snapshot, snapshotErr := store.ConfigExport(reconcileContext)
if snapshotErr != nil {
log.Printf("tera-api channel operation reconciliation degraded: %v", snapshotErr)
return
return snapshotErr
}
servicePorts, credentialErr := store.ResolvePortServiceCredentials(reconcileContext, "running", snapshot.Running.Ports)
if credentialErr != nil {
log.Printf("tera-api channel operation credential reconciliation degraded: %v", credentialErr)
return credentialErr
} else if applyErr := gateway.Apply(reconcileContext, servicePorts); applyErr != nil {
log.Printf("tera-api channel operation reconciliation degraded: %v", applyErr)
return applyErr
}
})
return nil
}))
}
startupTasks = append(startupTasks, func() {
startupTasks = append(startupTasks, requiredStartupTask(startupContext, boot, "management", func() error {
reconcileContext, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
reconcileErr := reconcileManagementRuntime(reconcileContext, store, managementController)
if reconcileErr != nil {
log.Printf("tera-api management access reconciliation degraded: %v", reconcileErr)
}
})
startupTasks = append(startupTasks, func() {
return reconcileErr
}))
startupTasks = append(startupTasks, requiredStartupTask(startupContext, boot, "serial", func() error {
reconcileContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
reconcileErr := reconcileSerialRuntime(reconcileContext, store, serialController)
reconcileErr := retryStartupSerial(reconcileContext, func() error { return reconcileSerialRuntime(reconcileContext, store, serialController) })
if reconcileErr != nil {
log.Printf("tera-api serial runtime reconciliation degraded: %v", reconcileErr)
}
})
return reconcileErr
}))
logRuntimeContext, stopLogRuntime := context.WithCancel(context.Background())
defer stopLogRuntime()
if resetButton, ok := systemController.(resetButtonStateReader); ok {
@ -358,7 +461,7 @@ func main() {
go func() { ticketErrors <- ticketServer.Serve(listener) }()
log.Printf("tera-api serving Direct Mode tickets on %s", ticketSocketPath)
}
var startupReady atomic.Bool
handler := api.NewRouterWithOptions(store, api.RouterOptions{
CORSAllowedOrigins: corsOrigins,
MaxRequestBytes: maxRequestBytes,
@ -372,7 +475,9 @@ func main() {
SystemMetricsReader: systemmetrics.NewLinuxReader(systemmetrics.LinuxReaderOptions{}),
MaxFirmwareBytes: int64(getenvInt("TERA_API_UPDATE_MAX_BYTES", 1280<<20)),
FirmwareTimeout: getenvDurationSeconds("TERA_API_FIRMWARE_TIMEOUT_SECONDS", 30*time.Minute),
CommandsReady: startupReady.Load,
CommandsReady: func() bool { return boot.Snapshot().Ready },
StartupStatus: boot.Snapshot,
ClockBootstrap: clockBootstrap,
})
server := &http.Server{
@ -389,8 +494,27 @@ func main() {
go func() { serverErrors <- server.ListenAndServe() }()
go func() {
runStartupTasks(startupTasks)
startupReady.Store(true)
log.Printf("tera-api runtime synchronization completed")
observationAfter := time.Now()
if err := boot.Retry(startupContext, "port_observation", 25*time.Second, time.Second, func() error {
ctx, cancel := context.WithTimeout(startupContext, 3*time.Second)
defer cancel()
return verifyStartupPortObservation(ctx, serialController, !allowMockActors, observationAfter)
}, func(err error) { log.Printf("tera-api initial port observation pending: %v", err) }); err != nil {
return
}
err := boot.Retry(startupContext, "health", 25*time.Second, 3*time.Second, func() error {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
return waitStartupServices(ctx, serialController, sessionController, !allowMockActors)
}, func(err error) { log.Printf("tera-api startup health retry: %v", err) })
if err != nil {
log.Printf("tera-api startup health failed: %v", err)
}
if !boot.Snapshot().Ready {
log.Printf("tera-api startup incomplete; login remains blocked")
return
}
log.Printf("tera-api runtime synchronization completed elapsed_ms=%d", time.Since(startupStarted).Milliseconds())
}()
signalContext, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stopSignals()
@ -522,7 +646,12 @@ func reconcileSerialRuntime(ctx context.Context, store serialRuntimeConfigStore,
_, lineErr := controller.Apply(ctx, lineConfigs)
var captureErr error
if captureController, ok := controller.(serial.CaptureController); ok {
captureErr = captureController.ApplyCapture(ctx, captureConfigs)
// Keep each request below seriald's 16 KiB control message limit.
for offset := 0; offset < len(captureConfigs); offset += 8 {
if captureErr = captureController.ApplyCapture(ctx, captureConfigs[offset:min(offset+8, len(captureConfigs))]); captureErr != nil {
break
}
}
}
return errors.Join(lineErr, captureErr)
}

2
backend/cmd/tera-api/main_test.go

@ -37,7 +37,7 @@ func (s *serialRuntimeControllerStub) Apply(_ context.Context, configs []serial.
return nil, s.lineErr
}
func (s *serialRuntimeControllerStub) ApplyCapture(_ context.Context, configs []serial.CaptureConfig) error {
s.capture = append([]serial.CaptureConfig(nil), configs...)
s.capture = append(s.capture, configs...)
return nil
}

46
backend/cmd/tera-api/startup_observation_test.go

@ -0,0 +1,46 @@
package main
import (
"context"
"fmt"
"tera-clone/services/tera-api/internal/serial"
"testing"
"time"
)
type observedPorts struct {
serial.MockController
observations []serial.CRProbeState
}
func (p observedPorts) CRProbeStates(context.Context) ([]serial.CRProbeState, error) {
return p.observations, nil
}
func TestInitialObservationWaitsForEveryChannel(t *testing.T) {
now := time.Now()
p := observedPorts{}
for i := 1; i <= 48; i++ {
p.observations = append(p.observations, serial.CRProbeState{Channel: fmt.Sprintf("CH%02d", i), Enabled: true, State: serial.CRProbeStateNoResponse, LastProbeAt: &now})
}
if err := verifyStartupPortObservation(context.Background(), p, true); err != nil {
t.Fatal(err)
}
for _, state := range []string{serial.CRProbeStatePending, serial.CRProbeStateConfirming, serial.CRProbeStateError, serial.CRProbeStateBusy, serial.CRProbeStateSuppressed} {
p.observations[47].State = state
if err := verifyStartupPortObservation(context.Background(), p, true); err == nil {
t.Fatalf("accepted incomplete last channel %s", state)
}
}
p.observations[47].State = serial.CRProbeStateResponded
if err := verifyStartupPortObservation(context.Background(), p, true); err != nil {
t.Fatal(err)
}
if err := verifyStartupPortObservation(context.Background(), p, true, now.Add(time.Second)); err == nil {
t.Fatal("stale observation accepted")
}
p.observations[47].Channel = "CH01"
if err := verifyStartupPortObservation(context.Background(), p, true); err == nil {
t.Fatal("duplicate accepted")
}
}

33
backend/cmd/tera-api/startup_retry_test.go

@ -0,0 +1,33 @@
package main
import (
"context"
"errors"
"tera-clone/services/tera-api/internal/serial"
"testing"
)
func TestStartupSerialRetriesOnlyBusy(t *testing.T) {
calls := 0
err := retryStartupSerial(context.Background(), func() error {
calls++
if calls == 1 {
return serial.ErrStreamLeaseBusy
}
return nil
})
if err != nil || calls != 2 {
t.Fatalf("calls=%d err=%v", calls, err)
}
calls = 0
failure := errors.New("invalid config")
err = retryStartupSerial(context.Background(), func() error { calls++; return failure })
if !errors.Is(err, failure) || calls != 1 {
t.Fatal("non-busy failure retried")
}
ctx, cancel := context.WithCancel(context.Background())
err = retryStartupSerial(ctx, func() error { cancel(); return serial.ErrStreamLeaseBusy })
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
}

130
backend/cmd/tera-api/startup_timing.go

@ -0,0 +1,130 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"tera-clone/services/tera-api/internal/serial"
"tera-clone/services/tera-api/internal/sessionhealth"
"tera-clone/services/tera-api/internal/startup"
"time"
)
func retryStartupSerial(ctx context.Context, work func() error) error {
for {
if err := ctx.Err(); err != nil {
return err
}
err := work()
if !errors.Is(err, serial.ErrStreamLeaseBusy) {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(125 * time.Millisecond):
}
}
}
func logStartupTiming(stage string, elapsed time.Duration, err error) {
if err != nil {
log.Printf("tera-api startup stage=%s elapsed_ms=%d error=%v", stage, elapsed.Milliseconds(), err)
return
}
log.Printf("tera-api startup stage=%s elapsed_ms=%d", stage, elapsed.Milliseconds())
}
func requiredStartupTask(ctx context.Context, boot *startup.Tracker, name string, task func() error) func() {
return func() {
started := time.Now()
err := boot.Retry(ctx, name, 25*time.Second, 3*time.Second, task, func(err error) { log.Printf("tera-api startup retry stage=%s error=%v", name, err) })
logStartupTiming("runtime."+name, time.Since(started), err)
}
}
func waitStartupServices(ctx context.Context, ports serial.Controller, sessions sessionhealth.Controller, requireSession bool) error {
for {
probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
states, err := ports.States(probeCtx)
if err == nil && len(states) != serial.ChannelCount {
err = fmt.Errorf("serial channel count %d", len(states))
}
if err == nil {
for _, s := range states {
if !s.Available || !s.ProfileApplied {
err = fmt.Errorf("serial channel unavailable")
break
}
}
}
if err == nil && requireSession {
status, e := sessions.Status(probeCtx)
err = e
if err == nil && status.Status != "ok" {
err = fmt.Errorf("session service not ready")
}
}
cancel()
if err == nil {
return nil
}
select {
case <-ctx.Done():
return fmt.Errorf("service verification: %w", err)
case <-time.After(time.Second):
}
}
}
func timedStartupTask(name string, task func()) func() {
return func() {
started := time.Now()
defer func() { logStartupTiming("runtime."+name, time.Since(started), nil) }()
task()
}
}
func verifyStartupPortObservation(ctx context.Context, ports serial.Controller, required bool, after ...time.Time) error {
observer, ok := ports.(serial.CRProbeStateController)
if !ok {
if required {
return fmt.Errorf("initial port observation unavailable")
}
return nil
}
states, err := observer.CRProbeStates(ctx)
if err != nil {
return err
}
if len(states) != serial.ChannelCount {
return fmt.Errorf("initial port observation incomplete")
}
seen := make(map[string]bool)
for _, state := range states {
if state.Channel < "CH01" || state.Channel > "CH48" || seen[state.Channel] {
return fmt.Errorf("invalid initial port observation")
}
seen[state.Channel] = true
if !state.Enabled && state.State == serial.CRProbeStateDisabled {
continue
}
if len(after) > 0 && (state.LastProbeAt == nil || state.LastProbeAt.Before(after[0])) {
return fmt.Errorf("port %s awaiting observation after settings applied", state.Channel)
}
switch state.State {
case serial.CRProbeStateResponded, serial.CRProbeStateNoResponse:
if state.LastProbeAt == nil {
return fmt.Errorf("port %s has no observation", state.Channel)
}
case serial.CRProbeStateBusy, serial.CRProbeStateSuppressed:
if state.LastConfirmedAt == nil && state.ConsecutiveMisses == 0 {
return fmt.Errorf("port %s awaiting first observation", state.Channel)
}
default:
return fmt.Errorf("port %s initial observation pending (%s)", state.Channel, state.State)
}
}
return nil
}

43
backend/deploy/buildroot/etc/init.d/S59snmpd

@ -0,0 +1,43 @@
#!/bin/sh
# The privileged adapter writes configuration only after this stop completes.
DAEMON=/usr/sbin/snmpd
CONFIG=/etc/snmp/snmpd.conf
PIDFILE=/var/run/snmpd.pid
SNMPDRUN=yes
SNMPDOPTS='-Lsd -Lf /dev/null -p /var/run/snmpd.pid'
export SNMP_PERSISTENT_DIR=/var/lib/net-snmp
[ ! -r /etc/default/snmpd ] || . /etc/default/snmpd
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE")"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
[ "$SNMPDRUN" = yes ] && [ -r "$CONFIG" ] || return 0
grep -Eq '^[[:space:]]*agentAddress[[:space:]]+' "$CONFIG" || return 0
if is_running; then return 0; fi
mkdir -p "$SNMP_PERSISTENT_DIR" || return 1
chmod 0700 "$SNMP_PERSISTENT_DIR" || return 1
start-stop-daemon -q -S -x "$DAEMON" -- $SNMPDOPTS
}
stop_service() {
if ! is_running; then return 0; fi
stopped_pid="$pid"
start-stop-daemon -q -K -o -p "$PIDFILE" -x "$DAEMON" || return 1
attempts=0
while kill -0 "$stopped_pid" 2>/dev/null; do
attempts=$((attempts + 1))
[ "$attempts" -lt 50 ] || return 1
sleep 0.1
done
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
reload|force-reload) if is_running; then kill -HUP "$pid"; fi ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|reload|force-reload|status}" >&2; exit 1 ;;
esac

5
backend/deploy/etc/tera/tera-accessd.env

@ -1,8 +1,9 @@
TERA_ACCESSD_SOCKET=/run/tera-accessd/control.sock
TERA_ACCESSD_BIND_HOST=0.0.0.0
TERA_ACCESSD_WEB_UPSTREAM=http://127.0.0.1:8083
TERA_ACCESSD_STATIC_ROOT=/usr/share/tera-web
TERA_ACCESSD_API_UPSTREAM=http://127.0.0.1:8082
TERA_ACCESSD_SESSION_UPSTREAM=http://127.0.0.1:8081
TERA_ACCESSD_TLS_CERT=/var/lib/tera-api/tls/active.crt
TERA_ACCESSD_TLS_KEY=/var/lib/tera-api/tls/active.key
TERA_ACCESSD_TLS_CERT=/var/lib/tera-clone-demo-api/tls/active.crt
TERA_ACCESSD_TLS_KEY=/var/lib/tera-clone-demo-api/tls/active.key
TERA_ACCESSD_TLS_HOSTS=192.168.0.223,localhost,127.0.0.1

1
backend/deploy/etc/tera/tera-clone-demo-web.env

@ -1,4 +1,3 @@
PORT=8083
TERACLONE_API_UPSTREAM=http://127.0.0.1:8082
TERACLONE_SESSION_UPSTREAM=http://127.0.0.1:8081
TERACLONE_DB_PATH=/var/lib/tera-clone-demo-web/teraclone.db

11
backend/internal/adapters/mock/factory_auth.go

@ -0,0 +1,11 @@
package mock
import "tera-clone/services/tera-api/internal/security/password"
func factoryAuthUsers(kdf password.KDF) (map[string]authUserRecord, error) {
admin, err := newAuthUser("terauser", "terauser", "administrator", permissionsForRole("administrator"), "terauser", kdf)
if err != nil {
return nil, err
}
return map[string]authUserRecord{"terauser": admin}, nil
}

132
backend/internal/adapters/mock/factory_reset.go

@ -0,0 +1,132 @@
package mock
import (
"context"
"errors"
"fmt"
"time"
"tera-clone/services/tera-api/internal/alarmdelivery"
"tera-clone/services/tera-api/internal/directstream"
"tera-clone/services/tera-api/internal/domain"
"tera-clone/services/tera-api/internal/logservice"
"tera-clone/services/tera-api/internal/networkconfig"
)
// FactoryReset is shared by the authenticated web API and the physical SW2
// monitor. Never expose narrower UI-only deletion with a different scope.
func (s *Store) FactoryReset(ctx context.Context) (domain.Operation, error) {
s.resetGate.Lock()
defer s.resetGate.Unlock()
if err := ctx.Err(); err != nil {
return domain.Operation{}, err
}
if s.logJournal != nil {
if _, ok := s.logJournal.(logservice.FactoryResetter); !ok {
return domain.Operation{}, errors.New("log service does not support factory reset")
}
}
if s.supportBundleService != nil {
if _, ok := s.supportBundleService.(interface{ Reset() error }); !ok {
return domain.Operation{}, errors.New("support bundle service does not support factory reset")
}
}
if _, ok := s.networkController.(networkconfig.FactoryResetter); !ok {
return domain.Operation{}, errors.New("network service does not support factory reset")
}
if _, ok := s.alarmDeliveryController.(alarmdelivery.FactoryResetter); !ok {
return domain.Operation{}, errors.New("alarm service does not support factory reset")
}
users, err := factoryAuthUsers(s.passwordKDF)
if err != nil {
return domain.Operation{}, err
}
// Stop diagnostic producers before removing their artifacts. Reboot starts
// them again; a failed reset remains visible and may be retried.
s.Close()
s.mu.Lock()
defer s.mu.Unlock()
if s.factoryResetCleanup != nil {
if err := s.factoryResetCleanup(); err != nil {
return domain.Operation{}, err
}
}
if err := s.alarmDeliveryController.(alarmdelivery.FactoryResetter).FactoryReset(ctx); err != nil {
return domain.Operation{}, err
}
if s.supportBundleService != nil {
if err := s.supportBundleService.(interface{ Reset() error }).Reset(); err != nil {
return domain.Operation{}, err
}
}
if s.logJournal != nil {
if err := s.logJournal.(logservice.FactoryResetter).FactoryReset(); err != nil {
return domain.Operation{}, err
}
}
if err := s.secrets.Reset(); err != nil {
return domain.Operation{}, err
}
metadata, err := s.tlsCertificates.Reset()
if err != nil {
return domain.Operation{}, err
}
s.resetConfigLocked()
if err := s.applyJournalRetentionLocked(s.runningRetention); err != nil {
return domain.Operation{}, err
}
if err := s.networkController.(networkconfig.FactoryResetter).FactoryReset(ctx, networkConfigForController(s.runningNetwork)); err != nil {
return domain.Operation{}, err
}
snmpConfig, err := s.snmpAgentRuntimeConfigLocked("running", s.runningSNMPAgent)
if err != nil {
return domain.Operation{}, err
}
if err := s.snmpAgentController.Apply(ctx, snmpConfig); err != nil {
return domain.Operation{}, err
}
s.runningTLS = tlsCertificateConfigFromMetadata(metadata)
s.persistentTLS = s.runningTLS
s.authUsers = users
s.authGroupOverrides = make(map[string]authGroupRecord)
s.authTokens = make(map[string]authTokenRecord)
s.sessions = make(map[string]sessionRecord)
s.loginFailures = make(map[string]loginFailureRecord)
s.addressFilters = domain.AddressFilterSettings{Mode: "blacklist"}
s.authTokenSeq, s.sessionSeq = 0, 0
s.directSessions = make(map[string]directSessionRecord)
s.directTickets = directstream.NewTicketStore(directstream.TicketStoreOptions{Clock: func() time.Time { return s.clock() }})
s.operations = make(map[string]domain.Operation)
s.auditEvents = nil
s.systemLogEvents = nil
s.portLogEvents = nil
s.linkLogEvents = nil
s.allowSampleLogs = false
s.auditSeq, s.operationSeq = 0, 0
s.journalLast = make(map[string]string)
s.journalSequence = make(map[string]uint64)
s.journalLocalSequences = make(map[string]map[uint64]struct{})
s.networkRollback = nil
s.networkTransactionID = ""
for _, persist := range []func() error{s.persistConfigStoreLocked, s.persistAuthStoreLocked, s.persistSessionStoreLocked, s.persistLoginFailureStoreLocked, s.persistOperationStoreLocked, s.persistAuditLogLocked, s.persistSystemLogStoreLocked, s.persistPortLogStoreLocked, s.persistLinkLogStoreLocked} {
if err := persist(); err != nil {
return domain.Operation{}, fmt.Errorf("persist factory reset: %w", err)
}
}
s.resetComplete = true
if err := s.systemController.Reboot(ctx); err != nil {
return domain.Operation{}, fmt.Errorf("data reset completed but reboot failed: %w", err)
}
return s.newOperationLocked(ctx, "system.factory_reset", "system", "all device data erased; only terauser restored; reboot scheduled")
}
// BeginMutation drains already accepted HTTP writes before a physical or web
// reset and rejects subsequent writes until reboot, preventing resurrection.
func (s *Store) BeginMutation() (func(), bool) {
s.resetGate.RLock()
if s.resetComplete {
s.resetGate.RUnlock()
return func() {}, false
}
return s.resetGate.RUnlock, true
}

164
backend/internal/adapters/mock/factory_reset_test.go

@ -0,0 +1,164 @@
package mock
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"tera-clone/services/tera-api/internal/domain"
"tera-clone/services/tera-api/internal/supportbundle"
"tera-clone/services/tera-api/internal/tlscert"
"testing"
)
type resetRebooter struct {
calls int
check func()
}
func (r *resetRebooter) Reboot(context.Context) error {
if r.check != nil {
r.check()
}
r.calls++
return nil
}
func TestFactoryResetErasesPersistedDataAndRestoresOnlyTerauser(t *testing.T) {
root := t.TempDir()
certOptions := tlscert.Options{Root: filepath.Join(root, "tls"), Clock: fixedClock}
certs, err := tlscert.Open(certOptions)
if err != nil {
t.Fatal(err)
}
bundles, err := supportbundle.NewManager(supportbundle.Options{Directory: filepath.Join(root, "bundles")})
if err != nil {
t.Fatal(err)
}
reboot := &resetRebooter{}
options := StoreOptions{ConfigPath: filepath.Join(root, "config.json"), AuthPath: filepath.Join(root, "auth.json"), SessionPath: filepath.Join(root, "sessions.json"), LoginFailurePath: filepath.Join(root, "failures.json"), SecretPath: filepath.Join(root, "secrets.json"), OperationPath: filepath.Join(root, "operations.json"), AuditPath: filepath.Join(root, "audit.jsonl"), SystemLogPath: filepath.Join(root, "system.jsonl"), PortLogPath: filepath.Join(root, "port.jsonl"), LinkLogPath: filepath.Join(root, "link.jsonl"), TLSCertificateStore: certs, SupportBundleService: bundles, SystemController: reboot}
store, err := NewStoreWithOptions(fixedClock, options)
if err != nil {
t.Fatal(err)
}
defer store.Close()
oldSession, err := store.Login(context.Background(), domain.AuthLoginRequest{Username: "admin", Password: "mock-admin-secret"})
if err != nil {
t.Fatal(err)
}
if err := store.secrets.Set("customer/secret", []byte("customer-secret-marker")); err != nil {
t.Fatal(err)
}
store.systemLogEvents = []domain.SystemLogEvent{{Message: "old-system-marker"}}
store.portLogEvents = []domain.PortLogEvent{{Message: "old-port-marker"}}
store.linkLogEvents = []domain.LinkLogEvent{{Message: "old-link-marker"}}
store.authGroupOverrides["customer"] = authGroupRecord{Name: "Customer", Role: "port_operator"}
store.addressFilters = domain.AddressFilterSettings{Enabled: true, Mode: "whitelist"}
store.loginFailures["customer"] = loginFailureRecord{}
if _, err := certs.Generate("customer", tlscert.Subject{CommonName: "customer.example"}); err != nil {
t.Fatal(err)
}
if _, err := bundles.Create(context.Background(), supportbundle.CreateRequest{BundleID: "support_bundle_20260908_000001", GeneratedAt: fixedClock(), Sections: []supportbundle.Section{{Name: "config.json", Value: "old-bundle-marker"}}}); err != nil {
t.Fatal(err)
}
store.runningManagement.CaptchaEnabled = false
store.persistentManagement.CaptchaEnabled = false
reboot.check = func() {
content, err := os.ReadFile(options.AuthPath)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(content), `"admin"`) || !strings.Contains(string(content), `"terauser"`) {
t.Fatal("reboot requested before account reset was persisted")
}
}
if _, err := store.FactoryReset(context.Background()); err != nil {
t.Fatal(err)
}
if !store.runningManagement.CaptchaEnabled || !store.persistentManagement.CaptchaEnabled {
t.Fatal("factory reset disabled CAPTCHA")
}
if reboot.calls != 1 {
t.Fatal("reset did not request reboot")
}
if _, err := store.ActorBySession(context.Background(), oldSession.SessionToken); err == nil {
t.Fatal("old session survived")
}
if len(store.authUsers) != 1 || len(store.authGroupOverrides) != 0 || len(store.authTokens) != 0 || len(store.loginFailures) != 0 || store.addressFilters.Enabled {
t.Fatal("security state survived reset")
}
if store.secrets.Has("customer/secret") {
t.Fatal("stored credential survived")
}
if len(certs.List("factory-default")) != 1 {
t.Fatal("customer certificate survived")
}
if _, err := bundles.Read(context.Background(), "support_bundle_20260908_000001"); err == nil {
t.Fatal("support bundle survived")
}
for _, path := range []string{options.SystemLogPath, options.PortLogPath, options.LinkLogPath, options.AuditPath} {
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(content), "old-") {
t.Fatalf("old log survived in %s", path)
}
}
options.RemoveLegacyAuthUsers = true
options.TLSCertificateStore, err = tlscert.Open(certOptions)
if err != nil {
t.Fatal(err)
}
reopened, err := NewStoreWithOptions(fixedClock, options)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if !reopened.runningManagement.CaptchaEnabled {
t.Fatal("CAPTCHA disabled after reboot")
}
if len(reopened.authUsers) != 1 {
t.Fatalf("bootstrap accounts resurrected: %d", len(reopened.authUsers))
}
if _, err := reopened.Login(context.Background(), domain.AuthLoginRequest{Username: "terauser", Password: "terauser"}); err != nil {
t.Fatal(err)
}
if _, err := reopened.Login(context.Background(), domain.AuthLoginRequest{Username: "admin", Password: "mock-admin-secret"}); err == nil {
t.Fatal("old account works after restart")
}
}
func TestFactoryResetCleanupFailureDoesNotRebootOrClearAccounts(t *testing.T) {
reboot := &resetRebooter{}
store, err := NewStoreWithOptions(fixedClock, StoreOptions{SystemController: reboot, FactoryResetCleanup: func() error { return errors.New("cleanup failed") }})
if err != nil {
t.Fatal(err)
}
defer store.Close()
if _, err := store.FactoryReset(context.Background()); err == nil {
t.Fatal("reset unexpectedly succeeded")
}
if reboot.calls != 0 || len(store.authUsers) != 3 {
t.Fatal("failed cleanup reported success or changed accounts")
}
}
func TestFactoryResetBlocksFurtherMutations(t *testing.T) {
s := NewStore(fixedClock)
defer s.Close()
release, ok := s.BeginMutation()
if !ok {
t.Fatal("new store rejected writes")
}
release()
if _, err := s.FactoryReset(context.Background()); err != nil {
t.Fatal(err)
}
release, ok = s.BeginMutation()
defer release()
if ok {
t.Fatal("writes allowed after reset before reboot")
}
}

90
backend/internal/adapters/mock/log_export_formats.go

@ -0,0 +1,90 @@
package mock
import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"sort"
"strings"
)
func logExportFormat(value string) (string, error) {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
value = "jsonl"
}
if value != "jsonl" && value != "json" && value != "csv" {
return "", fmt.Errorf("format must be csv, json, or jsonl")
}
return value, nil
}
func writeLogExport(w io.Writer, format string, rows []any) error {
format, err := logExportFormat(format)
if err != nil {
return err
}
if format == "json" {
return json.NewEncoder(w).Encode(rows)
}
if format == "jsonl" {
e := json.NewEncoder(w)
for _, row := range rows {
if err := e.Encode(row); err != nil {
return err
}
}
return nil
}
objects := make([]map[string]json.RawMessage, 0, len(rows))
keys := map[string]bool{}
for _, row := range rows {
b, err := json.Marshal(row)
if err != nil {
return err
}
var object map[string]json.RawMessage
if err = json.Unmarshal(b, &object); err != nil {
return err
}
objects = append(objects, object)
for key := range object {
keys[key] = true
}
}
columns := make([]string, 0, len(keys))
for key := range keys {
columns = append(columns, key)
}
sort.Strings(columns)
if _, err := io.WriteString(w, "\ufeff"); err != nil {
return err
}
writer := csv.NewWriter(w)
if err := writer.Write(columns); err != nil {
return err
}
for _, object := range objects {
values := make([]string, len(columns))
for i, key := range columns {
raw := object[key]
if len(raw) == 0 {
continue
}
var value string
if json.Unmarshal(raw, &value) != nil {
value = string(raw)
}
trimmed := strings.TrimLeft(value, " \t\r\n")
if len(trimmed) > 0 && strings.ContainsRune("=+-@", rune(trimmed[0])) {
value = "'" + value
}
values[i] = value
}
if err := writer.Write(values); err != nil {
return err
}
}
writer.Flush()
return writer.Error()
}

18
backend/internal/adapters/mock/log_port_scope.go

@ -0,0 +1,18 @@
package mock
import (
"context"
"tera-clone/services/tera-api/internal/domain"
)
func logPortVisible(ctx context.Context, channel string) bool {
actor, ok := domain.ActorFromContext(ctx)
if !ok {
return true
}
access := actor.PortAccess
if access == nil {
access = defaultPortAccessForRole(actor.Role)
}
return access[channel] == "read" || access[channel] == "write"
}

45
backend/internal/adapters/mock/log_web_status.go

@ -0,0 +1,45 @@
package mock
import (
"context"
"fmt"
"tera-clone/services/tera-api/internal/alarmdelivery"
)
func (s *Store) LogStorageStatus(ctx context.Context) (map[string]any, error) {
if s.logJournal == nil {
return nil, fmt.Errorf("log storage unavailable")
}
snapshot, err := s.logJournal.Snapshot()
if err != nil {
return nil, err
}
retention, err := s.LogRetention(ctx)
if err != nil {
return nil, err
}
return map[string]any{"storage": snapshot, "retention": retention, "usage_percent": protectedLogUsagePercent(snapshot), "warning": protectedLogUsagePercent(snapshot) >= retention.ProtectedUsageWarningPct}, nil
}
func (s *Store) rememberSyslog(result alarmdelivery.Result, err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.lastSyslogResult = result
s.lastSyslogError = ""
if err != nil {
s.lastSyslogError = err.Error()
}
}
func (s *Store) RemoteSyslogStatus(ctx context.Context) (map[string]any, error) {
s.mu.Lock()
result, lastError, enabled := s.lastSyslogResult, s.lastSyslogError, s.runningSyslog.Enabled
s.mu.Unlock()
if result.DeliveryID != "" {
latest, err := s.alarmDeliveryController.Status(ctx, result.DeliveryID)
if err != nil {
lastError = err.Error()
} else {
result = latest
}
}
return map[string]any{"enabled": enabled, "delivery": result, "error": lastError, "observed": result.DeliveryID != "" || lastError != ""}, nil
}

55
backend/internal/adapters/mock/log_web_test.go

@ -0,0 +1,55 @@
package mock
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"strings"
"tera-clone/services/tera-api/internal/domain"
"testing"
)
func TestPortLogScopeAppliesBeforePaginationAndExport(t *testing.T) {
s := NewStore(fixedClock)
defer s.Close()
s.portLogLoaded = true
s.portLogEvents = []domain.PortLogEvent{{Channel: "CH01", Timestamp: fixedClock(), EventType: "session", Message: "private-one"}, {Channel: "CH18", Timestamp: fixedClock(), EventType: "session", Message: "allowed-eighteen"}}
ctx := domain.WithActor(context.Background(), domain.Actor{Name: "operator", Role: "port_operator", PortAccess: map[string]string{"CH18": "read"}})
rows, err := s.PortLogEvents(ctx, domain.PortLogFilter{Limit: 1})
if err != nil || len(rows) != 1 || rows[0].Channel != "CH18" {
t.Fatalf("scope/page: %#v %v", rows, err)
}
for _, format := range []string{"csv", "json", "jsonl"} {
var out bytes.Buffer
_, err := s.StreamLogExport(ctx, domain.LogExportRequest{LogType: "port", Format: format}, &out)
if err != nil {
t.Fatal(err)
}
if strings.Contains(out.String(), "private-one") || !strings.Contains(out.String(), "allowed-eighteen") {
t.Fatalf("leaking export %s: %s", format, out.String())
}
}
}
func TestCSVExportQuotesCellsAndBlocksSpreadsheetFormulas(t *testing.T) {
var out bytes.Buffer
rows := []any{map[string]string{"message": "=HYPERLINK(\"bad\")", "actor": "a,b\nc"}}
if err := writeLogExport(&out, "csv", rows); err != nil {
t.Fatal(err)
}
data, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(out.String(), "\ufeff"))).ReadAll()
if err != nil {
t.Fatal(err)
}
if len(data) != 2 || data[1][0] != "a,b\nc" || !strings.HasPrefix(data[1][1], "'=HYPERLINK") {
t.Fatalf("unsafe CSV: %#v", data)
}
out.Reset()
if err := writeLogExport(&out, "json", rows); err != nil {
t.Fatal(err)
}
var decoded []map[string]string
if err := json.Unmarshal(out.Bytes(), &decoded); err != nil || len(decoded) != 1 {
t.Fatal("invalid JSON export")
}
}

83
backend/internal/adapters/mock/login_time_test.go

@ -0,0 +1,83 @@
package mock
import (
"context"
"errors"
"tera-clone/services/tera-api/internal/domain"
"tera-clone/services/tera-api/internal/systemtime"
"testing"
"time"
)
type loginTimeController struct {
now *time.Time
calls int
fail bool
}
func (c *loginTimeController) Sync(_ context.Context, r systemtime.Request) (systemtime.Result, error) {
c.calls++
if c.fail {
return systemtime.Result{}, errors.New("unavailable")
}
*c.now = r.BrowserTime
return systemtime.Result{AppliedTime: r.BrowserTime, Timezone: r.Timezone}, nil
}
func TestFirstAuthorizedLoginSyncsClockBeforeIssuingSession(t *testing.T) {
now := time.Unix(300, 0).UTC()
controller := &loginTimeController{now: &now}
s, err := NewStoreWithOptions(func() time.Time { return now }, StoreOptions{TimeController: controller})
if err != nil {
t.Fatal(err)
}
defer s.Close()
target := time.Date(2026, 9, 8, 3, 0, 0, 0, time.UTC)
request := domain.AuthLoginRequest{Username: "admin", Password: "wrong", BrowserTime: target}
if _, err := s.Login(context.Background(), request); err == nil || controller.calls != 0 {
t.Fatal("invalid credentials changed clock")
}
if _, err := s.Login(context.Background(), domain.AuthLoginRequest{Username: "port-operator", Password: "mock-port-operator-secret", BrowserTime: target}); err != nil || controller.calls != 0 {
t.Fatal("port operator changed clock")
}
request.Password = "mock-admin-secret"
session, err := s.Login(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if controller.calls != 1 || !now.Equal(target) || !session.ExpiresAt.Equal(target.Add(8*time.Hour)) {
t.Fatalf("clock/session mismatch: calls=%d expires=%v", controller.calls, session.ExpiresAt)
}
if _, err = s.ActorBySession(context.Background(), session.SessionToken); err != nil {
t.Fatalf("session invalid after clock jump: %v", err)
}
request.BrowserTime = target.Add(time.Hour)
if _, err = s.Login(context.Background(), request); err != nil {
t.Fatal(err)
}
if controller.calls != 1 {
t.Fatal("subsequent login changed clock again")
}
}
func TestAutomaticLoginTimeSyncFailureRetriesWithoutBlockingLogin(t *testing.T) {
now := fixedClock()
controller := &loginTimeController{now: &now, fail: true}
s, err := NewStoreWithOptions(func() time.Time { return now }, StoreOptions{TimeController: controller})
if err != nil {
t.Fatal(err)
}
defer s.Close()
request := domain.AuthLoginRequest{Username: "admin", Password: "mock-admin-secret", BrowserTime: now.Add(time.Hour)}
if _, err = s.Login(context.Background(), request); err != nil {
t.Fatal(err)
}
if s.browserTimeSynced {
t.Fatal("failed sync marked successful")
}
controller.fail = false
if _, err = s.Login(context.Background(), request); err != nil {
t.Fatal(err)
}
if controller.calls != 2 || !s.browserTimeSynced {
t.Fatal("failed sync not retried")
}
}

140
backend/internal/adapters/mock/startup_runtime.go

@ -0,0 +1,140 @@
package mock
import (
"context"
"errors"
"fmt"
"reflect"
"strings"
"tera-clone/services/tera-api/internal/alarmdelivery"
"tera-clone/services/tera-api/internal/networkconfig"
)
// ReconcileStartupNetwork checks the owning daemon's committed configuration
// and actual interfaces. Pending user changes are never applied at startup.
func (s *Store) ReconcileStartupNetwork(ctx context.Context) error {
s.mu.Lock()
desired, err := networkconfig.Normalize(networkConfigForController(s.runningNetwork))
s.mu.Unlock()
if err != nil {
return err
}
status, err := s.networkController.Status(ctx)
if err != nil {
return err
}
if status.State == "rollback_failed" || status.State == "pending_confirmation" {
return errors.New("network recovery not complete")
}
if status.Current == nil || !reflect.DeepEqual(desired, *status.Current) || verifyStartupNetworkInterfaces(desired, status) != nil {
result, err := s.networkController.Apply(ctx, desired)
if err != nil {
return err
}
status, err = s.networkController.Status(ctx)
if err == nil {
err = verifyStartupNetworkInterfaces(desired, status)
}
if err != nil {
_, rollbackErr := s.networkController.Rollback(ctx, result.TransactionID)
return errors.Join(err, rollbackErr)
}
status, err = s.networkController.Confirm(ctx, result.TransactionID)
if err != nil {
return err
}
}
return verifyStartupNetworkInterfaces(desired, status)
}
func verifyStartupNetworkInterfaces(config networkconfig.Config, status networkconfig.Status) error {
for _, wanted := range config.Interfaces {
found := false
for _, actual := range status.Interfaces {
if actual.Device != wanted.Device {
continue
}
found = actual.Link != "unknown"
if config.Advanced.Mode == networkconfig.AdvancedModeSingle && wanted.AddressMode == networkconfig.AddressModeStatic && wanted.Address != "" {
matched := false
for _, address := range actual.Addresses {
if address == wanted.Address {
matched = true
}
}
if !matched {
return fmt.Errorf("network address not applied: %s", wanted.Name)
}
}
}
if !found {
return fmt.Errorf("network interface not observed: %s", wanted.Name)
}
}
return nil
}
// Policies are consumed directly from the restored running store per request.
// Validate enabled policies and credential references without sending alarms or
// attempting an external login as a side effect of startup.
func (s *Store) VerifyStartupPolicies(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := ctx.Err(); err != nil {
return err
}
for _, err := range []error{validateAlarmDeliveryPolicy(s.runningAlarmDelivery), validateEmailNotification(s.runningEmail), validateRemoteSyslog(s.runningSyslog), validateExternalAuth(s.runningExternalAuth), validateLogRetention(s.runningRetention)} {
if err != nil {
return err
}
}
if s.runningEmail.Enabled && s.runningEmail.AuthEnabled && !s.secrets.Has(configSecretKey("running", "smtp/password")) {
return errors.New("SMTP credential unavailable")
}
for _, target := range s.runningSNMPTraps {
if !target.Enabled {
continue
}
prefix := "snmp/trap/" + strings.ToLower(target.TargetID)
if !s.secrets.Has(configSecretKey("running", prefix+"/credential")) {
return errors.New("SNMP trap credential unavailable")
}
if target.PrivacyProtocol != "" && target.PrivacyProtocol != "none" && !s.secrets.Has(configSecretKey("running", prefix+"/privacy")) {
return errors.New("SNMP trap privacy credential unavailable")
}
}
if s.runningExternalAuth.Enabled {
for kind, servers := range map[string][]externalAuthServerConfig{"radius": s.runningExternalAuth.RadiusServers, "tacacs": s.runningExternalAuth.TACACSServers} {
for _, server := range servers {
if server.Enabled && !s.secrets.Has(externalAuthSecretKey("running", kind, server.ServerID)) {
return errors.New("external authentication credential unavailable")
}
}
}
}
for _, rule := range s.runningAlarms {
if !rule.Enabled {
continue
}
channels, syslog, email, traps := s.availableDeliveryTargetsLocked(rule.Delivery, "startup validation")
// Disabled delivery targets are intentionally inactive; enabled targets
// must form valid requests before readiness can be reported.
if len(channels) == 0 {
continue
}
_, err := alarmdelivery.NormalizeRequest(alarmdelivery.Request{Message: "startup validation", Channels: channels, Syslog: syslog, Email: email, SNMPTraps: traps, RetryCount: s.runningAlarmDelivery.RetryCount, RetryIntervalSeconds: s.runningAlarmDelivery.RetryIntervalSeconds, QueueLimit: min(s.runningAlarmDelivery.PendingQueueLimit, 1000)})
if err != nil {
return err
}
}
return nil
}
func (s *Store) ReconcileStartupLogs(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := ctx.Err(); err != nil {
return err
}
return s.applyJournalRetentionLocked(s.runningRetention)
}

42
backend/internal/adapters/mock/startup_runtime_test.go

@ -0,0 +1,42 @@
package mock
import (
"context"
"tera-clone/services/tera-api/internal/networkconfig"
"testing"
"time"
)
func TestStartupPoliciesRejectMissingEnabledCredentials(t *testing.T) {
s := NewStore(time.Now)
defer s.Close()
if err := s.VerifyStartupPolicies(context.Background()); err != nil {
t.Fatal(err)
}
s.runningEmail.Enabled = true
s.runningEmail.AuthEnabled = true
s.runningEmail.SMTPHost = "smtp.example"
s.runningEmail.SMTPPort = 587
s.runningEmail.Username = "test"
s.runningEmail.From = "from@example.com"
s.runningEmail.To = []string{"to@example.com"}
if err := s.VerifyStartupPolicies(context.Background()); err == nil {
t.Fatal("missing SMTP secret accepted")
}
}
func TestStartupNetworkRequiresAppliedAddressEvenWhenLinkIsDown(t *testing.T) {
wanted := networkconfig.Config{Advanced: networkconfig.AdvancedConfig{Mode: "single"}, Interfaces: []networkconfig.Interface{{Name: "Interface-1", Device: "eth0", AddressMode: "static", Address: "192.168.0.223/24"}}}
status := networkconfig.Status{Interfaces: []networkconfig.ActualInterface{{Device: "eth0", Link: "down", Addresses: []string{"192.168.0.223/24"}}}}
if err := verifyStartupNetworkInterfaces(wanted, status); err != nil {
t.Fatal(err)
}
status.Interfaces[0].Addresses = nil
if err := verifyStartupNetworkInterfaces(wanted, status); err == nil {
t.Fatal("missing IP accepted")
}
status.Interfaces = nil
if err := verifyStartupNetworkInterfaces(wanted, status); err == nil {
t.Fatal("missing interface accepted")
}
}

95
backend/internal/adapters/mock/startup_test.go

@ -0,0 +1,95 @@
package mock
import (
"os"
"path/filepath"
"testing"
"time"
"tera-clone/services/tera-api/internal/security/password"
)
type startupCountingKDF struct {
password.PrototypeSHA256
hashes int
}
func (k *startupCountingKDF) Hash(value, salt string) (password.Record, error) {
k.hashes++
return k.PrototypeSHA256.Hash(value, salt)
}
func TestStartupReusesPersistedAuthWithoutBootstrapHashes(t *testing.T) {
path := filepath.Join(t.TempDir(), "auth.json")
kdf := &startupCountingKDF{}
stages := map[string]bool{}
options := StoreOptions{AuthPath: path, PasswordKDF: kdf, RemoveLegacyAuthUsers: true,
StartupTiming: func(stage string, elapsed time.Duration, err error) {
stages[stage] = true
if elapsed < 0 || err != nil {
t.Errorf("stage %s: elapsed=%v err=%v", stage, elapsed, err)
}
},
}
first, err := NewStoreWithOptions(time.Now, options)
if err != nil {
t.Fatal(err)
}
if kdf.hashes != 1 {
t.Fatalf("factory boot hashes=%d, want 1", kdf.hashes)
}
first.Close()
before, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
kdf.hashes = 0
second, err := NewStoreWithOptions(time.Now, options)
if err != nil {
t.Fatal(err)
}
defer second.Close()
if kdf.hashes != 0 {
t.Fatalf("restart hashes=%d, want 0", kdf.hashes)
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(before) != string(after) {
t.Fatal("restart changed persisted credentials")
}
for _, stage := range []string{"auth.load", "auth.persist", "store.total"} {
if !stages[stage] {
t.Errorf("missing stage %s", stage)
}
}
}
func TestStartupDoesNotRestoreDeletedAccounts(t *testing.T) {
path := filepath.Join(t.TempDir(), "auth.json")
if err := os.WriteFile(path, []byte(`{"users":{},"address_filters":{"mode":"blacklist","rules":[]}}`), 0600); err != nil {
t.Fatal(err)
}
kdf := &startupCountingKDF{}
s, err := NewStoreWithOptions(time.Now, StoreOptions{AuthPath: path, PasswordKDF: kdf, RemoveLegacyAuthUsers: true})
if err != nil {
t.Fatal(err)
}
defer s.Close()
if kdf.hashes != 0 || len(s.authUsers) != 0 {
t.Fatal("persisted empty inventory was bootstrapped")
}
}
func TestStartupReportsInvalidAuthStore(t *testing.T) {
path := filepath.Join(t.TempDir(), "auth.json")
if err := os.WriteFile(path, []byte(`{invalid`), 0600); err != nil {
t.Fatal(err)
}
failed := map[string]bool{}
_, err := NewStoreWithOptions(time.Now, StoreOptions{AuthPath: path, StartupTiming: func(stage string, _ time.Duration, err error) { failed[stage] = err != nil }})
if err == nil || !failed["auth.load"] || !failed["store.total"] {
t.Fatalf("missing startup failure: %v %v", err, failed)
}
}

205
backend/internal/adapters/mock/store.go

@ -44,6 +44,8 @@ import (
type Clock func() time.Time
type StoreOptions struct {
// StartupTiming observes synchronous startup stages; callbacks must return promptly.
StartupTiming func(stage string, elapsed time.Duration, err error)
LogJournal logservice.Journal
AllowSampleLogs bool
AuditPath string
@ -83,6 +85,7 @@ type StoreOptions struct {
DiagnosticsWorkers int
DiagnosticsQueue int
RemoveLegacyAuthUsers bool
FactoryResetCleanup func() error
}
const (
@ -137,6 +140,12 @@ var mockSupportBundleIncludedSections = []string{
}
type Store struct {
lastSyslogResult alarmdelivery.Result
lastSyslogError string
browserTimeSynced bool // Runtime only: first authorized browser login after service start.
factoryResetCleanup func() error
resetGate sync.RWMutex
resetComplete bool
clock Clock
timeController systemtime.Controller
ntpController systemtime.NTPController
@ -331,13 +340,23 @@ func storePasswordKDF(kdf password.KDF) password.KDF {
return password.NewArgon2id(password.Argon2idDefaultParams())
}
func NewStoreWithOptions(clock Clock, options StoreOptions) (*Store, error) {
func NewStoreWithOptions(clock Clock, options StoreOptions) (_ *Store, startupErr error) {
started := time.Now()
defer func() {
if options.StartupTiming != nil {
options.StartupTiming("store.total", time.Since(started), startupErr)
}
}()
measure := func(name string, work func() error) error {
start := time.Now()
err := work()
if options.StartupTiming != nil {
options.StartupTiming(name, time.Since(start), err)
}
return err
}
passwordKDF := storePasswordKDF(options.PasswordKDF)
passwordKDFs := storePasswordKDFs(options.PasswordKDFs, options.PasswordKDF)
authUsers, err := defaultAuthUsers(passwordKDF)
if err != nil {
return nil, err
}
secrets, err := secretstore.Open(options.SecretPath)
if err != nil {
return nil, err
@ -382,6 +401,7 @@ func NewStoreWithOptions(clock Clock, options StoreOptions) (*Store, error) {
diagnosticsWorkers: normalizeBoundedOption(options.DiagnosticsWorkers, defaultDiagnosticsWorkers, maxDiagnosticsWorkers),
operations: make(map[string]domain.Operation),
logJournal: options.LogJournal,
factoryResetCleanup: options.FactoryResetCleanup,
allowSampleLogs: options.AllowSampleLogs,
journalLast: make(map[string]string),
journalSequence: make(map[string]uint64),
@ -410,7 +430,8 @@ func NewStoreWithOptions(clock Clock, options StoreOptions) (*Store, error) {
minimumPasswordLength: normalizeMinimumPasswordLength(options.MinimumPasswordLength),
passwordKDFs: passwordKDFs,
passwordKDF: passwordKDF,
authUsers: authUsers,
// Bootstrap accounts are generated only if the auth store has no inventory.
authUsers: nil,
authGroupOverrides: make(map[string]authGroupRecord),
authTokens: make(map[string]authTokenRecord),
addressFilters: domain.AddressFilterSettings{Mode: "blacklist", Rules: []domain.AddressFilterRule{}},
@ -461,84 +482,90 @@ func NewStoreWithOptions(clock Clock, options StoreOptions) (*Store, error) {
return nil, errors.New("production log journal cannot be combined with prototype JSONL log paths")
}
if options.ConfigPath != "" {
if err := store.loadConfigStore(); err != nil {
if err := measure("config.load", store.loadConfigStore); err != nil {
return nil, err
}
if err := store.persistConfigStoreLocked(); err != nil {
if err := measure("config.persist", store.persistConfigStoreLocked); err != nil {
return nil, err
}
}
if options.OperationPath != "" {
if err := store.loadOperationStore(); err != nil {
if err := measure("operations.load", store.loadOperationStore); err != nil {
return nil, err
}
if err := store.persistOperationStoreLocked(); err != nil {
if err := measure("operations.persist", store.persistOperationStoreLocked); err != nil {
return nil, err
}
}
if options.AuthPath != "" {
if err := store.loadAuthStore(); err != nil {
if err := measure("auth.load", store.loadAuthStore); err != nil {
return nil, err
}
if err := store.persistAuthStoreLocked(); err != nil {
}
// nil means missing/empty storage; a persisted empty inventory stays empty.
if store.authUsers == nil {
if err := measure("auth.bootstrap", func() error {
var err error
if options.RemoveLegacyAuthUsers {
store.authUsers, err = factoryAuthUsers(passwordKDF)
} else {
store.authUsers, err = defaultAuthUsers(passwordKDF)
}
return err
}); err != nil {
return nil, err
}
}
if options.SessionPath != "" {
if err := store.loadSessionStore(); err != nil {
return nil, err
}
if err := store.persistSessionStoreLocked(); err != nil {
if err := measure("sessions.load", store.loadSessionStore); err != nil {
return nil, err
}
}
if options.LoginFailurePath != "" {
if err := store.loadLoginFailureStore(); err != nil {
return nil, err
}
if err := store.persistLoginFailureStoreLocked(); err != nil {
if err := measure("login_failures.load", store.loadLoginFailureStore); err != nil {
return nil, err
}
}
if options.RemoveLegacyAuthUsers {
store.removeLegacyAuthUsersLocked()
if err := store.persistAuthStoreLocked(); err != nil {
return nil, err
}
if err := store.persistSessionStoreLocked(); err != nil {
return nil, err
}
if err := store.persistLoginFailureStoreLocked(); err != nil {
return nil, err
}
}
// Persist once, after migration and legacy-account cleanup.
if err := measure("auth.persist", store.persistAuthStoreLocked); err != nil {
return nil, err
}
if err := measure("sessions.persist", store.persistSessionStoreLocked); err != nil {
return nil, err
}
if err := measure("login_failures.persist", store.persistLoginFailureStoreLocked); err != nil {
return nil, err
}
if options.LogJournal != nil {
if err := store.applyJournalRetentionLocked(store.runningRetention); err != nil {
if err := measure("logs.retention", func() error { return store.applyJournalRetentionLocked(store.runningRetention) }); err != nil {
return nil, err
}
if err := store.loadJournalLocked(); err != nil {
if err := measure("logs.journal.load", store.loadJournalLocked); err != nil {
return nil, err
}
} else if options.AuditPath != "" {
if err := store.loadAuditLog(); err != nil {
if err := measure("logs.audit.load", store.loadAuditLog); err != nil {
return nil, err
}
if err := store.persistAuditLogLocked(); err != nil {
if err := measure("logs.audit.persist", store.persistAuditLogLocked); err != nil {
return nil, err
}
}
if options.SystemLogPath != "" {
if err := store.loadSystemLogStore(); err != nil {
if err := measure("logs.system.load", store.loadSystemLogStore); err != nil {
return nil, err
}
}
if options.PortLogPath != "" {
if err := store.loadPortLogStore(); err != nil {
if err := measure("logs.port.load", store.loadPortLogStore); err != nil {
return nil, err
}
}
if options.LinkLogPath != "" {
if err := store.loadLinkLogStore(); err != nil {
if err := measure("logs.link.load", store.loadLinkLogStore); err != nil {
return nil, err
}
}
@ -546,7 +573,7 @@ func NewStoreWithOptions(clock Clock, options StoreOptions) (*Store, error) {
store.startDiagnosticsExecutor(normalizeBoundedOption(options.DiagnosticsQueue, defaultDiagnosticsQueue, maxDiagnosticsQueue))
store.mu.Lock()
store.recoverInterruptedDiagnosticsLocked("diagnostic operation interrupted by service restart")
if err := store.persistOperationStoreLocked(); err != nil {
if err := measure("operations.persist", store.persistOperationStoreLocked); err != nil {
store.mu.Unlock()
store.Close()
return nil, err
@ -715,6 +742,18 @@ func (s *Store) Login(ctx context.Context, request domain.AuthLoginRequest) (dom
return domain.AuthSession{}, err
}
actor := s.actorFromAuthUserLocked(user)
if !s.browserTimeSynced && !request.BrowserTime.IsZero() && actor.Permissions["time.sync"] {
before := s.clock().UTC()
result, syncErr := s.timeController.Sync(ctx, systemtime.Request{BrowserTime: request.BrowserTime, Timezone: s.runningTime.Timezone})
outcome, message := "failed", "automatic PC time synchronization failed; retry on next administrator login"
if syncErr == nil {
s.browserTimeSynced = true
now = s.clock().UTC() // Mint the new session after changing the clock.
outcome = "succeeded"
message = fmt.Sprintf("automatic PC time synchronization: %s -> %s", before.Format(time.RFC3339), result.AppliedTime.UTC().Format(time.RFC3339))
}
s.recordAuditEventLocked(enrichAuditEvent(ctx, domain.AuditEvent{Actor: actor.Name, Action: "time.sync_browser", Target: "time", Result: outcome, Message: message}))
}
if _, ok := s.loginFailures[username]; ok {
delete(s.loginFailures, username)
if err := s.persistLoginFailureStoreLocked(); err != nil {
@ -3173,6 +3212,9 @@ func (s *Store) PortLogEvents(ctx context.Context, filter domain.PortLogFilter)
}
events := make([]domain.PortLogEvent, 0)
for _, event := range source {
if !logPortVisible(ctx, event.Channel) {
continue
}
if !timestampInLogRange(event.Timestamp, filter.From, filter.To) {
continue
}
@ -3188,7 +3230,7 @@ func (s *Store) PortLogEvents(ctx context.Context, filter domain.PortLogFilter)
if actor != "" && !strings.Contains(strings.ToLower(event.Actor), actor) {
continue
}
searchText := strings.ToLower(event.Message + " " + event.Command + " " + event.ReturnValue)
searchText := strings.ToLower(event.Message + " " + event.Command + " " + event.ReturnValue + " " + string(event.Payload))
if keyword != "" && !strings.Contains(searchText, keyword) {
continue
}
@ -3387,7 +3429,10 @@ func (s *Store) UpdateLogRetention(ctx context.Context, update domain.LogRetenti
next.ProtectedUsageWarningPct = *update.ProtectedUsageWarningPct
}
if update.PreserveOnFactoryReset != nil {
next.PreserveOnFactoryReset = *update.PreserveOnFactoryReset
if *update.PreserveOnFactoryReset {
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "log.retention.update", "logs", errors.New("preserve_on_factory_reset must be false: factory reset erases all logs"))
}
next.PreserveOnFactoryReset = false
}
applyLogEventUpdate(&next, update)
if err := validateLogRetention(next); err != nil {
@ -3667,6 +3712,9 @@ type normalizedLogExportRequest struct {
}
func normalizeLogExportRequest(request domain.LogExportRequest) (normalizedLogExportRequest, error) {
if _, err := logExportFormat(request.Format); err != nil {
return normalizedLogExportRequest{}, err
}
normalized := normalizedLogExportRequest{
logType: strings.ToLower(strings.TrimSpace(request.LogType)),
category: strings.ToLower(strings.TrimSpace(request.Category)),
@ -3804,7 +3852,7 @@ func (s *Store) StreamLogExport(ctx context.Context, request domain.LogExportReq
if err != nil {
return domain.Operation{}, s.rejectOperation(ctx, "log.export", "logs", err)
}
rows, err := s.exportLogRows(normalized)
rows, err := s.exportLogRows(ctx, normalized)
if err != nil {
return domain.Operation{}, s.rejectOperation(ctx, "log.export", "logs", err)
}
@ -3812,16 +3860,14 @@ func (s *Store) StreamLogExport(ctx context.Context, request domain.LogExportReq
if err != nil {
return domain.Operation{}, err
}
encoder := json.NewEncoder(writer)
for _, row := range rows {
if err := encoder.Encode(row); err != nil {
return operation, fmt.Errorf("stream log export: %w", err)
}
if err := writeLogExport(writer, request.Format, rows); err != nil {
return operation, err
}
return operation, nil
}
func (s *Store) exportLogRows(request normalizedLogExportRequest) ([]any, error) {
func (s *Store) exportLogRows(ctx context.Context, request normalizedLogExportRequest) ([]any, error) {
keyword := strings.ToLower(request.keyword)
s.mu.Lock()
defer s.mu.Unlock()
@ -3880,6 +3926,9 @@ func (s *Store) exportLogRows(request normalizedLogExportRequest) ([]any, error)
events = samplePortLogEvents(s.clock().UTC())
}
for _, event := range events {
if !logPortVisible(ctx, event.Channel) {
continue
}
if !timestampInLogRange(event.Timestamp, request.from, request.to) || request.actor != "" && !strings.Contains(strings.ToLower(event.Actor), request.actor) {
continue
}
@ -3892,7 +3941,7 @@ func (s *Store) exportLogRows(request normalizedLogExportRequest) ([]any, error)
if request.direction != "" && event.Direction != request.direction {
continue
}
result = appendMatching(result, event, event.Channel+" "+event.Actor+" "+event.Command+" "+event.ReturnValue+" "+event.Message)
result = appendMatching(result, event, event.Channel+" "+event.Actor+" "+event.Command+" "+event.ReturnValue+" "+event.Message+" "+string(event.Payload))
}
case "link":
if s.logJournal != nil {
@ -4538,6 +4587,9 @@ func (s *Store) TestAlarmDelivery(ctx context.Context, request domain.AlarmDeliv
RetryCount: policy.RetryCount, RetryIntervalSeconds: policy.RetryIntervalSeconds,
QueueLimit: policy.PendingQueueLimit, Syslog: syslogTarget, Email: emailTarget, SNMPTraps: snmpTargets,
})
if syslogTarget != nil {
s.rememberSyslog(deliveryResult, err)
}
if err != nil {
return domain.Operation{}, s.rejectOperation(ctx, "alarm.delivery.test", "alarm_delivery", err)
}
@ -4597,20 +4649,6 @@ func (s *Store) EmitLifecycleAlarm(ctx context.Context, ruleID, message string)
return nil
}
func (s *Store) FactoryReset(ctx context.Context) (domain.Operation, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.resetConfigLocked()
if err := s.applyJournalRetentionLocked(s.runningRetention); err != nil {
return domain.Operation{}, err
}
if err := s.persistConfigStoreLocked(); err != nil {
return domain.Operation{}, err
}
return s.newOperationLocked(ctx, "system.factory_reset", "system", "factory reset completed; protected logs preserved")
}
func (s *Store) OpenDirectSession(ctx context.Context, channel string, request domain.DirectSessionRequest) (domain.DirectSession, error) {
index, err := parseChannel(channel)
if err != nil {
@ -5849,8 +5887,25 @@ func (s *Store) ReconcileTLSRuntime(ctx context.Context) error {
s.mu.Lock()
certificateID := s.runningTLS.ActiveCertificateID
s.mu.Unlock()
_, err := s.tlsCertificates.Activate(certificateID)
return err
metadata, err := s.tlsCertificates.Activate(certificateID)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.runningTLS.ActiveCertificateID == certificateID {
updated := tlsCertificateConfigFromMetadata(metadata)
if !equalTLSCertificate(s.runningTLS, updated) {
s.runningTLS = updated
if s.persistentTLS.ActiveCertificateID == certificateID {
s.persistentTLS = updated
}
if err := s.persistConfigStoreLocked(); err != nil {
return err
}
}
}
return nil
}
func (s *Store) applyPendingSNMPAgentRuntimeLocked(ctx context.Context) error {
@ -7768,6 +7823,10 @@ func (s *Store) loadAuthStore() error {
if len(snapshot.Tokens) > s.authMaxTokens {
return fmt.Errorf("auth token limit exceeded in auth store")
}
// A persisted account inventory replaces bootstrap users, never merges them.
s.authUsers = make(map[string]authUserRecord)
s.authGroupOverrides = make(map[string]authGroupRecord)
s.authTokens = make(map[string]authTokenRecord)
for username, user := range snapshot.Users {
normalized := normalizeUsername(username)
if normalized == "" {
@ -8825,7 +8884,7 @@ func baseAuthPermissions() []domain.AuthPermission {
{Permission: "port.read", Description: "Read serial channel configuration and status", Sensitive: true},
{Permission: "port.restart", Description: "Restart a serial channel service path", Sensitive: true},
{Permission: "port.write", Description: "Stage serial channel configuration changes", Sensitive: true},
{Permission: "system.factory_reset", Description: "Run factory reset while preserving protected logs", Sensitive: true},
{Permission: "system.factory_reset", Description: "Erase device data, restore terauser, and reboot", Sensitive: true},
{Permission: "system.reboot", Description: "Queue a device reboot operation", Sensitive: true},
{Permission: "system.read", Description: "Read dashboard system status", Sensitive: true},
{Permission: "time.sync", Description: "Synchronize device time from an operator browser", Sensitive: true},
@ -9567,7 +9626,7 @@ func basePortConfig() map[string]portConfig {
Mode: mode,
ListenerPort: listenerPortFor(index, mode),
SerialType: "rs232",
BaudRate: 115200,
BaudRate: 9600,
DataBits: 8,
Parity: "none",
StopBits: 1,
@ -9856,7 +9915,7 @@ func baseLogRetention() logRetentionConfig {
AuditDays: 365,
PortDays: 365,
ProtectedUsageWarningPct: 80,
PreserveOnFactoryReset: true,
PreserveOnFactoryReset: false,
LocalSystemEnabled: true,
LocalConfigurationEnabled: true,
LocalOperationEnabled: true,
@ -9935,7 +9994,7 @@ func baseManagementAccess() managementAccessConfig {
TelnetPort: 23,
SerialConsoleEnabled: true,
IdleTimeoutMinutes: 15,
CaptchaEnabled: false,
CaptchaEnabled: true,
LoginDetectionMinutes: 0,
LoginSecurityHitCount: 0,
}
@ -10681,7 +10740,7 @@ func makeLogRetentionSettings(config logRetentionConfig) domain.LogRetentionSett
AuditDays: config.AuditDays,
PortDays: config.PortDays,
ProtectedUsageWarningPct: config.ProtectedUsageWarningPct,
PreserveOnFactoryReset: config.PreserveOnFactoryReset,
PreserveOnFactoryReset: false,
LocalSystemEnabled: config.LocalSystemEnabled,
LocalConfigurationEnabled: config.LocalConfigurationEnabled,
LocalOperationEnabled: config.LocalOperationEnabled,
@ -11822,9 +11881,6 @@ func validateLogRetention(settings logRetentionConfig) error {
if settings.ProtectedUsageWarningPct < 50 || settings.ProtectedUsageWarningPct > 95 {
return fmt.Errorf("protected_usage_warning_pct must be between 50 and 95")
}
if !settings.PreserveOnFactoryReset {
return fmt.Errorf("preserve_on_factory_reset must remain true for protected logs")
}
if settings.KeepaliveIntervalMinutes != 3 && settings.KeepaliveIntervalMinutes != 5 && settings.KeepaliveIntervalMinutes != 10 {
return fmt.Errorf("keepalive_interval_minutes must be 3, 5, or 10")
}
@ -11843,7 +11899,7 @@ func logRetentionConfigFromExport(settings domain.LogRetentionSettings) (logRete
AuditDays: settings.AuditDays,
PortDays: settings.PortDays,
ProtectedUsageWarningPct: settings.ProtectedUsageWarningPct,
PreserveOnFactoryReset: settings.PreserveOnFactoryReset,
PreserveOnFactoryReset: false,
LocalSystemEnabled: settings.LocalSystemEnabled,
LocalConfigurationEnabled: settings.LocalConfigurationEnabled,
LocalOperationEnabled: settings.LocalOperationEnabled,
@ -13145,7 +13201,10 @@ func (s *Store) deliverBestEffort(ctx context.Context, request *alarmdelivery.Re
if request == nil {
return
}
_, _ = s.alarmDeliveryController.Deliver(ctx, *request)
result, err := s.alarmDeliveryController.Deliver(ctx, *request)
if request.Syslog != nil {
s.rememberSyslog(result, err)
}
}
func hasEnabledSNMPTrapTarget(targets []snmpTrapTargetConfig) bool {

14
backend/internal/adapters/mock/store_test.go

@ -91,7 +91,7 @@ func TestBrowserTimeSyncRejectsUnavailableTimedAndAuditsFailure(t *testing.T) {
}
}
func TestDefaultPortProfilesUse115200EightNOneWithoutFlowControl(t *testing.T) {
func TestDefaultPortProfilesUse9600EightNOneWithoutFlowControl(t *testing.T) {
store := NewStore(fixedClock)
ports, err := store.Ports(context.Background())
if err != nil {
@ -101,8 +101,8 @@ func TestDefaultPortProfilesUse115200EightNOneWithoutFlowControl(t *testing.T) {
t.Fatalf("default port count = %d, want 48", len(ports))
}
for _, port := range ports {
if port.BaudRate != 115200 || port.DataBits != 8 || port.Parity != "none" || port.StopBits != 1 || port.FlowControl != "none" {
t.Errorf("%s default line profile = %d/%d/%s/%d/%s, want 115200/8/none/1/none", port.Channel, port.BaudRate, port.DataBits, port.Parity, port.StopBits, port.FlowControl)
if port.BaudRate != 9600 || port.DataBits != 8 || port.Parity != "none" || port.StopBits != 1 || port.FlowControl != "none" {
t.Errorf("%s default line profile = %d/%d/%s/%d/%s, want 9600/8/none/1/none", port.Channel, port.BaudRate, port.DataBits, port.Parity, port.StopBits, port.FlowControl)
}
if port.Mode != "disabled" || port.ListenerPort != 0 || port.Operation1.Mode != "disabled" || port.Operation2.Mode != "disabled" || port.Operation1.MaxClients != 4 || port.Operation2.MaxClients != 4 {
t.Errorf("%s default operation profile = mode %q op1 %#v op2 %#v, want both Disabled with four-client ceiling", port.Channel, port.Mode, port.Operation1, port.Operation2)
@ -833,8 +833,8 @@ func TestConfigStorePersistsPendingRunningAndPersistentState(t *testing.T) {
if err != nil {
t.Fatalf("read port before apply: %v", err)
}
if beforeApply.Mode != "disabled" || beforeApply.BaudRate != 115200 || beforeApply.DataBits != 8 || beforeApply.Parity != "none" || beforeApply.StopBits != 1 || beforeApply.FlowControl != "none" {
t.Fatalf("running profile before apply = %#v, want unchanged disabled 115200 8N1 none", beforeApply)
if beforeApply.Mode != "disabled" || beforeApply.BaudRate != 9600 || beforeApply.DataBits != 8 || beforeApply.Parity != "none" || beforeApply.StopBits != 1 || beforeApply.FlowControl != "none" {
t.Fatalf("running profile before apply = %#v, want unchanged disabled 9600 8N1 none", beforeApply)
}
if _, err := second.ApplyConfig(context.Background()); err != nil {
@ -2322,8 +2322,8 @@ func TestConfigStorePersistsFactoryResetState(t *testing.T) {
if err != nil {
t.Fatalf("read reset port: %v", err)
}
if port.Mode != "disabled" || port.BaudRate != 115200 || port.Operation1.Mode != "disabled" || port.Operation2.Mode != "disabled" {
t.Fatalf("reset port = %#v, want default disabled operations and 115200", port)
if port.Mode != "disabled" || port.BaudRate != 9600 || port.Operation1.Mode != "disabled" || port.Operation2.Mode != "disabled" {
t.Fatalf("reset port = %#v, want default disabled operations and 9600", port)
}
status, err := second.ConfigStatus(context.Background())
if err != nil {

25
backend/internal/alarmdelivery/alarmsocket/socket.go

@ -77,6 +77,15 @@ func (s Server) handle(connection net.Conn) {
defer cancel()
var result alarmdelivery.Result
switch value.Action {
case "health":
result.State = "ready"
case "factory_reset":
resetter, ok := s.Controller.(alarmdelivery.FactoryResetter)
if !ok {
err = errors.New("alarm factory reset unavailable")
} else {
err = resetter.FactoryReset(ctx)
}
case "deliver":
result, err = s.Controller.Deliver(ctx, value.Request)
case "status":
@ -150,3 +159,19 @@ func (c *Client) call(ctx context.Context, command command) (alarmdelivery.Resul
}
return *value.Result, nil
}
func (c *Client) FactoryReset(ctx context.Context) error {
_, err := c.call(ctx, command{Action: "factory_reset"})
return err
}
func (c *Client) Health(ctx context.Context) error {
result, err := c.call(ctx, command{Action: "health"})
if err != nil {
return err
}
if result.State != "ready" {
return errors.New("alarm service not ready")
}
return nil
}

12
backend/internal/alarmdelivery/alarmsocket/socket_test.go

@ -59,3 +59,15 @@ func TestServerRejectsUnknownFieldsAndActions(t *testing.T) {
}
}
}
func TestHealthDoesNotDeliverNotification(t *testing.T) {
listener, err := net.Listen("unix", filepath.Join(t.TempDir(), "health.sock"))
if err != nil {
t.Fatal(err)
}
defer listener.Close()
go func() { _ = (Server{Controller: alarmdelivery.UnavailableController{}}).Serve(listener) }()
if err := NewClient(listener.Addr().String()).Health(context.Background()); err != nil {
t.Fatal(err)
}
}

4
backend/internal/alarmdelivery/controller.go

@ -254,3 +254,7 @@ func normalizeSyslogTarget(target SyslogTarget) (SyslogTarget, error) {
}
return target, nil
}
type FactoryResetter interface{ FactoryReset(context.Context) error }
func (MockController) FactoryReset(context.Context) error { return nil }

13
backend/internal/alarmdelivery/dispatcher.go

@ -449,3 +449,16 @@ func idSequence(value string) int {
_, _ = fmt.Sscanf(value[len(value)-6:], "%d", &sequence)
return sequence
}
// FactoryReset cancels in-flight deliveries before erasing durable retries.
func (d *Dispatcher) FactoryReset(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
d.Close()
d.mu.Lock()
defer d.mu.Unlock()
d.jobs = make(map[string]deliveryJob)
d.sequence = 0
return d.persistLocked()
}

27
backend/internal/alarmdelivery/dispatcher_test.go

@ -179,3 +179,30 @@ func waitForDeliveryState(t *testing.T, dispatcher *Dispatcher, deliveryID, stat
t.Fatalf("delivery state = %q, want %q", result.State, state)
return Result{}
}
func TestFactoryResetErasesDeliveryHistoryAndRetryState(t *testing.T) {
path := filepath.Join(t.TempDir(), "deliveries.json")
d, err := NewDispatcher(map[string]Transport{ChannelWeb: WebTransport{}}, DispatcherOptions{StatePath: path})
if err != nil {
t.Fatal(err)
}
defer d.Close()
result, err := d.Deliver(context.Background(), validRequest(ChannelWeb))
if err != nil {
t.Fatal(err)
}
if err := d.FactoryReset(context.Background()); err != nil {
t.Fatal(err)
}
if _, err := d.Status(context.Background(), result.DeliveryID); err == nil {
t.Fatal("old delivery survived")
}
reopened, err := NewDispatcher(nil, DispatcherOptions{StatePath: path})
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if len(reopened.jobs) != 0 {
t.Fatal("retry data resurrected after restart")
}
}

37
backend/internal/api/bootstrap_time_test.go

@ -0,0 +1,37 @@
package api
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"tera-clone/services/tera-api/internal/startup"
"testing"
"time"
)
func TestBootstrapTimeAvailableBeforeLoginButNotReplayable(t *testing.T) {
now := time.Unix(0, 0)
clock, _ := startup.NewClockBootstrap(func() time.Time { return now }, func(_ context.Context, v time.Time) error { now = v; return nil })
token := clock.Token()
router := NewRouterWithOptions(newPasswordAuthTestStore(t, fixedClock), RouterOptions{CommandsReady: func() bool { return false }, ClockBootstrap: clock})
status := httptest.NewRecorder()
router.ServeHTTP(status, httptest.NewRequest("GET", "/api/v1/auth/startup", nil))
if !strings.Contains(status.Body.String(), token) {
t.Fatal("missing bootstrap token")
}
body := fmt.Sprintf(`{"token":%q,"browser_time":"2026-09-09T01:00:00Z"}`, token)
for i, want := range []int{http.StatusOK, http.StatusConflict} {
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest("POST", "/api/v1/auth/bootstrap-time", strings.NewReader(body)))
if response.Code != want {
t.Fatalf("attempt %d: %d %s", i, response.Code, response.Body.String())
}
}
login := httptest.NewRecorder()
router.ServeHTTP(login, httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(`{}`)))
if login.Code != http.StatusServiceUnavailable {
t.Fatal("clock sync bypassed startup readiness")
}
}

12
backend/internal/api/captcha_test.go

@ -35,9 +35,10 @@ func (s *captchaServiceStub) Verify(subject, challengeID, answer string) bool {
}
func TestLoginPolicyDefaultsAndDisabledCaptchaEndpoint(t *testing.T) {
handler := NewRouter(mock.NewStore(fixedClock))
store := mock.NewStore(fixedClock)
handler := NewRouter(store)
policy := getJSON(t, handler, http.MethodGet, "/api/v1/auth/login-policy", "")
if policy["captcha_enabled"] != false || policy["login_detection_minutes"] != float64(0) || policy["login_security_hit_count"] != float64(0) {
if policy["captcha_enabled"] != true || policy["login_detection_minutes"] != float64(0) || policy["login_security_hit_count"] != float64(0) {
t.Fatalf("default login policy = %#v", policy)
}
if policy["fixed_safety_threshold"] != float64(5) || policy["fixed_safety_minutes"] != float64(15) {
@ -49,6 +50,13 @@ func TestLoginPolicyDefaultsAndDisabledCaptchaEndpoint(t *testing.T) {
t.Fatalf("login policy cache control=%q", policyResponse.Header().Get("Cache-Control"))
}
disabled := false
if _, err := store.UpdateManagementAccess(context.Background(), domain.ManagementAccessUpdate{CaptchaEnabled: &disabled}); err != nil {
t.Fatal(err)
}
if _, err := store.ApplyConfig(context.Background()); err != nil {
t.Fatal(err)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/api/v1/auth/captcha", nil))
if response.Code != http.StatusConflict {

37
backend/internal/api/log_web_status.go

@ -0,0 +1,37 @@
package api
import (
"context"
"net/http"
)
func (r *Router) handleLogStorageStatus(w http.ResponseWriter, req *http.Request) {
ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "log.storage.read", Target: "logs", Permission: "config.write"})
if !ok {
return
}
source, ok := r.store.(interface {
LogStorageStatus(context.Context) (map[string]any, error)
})
if !ok {
writeError(w, 503, "service_unavailable", "log storage unavailable")
return
}
result, err := source.LogStorageStatus(ctx)
writeResult(w, result, err)
}
func (r *Router) handleRemoteSyslogStatus(w http.ResponseWriter, req *http.Request) {
ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "log.syslog.status", Target: "logs", Permission: "config.write"})
if !ok {
return
}
source, ok := r.store.(interface {
RemoteSyslogStatus(context.Context) (map[string]any, error)
})
if !ok {
writeError(w, 503, "service_unavailable", "syslog status unavailable")
return
}
result, err := source.RemoteSyslogStatus(ctx)
writeResult(w, result, err)
}

42
backend/internal/api/log_web_test.go

@ -0,0 +1,42 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPortOperatorLogAccessAndExportAreScoped(t *testing.T) {
handler := NewRouter(newPasswordAuthTestStore(t, fixedClock))
getJSONAs(t, handler, http.MethodPost, "/api/v1/auth/users", `{"username":"logoperator","password":"strong-pass-123","role":"port_operator","force_change":false,"port_permissions":[{"channel":"CH18","access":"read"},{"channel":"CH01","access":"deny"}]}`, "mock-admin")
session := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"logoperator","password":"strong-pass-123"}`)
token := session["session_token"].(string)
for _, tc := range []struct {
method, path, body string
status int
}{
{"GET", "/api/v1/logs/port?channel=CH18", "", 200},
{"GET", "/api/v1/logs/port?channel=CH01", "", 403},
{"GET", "/api/v1/logs/system", "", 403},
{"POST", "/api/v1/logs/export/download", `{"log_type":"system","format":"csv"}`, 403},
{"POST", "/api/v1/logs/export/download", `{"log_type":"port","channel":"CH01","format":"csv"}`, 403},
{"POST", "/api/v1/logs/export/download", `{"log_type":"port","channel":"CH18","format":"csv"}`, 200},
{"POST", "/api/v1/logs/port/clear", `{"channel":"CH18","confirm":"CH18"}`, 403},
{"PATCH", "/api/v1/logs/retention", `{"port_days":30}`, 403},
} {
t.Run(tc.method+tc.path+tc.body, func(t *testing.T) {
r := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
r.Header.Set("Authorization", "Bearer "+token)
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != tc.status {
t.Fatalf("status %d want %d: %s", w.Code, tc.status, w.Body.String())
}
if tc.status == 200 && tc.method == "POST" && !strings.HasPrefix(w.Header().Get("Content-Type"), "text/csv") {
t.Fatal("CSV content type missing")
}
})
}
}

64
backend/internal/api/port_config_read_test.go

@ -0,0 +1,64 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"tera-clone/services/tera-api/internal/adapters/mock"
"tera-clone/services/tera-api/internal/domain"
)
func TestPortConfigurationReadSkipsTelemetryAndPreservesSettings(t *testing.T) {
store := mock.NewStore(fixedClock)
controller := &countingRuntimeSerialController{}
handler := NewRouterWithOptions(store, RouterOptions{
SerialController: controller, SessionController: runtimeSessionController{},
})
request := httptest.NewRequest(http.MethodGet, "/api/v1/ports?limit=48&runtime=false", nil)
request.Header.Set("X-Mock-Actor", "mock-admin")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
if controller.calls() != 0 {
t.Fatal("configuration read waited for serial telemetry")
}
var actual []domain.Port
if err := json.Unmarshal(response.Body.Bytes(), &actual); err != nil {
t.Fatal(err)
}
expected, err := store.Ports(request.Context())
if err != nil {
t.Fatal(err)
}
for i := range expected {
expected[i].Status = "unknown"
expected[i].Runtime = nil
expected[i].ConnectedUsers = 0
expected[i].Writer = ""
}
// Normalize JSON omission semantics while comparing every configuration field.
encoded, err := json.Marshal(expected)
if err != nil {
t.Fatal(err)
}
var normalized []domain.Port
if err := json.Unmarshal(encoded, &normalized); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(actual, normalized) {
t.Fatal("configuration fields changed")
}
request = httptest.NewRequest(http.MethodGet, "/api/v1/ports?limit=48", nil)
request.Header.Set("X-Mock-Actor", "mock-admin")
response = httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || controller.calls() != 1 {
t.Fatal("default read no longer collects live telemetry")
}
}

88
backend/internal/api/router.go

@ -11,6 +11,7 @@ import (
"slices"
"strconv"
"strings"
"tera-clone/services/tera-api/internal/startup"
"time"
"tera-clone/services/tera-api/internal/alarmdelivery"
@ -27,6 +28,7 @@ import (
)
type Router struct {
clockBootstrap *startup.ClockBootstrap
store domain.Store
mux *http.ServeMux
corsAllowAll bool
@ -44,6 +46,7 @@ type Router struct {
firmwareTimeout time.Duration
captcha authcaptcha.Service
commandsReady func() bool
startupStatus func() startup.Status
serialRuntime serialRuntimeCache
}
@ -54,6 +57,7 @@ type commandSpec struct {
}
type RouterOptions struct {
ClockBootstrap *startup.ClockBootstrap
CORSAllowedOrigins []string
MaxRequestBytes int64
DisableMockActors bool
@ -68,6 +72,7 @@ type RouterOptions struct {
FirmwareTimeout time.Duration
CaptchaService authcaptcha.Service
CommandsReady func() bool
StartupStatus func() startup.Status
}
const (
@ -147,6 +152,8 @@ func NewRouterWithOptions(store domain.Store, options RouterOptions) http.Handle
firmwareTimeout: firmwareTimeout,
captcha: captchaService,
commandsReady: options.CommandsReady,
startupStatus: options.StartupStatus,
clockBootstrap: options.ClockBootstrap,
}
for _, origin := range options.CORSAllowedOrigins {
origin = strings.TrimSpace(origin)
@ -207,6 +214,17 @@ func managementClientForRequest(req *http.Request) (string, string, int) {
}
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead && req.URL.Path != "/api/v1/system/factory-reset" {
if gate, ok := r.store.(interface{ BeginMutation() (func(), bool) }); ok {
release, accepted := gate.BeginMutation()
if !accepted {
writeError(w, http.StatusServiceUnavailable, "factory_reset_rebooting", "factory reset completed; device is rebooting")
return
}
defer release()
}
}
req = req.WithContext(domain.WithCommandMetadata(req.Context(), domain.CommandMetadata{
Surface: "web_api",
Command: req.Method + " " + req.URL.Path,
@ -255,7 +273,7 @@ func commandAllowedDuringStartup(req *http.Request) bool {
return false
}
switch req.URL.Path {
case "/api/v1/auth/login", "/api/v1/auth/logout", "/api/v1/auth/captcha":
case "/api/v1/auth/logout", "/api/v1/auth/bootstrap-time":
return true
default:
return false
@ -467,6 +485,8 @@ func (r *Router) routes() {
r.mux.HandleFunc("POST /api/v1/system/factory-reset", r.handleFactoryReset)
r.mux.HandleFunc("POST /api/v1/auth/login", r.handleAuthLogin)
r.mux.HandleFunc("GET /api/v1/auth/login-policy", r.handleAuthLoginPolicy)
r.mux.HandleFunc("GET /api/v1/auth/startup", r.handleStartupStatus)
r.mux.HandleFunc("POST /api/v1/auth/bootstrap-time", r.handleBootstrapTime)
r.mux.HandleFunc("POST /api/v1/auth/captcha", r.handleAuthCaptcha)
r.mux.HandleFunc("POST /api/v1/auth/logout", r.handleAuthLogout)
r.mux.HandleFunc("GET /api/v1/auth/me", r.handleAuthMe)
@ -549,6 +569,8 @@ func (r *Router) routes() {
r.mux.HandleFunc("POST /api/v1/logs/system/clear", r.handleClearSystemLogs)
r.mux.HandleFunc("GET /api/v1/logs/links", r.handleLinkLogEvents)
r.mux.HandleFunc("GET /api/v1/logs/policy", r.handleLogPolicy)
r.mux.HandleFunc("GET /api/v1/logs/storage", r.handleLogStorageStatus)
r.mux.HandleFunc("GET /api/v1/logs/remote-syslog/status", r.handleRemoteSyslogStatus)
r.mux.HandleFunc("GET /api/v1/logs/retention", r.handleLogRetention)
r.mux.HandleFunc("PATCH /api/v1/logs/retention", r.handleUpdateLogRetention)
r.mux.HandleFunc("GET /api/v1/logs/remote-syslog", r.handleRemoteSyslog)
@ -1534,7 +1556,18 @@ func (r *Router) handlePorts(w http.ResponseWriter, req *http.Request) {
}
ports, err := r.store.Ports(req.Context())
if err == nil {
ports = r.overlayPortRuntime(ports, r.readSerialRuntime(req.Context()))
if req.URL.Query().Get("runtime") != "false" {
ports = r.overlayPortRuntime(ports, r.readSerialRuntime(req.Context()))
} else {
// Configuration reads must not wait for hardware/session telemetry.
// Never expose unobserved store defaults as a live link state.
for i := range ports {
ports[i].Status = "unknown"
ports[i].Runtime = nil
ports[i].ConnectedUsers = 0
ports[i].Writer = ""
}
}
ports = r.filterAuthorizedPorts(ctx, ports, "read")
}
ports = windowPorts(ports, limit, offset)
@ -2042,7 +2075,8 @@ func (r *Router) handleSystemLogEvents(w http.ResponseWriter, req *http.Request)
}
func (r *Router) handlePortLogEvents(w http.ResponseWriter, req *http.Request) {
if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "log.read", Target: "logs", Permission: "log.read"}); !ok {
ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "log.port.read", Target: "logs", Permission: "log.read"})
if !ok {
return
}
limit, offset, ok := parsePagination(w, req)
@ -2064,7 +2098,10 @@ func (r *Router) handlePortLogEvents(w http.ResponseWriter, req *http.Request) {
Limit: limit,
Offset: offset,
}
events, err := r.store.PortLogEvents(req.Context(), filter)
if filter.Channel != "" && !r.requirePortAccess(w, ctx, strings.ToUpper(filter.Channel), "read", commandSpec{Action: "log.port.read", Target: filter.Channel}) {
return
}
events, err := r.store.PortLogEvents(ctx, filter)
writeResult(w, events, err)
}
@ -2346,6 +2383,14 @@ func (r *Router) handleExportLogs(w http.ResponseWriter, req *http.Request) {
if !r.decodeOptionalCommandJSON(w, req, ctx, spec, &exportRequest) {
return
}
if actor, ok := domain.ActorFromContext(ctx); ok && actor.Role == "port_operator" && exportRequest.LogType != "" && !strings.EqualFold(exportRequest.LogType, "port") {
writeError(w, http.StatusForbidden, "authorization_denied", "only assigned port logs may be exported")
return
}
if exportRequest.Channel != "" && !r.requirePortAccess(w, ctx, strings.ToUpper(exportRequest.Channel), "read", spec) {
return
}
operation, err := r.store.ExportLogs(ctx, exportRequest)
writeResult(w, operation, err)
}
@ -2364,7 +2409,15 @@ func (writer *logAttachmentWriter) commit() {
if writer.committed {
return
}
writer.Header().Set("Content-Type", "application/x-ndjson")
contentType := "application/x-ndjson"
if strings.HasSuffix(writer.filename, ".csv") {
contentType = "text/csv; charset=utf-8"
}
if strings.HasSuffix(writer.filename, ".json") {
contentType = "application/json"
}
writer.Header().Set("Content-Type", contentType)
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, writer.filename))
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.WriteHeader(http.StatusOK)
@ -2386,6 +2439,14 @@ func (r *Router) handleDownloadLogs(w http.ResponseWriter, req *http.Request) {
if !r.decodeOptionalCommandJSON(w, req, ctx, spec, &exportRequest) {
return
}
if actor, ok := domain.ActorFromContext(ctx); ok && actor.Role == "port_operator" && exportRequest.LogType != "" && !strings.EqualFold(exportRequest.LogType, "port") {
writeError(w, http.StatusForbidden, "authorization_denied", "only assigned port logs may be exported")
return
}
if exportRequest.Channel != "" && !r.requirePortAccess(w, ctx, strings.ToUpper(exportRequest.Channel), "read", spec) {
return
}
exporter, ok := r.store.(streamingLogExporter)
if !ok {
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "streaming log export is unavailable")
@ -2395,7 +2456,15 @@ func (r *Router) handleDownloadLogs(w http.ResponseWriter, req *http.Request) {
if logType == "" {
logType = "port"
}
filename := fmt.Sprintf("tera-%s-logs-%s.jsonl", logType, time.Now().UTC().Format("20060102T150405Z"))
format := strings.ToLower(strings.TrimSpace(exportRequest.Format))
if format == "" {
format = "jsonl"
}
if format != "jsonl" && format != "json" && format != "csv" {
writeError(w, http.StatusBadRequest, "invalid_request", "format must be csv, json, or jsonl")
return
}
filename := fmt.Sprintf("tera-%s-logs-%s.%s", logType, time.Now().UTC().Format("20060102T150405Z"), format)
attachment := &logAttachmentWriter{ResponseWriter: w, filename: filename}
if _, err := exporter.StreamLogExport(ctx, exportRequest, attachment); err != nil {
if !attachment.committed {
@ -3069,7 +3138,12 @@ func (r *Router) authorizeCommand(w http.ResponseWriter, req *http.Request, spec
return req.Context(), false
}
if !actor.Permissions[spec.Permission] {
if actor.Role == "port_operator" && strings.HasPrefix(spec.Action, "log.") && spec.Action != "log.port.read" && spec.Action != "log.export" {
writeError(w, http.StatusForbidden, "authorization_denied", "port operators may only read and export assigned port logs")
return req.Context(), false
}
scopedPortLog := actor.Role == "port_operator" && actor.Permissions["port.read"] && (spec.Action == "log.port.read" || spec.Action == "log.export")
if !actor.Permissions[spec.Permission] && !scopedPortLog {
_, _ = r.store.RecordAudit(domain.WithActor(req.Context(), actor), domain.AuditEvent{
Action: spec.Action,
Target: spec.Target,

112
backend/internal/api/router_test.go

@ -50,9 +50,9 @@ func TestManagementClientForRequestTrustsForwardedValuesOnlyFromLoopback(t *test
}
}
func TestStartupGateAllowsLoginAndReadsButBlocksCommands(t *testing.T) {
func TestStartupGateBlocksLoginUntilReady(t *testing.T) {
ready := false
handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{
handler := NewRouterWithOptions(newPasswordAuthTestStore(t, fixedClock), RouterOptions{
CommandsReady: func() bool { return ready },
})
@ -64,7 +64,7 @@ func TestStartupGateAllowsLoginAndReadsButBlocksCommands(t *testing.T) {
login := httptest.NewRecorder()
handler.ServeHTTP(login, httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"admin","password":"mock-admin-secret"}`)))
if login.Code != http.StatusOK {
if login.Code != http.StatusServiceUnavailable {
t.Fatalf("login status=%d body=%s", login.Code, login.Body.String())
}
@ -79,7 +79,22 @@ func TestStartupGateAllowsLoginAndReadsButBlocksCommands(t *testing.T) {
t.Fatalf("Retry-After=%q, want 1", blocked.Header().Get("Retry-After"))
}
captcha := httptest.NewRecorder()
handler.ServeHTTP(captcha, httptest.NewRequest(http.MethodPost, "/api/v1/auth/captcha", nil))
if captcha.Code != http.StatusServiceUnavailable {
t.Fatal("captcha allowed during startup")
}
status := httptest.NewRecorder()
handler.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/api/v1/auth/startup", nil))
if status.Code != 200 || !strings.Contains(status.Body.String(), `"ready":false`) {
t.Fatal(status.Body.String())
}
ready = true
login = httptest.NewRecorder()
handler.ServeHTTP(login, httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"admin","password":"mock-admin-secret"}`)))
if login.Code != http.StatusOK {
t.Fatal(login.Body.String())
}
accepted := httptest.NewRecorder()
acceptedRequest := httptest.NewRequest(http.MethodPost, "/api/v1/system/reboot", nil)
acceptedRequest.Header.Set("X-Mock-Actor", "mock-admin")
@ -811,7 +826,7 @@ func TestAuthMeShowsViewerPermissionBoundary(t *testing.T) {
}
func TestMockActorCanBeDisabledForBearerOnlyAuth(t *testing.T) {
handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{
handler := NewRouterWithOptions(newPasswordAuthTestStore(t, fixedClock), RouterOptions{
DisableMockActors: true,
})
@ -833,7 +848,7 @@ func TestMockActorCanBeDisabledForBearerOnlyAuth(t *testing.T) {
}
func TestAuthLoginReturnsSessionForBearerAuth(t *testing.T) {
handler := NewRouter(mock.NewStore(fixedClock))
handler := NewRouter(newPasswordAuthTestStore(t, fixedClock))
login := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"admin","password":"mock-admin-secret"}`)
requireKeys(t, login, "session_token", "actor", "role", "permissions", "prototype", "expires_at", "persistent")
@ -850,7 +865,7 @@ func TestAuthLoginReturnsSessionForBearerAuth(t *testing.T) {
}
func TestAuthTokenCreateListAuthenticateAndRevoke(t *testing.T) {
handler := NewRouter(mock.NewStore(fixedClock))
handler := NewRouter(newPasswordAuthTestStore(t, fixedClock))
login := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"admin","password":"mock-admin-secret"}`)
sessionToken := stringValue(t, login, "session_token")
@ -1031,7 +1046,7 @@ func TestAuthTokenRevokeValidationFailureRecordsRejectedAudit(t *testing.T) {
}
func TestAuthLoginRejectsInvalidPasswordAndAudits(t *testing.T) {
handler := NewRouter(mock.NewStore(fixedClock))
handler := NewRouter(newPasswordAuthTestStore(t, fixedClock))
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"admin","password":"wrong"}`))
@ -1055,7 +1070,7 @@ func TestAuthLoginRejectsInvalidPasswordAndAudits(t *testing.T) {
func TestAuthLoginTemporarilyLocksAfterRepeatedFailures(t *testing.T) {
now := time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC)
handler := NewRouter(mock.NewStore(func() time.Time { return now }))
handler := NewRouter(newPasswordAuthTestStore(t, func() time.Time { return now }))
for attempt := 0; attempt < 5; attempt++ {
response := httptest.NewRecorder()
@ -1094,7 +1109,7 @@ func TestAuthLoginTemporarilyLocksAfterRepeatedFailures(t *testing.T) {
}
func TestAuthLogoutRevokesBearerSession(t *testing.T) {
handler := NewRouter(mock.NewStore(fixedClock))
handler := NewRouter(newPasswordAuthTestStore(t, fixedClock))
login := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"admin","password":"mock-admin-secret"}`)
token := stringValue(t, login, "session_token")
@ -1225,7 +1240,7 @@ func TestAuthSessionsRequireAuthPermission(t *testing.T) {
}
func TestAuthSessionListAndRevokeContract(t *testing.T) {
handler := NewRouter(mock.NewStore(fixedClock))
handler := NewRouter(newPasswordAuthTestStore(t, fixedClock))
login := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"admin","password":"mock-admin-secret"}`)
token := stringValue(t, login, "session_token")
@ -1293,7 +1308,7 @@ func TestAuthSessionRevokeValidationFailureRecordsRejectedAudit(t *testing.T) {
}
func TestAuthSessionsSupportBoundedPagination(t *testing.T) {
handler := NewRouter(mock.NewStore(fixedClock))
handler := NewRouter(newPasswordAuthTestStore(t, fixedClock))
_ = getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"admin","password":"mock-admin-secret"}`)
viewerLogin := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"viewer","password":"mock-viewer-secret"}`)
@ -1592,7 +1607,7 @@ func TestCreateAuthUserRequiresAuthWritePermission(t *testing.T) {
}
func TestCreateAuthUserContract(t *testing.T) {
handler := NewRouter(mock.NewStore(fixedClock))
handler := NewRouter(newPasswordAuthTestStore(t, fixedClock))
user := getJSONAs(t, handler, http.MethodPost, "/api/v1/auth/users", `{"username":"FieldOps","password":"fieldops-secret-1","role":"port_operator"}`, "mock-admin")
requireKeys(t, user, "username", "actor", "role", "groups", "permissions", "enabled")
@ -1738,7 +1753,7 @@ func TestUpdateAuthUserRequiresAuthWritePermission(t *testing.T) {
}
func TestUpdateAuthUserEnabledContract(t *testing.T) {
handler := NewRouter(mock.NewStore(fixedClock))
handler := NewRouter(newPasswordAuthTestStore(t, fixedClock))
_ = getJSONAs(t, handler, http.MethodPost, "/api/v1/auth/users", `{"username":"fieldops","password":"fieldops-secret-1","role":"viewer"}`, "mock-admin")
fieldopsLogin := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"fieldops","password":"fieldops-secret-1"}`)
@ -1786,7 +1801,7 @@ func TestUpdateAuthUserEnabledContract(t *testing.T) {
}
func TestUpdateAuthUserRoleContract(t *testing.T) {
handler := NewRouter(mock.NewStore(fixedClock))
handler := NewRouter(newPasswordAuthTestStore(t, fixedClock))
_ = getJSONAs(t, handler, http.MethodPost, "/api/v1/auth/users", `{"username":"fieldops","password":"fieldops-secret-1","role":"viewer"}`, "mock-admin")
fieldopsLogin := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"fieldops","password":"fieldops-secret-1"}`)
@ -1920,7 +1935,7 @@ func TestUpdateAuthUserPasswordRequiresAuthWritePermission(t *testing.T) {
}
func TestUpdateAuthUserPasswordContract(t *testing.T) {
handler := NewRouter(mock.NewStore(fixedClock))
handler := NewRouter(newPasswordAuthTestStore(t, fixedClock))
_ = getJSONAs(t, handler, http.MethodPost, "/api/v1/auth/users", `{"username":"fieldops","password":"fieldops-secret-1","role":"viewer"}`, "mock-admin")
fieldopsLogin := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"fieldops","password":"fieldops-secret-1"}`)
@ -2040,7 +2055,7 @@ func TestDeleteAuthUserRequiresAuthWritePermission(t *testing.T) {
}
func TestDeleteAuthUserContract(t *testing.T) {
handler := NewRouter(mock.NewStore(fixedClock))
handler := NewRouter(newPasswordAuthTestStore(t, fixedClock))
_ = getJSONAs(t, handler, http.MethodPost, "/api/v1/auth/users", `{"username":"fieldops","password":"fieldops-secret-1","role":"viewer"}`, "mock-admin")
fieldopsLogin := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"fieldops","password":"fieldops-secret-1"}`)
@ -4019,8 +4034,8 @@ func TestFactoryResetContract(t *testing.T) {
assertOperationPayload(t, operation)
port := getPort(t, handler, "CH01")
if port.Mode != "disabled" || port.BaudRate != 115200 || port.Operation1.Mode != "disabled" || port.Operation2.Mode != "disabled" {
t.Fatalf("factory reset port = %#v, want default disabled operations and 115200", port)
if port.Mode != "disabled" || port.BaudRate != 9600 || port.Operation1.Mode != "disabled" || port.Operation2.Mode != "disabled" {
t.Fatalf("factory reset port = %#v, want default disabled operations and 9600", port)
}
status := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/status", "", "mock-admin")
assertNumber(t, status, "pending_changes", 0)
@ -4031,14 +4046,21 @@ func TestFactoryResetContract(t *testing.T) {
}
events := getAuditEvents(t, handler)
if len(events) != 3 {
t.Fatalf("audit event count = %d, want 3", len(events))
if len(events) != 1 {
t.Fatalf("audit event count = %d, want 1", len(events))
}
assertString(t, events[0], "audit_id", stringValue(t, operation, "audit_id"))
assertString(t, events[0], "actor", "mock-admin")
assertString(t, events[0], "action", "system.factory_reset")
assertString(t, events[0], "target", "system")
assertString(t, events[0], "result", "succeeded")
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPatch, "/api/v1/ports/CH01", strings.NewReader(`{"baud_rate":38400}`))
request.Header.Set("X-Mock-Actor", "mock-admin")
handler.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable {
t.Fatal("write accepted after factory reset")
}
assertString(t, events[2], "audit_id", stringValue(t, operation, "audit_id"))
assertString(t, events[2], "actor", "mock-admin")
assertString(t, events[2], "action", "system.factory_reset")
assertString(t, events[2], "target", "system")
assertString(t, events[2], "result", "succeeded")
}
func TestAlarmRuleUpdateRequiresActor(t *testing.T) {
@ -5446,7 +5468,7 @@ func TestLogRetentionRejectsUnsafeProtectedLogDeletionPolicy(t *testing.T) {
handler := NewRouter(mock.NewStore(fixedClock))
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPatch, "/api/v1/logs/retention", strings.NewReader(`{"preserve_on_factory_reset":false}`))
request := httptest.NewRequest(http.MethodPatch, "/api/v1/logs/retention", strings.NewReader(`{"preserve_on_factory_reset":true}`))
request.Header.Set("X-Mock-Actor", "mock-admin")
handler.ServeHTTP(response, request)
@ -5461,7 +5483,7 @@ func TestLogRetentionValidationFailureRecordsRejectedAudit(t *testing.T) {
handler := NewRouter(mock.NewStore(fixedClock))
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPatch, "/api/v1/logs/retention", strings.NewReader(`{"preserve_on_factory_reset":false}`))
request := httptest.NewRequest(http.MethodPatch, "/api/v1/logs/retention", strings.NewReader(`{"preserve_on_factory_reset":true}`))
request.Header.Set("X-Mock-Actor", "mock-admin")
handler.ServeHTTP(response, request)
@ -5479,7 +5501,7 @@ func TestLogRetentionValidationFailureRecordsRejectedAudit(t *testing.T) {
assertString(t, events[0], "action", "log.retention.update")
assertString(t, events[0], "target", "logs")
assertString(t, events[0], "result", "rejected")
assertString(t, events[0], "message", "preserve_on_factory_reset must remain true for protected logs")
assertString(t, events[0], "message", "preserve_on_factory_reset must be false: factory reset erases all logs")
}
func TestRemoteSyslogRequiresLogReadAndConfigWritePermission(t *testing.T) {
@ -6763,8 +6785,8 @@ func TestApplyConfigLeavesPendingBaudWhenSerialControllerUnavailable(t *testing.
assertErrorCode(t, response, "hardware_unavailable")
port := getPort(t, handler, "CH01")
if port.BaudRate != 115200 {
t.Fatalf("running baud rate = %d, want unchanged 115200", port.BaudRate)
if port.BaudRate != 9600 {
t.Fatalf("running baud rate = %d, want unchanged 9600", port.BaudRate)
}
status := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/status", "", "mock-admin")
assertNumber(t, status, "pending_changes", 1)
@ -6798,14 +6820,14 @@ func TestApplyConfigRetriesTransientCRProbeLease(t *testing.T) {
controller := &serialControllerSpy{busyFor: 2}
handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{SerialController: controller})
getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH16", `{"baud_rate":9600}`, "mock-admin")
getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH16", `{"baud_rate":38400}`, "mock-admin")
getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin")
if len(controller.applied) != 3 {
t.Fatalf("serial apply calls = %d, want two busy attempts and one success", len(controller.applied))
}
if port := getPort(t, handler, "CH16"); port.BaudRate != 9600 {
t.Fatalf("CH16 baud rate = %d, want 9600", port.BaudRate)
if port := getPort(t, handler, "CH16"); port.BaudRate != 38400 {
t.Fatalf("CH16 baud rate = %d, want 38400", port.BaudRate)
}
}
@ -7274,3 +7296,27 @@ func TestLogRetentionRejectsMoreThanOneYearThroughAPI(t *testing.T) {
func fixedClock() time.Time {
return time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC)
}
// Password/session contract tests explicitly disable CAPTCHA; captcha_test.go covers challenge enforcement.
func newPasswordAuthTestStore(t *testing.T, clock func() time.Time) *mock.Store {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
store, err := mock.NewStoreWithConfigFile(clock, path)
if err != nil {
t.Fatal(err)
}
disabled := false
if _, err := store.UpdateManagementAccess(context.Background(), domain.ManagementAccessUpdate{CaptchaEnabled: &disabled}); err != nil {
t.Fatal(err)
}
if _, err := store.ApplyConfig(context.Background()); err != nil {
t.Fatal(err)
}
store.Close()
reopened, err := mock.NewStoreWithConfigFile(clock, path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(reopened.Close)
return reopened
}

2
backend/internal/api/serial_runtime_test.go

@ -24,7 +24,7 @@ type runtimeSerialController struct{}
func (runtimeSerialController) States(context.Context) ([]serial.State, error) {
states := make([]serial.State, 0, serial.ChannelCount)
for index := 1; index <= serial.ChannelCount; index++ {
settings := &serial.LineSettings{BaudRate: 115200, DataBits: 8, Parity: "none", StopBits: 1, FlowControl: "none"}
settings := &serial.LineSettings{BaudRate: 9600, DataBits: 8, Parity: "none", StopBits: 1, FlowControl: "none"}
states = append(states, serial.State{
Channel: fmt.Sprintf("CH%02d", index), Device: fmt.Sprintf("/dev/ttyWCH%d", index+3),
HardwareProfile: "fet3568j-c", MappingVersion: "test-v1", Available: true,

45
backend/internal/api/startup.go

@ -0,0 +1,45 @@
package api
import (
"context"
"net/http"
"tera-clone/services/tera-api/internal/startup"
"time"
)
func (r *Router) handleStartupStatus(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Cache-Control", "no-store")
state := startup.Status{State: "ready", Ready: true}
if r.startupStatus != nil {
state = r.startupStatus()
} else if r.commandsReady != nil && !r.commandsReady() {
state.Ready = false
state.State = "starting"
}
if r.clockBootstrap != nil {
state.ClockSyncToken = r.clockBootstrap.Token()
}
writeJSON(w, http.StatusOK, state)
}
func (r *Router) handleBootstrapTime(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if r.clockBootstrap == nil {
writeError(w, http.StatusConflict, "clock_sync_unavailable", "clock initialization is unavailable")
return
}
var input struct {
Token string `json:"token"`
BrowserTime time.Time `json:"browser_time"`
}
if !decodeJSON(w, req, &input) {
return
}
ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
defer cancel()
if err := r.clockBootstrap.Sync(ctx, input.Token, input.BrowserTime); err != nil {
writeError(w, http.StatusConflict, "clock_sync_pending", "PC time synchronization did not complete; retry or check device time")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"synced": true})
}

4
backend/internal/authcaptcha/service.go

@ -19,7 +19,7 @@ const (
defaultTTL = 2 * time.Minute
defaultCapacity = 256
maxPerSubject = 4
answerLength = 8
answerLength = 4
)
var digitGlyphs = map[byte][7]string{
@ -192,7 +192,7 @@ func challengeDigest(subject, id, answer string) [sha256.Size]byte {
}
func renderPNG(answer string, noise []byte) (string, error) {
const width, height = 184, 52
width, height := 8 + len(answer)*22, 52
canvas := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {

10
backend/internal/authcaptcha/service_test.go

@ -24,10 +24,10 @@ func TestChallengeIsPNGSubjectBoundAndOneTime(t *testing.T) {
if err != nil || len(payload) < 8 || string(payload[:8]) != "\x89PNG\r\n\x1a\n" {
t.Fatalf("captcha image is not a PNG: error=%v bytes=%x", err, payload[:minInt(len(payload), 8)])
}
if manager.Verify("198.51.100.5", challenge.ChallengeID, "22222222") {
if manager.Verify("198.51.100.5", challenge.ChallengeID, "2222") {
t.Fatal("challenge verified for a different subject")
}
if manager.Verify("192.0.2.10", challenge.ChallengeID, "22222222") {
if manager.Verify("192.0.2.10", challenge.ChallengeID, "2222") {
t.Fatal("challenge was reusable after a failed subject check")
}
}
@ -44,10 +44,10 @@ func TestChallengeCorrectAnswerVerifiesOnceAndExpires(t *testing.T) {
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if !manager.Verify("peer", challenge.ChallengeID, "22222222") {
if !manager.Verify("peer", challenge.ChallengeID, "2222") {
t.Fatal("correct answer was rejected")
}
if manager.Verify("peer", challenge.ChallengeID, "22222222") {
if manager.Verify("peer", challenge.ChallengeID, "2222") {
t.Fatal("challenge verified twice")
}
@ -57,7 +57,7 @@ func TestChallengeCorrectAnswerVerifiesOnceAndExpires(t *testing.T) {
t.Fatalf("Create() error = %v", err)
}
now = now.Add(time.Minute)
if manager.Verify("peer", challenge.ChallengeID, "22222222") {
if manager.Verify("peer", challenge.ChallengeID, "2222") {
t.Fatal("expired challenge verified")
}
}

9
backend/internal/domain/auth.go

@ -28,10 +28,11 @@ type PortPermission struct {
}
type AuthLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
CaptchaID string `json:"captcha_id,omitempty"`
CaptchaAnswer string `json:"captcha_answer,omitempty"`
BrowserTime time.Time `json:"browser_time,omitempty"`
Username string `json:"username"`
Password string `json:"password"`
CaptchaID string `json:"captcha_id,omitempty"`
CaptchaAnswer string `json:"captcha_answer,omitempty"`
}
type AuthLoginPolicy struct {

1
backend/internal/domain/models.go

@ -645,6 +645,7 @@ type LinkLogEvent struct {
}
type LogExportRequest struct {
Format string `json:"format,omitempty"`
LogType string `json:"log_type,omitempty"`
Category string `json:"category,omitempty"`
Severity string `json:"severity,omitempty"`

15
backend/internal/logservice/socket.go

@ -49,6 +49,8 @@ type PortLogCleaner interface {
PortClearRevision() (uint64, error)
}
type FactoryResetter interface{ FactoryReset() error }
type SystemLogCleaner interface {
ClearSystem() (int, error)
SystemClearRevision() (uint64, error)
@ -136,6 +138,17 @@ func (s Server) handle(connection net.Conn) {
}
switch value.Action {
case "factory_reset":
if value.Class != "" || value.Channel != "" || len(value.Payload) != 0 {
err = errors.New("factory reset accepts no selector or payload")
break
}
err = s.Store.FactoryReset()
if err == nil {
s.write(connection, response{})
return
}
case "clear_system":
if value.Class != logstorage.ClassSystem || value.Channel != "" {
err = errors.New("only system events may be cleared; audit logs are preserved")
@ -342,3 +355,5 @@ func (c *Client) call(command command) (response, error) {
}
return value, nil
}
func (c *Client) FactoryReset() error { _, err := c.call(command{Action: "factory_reset"}); return err }

30
backend/internal/logservice/socket_test.go

@ -106,3 +106,33 @@ func TestClientReportsUnavailableSocket(t *testing.T) {
t.Fatalf("Read error = %v, want unavailable", err)
}
}
func TestFactoryResetSocketErasesProtectedLogs(t *testing.T) {
store, err := logstorage.Open(logstorage.Options{Root: t.TempDir(), FreeBytes: func(string) (int64, error) { return 1 << 40, nil }})
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "reset.sock")
listener, err := net.Listen("unix", path)
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- (Server{Store: store}).Serve(listener) }()
defer func() { listener.Close(); <-done }()
client := NewClient(path)
for _, class := range []string{logstorage.ClassAudit, logstorage.ClassSystem, logstorage.ClassPort, logstorage.ClassLink} {
if err := client.Append(class, time.Now(), map[string]string{"message": "old"}); err != nil {
t.Fatal(err)
}
}
if err := client.FactoryReset(); err != nil {
t.Fatal(err)
}
for _, class := range []string{logstorage.ClassAudit, logstorage.ClassSystem, logstorage.ClassPort, logstorage.ClassLink} {
items, err := client.Read(class)
if err != nil || len(items) != 0 {
t.Fatal("log survived socket reset")
}
}
}

44
backend/internal/logstorage/factory_reset.go

@ -0,0 +1,44 @@
package logstorage
import (
"fmt"
"os"
"path/filepath"
)
// FactoryReset removes every journal and export file, even corrupted segments.
// Only fixed service-owned buckets are touched; no path comes from the caller.
func (s *Store) FactoryReset() error {
s.mu.Lock()
defer s.mu.Unlock()
for _, bucket := range []string{"audit", "system", "port", "index", "export"} {
dir := filepath.Join(s.root, bucket)
info, err := os.Lstat(dir)
if err != nil {
return err
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("unsafe log bucket %s", dir)
}
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
target := filepath.Join(dir, entry.Name())
if filepath.Dir(target) != dir {
return fmt.Errorf("unsafe log entry %s", target)
}
// RemoveAll unlinks symlinks; it never follows them outside the bucket.
if err := os.RemoveAll(target); err != nil {
return err
}
}
if err := syncDirectory(dir); err != nil {
return err
}
}
s.portClears = portClearState{}
s.systemClear = systemClearState{}
return s.rebuildLocked()
}

50
backend/internal/logstorage/factory_reset_test.go

@ -0,0 +1,50 @@
package logstorage
import (
"bytes"
"os"
"path/filepath"
"testing"
"time"
)
func TestFactoryResetErasesEveryClassOnDiskAndAfterReopen(t *testing.T) {
root := t.TempDir()
s := openTestStore(t, Options{Root: root})
for _, class := range []string{ClassAudit, ClassSystem, ClassPort, ClassLink} {
if _, err := s.Append(class, time.Now(), map[string]string{"message": "customer-marker"}); err != nil {
t.Fatal(err)
}
}
if err := s.FactoryReset(); err != nil {
t.Fatal(err)
}
reopened := openTestStore(t, Options{Root: root})
for _, class := range []string{ClassAudit, ClassSystem, ClassPort, ClassLink} {
records, err := reopened.Read(class)
if err != nil || len(records) != 0 {
t.Fatalf("%s survived reset: %d %v", class, len(records), err)
}
}
if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return err
}
if bytes.Contains(content, []byte("customer-marker")) {
t.Fatalf("old payload remains on disk: %s", path)
}
return nil
}); err != nil {
t.Fatal(err)
}
if _, err := reopened.Append(ClassAudit, time.Now(), map[string]string{"message": "new-boot"}); err != nil {
t.Fatal(err)
}
}

16
backend/internal/networkconfig/netsocket/socket.go

@ -79,6 +79,17 @@ func (s Server) handleConnection(connection net.Conn) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
switch command.Action {
case "factory_reset":
resetter, ok := s.Controller.(networkconfig.FactoryResetter)
if !ok {
s.writeError(connection, errors.New("network factory reset unavailable"))
return
}
if err := resetter.FactoryReset(ctx, command.Config); err != nil {
s.writeError(connection, err)
return
}
s.writeResponse(connection, response{})
case "apply":
result, err := s.Controller.Apply(ctx, command.Config)
if err != nil {
@ -197,3 +208,8 @@ func (c *Client) execute(ctx context.Context, command request) (response, error)
}
return result, nil
}
func (c *Client) FactoryReset(ctx context.Context, config networkconfig.Config) error {
_, err := c.execute(ctx, request{Action: "factory_reset", Config: config})
return err
}

9
backend/internal/networkconfig/netsocket/socket_test.go

@ -34,6 +34,15 @@ func TestClientServerApplyConfirmRoundTrip(t *testing.T) {
if err != nil || status.State != "idle" {
t.Fatalf("Confirm() = %+v, %v", status, err)
}
if _, err := client.Apply(context.Background(), config); err != nil {
t.Fatal(err)
}
if err := client.FactoryReset(context.Background(), config); err != nil {
t.Fatal(err)
}
if controller.Pending != nil {
t.Fatal("network rollback survived factory reset")
}
_ = listener.Close()
<-done
}

20
backend/internal/networkconfig/network.go

@ -624,3 +624,23 @@ func IsUnavailable(err error) bool {
var unavailable *UnavailableError
return errors.As(err, &unavailable)
}
type FactoryResetter interface {
FactoryReset(context.Context, Config) error
}
func (c *MockController) FactoryReset(ctx context.Context, config Config) error {
if err := ctx.Err(); err != nil {
return err
}
normalized, err := Normalize(config)
if err != nil {
return err
}
c.mu.Lock()
defer c.mu.Unlock()
c.Current = normalized
c.Pending = nil
c.previous = nil
return nil
}

28
backend/internal/networkconfig/os_controller.go

@ -920,3 +920,31 @@ func RenderRoutingScript(config Config, ipPath string) ([]byte, error) {
func isAbsoluteCommandPath(path string) bool {
return filepath.IsAbs(path) || strings.HasPrefix(path, "/")
}
// FactoryReset replaces persistent network state without dropping the current
// management connection. Boot applies these files after the reset response.
func (c *OSController) FactoryReset(ctx context.Context, config Config) error {
normalized, err := Normalize(config)
if err != nil {
return err
}
c.mu.Lock()
defer c.mu.Unlock()
if err := ctx.Err(); err != nil {
return err
}
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
if err := c.writeConfigFiles(normalized); err != nil {
return err
}
if err := os.Remove(c.options.PendingPath); err != nil && !os.IsNotExist(err) {
return err
}
c.pending = nil
c.setCurrentConfigLocked(normalized)
c.state = "confirmed"
return nil
}

26
backend/internal/networkconfig/os_controller_test.go

@ -367,3 +367,29 @@ func TestRecoverRollsBackUnconfirmedTransactionAfterRestart(t *testing.T) {
t.Fatalf("interfaces after recovery = %q", content)
}
}
func TestFactoryResetClearsPendingNetworkAndOnlyWritesBootConfiguration(t *testing.T) {
c, runner, paths := testOSController(t)
if _, err := c.Apply(context.Background(), testConfig()); err != nil {
t.Fatal(err)
}
before := len(runner.calls)
config := testConfig()
config.HostName = "factory-host"
if err := c.FactoryReset(context.Background(), config); err != nil {
t.Fatal(err)
}
if len(runner.calls) != before {
t.Fatal("factory reset disconnected network before response")
}
if c.pending != nil || c.timer != nil {
t.Fatal("old rollback transaction survived reset")
}
if _, err := os.Stat(paths["pending"]); !os.IsNotExist(err) {
t.Fatal("old network snapshot survived on disk")
}
content, err := os.ReadFile(paths["hostname"])
if err != nil || strings.TrimSpace(string(content)) != "factory-host" {
t.Fatal("factory network settings not persisted")
}
}

13
backend/internal/secretstore/store.go

@ -228,3 +228,16 @@ func cloneValues(source map[string][]byte) map[string][]byte {
}
return result
}
// Reset removes every stored credential in one atomic write.
func (s *Store) Reset() error {
s.mu.Lock()
defer s.mu.Unlock()
old := s.values
s.values = make(map[string][]byte)
if err := s.persistLocked(); err != nil {
s.values = old
return err
}
return nil
}

126
backend/internal/snmpagent/device_test.go

@ -0,0 +1,126 @@
package snmpagent
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)
// Opt-in target test. It binds a private loopback port, never the production
// SNMP port, and keeps generated users/configuration in the test directory.
func TestDeviceNetSNMPVersions(t *testing.T) {
if os.Getenv("TERA_DEVICE_SNMP_TEST") != "1" {
t.Skip("requires target Net-SNMP executables")
}
root := t.TempDir()
mainPath, userPath := filepath.Join(root, "etc", "snmpd.conf"), filepath.Join(root, "state", "snmpd.conf")
secretBytes := make([]byte, 16)
if _, err := rand.Read(secretBytes); err != nil {
t.Fatal(err)
}
secret := hex.EncodeToString(secretBytes)
config := Config{Enabled: true, V1Enabled: true, V2CEnabled: true, V3Enabled: true, ReadOnlyCommunity: secret,
V3Users: []User{{Username: "codex_probe", Access: "read", AuthProtocol: "sha256", PrivacyProtocol: "aes", AuthKey: secret, PrivacyKey: secret, Enabled: true}}}
runner := &deviceSNMPRunner{root: root, mainPath: mainPath, userPath: userPath}
controller, err := NewOSController(OSOptions{ConfigPath: mainPath, PersistentPath: userPath, InitScriptPath: filepath.Join(root, "init"), Runner: runner})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = runner.Run(context.Background(), "", "stop") })
if err := controller.Apply(context.Background(), config); err != nil {
t.Fatal(err)
}
probe := func(args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
args = append(args, "-t", "1", "-r", "0", "127.0.0.1:1161", "1.3.6.1.2.1.1.1.0")
c := exec.CommandContext(ctx, "snmpget", args...)
c.Env = append(os.Environ(), "MIBS=")
return c.CombinedOutput()
}
for i := 0; i < 10; i++ {
if _, err := probe("-v2c", "-c", secret); err == nil {
break
}
time.Sleep(100 * time.Millisecond)
}
for _, version := range []string{"1", "2c", "3"} {
t.Run(version, func(t *testing.T) {
args := []string{"-v" + version, "-c", secret}
if version == "3" {
args = []string{"-v3", "-u", "codex_probe", "-l", "authPriv", "-a", "SHA-256", "-A", secret, "-x", "AES", "-X", secret}
}
out, err := probe(args...)
if err != nil || !strings.Contains(string(out), "STRING:") {
t.Fatalf("query failed: %v %s", err, strings.ReplaceAll(string(out), secret, "[redacted]"))
}
})
}
// Reconfigure a live daemon: graceful stop must not overwrite new USM keys.
config.V3Users[0].AuthKey = secret + "a"
config.V3Users[0].PrivacyKey = secret + "p"
if err := controller.Apply(context.Background(), config); err != nil {
t.Fatal(err)
}
time.Sleep(200 * time.Millisecond)
out, err := probe("-v3", "-u", "codex_probe", "-l", "authPriv", "-a", "SHA-256", "-A", secret+"a", "-x", "AES", "-X", secret+"p")
if err != nil || !strings.Contains(string(out), "STRING:") {
t.Fatalf("SNMPv3 query after credential update failed: %v", err)
}
t.Log("SNMPv3 credential update and restart: PASS")
if _, err := probe("-v2c", "-c", "incorrect-test-community"); err == nil {
t.Error("wrong community unexpectedly accepted")
}
}
// Emulates the synchronous init-script contract with an isolated real daemon.
type deviceSNMPRunner struct {
root, mainPath, userPath string
cmd *exec.Cmd
log *os.File
}
func (r *deviceSNMPRunner) Run(ctx context.Context, _ string, args ...string) ([]byte, error) {
if args[0] == "stop" {
if r.cmd == nil {
return nil, nil
}
_ = r.cmd.Process.Signal(syscall.SIGTERM)
done := make(chan error, 1)
go func() { done <- r.cmd.Wait() }()
select {
case <-done:
case <-time.After(5 * time.Second):
_ = r.cmd.Process.Kill()
<-done
return nil, fmt.Errorf("daemon stop timed out")
}
r.cmd = nil
_ = r.log.Close()
return nil, nil
}
raw, err := os.ReadFile(r.mainPath)
if err != nil {
return nil, err
}
raw = []byte(strings.Replace(string(raw), "agentAddress udp:161", "agentAddress udp:127.0.0.1:1161", 1))
if err = os.WriteFile(r.mainPath, raw, 0600); err != nil {
return nil, err
}
r.log, err = os.OpenFile(filepath.Join(r.root, "daemon.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return nil, err
}
r.cmd = exec.Command("/usr/sbin/snmpd", "-f", "-Lo", "-C", "-c", r.mainPath+","+r.userPath)
r.cmd.Env = append(os.Environ(), "SNMP_PERSISTENT_DIR="+filepath.Dir(r.userPath), "MIBS=")
r.cmd.Stdout, r.cmd.Stderr = r.log, r.log
return nil, r.cmd.Start()
}

46
backend/internal/snmpagent/os_controller.go

@ -10,6 +10,7 @@ import (
"sort"
"strings"
"sync"
"time"
"tera-clone/services/tera-api/internal/storage/atomicfile"
)
@ -64,7 +65,7 @@ func NewOSController(options OSOptions) (*OSController, error) {
return &OSController{options: options}, nil
}
func (c *OSController) Apply(ctx context.Context, config Config) error {
func (c *OSController) Apply(ctx context.Context, config Config) (applyErr error) {
if err := Validate(config); err != nil {
return err
}
@ -88,6 +89,37 @@ func (c *OSController) Apply(ctx context.Context, config Config) error {
if err := os.MkdirAll(filepath.Dir(c.options.PersistentPath), 0o700); err != nil {
return fmt.Errorf("prepare SNMP persistent directory: %w", err)
}
// Net-SNMP writes its USM state on shutdown. Stop it before replacing
// bootstrap users, otherwise shutdown can overwrite the new credentials.
if output, err := c.runService(ctx, "stop"); err != nil {
return fmt.Errorf("stop Net-SNMP agent: %w: %s", err, boundedOutput(output))
}
defer func() {
if applyErr == nil {
return
}
recovery, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, stopErr := c.runService(recovery, "stop")
restoreErr := errors.Join(restore(mainBefore), restore(persistentBefore))
var restartErr error
if mainBefore.exists && strings.Contains(string(mainBefore.content), "agentAddress ") {
_, restartErr = c.runService(recovery, "start")
}
applyErr = errors.Join(applyErr, stopErr, restoreErr, restartErr)
}()
// Retain the final engine identity/boot counter, but regenerate users.
stoppedState, err := snapshot(c.options.PersistentPath)
if err != nil {
return err
}
persistentBefore = stoppedState
for _, line := range strings.Split(string(persistentBefore.content), "\n") {
fields := strings.Fields(line)
if len(fields) == 2 && (fields[0] == "engineBoots" || fields[0] == "oldEngineID") {
persistentConfig = append(persistentConfig, []byte(line+"\n")...)
}
}
if err := atomicfile.Write(c.options.ConfigPath, mainConfig, 0o600); err != nil {
return fmt.Errorf("write SNMP agent configuration: %w", err)
}
@ -95,18 +127,10 @@ func (c *OSController) Apply(ctx context.Context, config Config) error {
_ = restore(mainBefore)
return fmt.Errorf("write SNMPv3 persistent configuration: %w", err)
}
action := "stop"
if config.Enabled {
action = "restart"
}
if output, err := c.runService(ctx, action); err != nil {
restoreErr := errors.Join(restore(mainBefore), restore(persistentBefore))
rollbackAction := "stop"
if mainBefore.exists && strings.Contains(string(mainBefore.content), "agentAddress ") {
rollbackAction = "restart"
if output, err := c.runService(ctx, "start"); err != nil {
return fmt.Errorf("start Net-SNMP agent: %w: %s", err, boundedOutput(output))
}
_, restartErr := c.runService(context.Background(), rollbackAction)
return errors.Join(fmt.Errorf("%s Net-SNMP agent: %w: %s", action, err, boundedOutput(output)), restoreErr, restartErr)
}
return nil
}

6
backend/internal/snmpagent/os_controller_test.go

@ -17,7 +17,7 @@ type recordingRunner struct {
func (r *recordingRunner) Run(_ context.Context, _ string, args ...string) ([]byte, error) {
r.actions = append(r.actions, strings.Join(args, " "))
if r.fail && len(r.actions) == 1 {
if r.fail && len(r.actions) == 2 {
return []byte("restart failed"), errors.New("exit status 1")
}
return nil, nil
@ -73,7 +73,7 @@ func TestOSControllerRendersVersionedCommunitiesAndV3Users(t *testing.T) {
if !strings.Contains(string(persistentConfig), `createUser snmp-admin SHA-256 "auth-secret-123" AES "privacy-secret-123"`) {
t.Fatalf("persistent configuration = %s", persistentConfig)
}
if len(runner.actions) != 1 || runner.actions[0] != "restart" {
if len(runner.actions) != 2 || runner.actions[0] != "stop" || runner.actions[1] != "start" {
t.Fatalf("service actions = %v", runner.actions)
}
if runtime.GOOS != "windows" {
@ -113,7 +113,7 @@ func TestOSControllerRestoresFilesWhenRestartFails(t *testing.T) {
if string(mainConfig) != "agentAddress udp:161\nold-main\n" || string(persistentConfig) != "old-persistent\n" {
t.Fatalf("rollback files = %q, %q", mainConfig, persistentConfig)
}
if len(runner.actions) != 2 || runner.actions[1] != "restart" {
if strings.Join(runner.actions, ",") != "stop,start,stop,start" {
t.Fatalf("rollback actions = %v", runner.actions)
}
}

63
backend/internal/startup/clock.go

@ -0,0 +1,63 @@
package startup
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"errors"
"sync"
"time"
)
// ClockBootstrap only repairs an uninitialized clock; it is not a public
// replacement for the authenticated time settings API.
type ClockBootstrap struct {
mu sync.Mutex
now func() time.Time
apply func(context.Context, time.Time) error
token string
used bool
lastAttempt time.Time
}
func NewClockBootstrap(now func() time.Time, apply func(context.Context, time.Time) error) (*ClockBootstrap, error) {
var token [32]byte
if _, err := rand.Read(token[:]); err != nil {
return nil, err
}
return &ClockBootstrap{now: now, apply: apply, token: hex.EncodeToString(token[:])}, nil
}
func (b *ClockBootstrap) Token() string {
b.mu.Lock()
defer b.mu.Unlock()
if b.used || b.now().Year() >= 2000 {
return ""
}
return b.token
}
func (b *ClockBootstrap) Sync(ctx context.Context, token string, target time.Time) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.used || b.now().Year() >= 2000 {
return errors.New("clock bootstrap is no longer available")
}
if subtle.ConstantTimeCompare([]byte(token), []byte(b.token)) != 1 {
return errors.New("invalid clock bootstrap token")
}
if target.Year() < 2020 || target.Year() > 2100 {
return errors.New("PC clock is outside the supported range")
}
if !b.lastAttempt.IsZero() && time.Since(b.lastAttempt) < 3*time.Second {
return errors.New("clock bootstrap retry is too soon")
}
b.lastAttempt = time.Now()
if err := b.apply(ctx, target); err != nil {
return err
}
if b.now().Year() < 2000 {
return errors.New("device clock has not been updated")
}
b.used = true
return nil
}

49
backend/internal/startup/clock_test.go

@ -0,0 +1,49 @@
package startup
import (
"context"
"errors"
"testing"
"time"
)
func TestBootstrapOnlyRepairsUninitializedClockOnce(t *testing.T) {
now := time.Unix(300, 0)
calls := 0
b, err := NewClockBootstrap(func() time.Time { return now }, func(_ context.Context, target time.Time) error { calls++; now = target; return nil })
if err != nil {
t.Fatal(err)
}
token := b.Token()
target := time.Date(2026, 9, 9, 1, 0, 0, 0, time.UTC)
if err = b.Sync(context.Background(), "wrong", target); err == nil || calls != 0 {
t.Fatal("bad token accepted")
}
if err = b.Sync(context.Background(), token, time.Time{}); err == nil || calls != 0 {
t.Fatal("bad PC clock accepted")
}
if err = b.Sync(context.Background(), token, target); err != nil || calls != 1 || b.Token() != "" {
t.Fatalf("%v calls=%d", err, calls)
}
now = time.Unix(0, 0)
if err = b.Sync(context.Background(), token, target); err == nil || calls != 1 {
t.Fatal("replay accepted")
}
}
func TestBootstrapDoesNotOverwriteNTPOrConsumeFailedAttempt(t *testing.T) {
now := time.Unix(0, 0)
calls := 0
b, _ := NewClockBootstrap(func() time.Time { return now }, func(context.Context, time.Time) error { calls++; return errors.New("unavailable") })
token := b.Token()
target := time.Date(2026, 9, 9, 1, 0, 0, 0, time.UTC)
if b.Sync(context.Background(), token, target) == nil || b.Token() == "" {
t.Fatal("failure marked successful")
}
if b.Sync(context.Background(), token, target) == nil || calls != 1 {
t.Fatal("failure retry not throttled")
}
now = target
if b.Token() != "" || b.Sync(context.Background(), token, target.Add(time.Hour)) == nil || calls != 1 {
t.Fatal("valid time overwritten")
}
}

54
backend/internal/startup/retry_test.go

@ -0,0 +1,54 @@
package startup
import (
"context"
"errors"
"testing"
"time"
)
func TestRetryRecoversWithoutOpeningLoginEarly(t *testing.T) {
b := New(time.Now())
b.Add("tls", "tls")
calls := 0
err := b.Retry(context.Background(), "tls", time.Second, time.Millisecond, func() error {
calls++
if b.Snapshot().Ready {
t.Error("ready before success")
}
if calls < 3 {
return errors.New("temporary")
}
return nil
}, nil)
if err != nil || calls != 3 || !b.Snapshot().Ready {
t.Fatalf("%d %v %+v", calls, err, b.Snapshot())
}
}
func TestDelayedAttemptNeverOverlapsAndCanRecover(t *testing.T) {
b := New(time.Now())
b.Add("tls", "tls")
release := make(chan struct{})
done := make(chan error, 1)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
go func() {
done <- b.Retry(ctx, "tls", time.Millisecond, time.Millisecond, func() error { <-release; return nil }, nil)
}()
for b.Snapshot().State != "delayed" {
select {
case <-ctx.Done():
t.Fatal("no delayed state")
default:
time.Sleep(time.Millisecond)
}
}
if b.Snapshot().Ready {
t.Fatal("waiting opened login")
}
close(release)
if err := <-done; err != nil || !b.Snapshot().Ready {
t.Fatalf("%v %+v", err, b.Snapshot())
}
}

157
backend/internal/startup/status.go

@ -0,0 +1,157 @@
package startup
import (
"context"
"sync"
"time"
)
type Stage struct {
ID string `json:"id"`
Label string `json:"label"`
State string `json:"state"`
}
type Status struct {
ClockSyncToken string `json:"clock_sync_token,omitempty"`
Ready bool `json:"ready"`
State string `json:"state"`
ElapsedMS int64 `json:"elapsed_ms"`
Completed int `json:"completed"`
Total int `json:"total"`
Stages []Stage `json:"stages"`
}
type Tracker struct {
mu sync.Mutex
started time.Time
finished time.Time
stages []Stage
}
func New(started time.Time) *Tracker { return &Tracker{started: started} }
func (t *Tracker) Add(id, label string) {
t.mu.Lock()
defer t.mu.Unlock()
t.stages = append(t.stages, Stage{ID: id, Label: label, State: "pending"})
}
func (t *Tracker) Set(id, state string) {
t.mu.Lock()
defer t.mu.Unlock()
for i := range t.stages {
if t.stages[i].ID == id {
t.stages[i].State = state
}
}
done := len(t.stages) > 0
for _, s := range t.stages {
if s.State != "ready" {
done = false
}
}
if done && t.finished.IsZero() {
t.finished = time.Now()
}
}
// Run bounds the readiness decision even if a device call fails to return.
// A late result can never overwrite the recorded timeout.
func (t *Tracker) Run(id string, timeout time.Duration, work func() error) error {
t.Set(id, "running")
result := make(chan error, 1)
go func() { result <- work() }()
timer := time.NewTimer(timeout)
defer timer.Stop()
var err error
select {
case err = <-result:
case <-timer.C:
err = context.DeadlineExceeded
}
if err != nil {
t.Set(id, "failed")
} else {
t.Set(id, "ready")
}
return err
}
// Retry never overlaps attempts, including when a device call stops responding.
// Delayed means work is still pending, not that it succeeded or was abandoned.
func (t *Tracker) Retry(ctx context.Context, id string, delayedAfter, interval time.Duration, work func() error, onError func(error)) error {
t.Set(id, "running")
delay := time.NewTimer(delayedAfter)
defer delay.Stop()
delayed := false
markDelayed := func() { delayed = true; t.Set(id, "delayed") }
for {
if err := ctx.Err(); err != nil {
return err
}
result := make(chan error, 1)
go func() { result <- work() }()
var err error
wait:
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-delay.C:
markDelayed()
case err = <-result:
break wait
}
}
if ctx.Err() != nil {
return ctx.Err()
}
if err == nil {
t.Set(id, "ready")
return nil
}
if onError != nil {
onError(err)
}
if !delayed {
t.Set(id, "retrying")
}
pause := time.NewTimer(interval)
select {
case <-ctx.Done():
pause.Stop()
return ctx.Err()
case <-delay.C:
markDelayed()
select {
case <-ctx.Done():
pause.Stop()
return ctx.Err()
case <-pause.C:
}
case <-pause.C:
}
}
}
func (t *Tracker) Snapshot() Status {
t.mu.Lock()
defer t.mu.Unlock()
end := time.Now()
if !t.finished.IsZero() {
end = t.finished
}
s := Status{State: "starting", ElapsedMS: end.Sub(t.started).Milliseconds(), Total: len(t.stages), Stages: append([]Stage(nil), t.stages...)}
for _, stage := range t.stages {
if stage.State == "ready" {
s.Completed++
}
if stage.State == "failed" {
s.State = "failed"
}
if stage.State == "delayed" && s.State != "failed" {
s.State = "delayed"
}
}
s.Ready = s.Total > 0 && s.Completed == s.Total
if s.Ready {
s.State = "ready"
}
return s
}

46
backend/internal/startup/status_test.go

@ -0,0 +1,46 @@
package startup
import (
"errors"
"testing"
"time"
)
func TestFailureNeverBecomesReady(t *testing.T) {
b := New(time.Now())
b.Add("config", "config")
b.Add("health", "health")
_ = b.Run("config", time.Second, func() error { return errors.New("private detail") })
_ = b.Run("health", time.Second, func() error { return nil })
s := b.Snapshot()
if s.Ready || s.State != "failed" || s.Completed != 1 {
t.Fatalf("%+v", s)
}
}
func TestTimeoutIgnoresLateSuccess(t *testing.T) {
b := New(time.Now())
b.Add("config", "config")
release := make(chan struct{})
returned := make(chan struct{})
err := b.Run("config", time.Millisecond, func() error { <-release; close(returned); return nil })
if err == nil {
t.Fatal("expected timeout")
}
close(release)
<-returned
if b.Snapshot().Ready || b.Snapshot().State != "failed" {
t.Fatal("late result opened login")
}
}
func TestReadyTimeIsFrozenAndSnapshotIsIndependent(t *testing.T) {
b := New(time.Now())
b.Add("config", "config")
_ = b.Run("config", time.Second, func() error { return nil })
s := b.Snapshot()
s.Stages[0].State = "failed"
time.Sleep(2 * time.Millisecond)
next := b.Snapshot()
if !next.Ready || next.ElapsedMS != s.ElapsedMS {
t.Fatalf("%+v", next)
}
}

22
backend/internal/supportbundle/manager.go

@ -337,3 +337,25 @@ func validateSectionName(value string) error {
}
return nil
}
// Reset deletes diagnostic archives after producers have been stopped.
func (m *Manager) Reset() error {
m.mu.Lock()
defer m.mu.Unlock()
entries, err := os.ReadDir(m.directory)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() {
return errors.New("unexpected directory in support bundle store")
}
if err := os.Remove(filepath.Join(m.directory, entry.Name())); err != nil {
return err
}
}
return nil
}

2
backend/internal/systemtime/os_ntp_applier.go

@ -45,6 +45,7 @@ type OSNTPApplier struct {
daemonPath string
runner commandRunner
clock func() time.Time
running func() (bool, error)
}
func NewOSNTPApplier(configPath, initPath, daemonPath string) (*OSNTPApplier, error) {
@ -63,6 +64,7 @@ func NewOSNTPApplier(configPath, initPath, daemonPath string) (*OSNTPApplier, er
daemonPath: filepath.Clean(daemonPath),
runner: execRunner{},
clock: time.Now,
running: ntpDaemonRunning,
}, nil
}

152
backend/internal/systemtime/settings.go

@ -0,0 +1,152 @@
package systemtime
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// Settings configures clock discipline without waiting for an external server
// or rewriting an already valid clock. Browser bootstrap remains independent.
type Settings struct {
Timezone string `json:"timezone"`
NTPEnabled bool `json:"ntp_enabled"`
Servers []string `json:"servers"`
}
type SettingsController interface {
Configure(context.Context, Settings) error
}
type NTPSettingsApplier interface {
ConfigureNTP(context.Context, bool, []string) error
}
func (c *TransactionalController) Configure(ctx context.Context, settings Settings) error {
zone, err := NormalizeTimezone(settings.Timezone)
if err != nil {
return err
}
if settings.NTPEnabled {
normalized, err := NormalizeNTPRequest(NTPRequest{Timezone: zone, Servers: settings.Servers})
if err != nil {
return err
}
settings.Servers = normalized.Servers
}
if c == nil || c.applier == nil {
return errors.New("time settings applier unavailable")
}
ntp, ok := c.ntpApplier.(NTPSettingsApplier)
if !ok {
return errors.New("NTP settings applier unavailable")
}
c.mu.Lock()
defer c.mu.Unlock()
rollback, err := c.applier.ApplyTimezone(ctx, zone)
if err != nil {
return err
}
if rollback == nil {
return errors.New("timezone rollback missing")
}
if err := ntp.ConfigureNTP(ctx, settings.NTPEnabled, settings.Servers); err != nil {
return errors.Join(err, rollback())
}
return nil
}
func (MockController) Configure(ctx context.Context, settings Settings) error {
_, err := NormalizeTimezone(settings.Timezone)
return err
}
func (a *OSNTPApplier) ConfigureNTP(ctx context.Context, enabled bool, servers []string) error {
var normalized NTPRequest
var err error
if enabled {
normalized, err = NormalizeNTPRequest(NTPRequest{Timezone: "UTC", Servers: servers})
if err != nil {
return err
}
}
previous, mode, existed, err := readExistingNTPConfig(a.configPath)
if err != nil {
return err
}
running, err := a.running()
if err != nil {
return err
}
if running {
if err := a.runner.Run(ctx, a.initPath, "stop"); err != nil {
return fmt.Errorf("stop NTP: %w", err)
}
if err := a.waitNTPState(ctx, false); err != nil {
return err
}
}
if !enabled {
return nil
}
err = writeAtomicFile(a.configPath, buildNTPConfig(normalized.Servers), 0o644)
if err == nil {
err = a.runner.Run(ctx, a.initPath, "start")
if err == nil {
err = a.waitNTPState(ctx, true)
}
}
if err == nil {
return nil
}
// Preserve the previous configuration if the daemon rejects the change.
if existed {
err = errors.Join(err, writeAtomicFile(a.configPath, previous, mode))
} else if removeErr := os.Remove(a.configPath); removeErr != nil && !os.IsNotExist(removeErr) {
err = errors.Join(err, removeErr)
}
return errors.Join(err, a.runRecovery(a.initPath, "start"))
}
func ntpDaemonRunning() (bool, error) {
entries, err := os.ReadDir("/proc")
if err != nil {
return false, err
}
for _, entry := range entries {
if _, err := strconv.Atoi(entry.Name()); err != nil {
continue
}
exe, err := os.Readlink(filepath.Join("/proc", entry.Name(), "exe"))
if err == nil && filepath.Base(strings.TrimSuffix(exe, " (deleted)")) == "ntpd" {
return true, nil
}
}
return false, nil
}
func (a *OSNTPApplier) waitNTPState(ctx context.Context, desired bool) error {
timeout := time.NewTimer(2 * time.Second)
defer timeout.Stop()
for {
running, err := a.running()
if err != nil {
return err
}
if running == desired {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-timeout.C:
return errors.New("NTP daemon did not reach configured state")
case <-time.After(50 * time.Millisecond):
}
}
}

75
backend/internal/systemtime/settings_test.go

@ -0,0 +1,75 @@
package systemtime
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
type settingsNTPFake struct {
fakeNTPApplier
fail error
}
func (a *settingsNTPFake) ConfigureNTP(context.Context, bool, []string) error { return a.fail }
func TestConfigureTimeDoesNotRewriteClockAndRollsBackOnFailure(t *testing.T) {
applier := &fakeApplier{}
ntp := &settingsNTPFake{}
controller := NewTransactionalController(applier, ntp)
settings := Settings{Timezone: "Asia/Seoul", NTPEnabled: true, Servers: []string{"time.example"}}
if err := controller.Configure(context.Background(), settings); err != nil {
t.Fatal(err)
}
if !applier.appliedTime.IsZero() || applier.timezone != "Asia/Seoul" {
t.Fatal("unexpected clock mutation")
}
ntp.fail = errors.New("daemon failed")
if err := controller.Configure(context.Background(), settings); err == nil || !applier.rollbackCalled {
t.Fatal("failure not rolled back")
}
}
type settingsRunner struct {
running bool
calls []string
}
func (r *settingsRunner) Run(_ context.Context, _ string, args ...string) error {
r.calls = append(r.calls, strings.Join(args, " "))
r.running = args[0] == "start"
return nil
}
func TestConfigureNTPWorksOfflineAndDisabledIsIdempotent(t *testing.T) {
root := t.TempDir()
a, err := NewOSNTPApplier(filepath.Join(root, "ntp.conf"), filepath.Join(root, "S49ntp"), filepath.Join(root, "ntpd"))
if err != nil {
t.Fatal(err)
}
runner := &settingsRunner{}
a.runner = runner
a.running = func() (bool, error) { return runner.running, nil }
if err := a.ConfigureNTP(context.Background(), true, []string{"internal.example"}); err != nil {
t.Fatal(err)
}
content, _ := os.ReadFile(a.configPath)
if !strings.Contains(string(content), "server internal.example iburst") || !runner.running {
t.Fatal("configuration not applied")
}
if len(runner.calls) != 1 || runner.calls[0] != "start" {
t.Fatal("unexpected one-shot sync", runner.calls)
}
if err := a.ConfigureNTP(context.Background(), false, nil); err != nil {
t.Fatal(err)
}
if err := a.ConfigureNTP(context.Background(), false, nil); err != nil {
t.Fatal(err)
}
if runner.running || len(runner.calls) != 2 {
t.Fatal("disable not idempotent")
}
}

51
backend/internal/systemtime/timesocket/socket.go

@ -18,10 +18,11 @@ import (
const DefaultSocketPath = "/run/tera-timed/time.sock"
type request struct {
Action string `json:"action"`
BrowserTime time.Time `json:"browser_time,omitempty"`
Timezone string `json:"timezone,omitempty"`
Servers []string `json:"servers,omitempty"`
Action string `json:"action"`
BrowserTime time.Time `json:"browser_time,omitempty"`
Timezone string `json:"timezone,omitempty"`
Servers []string `json:"servers,omitempty"`
Settings *systemtime.Settings `json:"settings,omitempty"`
}
type response struct {
@ -78,7 +79,7 @@ func (s Server) handleConnection(connection net.Conn) {
s.writeResponse(connection, response{Code: "validation_failed", Error: "system time request must contain a single JSON value"})
return
}
if command.Action != "sync" && command.Action != "sync_ntp" {
if command.Action != "sync" && command.Action != "sync_ntp" && command.Action != "configure" {
s.writeResponse(connection, response{Code: "validation_failed", Error: "system time action is not supported"})
return
}
@ -90,6 +91,19 @@ func (s Server) handleConnection(connection net.Conn) {
_ = connection.SetDeadline(time.Now().Add(timeout + 2*time.Second))
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if command.Action == "configure" {
controller, ok := s.Controller.(systemtime.SettingsController)
if !ok || command.Settings == nil {
s.writeResponse(connection, response{Error: "time settings controller unavailable"})
return
}
if err := controller.Configure(ctx, *command.Settings); err != nil {
s.writeResponse(connection, response{Error: err.Error()})
return
}
s.writeResponse(connection, response{Result: &systemtime.Result{Timezone: command.Settings.Timezone}})
return
}
if command.Action == "sync_ntp" {
controller, ok := s.Controller.(systemtime.NTPController)
if !ok {
@ -203,3 +217,30 @@ func (c *Client) SyncNTP(ctx context.Context, value systemtime.NTPRequest) (syst
}
return *result.NTPResult, nil
}
func (c *Client) Configure(ctx context.Context, settings systemtime.Settings) error {
connection, err := (&net.Dialer{Timeout: c.timeout}).DialContext(ctx, "unix", c.path)
if err != nil {
return err
}
defer connection.Close()
deadline := time.Now().Add(c.timeout)
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
deadline = d
}
_ = connection.SetDeadline(deadline)
if err := json.NewEncoder(connection).Encode(request{Action: "configure", Settings: &settings}); err != nil {
return err
}
var result response
if err := json.NewDecoder(io.LimitReader(connection, 65536)).Decode(&result); err != nil {
return err
}
if result.Error != "" {
return errors.New(result.Error)
}
if result.Result == nil {
return errors.New("time settings acknowledgement missing")
}
return nil
}

13
backend/internal/systemtime/timesocket/socket_test.go

@ -90,3 +90,16 @@ func TestServerRejectsUnsafeTimezoneWithoutCallingController(t *testing.T) {
t.Fatal("Sync() accepted an unsafe timezone")
}
}
func TestClientConfiguresWithoutClockSync(t *testing.T) {
listener, err := net.Listen("unix", filepath.Join(t.TempDir(), "settings.sock"))
if err != nil {
t.Fatal(err)
}
defer listener.Close()
go func() { _ = (Server{Controller: systemtime.MockController{}}).Serve(listener) }()
client := NewClient(listener.Addr().String())
if err := client.Configure(context.Background(), systemtime.Settings{Timezone: "Asia/Seoul", NTPEnabled: true, Servers: []string{"internal.example"}}); err != nil {
t.Fatal(err)
}
}

54
backend/internal/tlscert/bootstrap_test.go

@ -0,0 +1,54 @@
package tlscert
import (
"bytes"
"path/filepath"
"testing"
"time"
)
func TestFactoryCertificateRecoversAfterInitialClockSync(t *testing.T) {
now := time.Unix(100, 0)
options := Options{Root: t.TempDir(), Clock: func() time.Time { return now }}
options.ActiveCert = filepath.Join(options.Root, "active", "server.crt")
options.ActiveKey = filepath.Join(options.Root, "active", "server.key")
r, err := Open(options)
if err != nil {
t.Fatal(err)
}
key := append([]byte(nil), r.records[FactoryCertificateID].keyPEM...)
old, _ := r.Metadata(FactoryCertificateID)
if _, err = r.Generate("user-cert", Subject{CommonName: "user.example"}); err != nil {
t.Fatal(err)
}
now = time.Date(2026, 9, 9, 0, 0, 0, 0, time.UTC)
fresh, err := r.Activate(FactoryCertificateID)
if err != nil {
t.Fatal(err)
}
if fresh.NotBefore.Year() != 2026 || fresh.FingerprintSHA256 == old.FingerprintSHA256 {
t.Fatal("factory certificate was not reissued")
}
if !bytes.Equal(key, r.records[FactoryCertificateID].keyPEM) {
t.Fatal("factory private key changed")
}
again, err := r.Activate(FactoryCertificateID)
if err != nil || again.FingerprintSHA256 != fresh.FingerprintSHA256 {
t.Fatal("unnecessary repeat renewal")
}
if _, err = r.Activate("user-cert"); err == nil {
t.Fatal("invalid user certificate accepted")
}
reopened, err := Open(options)
if err != nil {
t.Fatal(err)
}
persisted, err := reopened.Activate(FactoryCertificateID)
if err != nil || persisted.FingerprintSHA256 != fresh.FingerprintSHA256 {
t.Fatal("repaired certificate not persisted")
}
now = now.AddDate(11, 0, 0)
if _, err = r.Activate(FactoryCertificateID); err == nil {
t.Fatal("normally expired factory certificate silently renewed")
}
}

83
backend/internal/tlscert/repository.go

@ -276,6 +276,20 @@ func (r *Repository) Activate(certificateID string) (Metadata, error) {
return Metadata{}, errors.New("TLS certificate does not exist")
}
now := r.clock().UTC()
// A factory certificate created before the device acquired a clock must
// be reissued after bootstrap. Keep its key and subject; never replace a
// user certificate or a normally dated expired factory certificate.
if id == FactoryCertificateID && item.metadata.Source == "factory" && item.metadata.NotBefore.Year() < 2000 && now.Year() >= 2020 {
repaired, err := reissueBootstrapFactory(item, now)
if err != nil {
return Metadata{}, err
}
if err := r.persist(repaired); err != nil {
return Metadata{}, err
}
r.records[id] = repaired
item = repaired
}
if now.Before(item.metadata.NotBefore) || !now.Before(item.metadata.NotAfter) {
return Metadata{}, errors.New("TLS certificate is not currently valid")
}
@ -552,3 +566,72 @@ func nonEmpty(value string) []string {
}
return []string{value}
}
// Reset deletes customer key material and generates a fresh factory identity.
func (r *Repository) Reset() (Metadata, error) {
fresh, err := generateRecord(FactoryCertificateID, Subject{Organization: "Teraclone Co.,Ltd", CommonName: "tera-management.local"}, "factory", r.clock())
if err != nil {
return Metadata{}, err
}
r.mu.Lock()
defer r.mu.Unlock()
if r.root != "" {
entries, err := os.ReadDir(r.root)
if err != nil {
return Metadata{}, err
}
for _, entry := range entries {
if entry.IsDir() {
return Metadata{}, errors.New("unexpected directory in TLS store")
}
if err := os.Remove(filepath.Join(r.root, entry.Name())); err != nil {
return Metadata{}, err
}
}
}
if err := r.persist(fresh); err != nil {
return Metadata{}, err
}
r.records = map[string]record{FactoryCertificateID: fresh}
for path, content := range map[string][]byte{r.activeKey: fresh.keyPEM, r.activeCert: fresh.certPEM} {
if path == "" {
continue
}
if err := atomicfile.Write(path, content, 0o600); err != nil {
return Metadata{}, err
}
}
return fresh.metadata, nil
}
func reissueBootstrapFactory(item record, now time.Time) (record, error) {
pair, err := tls.X509KeyPair(item.certPEM, item.keyPEM)
if err != nil {
return record{}, err
}
certificate, err := x509.ParseCertificate(pair.Certificate[0])
if err != nil {
return record{}, err
}
signer, ok := pair.PrivateKey.(crypto.Signer)
if !ok {
return record{}, errors.New("factory certificate signer unavailable")
}
certificate.SerialNumber, err = rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return record{}, err
}
certificate.NotBefore = now.Add(-5 * time.Minute)
certificate.NotAfter = now.AddDate(10, 0, 0)
der, err := x509.CreateCertificate(rand.Reader, certificate, certificate, signer.Public(), signer)
if err != nil {
return record{}, err
}
certificate, err = x509.ParseCertificate(der)
if err != nil {
return record{}, err
}
item.certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
item.metadata = metadataFromCertificate(FactoryCertificateID, certificate, pair.PrivateKey, "factory")
return item, nil
}

28
backend/internal/update/controller.go

@ -61,18 +61,20 @@ type Manifest struct {
}
type Status struct {
FormatVersion int `json:"format_version"`
OperationID string `json:"operation_id,omitempty"`
State string `json:"state"`
Message string `json:"message"`
Filename string `json:"filename,omitempty"`
DeviceVersion string `json:"device_version,omitempty"`
BytesReceived int64 `json:"bytes_received"`
BytesTotal int64 `json:"bytes_total"`
ProgressPercent int `json:"progress_percent"`
PackagePath string `json:"-"`
Manifest *Manifest `json:"manifest,omitempty"`
SignerKeyID string `json:"signer_key_id,omitempty"`
FormatVersion int `json:"format_version"`
OperationID string `json:"operation_id,omitempty"`
State string `json:"state"`
Message string `json:"message"`
Filename string `json:"filename,omitempty"`
DeviceVersion string `json:"device_version,omitempty"`
BytesReceived int64 `json:"bytes_received"`
BytesTotal int64 `json:"bytes_total"`
ProgressPercent int `json:"progress_percent"`
PackagePath string `json:"-"`
Manifest *Manifest `json:"manifest,omitempty"`
SignerKeyID string `json:"signer_key_id,omitempty"`
// ApplyPolicy is persisted by field firmware; retain it across upgrades.
ApplyPolicy string `json:"apply_policy,omitempty"`
ApplyEnabled bool `json:"apply_enabled"`
ApplyDisabledReason string `json:"apply_disabled_reason,omitempty"`
RecoveryInstructions string `json:"recovery_instructions,omitempty"`
@ -137,3 +139,5 @@ func (u UnavailableController) Upload(context.Context, UploadRequest) (Status, e
func (u UnavailableController) Apply(context.Context, ApplyRequest) (Status, error) {
return Status{}, u.unavailable()
}
type FactoryResetter interface{ FactoryReset(context.Context) error }

36
backend/internal/update/manager.go

@ -464,3 +464,39 @@ func (w *progressWriter) Write(buffer []byte) (int, error) {
}
return n, err
}
// FactoryReset removes uploaded packages and their journal, not installed
// firmware or the firmware-signing trust anchors.
func (m *Manager) FactoryReset(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
if err := ctx.Err(); err != nil {
return err
}
if m.active || m.status.State == StateRebootPending || m.status.State == StateApplying {
return errors.New("cannot factory reset during firmware update")
}
for _, dir := range []string{m.incoming, m.validated, filepath.Dir(m.journalPath)} {
if err := requireDirectory(dir); err != nil {
return err
}
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if filepath.Dir(path) != dir || entry.IsDir() {
return errors.New("unexpected firmware staging entry")
}
if err := os.Remove(path); err != nil {
return err
}
}
if err := syncDirectory(dir); err != nil {
return err
}
}
m.status = Status{FormatVersion: 1, State: StateIdle, Message: "No firmware package has been uploaded", DeviceVersion: m.currentVersion, UpdatedAt: m.clock().UTC()}
return m.persistLocked()
}

57
backend/internal/update/manager_test.go

@ -276,3 +276,60 @@ func writeJSON(t *testing.T, path string, value any) {
t.Fatal(err)
}
}
func TestFactoryResetErasesUploadedFirmwareAndKeepsInstalledVersion(t *testing.T) {
m, key := newTestManager(t)
payload := signedPackage(t, key, testProfile, []byte("customer-upload"))
if _, err := m.Upload(context.Background(), UploadRequest{Filename: "release.bin", ContentLength: int64(len(payload)), Body: bytes.NewReader(payload)}); err != nil {
t.Fatal(err)
}
if err := m.FactoryReset(context.Background()); err != nil {
t.Fatal(err)
}
for _, dir := range []string{m.incoming, m.validated} {
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 0 {
t.Fatal("uploaded firmware survived reset")
}
}
reopened, err := NewManager(testManagerOptions(filepath.Dir(m.root)))
if err != nil {
t.Fatal(err)
}
status, err := reopened.Status(context.Background())
if err != nil || status.State != StateIdle || status.DeviceVersion != m.currentVersion || status.Manifest != nil {
t.Fatal("firmware reset state is incorrect")
}
m.active = true
if err := m.FactoryReset(context.Background()); err == nil {
t.Fatal("reset accepted during update")
}
}
func TestManagerRecoversFieldApplyPolicyJournal(t *testing.T) {
manager, _ := newTestManager(t)
data, err := os.ReadFile(manager.journalPath)
if err != nil {
t.Fatal(err)
}
var value map[string]any
if err := json.Unmarshal(data, &value); err != nil {
t.Fatal(err)
}
value["apply_policy"] = "field"
data, err = json.Marshal(value)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(manager.journalPath, data, 0600); err != nil {
t.Fatal(err)
}
reopened, err := NewManager(testManagerOptions(filepath.Dir(manager.root)))
if err != nil {
t.Fatal(err)
}
status, err := reopened.Status(context.Background())
if err != nil || status.ApplyPolicy != "field" {
t.Fatal("field policy was not preserved")
}
}

16
backend/internal/update/updatesocket/socket.go

@ -36,6 +36,7 @@ func (s Server) Serve(listener net.Listener) error {
mux.HandleFunc("GET /v1/status", s.handleStatus)
mux.HandleFunc("POST /v1/upload", s.handleUpload)
mux.HandleFunc("POST /v1/apply", s.handleApply)
mux.HandleFunc("POST /v1/factory-reset", s.handleFactoryReset)
server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second}
err := server.Serve(listener)
if errors.Is(err, http.ErrServerClosed) || errors.Is(err, net.ErrClosed) {
@ -151,3 +152,18 @@ func statusValue(value *update.Status) update.Status {
}
return *value
}
func (s Server) handleFactoryReset(w http.ResponseWriter, request *http.Request) {
resetter, ok := s.Controller.(update.FactoryResetter)
if !ok {
s.write(w, update.Status{}, errors.New("firmware cleanup unavailable"))
return
}
err := resetter.FactoryReset(request.Context())
s.write(w, update.Status{State: update.StateIdle}, err)
}
func (c *Client) FactoryReset(ctx context.Context) error {
request, _ := http.NewRequestWithContext(ctx, http.MethodPost, "http://tera-updated/v1/factory-reset", nil)
_, err := c.do(c.controlClient, request)
return err
}

22
backend/internal/webgateway/gateway.go

@ -19,6 +19,9 @@ var (
)
type Options struct {
// WebUpstream serves the complete authenticated console through HTTPS.
// Empty retains the legacy static UI gateway.
WebUpstream string
StaticRoot string
APIUpstream string
SessionUpstream string
@ -31,6 +34,13 @@ type gateway struct {
}
func NewHandler(options Options) (http.Handler, error) {
if options.WebUpstream != "" {
target, err := parseLoopbackUpstream(options.WebUpstream)
if err != nil {
return nil, fmt.Errorf("web upstream: %w", err)
}
return securityHeaders(reverseProxy(target)), nil
}
root, err := filepath.Abs(options.StaticRoot)
if err != nil || options.StaticRoot == "" {
return nil, errors.New("tera-web static root is required")
@ -89,9 +99,18 @@ func reverseProxy(target *url.URL) http.Handler {
director := proxy.Director
proxy.Director = func(request *http.Request) {
publicHost := request.Host
clientIP, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
clientIP = request.RemoteAddr
}
port := "80"
forwardedProto := "http"
if request.TLS != nil {
forwardedProto = "https"
port = "443"
}
if _, value, err := net.SplitHostPort(publicHost); err == nil {
port = value
}
// Discard any client-supplied forwarding chain. ReverseProxy appends
// the actual peer IP after Director returns, giving tera-sessiond one
@ -100,6 +119,9 @@ func reverseProxy(target *url.URL) http.Handler {
director(request)
request.Host = publicHost
request.Header.Set("X-Forwarded-Proto", forwardedProto)
request.Header.Set("X-Tera-Client-IP", clientIP)
request.Header.Set("X-Tera-Client-Protocol", forwardedProto)
request.Header.Set("X-Tera-Client-Port", port)
}
proxy.ErrorHandler = func(response http.ResponseWriter, _ *http.Request, _ error) {
http.Error(response, "upstream unavailable", http.StatusBadGateway)

29
backend/internal/webgateway/gateway_test.go

@ -1,6 +1,7 @@
package webgateway
import (
"crypto/tls"
"io"
"net"
"net/http"
@ -13,6 +14,34 @@ import (
"github.com/gorilla/websocket"
)
func TestConsoleUpstreamPreservesHostAndReplacesSpoofedMetadata(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Host != "console.example:9443" || r.Header.Get("X-Tera-Client-IP") != "192.0.2.77" || r.Header.Get("X-Tera-Client-Protocol") != "https" || r.Header.Get("X-Tera-Client-Port") != "9443" {
t.Errorf("incorrect trusted client metadata: host=%s headers=%v", r.Host, r.Header)
}
w.Header().Set("Set-Cookie", "session=test; Secure; HttpOnly")
_, _ = io.WriteString(w, r.URL.Path)
}))
defer upstream.Close()
handler, err := NewHandler(Options{WebUpstream: upstream.URL})
if err != nil {
t.Fatal(err)
}
for _, path := range []string{"/login", "/admin/ssl", "/api/v1/config/status"} {
r := httptest.NewRequest("GET", "https://console.example:9443"+path, nil)
r.RemoteAddr = "192.0.2.77:5000"
r.TLS = &tls.ConnectionState{}
r.Header.Set("X-Tera-Client-IP", "127.0.0.1")
r.Header.Set("X-Tera-Client-Protocol", "http")
r.Header.Set("X-Tera-Client-Port", "80")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != 200 || w.Body.String() != path || !strings.Contains(w.Header().Get("Set-Cookie"), "Secure") {
t.Fatalf("console proxy failed: %d %s", w.Code, w.Body.String())
}
}
}
func TestGatewayServesStaticAndRoutesRESTAndOnlyStreamWebSocketPath(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
response.Header().Set("X-Upstream", "api")

26
demo-services/compose.yaml

@ -0,0 +1,26 @@
name: tera-observability-demo
services:
mailpit:
image: axllent/mailpit:v1.27.4
restart: "no"
ports:
- "${DEMO_BIND_ADDRESS:-127.0.0.1}:1025:1025/tcp"
- "127.0.0.1:8025:8025/tcp"
environment:
MP_DATABASE: /data/mailpit.db
volumes:
- mail-data:/data
syslog:
build: ./syslog
restart: "no"
ports:
- "${DEMO_BIND_ADDRESS:-127.0.0.1}:1514:514/udp"
- "${DEMO_BIND_ADDRESS:-127.0.0.1}:1514:514/tcp"
volumes:
- syslog-data:/var/log/tera
volumes:
mail-data:
syslog-data:

10
demo-services/syslog/Dockerfile

@ -0,0 +1,10 @@
FROM ubuntu:24.04
RUN apt-get update \
&& apt-get install -y --no-install-recommends rsyslog \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /var/log/tera \
&& touch /var/log/tera/remote.log
COPY rsyslog.conf /etc/rsyslog.conf
RUN rsyslogd -N1
EXPOSE 514/udp 514/tcp
CMD ["rsyslogd", "-n", "-i", "/tmp/rsyslog.pid"]

7
demo-services/syslog/rsyslog.conf

@ -0,0 +1,7 @@
module(load="imudp")
module(load="imtcp")
input(type="imudp" port="514")
input(type="imtcp" port="514")
template(name="TeraDemo" type="string"
string="%timegenerated:::date-rfc3339% from=%fromhost-ip% %rawmsg%\n")
action(type="omfile" file="/var/log/tera/remote.log" template="TeraDemo")

17
frontend/cmd/server/main.go

@ -4,7 +4,6 @@ import (
"log"
"net/http"
"os"
"path/filepath"
"teraclone/internal/routes"
"teraclone/internal/service"
@ -28,20 +27,8 @@ func runServer() {
sessionUpstream = "http://127.0.0.1:8081"
}
dbPath := os.Getenv("TERACLONE_DB_PATH")
if dbPath == "" {
dbPath = filepath.Join("data", "teraclone.db")
}
appService, err := service.NewAppService(dbPath)
if err != nil {
log.Fatal(err)
}
defer func() {
if closeErr := appService.Close(); closeErr != nil {
log.Printf("sqlite close error: %v", closeErr)
}
}()
// Device state and accounts belong exclusively to tera-api.
appService := service.NewStatelessAppService()
mux := http.NewServeMux()
if err := routes.RegisterWithSessionUpstream(mux, appService, apiUpstream, sessionUpstream); err != nil {

11
frontend/internal/handlers/account_roles_test.go

@ -29,16 +29,25 @@ func TestAccountRoleSelectorsExcludeViewer(t *testing.T) {
request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "admin-session"})
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if path == "/admin/groups" {
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/admin/users" {
t.Fatalf("removed group page must redirect to users: status=%d location=%q", response.Code, response.Header().Get("Location"))
}
return
}
if response.Code != http.StatusOK {
t.Fatalf("page status = %d", response.Code)
}
body := response.Body.String()
if strings.Contains(body, `href="/admin/groups"`) {
t.Error("page still links to removed group management")
}
for _, removed := range []string{`value="viewer"`, `value="viewers"`, "조회 사용자"} {
if strings.Contains(body, removed) {
t.Errorf("page still offers removed viewer role: %s", removed)
}
}
for _, required := range []string{`value="administrator"`, `value="port_operator"`, "integration.js?v=20260907-storage-logs-1"} {
for _, required := range []string{`value="administrator"`, `value="port_operator"`, "integration.js?v=20260909-config-read-1"} {
if !strings.Contains(body, required) {
t.Errorf("page is missing %s", required)
}

15
frontend/internal/handlers/admin_controls_test.go

@ -18,6 +18,8 @@ func TestLoginCaptchaIsPublicPeerBoundAndForwardedWithCredentials(t *testing.T)
t.Error("credentials leaked through public login request")
}
switch r.URL.Path {
case "/api/v1/auth/startup":
_ = json.NewEncoder(w).Encode(map[string]any{"ready": true, "state": "ready"})
case "/api/v1/auth/login-policy":
_ = json.NewEncoder(w).Encode(map[string]bool{"captcha_enabled": true})
case "/api/v1/auth/captcha":
@ -31,6 +33,9 @@ func TestLoginCaptchaIsPublicPeerBoundAndForwardedWithCredentials(t *testing.T)
if r.Header.Get("X-Forwarded-For") != "192.0.2.44" {
t.Error("login and challenge peers differ")
}
if input["browser_time"] != "2026-09-08T03:00:00Z" {
t.Error("browser time was not forwarded")
}
if input["captcha_id"] != "test-challenge" || input["captcha_answer"] != "23456789" {
w.WriteHeader(401)
_, _ = w.Write([]byte(`{"error":{"code":"captcha_failed","message":"login verification failed"}}`))
@ -73,7 +78,7 @@ func TestLoginCaptchaIsPublicPeerBoundAndForwardedWithCredentials(t *testing.T)
}
}
for _, answer := range []string{"wrong", "23456789"} {
body := url.Values{"username": {"admin"}, "password": {"test-password"}, "captcha_id": {"test-challenge"}, "captcha_answer": {answer}}
body := url.Values{"username": {"admin"}, "password": {"test-password"}, "captcha_id": {"test-challenge"}, "captcha_answer": {answer}, "browser_time": {"2026-09-08T03:00:00Z"}}
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(body.Encode()))
req.RemoteAddr = "192.0.2.44:45001"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@ -93,11 +98,9 @@ func TestLoginCaptchaIsPublicPeerBoundAndForwardedWithCredentials(t *testing.T)
}
func TestFactoryResetPageRequiresAdministratorAndExplainsScope(t *testing.T) {
app, err := service.NewAppService(filepath.Join(t.TempDir(), "frontend.db"))
if err != nil {
t.Fatal(err)
}
app := service.NewStatelessAppService()
defer app.Close()
handler, err := NewDeviceHandler(app, "http://127.0.0.1:1")
if err != nil {
t.Fatal(err)
@ -116,7 +119,7 @@ func TestFactoryResetPageRequiresAdministratorAndExplainsScope(t *testing.T) {
}
continue
}
for _, expected := range []string{"공장 초기화", `name="confirmation"`, "로컬 사용자 계정은 보존", "자동 재부팅하지 않습니다"} {
for _, expected := range []string{"공장 초기화", `name="confirmation"`, "terauser / terauser", "자동 재부팅됩니다"} {
if !strings.Contains(res.Body.String(), expected) {
t.Errorf("missing reset warning: %s", expected)
}

84
frontend/internal/handlers/device_handler.go

@ -166,7 +166,7 @@ func NewDeviceHandlerWithSessionUpstream(appService *service.AppService, apiUpst
}
browserToken, _ := response.Request.Context().Value(browserSessionContextKey{}).(string)
handler.invalidateBrowserSession(browserToken)
response.Header.Add("Set-Cookie", expiredSessionCookie(response.Request.TLS != nil).String())
response.Header.Add("Set-Cookie", expiredSessionCookie(secureBrowserRequest(response.Request)).String())
return nil
}
handler.apiProxy = proxy
@ -199,6 +199,7 @@ func (h *DeviceHandler) RegisterRoutes(mux *http.ServeMux) {
mux.Handle("/static/", http.StripPrefix("/static/", h.static))
mux.HandleFunc("/api/v1/", h.handleAPIProxy)
mux.HandleFunc("/login/backend-status", h.handleLoginBackendStatus)
mux.HandleFunc("/login/bootstrap-time", h.handleBootstrapTime)
mux.HandleFunc("/login/captcha", h.handleLoginCaptcha)
mux.HandleFunc("/port-debug/readiness", h.handlePortDebugReadiness)
mux.HandleFunc("/login", h.handleLogin)
@ -256,18 +257,41 @@ func (h *DeviceHandler) handleLoginBackendStatus(w http.ResponseWriter, r *http.
w.Header().Set("Cache-Control", "no-store")
ctx, cancel := context.WithTimeout(r.Context(), 1500*time.Millisecond)
defer cancel()
endpoint := h.apiTarget.ResolveReference(&url.URL{Path: "/api/v1/auth/login-policy"})
endpoint := h.apiTarget.ResolveReference(&url.URL{Path: "/api/v1/auth/startup"})
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
writeLoginBackendStatus(w, false)
return
}
request.Header.Set("Accept", "application/json")
response, err := h.apiClient.Do(request)
if err != nil {
writeLoginBackendStatus(w, false)
return
}
var state loginStartupStatus
decodeErr := json.NewDecoder(io.LimitReader(response.Body, 64*1024)).Decode(&state)
response.Body.Close()
if decodeErr != nil || response.StatusCode != http.StatusOK {
writeLoginBackendStatus(w, false)
return
}
if !state.Ready || state.State != "ready" {
state.Ready = false
writeStartupResponse(w, state)
return
}
endpoint = h.apiTarget.ResolveReference(&url.URL{Path: "/api/v1/auth/login-policy"})
request, err = http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
writeLoginBackendStatus(w, false)
return
}
request.Header.Set("Accept", "application/json")
response, err = h.apiClient.Do(request)
if err != nil {
writeLoginBackendStatus(w, false)
return
}
defer response.Body.Close()
ready := response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices
var policy struct {
@ -276,7 +300,12 @@ func (h *DeviceHandler) handleLoginBackendStatus(w http.ResponseWriter, r *http.
if err := json.NewDecoder(io.LimitReader(response.Body, 64*1024)).Decode(&policy); err != nil {
ready = false
}
writeLoginBackendStatus(w, ready, policy.CaptchaEnabled)
state.Ready = ready
state.CaptchaEnabled = policy.CaptchaEnabled
if !ready {
state.State = "starting"
}
writeStartupResponse(w, state)
}
func writeLoginBackendStatus(w http.ResponseWriter, ready bool, captcha ...bool) {
@ -337,6 +366,16 @@ func (h *DeviceHandler) handlePage(w http.ResponseWriter, r *http.Request) {
return
}
if strings.HasPrefix(r.URL.Path, "/admin/address-filter/ipv6") {
http.Redirect(w, r, "/admin/address-filter/ipv4", http.StatusSeeOther)
return
}
if r.URL.Path == "/admin/groups" {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return
}
page, ok := mock.ResolvePage(r.URL.Path)
if !ok {
http.NotFound(w, r)
@ -490,7 +529,7 @@ func (h *DeviceHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
Value: token,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
Secure: secureBrowserRequest(r),
SameSite: http.SameSiteLaxMode,
})
@ -520,7 +559,7 @@ func (h *DeviceHandler) handleLogout(w http.ResponseWriter, r *http.Request) {
}
}
http.SetCookie(w, expiredSessionCookie(r.TLS != nil))
http.SetCookie(w, expiredSessionCookie(secureBrowserRequest(r)))
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
@ -551,7 +590,7 @@ func (h *DeviceHandler) requirePageAuth(w http.ResponseWriter, r *http.Request)
}
func portOperatorPageAllowed(path string) bool {
if path == "/" || path == "/port-status" || path == "/password" {
if path == "/" || path == "/port-status" || path == "/password" || path == "/logs/ports" {
return true
}
return strings.HasPrefix(path, "/ports/") && path != "/ports/restart"
@ -609,7 +648,7 @@ func expiredSessionCookie(secure bool) *http.Cookie {
}
}
func desktopClientID(r *http.Request) string {
func directClientIP(r *http.Request) string {
address := strings.TrimSpace(r.RemoteAddr)
host, _, err := net.SplitHostPort(address)
if err != nil {
@ -622,6 +661,7 @@ func desktopClientID(r *http.Request) string {
}
func browserClientMetadata(r *http.Request) (string, string, string) {
address := directClientIP(r)
protocol := "http"
port := "80"
if r.TLS != nil {
@ -631,7 +671,25 @@ func browserClientMetadata(r *http.Request) (string, string, string) {
if _, parsedPort, err := net.SplitHostPort(r.Host); err == nil && parsedPort != "" {
port = parsedPort
}
return desktopClientID(r), protocol, port
if peer := net.ParseIP(address); peer != nil && peer.IsLoopback() {
forwardedIP := net.ParseIP(r.Header.Get("X-Tera-Client-IP"))
forwardedProtocol := r.Header.Get("X-Tera-Client-Protocol")
forwardedPort, err := strconv.Atoi(r.Header.Get("X-Tera-Client-Port"))
if forwardedIP != nil && (forwardedProtocol == "http" || forwardedProtocol == "https") && err == nil && forwardedPort >= 1 && forwardedPort <= 65535 {
address, protocol, port = forwardedIP.String(), forwardedProtocol, strconv.Itoa(forwardedPort)
}
}
return address, protocol, port
}
func desktopClientID(r *http.Request) string {
address, _, _ := browserClientMetadata(r)
return address
}
func secureBrowserRequest(r *http.Request) bool {
_, protocol, _ := browserClientMetadata(r)
return protocol == "https"
}
func (h *DeviceHandler) backendToken(r *http.Request) (string, bool) {
@ -671,11 +729,15 @@ func (h *DeviceHandler) loginBackend(browserRequest *http.Request, username, pas
if username == "" || password == "" {
return backendAuthSession{}, errors.New("아이디와 비밀번호를 입력하세요.")
}
body, err := json.Marshal(map[string]string{
loginFields := map[string]string{
"username": username, "password": password,
"captcha_id": browserRequest.FormValue("captcha_id"),
"captcha_answer": browserRequest.FormValue("captcha_answer"),
})
}
if stamp := browserRequest.FormValue("browser_time"); stamp != "" {
loginFields["browser_time"] = stamp
}
body, err := json.Marshal(loginFields)
if err != nil {
return backendAuthSession{}, err
}

4
frontend/internal/handlers/device_handler_integration_test.go

@ -22,6 +22,8 @@ func TestBackendLoginAndAPIProxy(t *testing.T) {
var readinessCalls atomic.Int32
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/auth/startup":
_ = json.NewEncoder(w).Encode(map[string]any{"ready": true, "state": "ready"})
case "/api/v1/auth/login-policy":
if got := r.Header.Get("Authorization"); got != "" {
t.Fatalf("readiness probe leaked authorization = %q", got)
@ -603,7 +605,7 @@ func TestNetworkPagesRenderOptionalIPv6AndModeSpecificOOBFields(t *testing.T) {
!strings.Contains(addressFilterBody, `/admin/address-filter/ipv4`) ||
!strings.Contains(addressFilterBody, `/admin/address-filter/mac`) ||
strings.Contains(addressFilterBody, `/admin/address-filter/ipv6`) {
t.Fatalf("address filter tabs must expose only basic, IPv4, and MAC pages: %d %s", addressFilterResponse.Code, addressFilterBody)
t.Fatalf("address filter tabs must expose basic, IPv4, and MAC pages only: %d %s", addressFilterResponse.Code, addressFilterBody)
}
assetResponse := httptest.NewRecorder()

8
frontend/internal/handlers/log_view_test.go

@ -30,7 +30,7 @@ func TestLogExplorerPagesPreserveFiltersAndRoleRestrictions(t *testing.T) {
request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: role})
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if role == "port_operator" {
if role == "port_operator" && path != "/logs/ports" {
if response.Code != http.StatusForbidden {
t.Fatalf("operator log access = %d, want 403", response.Code)
}
@ -41,9 +41,9 @@ func TestLogExplorerPagesPreserveFiltersAndRoleRestrictions(t *testing.T) {
}
body := response.Body.String()
for _, required := range []string{
`log-view.js?v=20260907-storage-logs-1`, `port-log-clear.js?v=20260907-storage-logs-1`, `integration.js?v=20260907-storage-logs-1`,
`log-view.js?v=20260907-storage-logs-1`, `port-log-clear.js?v=20260907-storage-logs-1`, `integration.js?v=20260909-config-read-1`,
`name="keyword"`, `name="from"`, `name="to"`, `name="limit"`, `name="auto_refresh"`,
`data-ui-action="primary-action"`, `data-ui-action="secondary-action"`, "JSONL 내보내기", "검색 초기화",
`data-ui-action="primary-action"`, `data-ui-action="secondary-action"`, "로그 다운로드", "검색 초기화",
} {
if !strings.Contains(body, required) {
t.Errorf("missing log UI element: %s", required)
@ -59,7 +59,7 @@ func TestLogExplorerPagesPreserveFiltersAndRoleRestrictions(t *testing.T) {
t.Fatal("port log clear must load before integration")
}
if path == "/logs/ports" {
for _, required := range []string{`value="CH01"`, `value="CH48"`, "RX/TX 데이터", "UP/DOWN 변경은 포트 상태 이벤트", "내보내기는 현재 검색 조건과 표시 개수"} {
for _, required := range []string{`value="CH01"`, `value="CH48"`, "RX/TX 데이터", "UP/DOWN 변경은 포트 상태 이벤트", "내보내기는 현재 검색 조건과 최대 다운로드 건수"} {
if !strings.Contains(body, required) {
t.Errorf("missing port log requirement: %s", required)
}

27
frontend/internal/handlers/proxy_metadata_test.go

@ -0,0 +1,27 @@
package handlers
import (
"net/http/httptest"
"testing"
)
func TestHTTPSGatewayMetadataTrustBoundary(t *testing.T) {
for _, tc := range []struct{ peer, ip, protocol, port string }{
{"127.0.0.1:10000", "192.0.2.77", "https", "443"},
{"[::1]:10000", "192.0.2.77", "https", "443"},
{"192.0.2.88:10000", "192.0.2.88", "http", "8083"},
} {
r := httptest.NewRequest("GET", "http://console:8083/", nil)
r.RemoteAddr = tc.peer
r.Header.Set("X-Tera-Client-IP", "192.0.2.77")
r.Header.Set("X-Tera-Client-Protocol", "https")
r.Header.Set("X-Tera-Client-Port", "443")
ip, protocol, port := browserClientMetadata(r)
if ip != tc.ip || protocol != tc.protocol || port != tc.port {
t.Fatalf("peer %s: got %s/%s/%s", tc.peer, ip, protocol, port)
}
if desktopClientID(r) != tc.ip || secureBrowserRequest(r) != (tc.protocol == "https") {
t.Fatal("session identity or secure cookie uses incorrect proxy metadata")
}
}
}

71
frontend/internal/handlers/startup_status.go

@ -0,0 +1,71 @@
package handlers
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type loginStartupStatus struct {
ClockSyncToken string `json:"clock_sync_token,omitempty"`
Ready bool `json:"ready"`
State string `json:"state"`
ElapsedMS int64 `json:"elapsed_ms"`
Completed int `json:"completed"`
Total int `json:"total"`
Stages []struct {
ID string `json:"id"`
Label string `json:"label"`
State string `json:"state"`
} `json:"stages"`
CaptchaEnabled bool `json:"captcha_enabled"`
}
func (h *DeviceHandler) handleBootstrapTime(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
// Custom header + JSON require a CORS preflight for cross-origin browsers.
if r.Method != http.MethodPost || r.Header.Get("X-Clock-Bootstrap") != "1" || !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
http.Error(w, "invalid clock initialization request", http.StatusForbidden)
return
}
if origin := r.Header.Get("Origin"); origin != "" {
u, err := url.Parse(origin)
if err != nil || u.Host != r.Host || (u.Scheme != "http" && u.Scheme != "https") {
http.Error(w, "invalid origin", http.StatusForbidden)
return
}
}
ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second)
defer cancel()
r.Body = http.MaxBytesReader(w, r.Body, 1024)
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
endpoint := h.apiTarget.ResolveReference(&url.URL{Path: "/api/v1/auth/bootstrap-time"})
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(string(payload)))
if err != nil {
http.Error(w, "clock service unavailable", 502)
return
}
request.Header.Set("Content-Type", "application/json")
response, err := h.apiClient.Do(request)
if err != nil {
http.Error(w, "clock service unavailable", 502)
return
}
defer response.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(response.StatusCode)
_, _ = io.Copy(w, io.LimitReader(response.Body, 4096))
}
func writeStartupResponse(w http.ResponseWriter, state loginStartupStatus) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(state)
}

51
frontend/internal/handlers/startup_status_test.go

@ -0,0 +1,51 @@
package handlers
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestLoginReadinessRequiresSuccessfulStartupAndPolicy(t *testing.T) {
for _, tc := range []struct {
name, startup string
policy int
ready bool
}{
{"pending", `{"ready":false,"state":"starting","completed":1,"total":3}`, 200, false},
{"failed", `{"ready":false,"state":"failed","stages":[{"label":"serial","state":"failed"}]}`, 200, false},
{"malformed", `{`, 200, false},
{"policy failure", `{"ready":true,"state":"ready"}`, 503, false},
{"ready", `{"ready":true,"state":"ready","elapsed_ms":2500}`, 200, true},
} {
t.Run(tc.name, func(t *testing.T) {
policyCalls := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/auth/startup" {
fmt.Fprint(w, tc.startup)
return
}
policyCalls++
w.WriteHeader(tc.policy)
fmt.Fprint(w, `{"captcha_enabled":true}`)
}))
defer upstream.Close()
target, _ := url.Parse(upstream.URL)
h := &DeviceHandler{apiTarget: target, apiClient: upstream.Client()}
response := httptest.NewRecorder()
h.handleLoginBackendStatus(response, httptest.NewRequest("GET", "/login/backend-status", nil))
if strings.Contains(response.Body.String(), `"ready":true`) != tc.ready {
t.Fatal(response.Body.String())
}
if !strings.Contains(tc.startup, `"ready":true`) && policyCalls != 0 {
t.Fatal("policy requested before readiness")
}
if tc.name == "failed" && !strings.Contains(response.Body.String(), `"state":"failed"`) {
t.Fatal("failure lost")
}
})
}
}

51
frontend/internal/mock/mock_data.go

@ -185,9 +185,9 @@ func ResolvePage(path string) (PageData, bool) {
{Label: "확인 문구", Name: "confirmation", Type: "text", Required: true, Placeholder: "공장 초기화", Hint: "실행하려면 ‘공장 초기화’를 정확히 입력하세요."},
}, Notes: []string{
"네트워크·포트·외부 인증 등 저장된 구성과 보류 중인 변경이 기본값으로 초기화됩니다.",
"시스템·감사·포트 로그와 로컬 사용자 계정은 보존됩니다. 별도 백업 파일은 삭제하지 않습니다.",
"현재 API는 구성을 초기화합니다. 펌웨어를 과거 버전으로 되돌리거나 장비를 자동 재부팅하지 않습니다.",
"기본 구성의 하드웨어 반영과 재접속 주소는 재시작 전에 확인하세요.",
"사용자·사용자 정의 그룹·세션·로그·인증서·저장된 인증정보와 장비 내 진단 파일을 삭제합니다. 기본 계정 terauser / terauser만 복원됩니다.",
"초기화가 완료되면 자동 재부팅됩니다. 설치된 펌웨어는 유지됩니다.",
"연결이 끊어집니다. 재부팅 후 기본 접속 주소에서 terauser / terauser로 로그인하세요.",
}},
}}), true
case path == "/device/restart":
@ -452,7 +452,7 @@ func portPage(path string) (PageData, bool) {
return newPortPage(path, portID, subtabs, []ContentBlock{
portSelectorBlock(),
formBlock("고급 설정", "port-advanced", []FormField{
textField("Alarm 키워드", "alarmKeyword", "", false),
FormField{Label: "Alarm 키워드", Name: "alarmKeyword", Type: "text", Hint: "대소문자를 구분하지 않는 문자열 일치 방식입니다. 수신 키워드 감지는 알림 설정에서 켤 수 있습니다."},
}, "적용", ""),
}), true
default:
@ -506,14 +506,10 @@ func portDebugPage(path string) (PageData, bool) {
func adminPage(path string) (PageData, bool) {
switch path {
case "/admin/users":
return newPage(path, "관리자 운영", "사용자 계정", []string{"관리자 운영", "사용자 계정"}, userTabs(path), []ContentBlock{
return newPage(path, "관리자 운영", "사용자 계정", []string{"관리자 운영", "사용자 계정"}, nil, []ContentBlock{
tableBlock("사용자 계정", []string{"상태", "사용자 이름", "역할", "그룹", "비밀번호", "포트 권한"}, nil, "사용자 추가", ""),
userAccountModalBlock(),
}), true
case "/admin/groups":
return newPage(path, "관리자 운영", "그룹 관리", []string{"관리자 운영", "그룹 관리"}, userTabs(path), []ContentBlock{
tableBlock("그룹", []string{"그룹 ID", "그룹 이름", "기본 역할", "권한", "사용자"}, nil, "그룹 추가", ""),
}), true
case "/admin/auth/radius":
return newPage(path, "관리자 운영", "인증 서버", []string{"관리자 운영", "인증 서버", "RADIUS"}, authServerTabs(path), []ContentBlock{
formBlock("RADIUS 인증", "external-auth-radius", []FormField{
@ -546,9 +542,9 @@ func adminPage(path string) (PageData, bool) {
fixedTextField("안전 안내", "safety", "허용 목록 적용 전 현재 PC 주소를 반드시 등록하세요"),
}, "적용", ""),
}), true
case "/admin/address-filter/ipv4", "/admin/address-filter/ipv6", "/admin/address-filter/mac":
case "/admin/address-filter/ipv4", "/admin/address-filter/mac":
return addressFilterListPage(path), true
case "/admin/address-filter/ipv4/new", "/admin/address-filter/ipv6/new", "/admin/address-filter/mac/new":
case "/admin/address-filter/ipv4/new", "/admin/address-filter/mac/new":
return addressFilterNewPage(path), true
case "/admin/snmp":
return newPage(path, "관리자 운영", "SNMP", []string{"관리자 운영", "SNMP"}, nil, []ContentBlock{
@ -806,7 +802,9 @@ func logsPage(path string) (PageData, bool) {
{Label: "검색어", Name: "keyword", Type: "text", Placeholder: "소스 또는 메시지"},
{Label: "시작 시간", Name: "from", Type: "datetime-local"},
{Label: "종료 시간", Name: "to", Type: "datetime-local"},
selectOptionsField("표시 개수", "limit", "100", limitOptions),
selectOptionsField("페이지당 표시", "limit", "100", limitOptions),
selectField("다운로드 형식", "export_format", "CSV", []string{"CSV", "JSON", "JSONL"}),
selectField("최대 다운로드 건수", "export_limit", "1000", []string{"100", "500", "1000", "5000"}),
toggleField("자동 갱신 (5초)", "auto_refresh", true),
},
},
@ -819,7 +817,7 @@ func logsPage(path string) (PageData, bool) {
Columns: []string{"시간", "등급", "분류", "발생 위치", "내용"},
EmptyMessage: "조건에 맞는 시스템 로그가 없습니다.",
PrimaryLabel: "새로고침",
SecondaryLabel: "JSONL 내보내기",
SecondaryLabel: "로그 다운로드",
},
},
}), true
@ -845,7 +843,9 @@ func logsPage(path string) (PageData, bool) {
{Label: "검색어", Name: "keyword", Type: "text", Placeholder: "명령, 결과 또는 메시지"},
{Label: "시작 시간", Name: "from", Type: "datetime-local"},
{Label: "종료 시간", Name: "to", Type: "datetime-local"},
selectOptionsField("표시 개수", "limit", "100", limitOptions),
selectOptionsField("페이지당 표시", "limit", "100", limitOptions),
selectField("다운로드 형식", "export_format", "CSV", []string{"CSV", "JSON", "JSONL"}),
selectField("최대 다운로드 건수", "export_limit", "1000", []string{"100", "500", "1000", "5000"}),
toggleField("자동 갱신 (5초)", "auto_refresh", true),
},
},
@ -853,12 +853,12 @@ func logsPage(path string) (PageData, bool) {
{
Kind: "table",
Title: "포트 로그",
Subtitle: "포트별 통신 이력 · 내보내기는 현재 검색 조건과 표시 개수 범위에 적용됩니다.",
Subtitle: "포트별 통신 이력 · 내보내기는 현재 검색 조건과 최대 다운로드 건수 범위에 적용됩니다.",
Table: &TableData{
Columns: []string{"시간", "포트", "유형", "방향", "사용자", "내용"},
EmptyMessage: "조건에 맞는 포트 로그가 없습니다.",
PrimaryLabel: "새로고침",
SecondaryLabel: "JSONL 내보내기",
SecondaryLabel: "로그 다운로드",
},
},
}), true
@ -883,7 +883,9 @@ func logsPage(path string) (PageData, bool) {
{Label: "신호 / 검색어", Name: "keyword", Type: "text", Placeholder: "DCD, DSR, CTS 또는 메시지"},
{Label: "시작 시간", Name: "from", Type: "datetime-local"},
{Label: "종료 시간", Name: "to", Type: "datetime-local"},
selectOptionsField("표시 개수", "limit", "100", limitOptions),
selectOptionsField("페이지당 표시", "limit", "100", limitOptions),
selectField("다운로드 형식", "export_format", "CSV", []string{"CSV", "JSON", "JSONL"}),
selectField("최대 다운로드 건수", "export_limit", "1000", []string{"100", "500", "1000", "5000"}),
toggleField("자동 갱신 (5초)", "auto_refresh", true),
},
Notes: []string{"장비 하드웨어가 제공하지 않는 DCD/DSR 신호는 기록할 수 없습니다.", "수신 키워드는 포트 로그의 검색어로 조회할 수 있습니다."},
@ -897,7 +899,7 @@ func logsPage(path string) (PageData, bool) {
Columns: []string{"시간", "포트", "신호", "상태 변화", "유형", "내용"},
EmptyMessage: "조건에 맞는 포트 상태 이벤트가 없습니다.",
PrimaryLabel: "새로고침",
SecondaryLabel: "JSONL 내보내기",
SecondaryLabel: "로그 다운로드",
},
},
}), true
@ -999,6 +1001,10 @@ func ApplyRole(page PageData, role string) PageData {
filtered = append(filtered, group)
}
}
filtered = append(filtered, MenuGroup{Label: "포트 로그", Path: "/logs/ports"})
if page.CurrentPath == "/logs/ports" {
page.SubTabs = []SubTab{{Label: "포트 로그", Path: "/logs/ports", Active: true}}
}
page.Menu = filtered
return page
}
@ -1183,13 +1189,6 @@ func channelSelectOptions() []Option {
return options
}
func userTabs(path string) []SubTab {
return []SubTab{
{"사용자", "/admin/users", path == "/admin/users"},
{"그룹", "/admin/groups", path == "/admin/groups"},
}
}
func workspaceTabs(path string, title string) []WorkspaceTab {
return []WorkspaceTab{
{Key: tabKeyForPath(path), Label: tabLabelForPath(path, title), Path: path, Active: true},
@ -1226,7 +1225,7 @@ func menu(path string) []MenuGroup {
case "관리자 운영":
groups[i].Expanded = strings.HasPrefix(path, "/admin/")
groups[i].Children = append(groups[i].Children,
MenuChild{"사용자 계정", "/admin/users", strings.HasPrefix(path, "/admin/users") || path == "/admin/groups"},
MenuChild{"사용자 계정", "/admin/users", strings.HasPrefix(path, "/admin/users")},
MenuChild{"인증 서버", "/admin/auth/radius", strings.HasPrefix(path, "/admin/auth/")},
MenuChild{"주소 필터", "/admin/address-filter", strings.HasPrefix(path, "/admin/address-filter")},
MenuChild{"SNMP", "/admin/snmp", path == "/admin/snmp"},

1
frontend/internal/routes/routes.go

@ -14,7 +14,6 @@ func Register(mux *http.ServeMux, appService *service.AppService, apiUpstream st
func RegisterWithSessionUpstream(mux *http.ServeMux, appService *service.AppService, apiUpstream string, sessionUpstream string) error {
legacyHandler := handler.NewAppHandler(appService)
legacyHandler.RegisterLegacyRoutes(mux)
deviceHandler, err := handlers.NewDeviceHandlerWithSessionUpstream(appService, apiUpstream, sessionUpstream)
if err != nil {

43
frontend/internal/service/app_service.go

@ -57,6 +57,8 @@ type UpdateGroupInput struct {
Permissions []string `json:"permissions"`
}
func NewStatelessAppService() *AppService { return &AppService{} }
func NewAppService(dbPath string) (*AppService, error) {
sqliteStore, err := store.NewSQLiteStore(dbPath)
if err != nil {
@ -79,25 +81,37 @@ func (s *AppService) NewTerminalSession() (*cli.TerminalSession, error) {
}
func (s *AppService) Close() error {
if s == nil {
if s == nil || s.store == nil {
return nil
}
return s.store.Close()
}
func (s *AppService) RecordPageVisit(path string, title string) error {
if s.store == nil {
return nil
}
return s.store.RecordPageVisit(path, title)
}
func (s *AppService) RecordMockAction(action string, path string) error {
if s.store == nil {
return nil
}
return s.store.RecordMockAction(action, path)
}
func (s *AppService) RecentActivity(limit int) ([]store.ActivityRecord, error) {
if s.store == nil {
return nil, nil
}
return s.store.RecentActivity(limit)
}
func (s *AppService) ListUsers() ([]User, error) {
if s.store == nil {
return nil, errors.New("local accounts are disabled; use tera-api")
}
records, err := s.store.ListUsers()
if err != nil {
return nil, err
@ -111,6 +125,9 @@ func (s *AppService) ListUsers() ([]User, error) {
}
func (s *AppService) ListGroups() ([]Group, error) {
if s.store == nil {
return nil, errors.New("local accounts are disabled; use tera-api")
}
records, err := s.store.ListGroups()
if err != nil {
return nil, err
@ -124,6 +141,9 @@ func (s *AppService) ListGroups() ([]Group, error) {
}
func (s *AppService) GetGroup(id int64) (Group, error) {
if s.store == nil {
return Group{}, errors.New("local accounts are disabled; use tera-api")
}
record, err := s.store.GetGroup(id)
if err != nil {
return Group{}, err
@ -132,6 +152,9 @@ func (s *AppService) GetGroup(id int64) (Group, error) {
}
func (s *AppService) CreateGroup(input CreateGroupInput) (Group, error) {
if s.store == nil {
return Group{}, errors.New("local accounts are disabled; use tera-api")
}
if err := validateGroupInput(input.Name, input.Permissions); err != nil {
return Group{}, err
}
@ -148,6 +171,9 @@ func (s *AppService) CreateGroup(input CreateGroupInput) (Group, error) {
}
func (s *AppService) UpdateGroup(input UpdateGroupInput) (Group, error) {
if s.store == nil {
return Group{}, errors.New("local accounts are disabled; use tera-api")
}
if input.ID <= 0 {
return Group{}, errors.New("invalid group id")
}
@ -168,6 +194,9 @@ func (s *AppService) UpdateGroup(input UpdateGroupInput) (Group, error) {
}
func (s *AppService) DeleteGroup(id int64) error {
if s.store == nil {
return errors.New("local accounts are disabled; use tera-api")
}
if id <= 0 {
return errors.New("invalid group id")
}
@ -175,6 +204,9 @@ func (s *AppService) DeleteGroup(id int64) error {
}
func (s *AppService) GetUser(id int64) (User, error) {
if s.store == nil {
return User{}, errors.New("local accounts are disabled; use tera-api")
}
record, err := s.store.GetUser(id)
if err != nil {
return User{}, err
@ -183,6 +215,9 @@ func (s *AppService) GetUser(id int64) (User, error) {
}
func (s *AppService) CreateUser(input CreateUserInput) (User, error) {
if s.store == nil {
return User{}, errors.New("local accounts are disabled; use tera-api")
}
group, err := s.store.GetGroupByName(strings.TrimSpace(input.Group))
if err != nil {
return User{}, errors.New("group not found")
@ -204,6 +239,9 @@ func (s *AppService) CreateUser(input CreateUserInput) (User, error) {
}
func (s *AppService) UpdateUser(input UpdateUserInput) (User, error) {
if s.store == nil {
return User{}, errors.New("local accounts are disabled; use tera-api")
}
if input.ID <= 0 {
return User{}, errors.New("invalid user id")
}
@ -229,6 +267,9 @@ func (s *AppService) UpdateUser(input UpdateUserInput) (User, error) {
}
func (s *AppService) DeleteUser(id int64) error {
if s.store == nil {
return errors.New("local accounts are disabled; use tera-api")
}
if id <= 0 {
return errors.New("invalid user id")
}

23
frontend/internal/service/stateless_test.go

@ -0,0 +1,23 @@
package service
import "testing"
func TestStatelessFrontendDoesNotKeepLocalAccountsOrActivity(t *testing.T) {
app := NewStatelessAppService()
if app.store != nil {
t.Fatal("stateless frontend opened SQLite")
}
if err := app.RecordPageVisit("/admin/users", "Users"); err != nil {
t.Fatal(err)
}
records, err := app.RecentActivity(20)
if err != nil || len(records) != 0 {
t.Fatal("frontend retained visits")
}
if _, err := app.CreateUser(CreateUserInput{Username: "local", Password: "password", Group: "admin"}); err == nil {
t.Fatal("frontend created a second account store")
}
if err := app.Close(); err != nil {
t.Fatal(err)
}
}

34
frontend/web/static/css/style.css

@ -112,7 +112,8 @@ button, input, select, textarea { font: inherit; }
height: 100%;
border-radius: inherit;
background: #3f97f7;
animation: login-progress-slide 1.05s ease-in-out infinite;
/* Update only when readiness changes; continuous compositing is costly on the device. */
animation: none;
}
.login-progress[data-state="ready"] {
@ -133,25 +134,12 @@ button, input, select, textarea { font: inherit; }
.login-progress[data-state="ready"] p { color: #34754e; }
.login-progress[data-state="authenticating"] .login-progress-track span {
animation-duration: 0.75s;
}
.login-progress p {
margin: 0;
color: #536b89;
font-size: 12px;
}
@keyframes login-progress-slide {
0% { transform: translateX(-110%); }
100% { transform: translateX(245%); }
}
@media (prefers-reduced-motion: reduce) {
.login-progress-track span { animation-duration: 2.2s; }
}
.login-error {
margin-top: 18px;
border: 1px solid #f1c6c6;
@ -932,6 +920,11 @@ body[data-current-path^="/network/interface-"] .network-mode-context { width: 78
background: #edf8f0;
color: #176b37;
}
.firmware-status progress { display: block; width: 100%; margin-top: 12px; height: 16px; }
.firmware-status progress[hidden] { display: none; }
.firmware-status p { margin: 10px 0 0; line-height: 1.6; overflow-wrap: anywhere; }
.firmware-status a { display: inline-block; margin-top: 10px; }
.firmware-status a[hidden] { display: none; }
.firmware-status.is-error {
border-color: #ef9a9a;
background: #fff1f1;
@ -1889,8 +1882,17 @@ body[data-current-path="/tools/alias"] .mock-form .form-grid {
}
.login-captcha[hidden], .login-captcha img[hidden] { display: none; }
.login-captcha-image-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.login-captcha-image-row img { display: block; max-width: 100%; width: 256px; height: auto; border: 1px solid #d6deea; border-radius: 6px; background: white; }
.login-captcha [data-captcha-message] { margin: 0; font-size: 12px; line-height: 1.5; color: #64748b; }
.login-captcha-image-row img { display: block; max-width: 100%; width: 140px; height: auto; border: 1px solid #d6deea; border-radius: 6px; background: white; }
.login-captcha .login-captcha-hint { margin: 0; font-size: 12px; line-height: 1.5; color: #64748b; }
.btn.danger { color: #fff; background: #c93641; border: 1px solid #c93641; }
.btn.danger:hover:not(:disabled) { background: #aa2530; border-color: #aa2530; }
.btn.danger:disabled { opacity: .5; cursor: not-allowed; }
.login-progress[data-measured="true"] .login-progress-track span { animation: none; transform: none; transition: width .2s ease; }
.login-progress[data-state="failed"] { border-color: #d5a3a3; background: #fff4f4; }
.login-progress[data-state="failed"] p { color: #a12828; }
.login-progress[data-state="failed"] .login-progress-track span { background: #b83a3a; animation: none; }
.login-progress[data-state="delayed"] { border-color: #e4c58d; background: #fff9ed; }
.login-progress[data-state="delayed"] p { color: #875d13; }
.login-progress[data-state="delayed"] .login-progress-track span { background: #c88b23; animation: none; }

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save