Browse Source

Initial source-only commit

main
gudae 2 months ago
commit
b283a445c5
  1. 8
      .gitattributes
  2. 33
      .gitignore
  3. 4
      README.md
  4. 281
      backend/README.md
  5. 114
      backend/cmd/tera-accessd/main.go
  6. 110
      backend/cmd/tera-alarmd/main.go
  7. 43
      backend/cmd/tera-alarmd/main_test.go
  8. 718
      backend/cmd/tera-api/main.go
  9. 311
      backend/cmd/tera-api/main_test.go
  10. 83
      backend/cmd/tera-api/reset_button_monitor.go
  11. 41
      backend/cmd/tera-api/reset_button_monitor_test.go
  12. 291
      backend/cmd/tera-direct-load/main.go
  13. 25
      backend/cmd/tera-direct-load/main_test.go
  14. 105
      backend/cmd/tera-hwd/main.go
  15. 125
      backend/cmd/tera-initramfs-tool/archive.go
  16. 95
      backend/cmd/tera-initramfs-tool/archive_test.go
  17. 79
      backend/cmd/tera-initramfs-tool/main.go
  18. 110
      backend/cmd/tera-logd/main.go
  19. 110
      backend/cmd/tera-netd/main.go
  20. 24
      backend/cmd/tera-netd/main_test.go
  21. 34
      backend/cmd/tera-recovery-probe/journal.go
  22. 36
      backend/cmd/tera-recovery-probe/journal_test.go
  23. 128
      backend/cmd/tera-recovery-probe/main_linux.go
  24. 9
      backend/cmd/tera-recovery-probe/main_other.go
  25. 5
      backend/cmd/tera-recovery-probe/mount_contract.go
  26. 9
      backend/cmd/tera-recovery-probe/mount_contract_test.go
  27. 130
      backend/cmd/tera-recovery-request-tool/main.go
  28. 189
      backend/cmd/tera-recovery-updater/main_linux.go
  29. 5
      backend/cmd/tera-recovery-updater/main_other.go
  30. 5
      backend/cmd/tera-recovery-updater/mount_contract.go
  31. 18
      backend/cmd/tera-recovery-updater/mount_contract_test.go
  32. 23
      backend/cmd/tera-recovery-updater/partition_contract.go
  33. 23
      backend/cmd/tera-recovery-updater/partition_contract_test.go
  34. 144
      backend/cmd/tera-serial-check/main.go
  35. 147
      backend/cmd/tera-serial-check/main_test.go
  36. 142
      backend/cmd/tera-serial-lease-check/main.go
  37. 69
      backend/cmd/tera-serial-lease-check/main_test.go
  38. 122
      backend/cmd/tera-serial-line-check/main.go
  39. 64
      backend/cmd/tera-serial-line-check/main_test.go
  40. 137
      backend/cmd/tera-serial-stream-check/main.go
  41. 57
      backend/cmd/tera-serial-stream-check/main_test.go
  42. 280
      backend/cmd/tera-seriald/main.go
  43. 52
      backend/cmd/tera-seriald/main_test.go
  44. 149
      backend/cmd/tera-sessiond/main.go
  45. 76
      backend/cmd/tera-sessiond/main_test.go
  46. 88
      backend/cmd/tera-snmpd/main.go
  47. 506
      backend/cmd/tera-target-session-load/main.go
  48. 57
      backend/cmd/tera-target-session-load/main_test.go
  49. 96
      backend/cmd/tera-timed/main.go
  50. 24
      backend/cmd/tera-timed/main_test.go
  51. 485
      backend/cmd/tera-update-tool/main.go
  52. 52
      backend/cmd/tera-update-tool/main_test.go
  53. 189
      backend/cmd/tera-updated/main.go
  54. 48
      backend/cmd/tera-updated/main_test.go
  55. 57
      backend/cmd/tera-web/main.go
  56. 118
      backend/deploy/buildroot/etc/init.d/S40tera-seriald
  57. 63
      backend/deploy/buildroot/etc/init.d/S42tera-hwd
  58. 65
      backend/deploy/buildroot/etc/init.d/S43tera-netd
  59. 69
      backend/deploy/buildroot/etc/init.d/S44tera-logd
  60. 63
      backend/deploy/buildroot/etc/init.d/S45tera-timed
  61. 67
      backend/deploy/buildroot/etc/init.d/S46tera-snmpd
  62. 69
      backend/deploy/buildroot/etc/init.d/S47tera-alarmd
  63. 70
      backend/deploy/buildroot/etc/init.d/S48tera-updated
  64. 66
      backend/deploy/buildroot/etc/init.d/S49tera-accessd
  65. 83
      backend/deploy/buildroot/etc/init.d/S50tera-api
  66. 48
      backend/deploy/buildroot/etc/init.d/S55tera-sessiond
  67. 57
      backend/deploy/buildroot/etc/init.d/S60tera-web
  68. 56
      backend/deploy/buildroot/etc/init.d/S61tera-clone-demo-web
  69. 2
      backend/deploy/buildroot/etc/udev/rules.d/99-tera-serial.rules
  70. 112
      backend/deploy/clone-demo/etc/init.d/S39tera-ethernet-map
  71. 58
      backend/deploy/clone-demo/etc/init.d/S48tera-clone-demo-alarmd
  72. 57
      backend/deploy/clone-demo/etc/init.d/S51tera-clone-demo-api
  73. 56
      backend/deploy/clone-demo/etc/init.d/S55tera-sessiond-shared-session
  74. 56
      backend/deploy/clone-demo/etc/init.d/S61tera-clone-demo-web
  75. 96
      backend/deploy/docker-compose.yml
  76. 21
      backend/deploy/docker/Dockerfile
  77. 16
      backend/deploy/docker/entrypoint.sh
  78. 57
      backend/deploy/etc/tera/serial-map.json
  79. 8
      backend/deploy/etc/tera/tera-accessd.env
  80. 3
      backend/deploy/etc/tera/tera-alarmd.env
  81. 49
      backend/deploy/etc/tera/tera-api.env
  82. 4
      backend/deploy/etc/tera/tera-clone-demo-web.env
  83. 4
      backend/deploy/etc/tera/tera-hwd.env
  84. 4
      backend/deploy/etc/tera/tera-logd.env
  85. 10
      backend/deploy/etc/tera/tera-netd.env
  86. 19
      backend/deploy/etc/tera/tera-seriald.env
  87. 5
      backend/deploy/etc/tera/tera-sessiond.env
  88. 6
      backend/deploy/etc/tera/tera-snmpd-systemd.env
  89. 5
      backend/deploy/etc/tera/tera-snmpd.env
  90. 6
      backend/deploy/etc/tera/tera-timed.env
  91. 10
      backend/deploy/etc/tera/tera-updated.env
  92. 4
      backend/deploy/etc/tera/tera-web.env
  93. 2
      backend/deploy/etc/tera/update-trust/README
  94. 1
      backend/deploy/etc/tera/update-trust/tera-prod-2026-01.json
  95. 29
      backend/deploy/systemd/tera-accessd.service
  96. 31
      backend/deploy/systemd/tera-alarmd.service
  97. 26
      backend/deploy/systemd/tera-api.service
  98. 24
      backend/deploy/systemd/tera-clone-demo-web.service
  99. 24
      backend/deploy/systemd/tera-hwd.service
  100. 31
      backend/deploy/systemd/tera-logd.service

8
.gitattributes

@ -0,0 +1,8 @@
*.sh text eol=lf
*.go text eol=lf
*.html text eol=lf
*.css text eol=lf
*.js text eol=lf
*.cjs text eol=lf
backend/deploy/**/etc/init.d/* text eol=lf
SHA256SUMS text eol=lf

33
.gitignore

@ -0,0 +1,33 @@
/runtime/
/.tools/
/.go*.zip
*.log
# Local state, signing secrets and generated historical builds must not be uploaded.
/tmp/
# Signing key storage and private-key exports (JSON, PEM, DER, etc.).
/signing/
*.private.*
*.key
*.pfx
*.p12
*.db
*.db-shm
*.db-wal
*.exe
/*.env
**/.env
**/.env.local
/*-linux-arm64*
/*.tar.gz
/bin/
/backend/bin/
__pycache__/
# Source-only repository: keep release artifacts and local lab/history off Git.
/release/
/build/
/aaa-lab/
/docs/
/frontend/docs/
/scripts/verify-final-release.ps1

4
README.md

@ -0,0 +1,4 @@
# 최종 빌드 파일 위치
- 프론트엔드: `build/linux-arm64/tera-clone-demo-web`
- 백엔드: `build/linux-arm64/tera-clone-demo-api`

281
backend/README.md

@ -0,0 +1,281 @@
# tera-api
Initial mock backend for TERA Core-X48 PRO management APIs.
## First user task
Portable Go is installed under the repository `.tools/` directory. Run:
```powershell
cd services\tera-api
powershell -NoProfile -ExecutionPolicy Bypass -File ..\..\scripts\use-go.ps1 test ./...
powershell -NoProfile -ExecutionPolicy Bypass -File ..\..\scripts\use-go.ps1 run ./cmd/tera-api
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\check-linux-arm64-build.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\check-linux-arm64-build.ps1 -Command tera-logd
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\check-linux-arm64-build.ps1 -Command tera-hwd
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\check-linux-arm64-build.ps1 -Command tera-seriald
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\check-linux-arm64-build.ps1 -Command tera-serial-check
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\check-linux-arm64-build.ps1 -Command tera-serial-line-check
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\check-linux-arm64-serial-termios.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\check-target-rootfs-staging.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\check-target-rootfs-staging.ps1 -InitSystem buildroot
```
For a same-origin Direct Mode attachment load against a deployed target, keep
the web password in the process environment and run the dedicated client. The
client sends the browser-required `Origin` header and `tera.direct.v1`
subprotocol, consumes one-use stream tickets, keeps reading to service
WebSocket heartbeats, validates one welcome frame for every attachment, checks
the matching channel `session` logs, and closes every session during cleanup.
With `-attachments 48`, every CH01-CH48 channel is covered once without sending
serial payload; `192` covers four viewers per channel:
```powershell
$env:TERA_TARGET_PASSWORD = '<read from the protected lab credential source>'
powershell -NoProfile -ExecutionPolicy Bypass -File ..\..\scripts\use-go.ps1 run ./cmd/tera-target-session-load -base-url http://192.168.0.223:8083 -attachments 192 -hold-seconds 60
Remove-Item Env:\TERA_TARGET_PASSWORD
```
Then verify:
```powershell
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/system/status
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/system/services
Invoke-RestMethod -Method Post -ContentType 'application/json' -Body '{"username":"admin","password":"mock-admin-secret"}' http://127.0.0.1:8080/api/v1/auth/login
Invoke-RestMethod -Method Post -Headers @{"Authorization"="Bearer <session-token>"} http://127.0.0.1:8080/api/v1/auth/logout
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/auth/me
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/auth/tokens
Invoke-RestMethod -Method Post -Headers @{"X-Mock-Actor"="mock-admin"} -ContentType 'application/json' -Body '{"name":"automation"}' http://127.0.0.1:8080/api/v1/auth/tokens
Invoke-RestMethod -Method Delete -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/auth/tokens/token_000001
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/auth/users
Invoke-RestMethod -Method Post -Headers @{"X-Mock-Actor"="mock-admin"} -ContentType 'application/json' -Body '{"username":"fieldops","password":"fieldops-secret-1","role":"port_operator"}' http://127.0.0.1:8080/api/v1/auth/users
Invoke-RestMethod -Method Patch -Headers @{"X-Mock-Actor"="mock-admin"} -ContentType 'application/json' -Body '{"enabled":false}' http://127.0.0.1:8080/api/v1/auth/users/fieldops
Invoke-RestMethod -Method Patch -Headers @{"X-Mock-Actor"="mock-admin"} -ContentType 'application/json' -Body '{"password":"fieldops-secret-2"}' http://127.0.0.1:8080/api/v1/auth/users/fieldops/password
Invoke-RestMethod -Method Delete -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/auth/users/fieldops
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/auth/groups
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/auth/permissions
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/config/status
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/config/export
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/time/settings
Invoke-RestMethod -Method Patch -Headers @{"X-Mock-Actor"="mock-admin"} -ContentType 'application/json' -Body '{"timezone":"Asia/Seoul","ntp_servers":["time1.example.local","time2.example.local"]}' http://127.0.0.1:8080/api/v1/time/settings
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/security/management-access
Invoke-RestMethod -Method Patch -Headers @{"X-Mock-Actor"="mock-admin"} -ContentType 'application/json' -Body '{"https_enabled":true,"https_port":8443,"ssh_enabled":true,"ssh_port":2222,"http_enabled":false,"http_port":80,"telnet_enabled":false,"telnet_port":23,"idle_timeout_minutes":30}' http://127.0.0.1:8080/api/v1/security/management-access
Invoke-RestMethod -Method Patch -Headers @{"X-Mock-Actor"="mock-admin"} -ContentType 'application/json' -Body '{"interfaces":[{"name":"Interface-1","address":"192.168.10.233/24","gateway":"192.168.10.1"}]}' http://127.0.0.1:8080/api/v1/network/running
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/ports
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/alarms/rules
Invoke-RestMethod -Method Patch -Headers @{"X-Mock-Actor"="mock-admin"} -ContentType 'application/json' -Body '{"mode":"Reverse Telnet"}' http://127.0.0.1:8080/api/v1/ports/CH01
Invoke-RestMethod -Method Patch -Headers @{"X-Mock-Actor"="mock-admin"} -ContentType 'application/json' -Body '{"enabled":false}' http://127.0.0.1:8080/api/v1/alarms/rules/auth_failures
Invoke-RestMethod -Method Post -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/config/apply
Invoke-RestMethod -Method Post -Headers @{"X-Mock-Actor"="mock-admin"} -ContentType 'application/json' -Body '{"browser_time":"2026-06-16T10:31:12+09:00","timezone":"Asia/Seoul"}' http://127.0.0.1:8080/api/v1/time/sync-browser
Invoke-RestMethod -Method Post -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/time/sync-ntp
Invoke-RestMethod -Method Post -Headers @{"X-Mock-Actor"="mock-admin"} -ContentType 'application/json' -Body '{"target":"192.168.0.1"}' http://127.0.0.1:8080/api/v1/diagnostics/network-test
Invoke-RestMethod -Method Post -Headers @{"X-Mock-Actor"="mock-admin"} -ContentType 'application/json' -Body '{"target":"8.8.8.8","count":4}' http://127.0.0.1:8080/api/v1/diagnostics/network-test
Invoke-RestMethod -Method Post -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/diagnostics/support-bundle
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} 'http://127.0.0.1:8080/api/v1/logs/audit?actor=admin&order=desc&limit=100'
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} 'http://127.0.0.1:8080/api/v1/logs/system?severity=warning&keyword=failover&from=2026-06-15T00:00:00Z&to=2026-06-17T00:00:00Z'
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} 'http://127.0.0.1:8080/api/v1/logs/port?channel=CH01&keyword=LOGIN&actor=admin&from=2026-06-15T00:00:00Z&to=2026-06-17T00:00:00Z'
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} 'http://127.0.0.1:8080/api/v1/logs/links?kind=serial&resource=CH01'
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/logs/policy
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/logs/retention
Invoke-RestMethod -Method Patch -Headers @{"X-Mock-Actor"="mock-admin"} -ContentType 'application/json' -Body '{"port_days":180,"protected_usage_warning_pct":85}' http://127.0.0.1:8080/api/v1/logs/retention
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/logs/remote-syslog
Invoke-RestMethod -Method Patch -Headers @{"X-Mock-Actor"="mock-admin"} -ContentType 'application/json' -Body '{"enabled":true,"host":"syslog.local","port":1514,"protocol":"tcp","facility":"local4"}' http://127.0.0.1:8080/api/v1/logs/remote-syslog
Invoke-RestMethod -Method Post -Headers @{"X-Mock-Actor"="mock-admin"} -ContentType 'application/json' -Body '{"log_type":"port","channel":"CH01"}' http://127.0.0.1:8080/api/v1/logs/export
Invoke-WebRequest -Method Post -Headers @{"X-Mock-Actor"="mock-admin"} -ContentType 'application/json' -Body '{"log_type":"port","channel":"CH01","actor":"admin","from":"2026-06-15T00:00:00Z","to":"2026-06-17T00:00:00Z"}' -OutFile tera-port-logs.jsonl http://127.0.0.1:8080/api/v1/logs/export/download
Invoke-RestMethod -Method Post -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/ports/CH01/restart
Invoke-RestMethod -Headers @{"X-Mock-Actor"="mock-admin"} http://127.0.0.1:8080/api/v1/operations
```
## Current scope
- Mock domain data with optional target-backed local service adapters and
bounded file-backed state for host and rootfs verification.
- In-memory `pending`/`running`/`persistent` config transaction state.
- JSON request bodies are bounded by `TERA_API_MAX_REQUEST_BYTES` and must
contain a single JSON value. Oversized bodies return `payload_too_large`
with HTTP 413; malformed or trailing JSON returns `validation_failed`.
- Audit events for user-verifiable command validation. By default these are
in-memory. Set `TERA_API_AUDIT_LOG` to a JSON Lines file path to enable
bounded file-backed prototype audit storage; set
`TERA_API_AUDIT_MAX_EVENTS` to control the retained event count.
- Config transaction state is in-memory by default. Set
`TERA_API_CONFIG_STORE` to a JSON file path to retain prototype
port, alarm, network, time, and log-retention `pending`/`running`/
`persistent` state across `tera-api` restarts. Prototype config export
returns running, persistent, and pending port/alarm/network/time/log-retention/
management-access/remote-syslog state and requires `config.read`.
- Operation state is in-memory by default. Set `TERA_API_OPERATION_STORE` to a
JSON file path to retain prototype operation results across `tera-api`
restarts. Operation result lookup and operation history listing require
`operation.read`.
- Prototype local auth users are in-memory by default. Set
`TERA_API_AUTH_STORE` to a JSON file path to retain prototype users with
nested password KDF records containing `algorithm`, `version`, `params`,
`salt`, and `hash`. Local password hashing uses Argon2id by default; the
record schema is versioned so a future FIPS-compatible
`pbkdf2-hmac-sha256` mode can be added without changing the auth store
shape. Local user creation and password reset enforce an eight-character
minimum by default. `TERA_API_MIN_PASSWORD_LENGTH` may raise that policy up
to 128 characters but cannot reduce it below eight. The target profile sets
`TERA_API_AUTH_STORE=/var/lib/tera-api/auth.json` so provisioned users and
disabled bootstrap accounts survive service restarts.
- Prototype bearer sessions use crypto-random tokens, store token hashes
in memory, expire after eight hours, and can be revoked through
`POST /api/v1/auth/logout`.
- Prototype API bearer tokens are available behind `auth.read`/`auth.write`.
Raw token material is returned only on creation, stored by hash, and bounded
by `TERA_API_AUTH_MAX_TOKENS` when configured. Token records are listed by
opaque `token_id` without exposing raw token material and can be revoked by
`DELETE /api/v1/auth/tokens/{token_id}`.
- Prototype local login tracks repeated failures in memory and temporarily
locks a normalized username after five failures for 15 minutes. Client
responses remain generic; lock details are recorded only in the audit log.
- Prototype local user, group, and permission read APIs are available behind
`auth.read`. Prototype local user creation, enable/disable updates, and
role updates, password resets/deletes are available behind `auth.write`.
These responses intentionally exclude password KDF records, salts, hashes,
session tokens, and token hashes. Deleting or demoting the last enabled
administrator is rejected to avoid local lockout. Disabling, deleting,
changing role, or resetting the password for a local user immediately revokes
that user's existing bearer sessions and API bearer tokens.
- Prototype audit log reads require `audit.read`; only `mock-admin` has this
permission in the current mock policy.
- System, structured port command/result, and serial/Ethernet link log search
require `log.read`; audited log export and NDJSON download require
`log.export`.
`GET /api/v1/logs/policy` exposes the 365-day maximum, command/result and link
capture, no-at-rest-encryption decision, and enabled bounded UART/Direct Mode
payload capture with a 4-KiB per-record maximum. The production environment connects `tera-api` to the
independent `tera-logd` Unix socket for authoritative reads/writes,
retention, health, and filtered NDJSON export.
- Mock log retention policy reads require `log.read`; updates require
`config.write`, participate in the config transaction, and preserve protected
logs across factory reset by policy. All retention values are limited to 1
through 365 days.
- In explicit host-prototype mode, `TERA_API_SYSTEM_LOG_STORE`,
`TERA_API_PORT_LOG_STORE`, and
`TERA_API_LINK_LOG_STORE` enable bounded JSON Lines prototype persistence;
`TERA_API_LOG_MAX_EVENTS` bounds each of those stores. Command and result
fields are credential-redacted and size-bounded before persistence. A
configured/enabled remote syslog target receives best-effort forwarded
records through the existing alarm-delivery boundary.
- Target packaging instead sets `TERA_API_LOG_SOCKET` to
`/run/tera-logd/log-store.sock` and enables the production scheduler with
`TERA_API_LOG_SCHEDULER_ENABLED=true`. It must not configure the prototype
JSON Lines paths at the same time.
- Mock remote syslog reads require `log.read`; updates require `config.write`,
participate in the config transaction, and intentionally contain no shared
secrets or credentials.
- Mock alarm rule read/update endpoints are available for Web UI flow
validation. Reads require `alarm.read`; updates are config transaction
changes and require `alarm.write`.
- Alarm delivery tests call the group-restricted `tera-alarmd` Unix socket in
target mode. `tera-alarmd` persists a bounded retry queue atomically,
restores queued/retry-pending work after restart, delivers local/web events,
and sends configured remote syslog over UDP or TCP without a shell. SMTP and
SNMP trap transports fail explicitly until protected credential storage is
implemented; no placeholder secret material is embedded in configuration.
- Mock serial channel inventory/detail reads require `port.read`. The current
mock policy grants it to `mock-admin` and `mock-port-operator`.
- Mock network state reads require `network.read`; only `mock-admin` has this
permission in the current mock policy. Mock network interface address/gateway
staging requires `network.write`, participates in the config transaction, and
becomes visible in running state after config apply.
- Mock dashboard system status reads require `system.read`; only `mock-admin`
has this permission in the current mock policy. Mock service health reads use
the same permission and report prototype logical service readiness.
- Mock config transaction status lookup requires `config.read`; only
`mock-admin` has this permission in the current mock policy. Mock config
export uses the same permission and intentionally excludes auth, session,
token, and password KDF material.
- Browser/PC time sync requires `time.sync`, calls the group-restricted
`tera-timed` Unix socket, returns an operation result, and records the final
audit outcome. `tera-timed` validates the bounded RFC3339 time and IANA
timezone, applies `/etc/localtime` transactionally, and restores the prior
timezone if the Linux clock update fails. The in-process mock is used only
when mock actors are explicitly enabled.
- Mock time settings read/update is available for maintenance flow validation.
Reads require `config.read`; updates require `config.write` and participate
in the config transaction.
- Mock management access policy read/update is available for security flow
validation. Reads require `config.read`; updates require `config.write`,
participate in the config transaction, and reject changes that disable every
HTTPS, SSH, and serial-console recovery path.
- Network-test and traceroute diagnostics require `diagnostics.run` and use a
bounded asynchronous operation queue in target mode. The process adapter
invokes only fixed absolute `ping`/`traceroute` executables without a shell,
revalidates targets, enforces request timeouts, bounds combined output, and
records accepted plus final audit events. Queue/worker counts are bounded and
unfinished work is marked failed on restart or shutdown. When
`TERA_API_SUPPORT_BUNDLE_DIR` is configured, support-bundle requests use the
same bounded queue to create mode-`0600` ZIP artifacts atomically. Bundles
include only system status, service health, redacted config export, bounded
audit/system/port events, and operation metadata; auth users, password KDF
records, bearer sessions, API token hashes, and secret material are excluded.
Artifact count, total bytes, and per-section bytes are bounded. Authorized
downloads use `GET /api/v1/diagnostics/support-bundles/{bundle_id}` with
`no-store`, checksum, and attachment headers and create a download audit.
- Green LED control is a real target hardware boundary, not mock serial data:
`hardware.read` lists the CH01–CH48 PCF8574 mapping and `hardware.write`
force/releases or pulses one green LED. `tera-api` stays unprivileged and
calls the group-restricted `tera-hwd` Unix socket; `tera-hwd` alone writes
the kernel `gpio-leds` brightness entries after the target DTB is active.
- Serial line configuration is routed through the separate `tera-seriald`
Unix-socket boundary. `tera-api` never opens `/dev/ttyWCH*`. The controller
accepts only the validated `fet3568j-c-20260721-v1` mapping: CH01-CH24 use
`/dev/ttyWCH4`-`/dev/ttyWCH27`, and CH25-CH48 use
`/dev/ttyWCH32`-`/dev/ttyWCH55`. Raw nodes 0-3 and 28-31 are excluded as the
unconnected direct CH384 UARTs. Missing or mismatched mapping evidence still
fails closed. See
`docs/sw-detail-design/serial-hardware-api-contract.md`.
- Port updates now stage the bounded full UART profile (supported baud rate,
5-8 data bits, none/even/odd parity, one or two stop bits, and none/RTS-CTS
flow control). Apply operations pass the complete profile to `tera-seriald`;
readback mismatch or partial failure restores all opened channels.
- Mock operation result lookup and operation history listing require
`operation.read`; only `mock-admin` has this permission in the current mock
policy.
- Prototype `X-Mock-Actor` auth/RBAC skeleton for mutating commands and
`GET /api/v1/auth/me` profile checks. Set
`TERA_API_ALLOW_MOCK_ACTORS=false` to disable this header-based prototype
bypass and require bearer session or API token authentication for protected
routes; CORS preflight responses also stop advertising `X-Mock-Actor` when
this bypass is disabled.
- Firmware upload is streamed through the protected `tera-updated` Unix socket;
`tera-api` does not own `/userdata/update` and cannot mutate partitions or the
eMMC layout. The current updater accepts validation-only packages and rejects
apply. Read-only diagnostics and explicitly configured remote syslog are the
only new external network actions.
The only implemented clock mutation crosses the narrow `tera-timed` local
socket; HTTP handlers and the unprivileged `tera-api` process cannot set the
system clock or write `/etc/localtime` directly.
- No production login, session token, password hash, external auth secret, or
TLS key storage exists in this prototype. File-backed audit/auth/config/
operation storage is prototype-only and is not a replacement for the
production `tera-logd`, product persistent configuration database, protected
secret storage, and operation journal paths. Prototype snapshot rewrites do
use same-directory temporary files, file and directory sync on Linux, mode
`0600`, atomic rename, and symbolic-link refusal so a crash cannot expose a
partially written JSON destination.
- API contract is documented in
`docs/sw-detail-design/web-api-contract.md`.
- Backend-to-target implementation and acceptance gates are documented in
`docs/sw-detail-design/backend-target-test-gates.md`.
- Green LED API and deployment details are documented in
`docs/sw-detail-design/green-led-api-contract.md` and
`target-bsp/docs/pcf8574-green-led-control.md`.
- Architecture/FigJam guardrails are documented in
`docs/sw-detail-design/architecture-figma-implementation-guardrails.md`.
## Next implementation steps
1. Replace prototype auth/RBAC storage with production session identity and
protected token storage. Direct Mode already reuses the common permission
and audit path and must not introduce a second auth store.
2. Add protected SMTP/SNMP credential storage before enabling those
`tera-alarmd` transports.
3. Replace the remaining prototype config and operation stores with product
persistence; authoritative log persistence is now owned by `tera-logd` but
still requires target Stage 4 application and soak evidence.
4. Keep update/recovery and GPT/eMMC changes disabled until their separate
device-side safety and rollback gates are explicitly approved.

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

@ -0,0 +1,114 @@
// tera-accessd owns the target's dynamically configured management listeners.
package main
import (
"context"
"errors"
"log"
"net"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"tera-clone/services/tera-api/internal/managementaccess"
"tera-clone/services/tera-api/internal/webgateway"
)
func main() {
socketPath := accessGetenv("TERA_ACCESSD_SOCKET", managementaccess.DefaultSocketPath)
handler, err := webgateway.NewHandler(webgateway.Options{
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"),
})
if err != nil {
log.Fatalf("tera-accessd web setup failed: %v", err)
}
runtime, err := managementaccess.NewRuntime(managementaccess.RuntimeOptions{
BindHost: accessGetenv("TERA_ACCESSD_BIND_HOST", "0.0.0.0"),
Handler: handler,
CertFile: accessGetenv("TERA_ACCESSD_TLS_CERT", "/var/lib/tera-accessd/tls/server.crt"),
KeyFile: accessGetenv("TERA_ACCESSD_TLS_KEY", "/var/lib/tera-accessd/tls/server.key"),
TLSHosts: accessGetenvList("TERA_ACCESSD_TLS_HOSTS", []string{"192.168.0.223", "localhost", "127.0.0.1"}),
})
if err != nil {
log.Fatalf("tera-accessd runtime setup failed: %v", err)
}
defer runtime.Close()
listener, err := listenControlSocket(socketPath)
if err != nil {
log.Fatalf("tera-accessd control socket setup failed: %v", err)
}
defer func() {
_ = listener.Close()
_ = os.Remove(socketPath)
}()
server := managementaccess.SocketServer{Controller: runtime}
errorsChannel := make(chan error, 1)
go func() { errorsChannel <- server.Serve(listener) }()
log.Printf("tera-accessd serving management access control on %s", socketPath)
signalContext, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stopSignals()
select {
case <-signalContext.Done():
_ = listener.Close()
if err := <-errorsChannel; err != nil {
log.Printf("tera-accessd control shutdown failed: %v", err)
}
case err := <-errorsChannel:
if err != nil {
log.Fatalf("tera-accessd control service failed: %v", err)
}
}
}
func listenControlSocket(socketPath string) (net.Listener, error) {
if socketPath == "" || !filepath.IsAbs(socketPath) {
return nil, errors.New("TERA_ACCESSD_SOCKET must be an absolute path")
}
if err := os.MkdirAll(filepath.Dir(socketPath), 0o750); err != nil {
return nil, err
}
if info, err := os.Lstat(socketPath); err == nil {
if info.Mode()&os.ModeSocket == 0 {
return nil, errors.New("refusing to replace non-socket management access path")
}
if err := os.Remove(socketPath); err != nil {
return nil, err
}
} else if !os.IsNotExist(err) {
return nil, err
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
return nil, err
}
if err := os.Chmod(socketPath, 0o660); err != nil {
_ = listener.Close()
return nil, err
}
return listener, nil
}
func accessGetenv(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func accessGetenvList(key string, fallback []string) []string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
result := make([]string, 0)
for _, item := range strings.Split(value, ",") {
if item = strings.TrimSpace(item); item != "" {
result = append(result, item)
}
}
return result
}

110
backend/cmd/tera-alarmd/main.go

@ -0,0 +1,110 @@
// tera-alarmd is the bounded alarm delivery and retry service.
package main
import (
"errors"
"log"
"net"
"os"
"os/signal"
"path/filepath"
"strconv"
"syscall"
"tera-clone/services/tera-api/internal/alarmdelivery"
"tera-clone/services/tera-api/internal/alarmdelivery/alarmsocket"
"tera-clone/services/tera-api/internal/secretstore"
)
func main() {
secrets, err := secretstore.Open(getenv("TERA_ALARMD_SECRET_STORE", "/var/lib/tera-api/secrets.json"))
if err != nil {
log.Fatalf("tera-alarmd secret store setup failed: %v", err)
}
dispatcher, err := alarmdelivery.NewDispatcher(map[string]alarmdelivery.Transport{
alarmdelivery.ChannelWeb: alarmdelivery.WebTransport{},
alarmdelivery.ChannelSyslog: alarmdelivery.SyslogTransport{},
alarmdelivery.ChannelEmail: alarmdelivery.EmailTransport{Secrets: secrets},
alarmdelivery.ChannelSNMPTrap: alarmdelivery.SNMPTrapTransport{Secrets: secrets},
}, alarmdelivery.DispatcherOptions{
StatePath: getenv("TERA_ALARMD_STATE", "/var/lib/tera-alarmd/deliveries.json"),
MaxJobs: getenvInt("TERA_ALARMD_MAX_JOBS", 256),
})
if err != nil {
log.Fatalf("tera-alarmd dispatcher setup failed: %v", err)
}
defer dispatcher.Close()
socketPath := getenv("TERA_ALARMD_SOCKET", alarmsocket.DefaultSocketPath)
listener, err := listen(socketPath)
if err != nil {
log.Fatalf("tera-alarmd socket setup failed: %v", err)
}
defer func() {
_ = listener.Close()
_ = os.Remove(socketPath)
}()
server := alarmsocket.Server{Controller: dispatcher}
done := make(chan error, 1)
go func() { done <- server.Serve(listener) }()
log.Printf("tera-alarmd serving delivery control on %s", socketPath)
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
select {
case signal := <-signals:
log.Printf("tera-alarmd stopping after %s", signal)
_ = listener.Close()
if err := <-done; err != nil {
log.Printf("tera-alarmd stop error: %v", err)
}
case err := <-done:
if err != nil {
log.Fatalf("tera-alarmd failed: %v", err)
}
}
}
func listen(socketPath string) (net.Listener, error) {
if socketPath == "" || !filepath.IsAbs(socketPath) {
return nil, errors.New("TERA_ALARMD_SOCKET must be an absolute path")
}
if err := os.MkdirAll(filepath.Dir(socketPath), 0o750); err != nil {
return nil, err
}
if info, err := os.Lstat(socketPath); err == nil {
if info.Mode()&os.ModeSocket == 0 {
return nil, errors.New("refusing to replace non-socket alarm delivery path")
}
if err := os.Remove(socketPath); err != nil {
return nil, err
}
} else if !os.IsNotExist(err) {
return nil, err
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
return nil, err
}
if err := os.Chmod(socketPath, 0o660); err != nil {
_ = listener.Close()
return nil, err
}
return listener, nil
}
func getenv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func getenvInt(key string, fallback int) int {
value, err := strconv.Atoi(os.Getenv(key))
if err != nil || value <= 0 {
return fallback
}
return value
}

43
backend/cmd/tera-alarmd/main_test.go

@ -0,0 +1,43 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestListenRequiresAbsoluteSocketPath(t *testing.T) {
if _, err := listen("relative.sock"); err == nil {
t.Fatal("relative alarm socket path accepted")
}
}
func TestListenRefusesNonSocketPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "alarm.sock")
if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := listen(path); err == nil {
t.Fatal("non-socket path was replaced")
}
content, err := os.ReadFile(path)
if err != nil || string(content) != "keep" {
t.Fatalf("protected path content = %q, %v", content, err)
}
}
func TestListenCreatesUnixSocket(t *testing.T) {
path := filepath.Join(t.TempDir(), "runtime", "alarm.sock")
listener, err := listen(path)
if err != nil {
t.Fatal(err)
}
defer listener.Close()
info, err := os.Lstat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSocket == 0 {
t.Fatalf("socket mode = %v", info.Mode())
}
}

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

@ -0,0 +1,718 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"tera-clone/services/tera-api/internal/adapters/mock"
"tera-clone/services/tera-api/internal/alarmdelivery"
"tera-clone/services/tera-api/internal/alarmdelivery/alarmsocket"
"tera-clone/services/tera-api/internal/api"
"tera-clone/services/tera-api/internal/diagnostics"
"tera-clone/services/tera-api/internal/directstream"
"tera-clone/services/tera-api/internal/domain"
"tera-clone/services/tera-api/internal/hardware/greenledsocket"
"tera-clone/services/tera-api/internal/hardware/systemcontrol"
"tera-clone/services/tera-api/internal/logruntime"
"tera-clone/services/tera-api/internal/logservice"
"tera-clone/services/tera-api/internal/managementaccess"
"tera-clone/services/tera-api/internal/networkconfig"
"tera-clone/services/tera-api/internal/networkconfig/netsocket"
"tera-clone/services/tera-api/internal/portservice"
"tera-clone/services/tera-api/internal/security/password"
"tera-clone/services/tera-api/internal/serial"
"tera-clone/services/tera-api/internal/sessionhealth"
"tera-clone/services/tera-api/internal/snmpagent"
"tera-clone/services/tera-api/internal/snmpagent/snmpsocket"
"tera-clone/services/tera-api/internal/storage/atomicfile"
"tera-clone/services/tera-api/internal/supportbundle"
"tera-clone/services/tera-api/internal/systemmetrics"
"tera-clone/services/tera-api/internal/systemtime"
"tera-clone/services/tera-api/internal/systemtime/timesocket"
"tera-clone/services/tera-api/internal/tlscert"
"tera-clone/services/tera-api/internal/update"
"tera-clone/services/tera-api/internal/update/updatesocket"
)
func main() {
addr := listenAddrConfig()
readHeaderTimeout := getenvDurationSeconds("TERA_API_READ_HEADER_TIMEOUT_SECONDS", 5*time.Second)
readTimeout := getenvDurationSeconds("TERA_API_READ_TIMEOUT_SECONDS", 15*time.Second)
writeTimeout := getenvDurationSeconds("TERA_API_WRITE_TIMEOUT_SECONDS", 30*time.Second)
idleTimeout := getenvDurationSeconds("TERA_API_IDLE_TIMEOUT_SECONDS", 60*time.Second)
auditPath := getenv("TERA_API_AUDIT_LOG", "")
auditMax := getenvInt("TERA_API_AUDIT_MAX_EVENTS", 1000)
systemLogPath := getenv("TERA_API_SYSTEM_LOG_STORE", "")
portLogPath := getenv("TERA_API_PORT_LOG_STORE", "")
linkLogPath := getenv("TERA_API_LINK_LOG_STORE", "")
logMaxEvents := getenvInt("TERA_API_LOG_MAX_EVENTS", 10000)
authMaxSessions := getenvInt("TERA_API_AUTH_MAX_SESSIONS", 128)
authMaxTokens := getenvInt("TERA_API_AUTH_MAX_TOKENS", 128)
authMaxLoginFailures := getenvInt("TERA_API_AUTH_MAX_LOGIN_FAILURES", 1024)
authMaxUsers := getenvInt("TERA_API_AUTH_MAX_USERS", 64)
minimumPasswordLength := getenvInt("TERA_API_MIN_PASSWORD_LENGTH", 8)
configPath := getenv("TERA_API_CONFIG_STORE", "")
operationPath := getenv("TERA_API_OPERATION_STORE", "")
operationMax := getenvInt("TERA_API_OPERATION_MAX_EVENTS", 256)
authPath := getenv("TERA_API_AUTH_STORE", "")
sessionPath := getenv("TERA_API_SESSION_STORE", "")
loginFailurePath := getenv("TERA_API_LOGIN_FAILURE_STORE", "")
secretPath := getenv("TERA_API_SECRET_STORE", "/var/lib/tera-api/secrets.json")
greenLEDSocket := getenv("TERA_API_GREEN_LED_SOCKET", greenledsocket.DefaultSocketPath)
corsOrigins := getenvList("TERA_API_CORS_ORIGINS", nil)
maxRequestBytes := int64(getenvInt("TERA_API_MAX_REQUEST_BYTES", 1<<20))
allowMockActors := allowMockActorsConfig()
diagnosticsRunner, err := diagnosticsRunnerConfig(getenvBool("TERA_API_DIAGNOSTICS_ENABLED", false))
if err != nil {
log.Fatalf("tera-api diagnostics setup failed: %v", err)
}
supportBundleService, err := supportBundleServiceConfig(os.Getenv("TERA_API_SUPPORT_BUNDLE_DIR"))
if err != nil {
log.Fatalf("tera-api support bundle setup failed: %v", err)
}
alarmDeliveryController := alarmdelivery.Controller(alarmdelivery.UnavailableController{Reason: "TERA_API_ALARM_SOCKET is not configured"})
if alarmSocket := os.Getenv("TERA_API_ALARM_SOCKET"); alarmSocket != "" {
alarmDeliveryController = alarmsocket.NewClient(alarmSocket)
} else if allowMockActors {
alarmDeliveryController = alarmdelivery.MockController{}
}
timeController := systemtime.Controller(systemtime.UnavailableController{Reason: "TERA_API_TIME_SOCKET is not configured"})
if timeSocket := os.Getenv("TERA_API_TIME_SOCKET"); timeSocket != "" {
timeController = timesocket.NewClient(timeSocket)
} else if allowMockActors {
timeController = systemtime.MockController{}
}
networkController := networkconfig.Controller(networkconfig.UnavailableController{Reason: "TERA_API_NETWORK_SOCKET is not configured"})
if networkSocket := os.Getenv("TERA_API_NETWORK_SOCKET"); networkSocket != "" {
networkController = netsocket.NewClient(networkSocket)
} else if allowMockActors {
networkController = &networkconfig.MockController{}
}
managementController := managementaccess.Controller(managementaccess.NoopController{})
if managementSocket := os.Getenv("TERA_API_MANAGEMENT_SOCKET"); managementSocket != "" {
managementController = managementaccess.NewSocketClient(managementSocket)
}
snmpAgentController := snmpagent.Controller(snmpagent.UnavailableController{Reason: "TERA_API_SNMP_SOCKET is not configured"})
if snmpSocket := os.Getenv("TERA_API_SNMP_SOCKET"); snmpSocket != "" {
snmpAgentController = snmpsocket.NewClient(snmpSocket)
} else if allowMockActors {
snmpAgentController = &snmpagent.MockController{}
}
serialController := serial.Controller(serial.UnavailableController{Reason: "TERA_API_SERIAL_SOCKET is not configured"})
if serialSocket := os.Getenv("TERA_API_SERIAL_SOCKET"); serialSocket != "" {
serialController = serial.NewSocketClient(serialSocket)
} else if allowMockActors {
serialController = serial.MockController{}
}
sessionController := sessionhealth.Controller(sessionhealth.UnavailableController{Reason: "TERA_API_SESSIOND_STATUS_URL is not configured"})
if sessionStatusURL := os.Getenv("TERA_API_SESSIOND_STATUS_URL"); sessionStatusURL != "" {
client, sessionErr := sessionhealth.NewHTTPClient(sessionStatusURL)
if sessionErr != nil {
log.Fatalf("tera-api sessiond status setup failed: %v", sessionErr)
}
sessionController = client
}
firmwareController := update.Controller(update.UnavailableController{Reason: "TERA_API_UPDATE_SOCKET is not configured"})
if updateSocket := os.Getenv("TERA_API_UPDATE_SOCKET"); updateSocket != "" {
firmwareController = updatesocket.NewClient(updateSocket)
}
systemController := systemcontrol.Controller(systemcontrol.UnavailableController{Reason: "TERA_API_SYSTEM_SOCKET is not configured"})
if systemSocket := os.Getenv("TERA_API_SYSTEM_SOCKET"); systemSocket != "" {
systemController = systemcontrol.NewClient(systemSocket)
} else if allowMockActors {
systemController = systemcontrol.MockController{}
}
passwordKDF, passwordKDFs, err := passwordKDFConfig(getenv("TERA_API_PASSWORD_KDF", password.AlgorithmArgon2id))
if err != nil {
log.Fatalf("tera-api password KDF setup failed: %v", err)
}
var logJournal logservice.Journal
if logSocket := os.Getenv("TERA_API_LOG_SOCKET"); logSocket != "" {
logJournal = logservice.NewClient(logSocket)
}
tlsCertificates, err := tlscert.Open(tlscert.Options{
Root: getenv("TERA_API_TLS_STORE", "/var/lib/tera-api/tls/library"),
ActiveCert: getenv("TERA_API_TLS_ACTIVE_CERT", "/var/lib/tera-api/tls/active.crt"),
ActiveKey: getenv("TERA_API_TLS_ACTIVE_KEY", "/var/lib/tera-api/tls/active.key"),
Clock: time.Now,
})
if err != nil {
log.Fatalf("tera-api TLS certificate store setup failed: %v", err)
}
store, err := mock.NewStoreWithOptions(time.Now, mock.StoreOptions{
LogJournal: logJournal,
AllowSampleLogs: allowMockActors,
AuditPath: auditPath,
AuditMaxEvents: auditMax,
SystemLogPath: systemLogPath,
SystemLogMaxEvents: logMaxEvents,
PortLogPath: portLogPath,
PortLogMaxEvents: logMaxEvents,
LinkLogPath: linkLogPath,
LinkLogMaxEvents: logMaxEvents,
ConfigPath: configPath,
OperationPath: operationPath,
OperationMaxEvents: operationMax,
AuthPath: authPath,
SessionPath: sessionPath,
LoginFailurePath: loginFailurePath,
SecretPath: secretPath,
AuthMaxSessions: authMaxSessions,
AuthMaxTokens: authMaxTokens,
AuthMaxLoginFailures: authMaxLoginFailures,
AuthMaxUsers: authMaxUsers,
MinimumPasswordLength: minimumPasswordLength,
PasswordKDF: passwordKDF,
PasswordKDFs: passwordKDFs,
TimeController: timeController,
NetworkController: networkController,
ManagementController: managementController,
SystemController: systemController,
AlarmDeliveryController: alarmDeliveryController,
SNMPAgentController: snmpAgentController,
TLSCertificateStore: tlsCertificates,
DiagnosticsRunner: diagnosticsRunner,
SupportBundleService: supportBundleService,
DiagnosticsWorkers: getenvInt("TERA_API_DIAGNOSTICS_WORKERS", 2),
DiagnosticsQueue: getenvInt("TERA_API_DIAGNOSTICS_QUEUE", 16),
RemoveLegacyAuthUsers: true,
})
if err != nil {
log.Fatalf("tera-api store setup failed: %v", err)
}
defer store.Close()
startupTasks := make([]func(), 0, 6)
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() {
if bootID, unnotified, bootErr := unnotifiedKernelBoot(bootIDPath, bootMarkerPath); bootErr != nil {
log.Printf("tera-api cold-start detection degraded: %v", bootErr)
} else if unnotified {
lifecycleContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
notifyErr := store.EmitLifecycleAlarm(lifecycleContext, "cold_start", "device cold start detected")
cancel()
if notifyErr != nil {
log.Printf("tera-api cold-start alarm degraded: %v", notifyErr)
} else if markerErr := atomicfile.Write(bootMarkerPath, []byte(bootID+"\n"), 0o600); markerErr != nil {
log.Printf("tera-api cold-start marker degraded: %v", markerErr)
}
}
})
startupTasks = append(startupTasks, func() {
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() {
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)
}
})
var portServiceController portservice.Controller = portservice.NoopController{}
if getenvBool("TERA_API_PORT_SERVICES_ENABLED", !allowMockActors) {
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)
}
streamFactory := serial.NewStreamSocketClient(getenv("TERA_API_SERIAL_STREAM_SOCKET", serial.DefaultStreamSocketPath), "tera-port-service")
gateway, gatewayErr := portservice.New(portservice.Options{
TransportFactory: streamFactory,
ListenHost: getenv("TERA_API_PORT_LISTEN_HOST", "0.0.0.0"),
SSHSigner: signer,
KnownHostsPath: getenv("TERA_API_PORT_SSH_KNOWN_HOSTS", "/etc/tera/ssh_known_hosts"),
AuthenticateMethod: func(ctx context.Context, method, username, credential, channel string) (string, error) {
return store.AuthenticatePort(ctx, method, username, credential, channel)
},
Record: func(ctx context.Context, event domain.PortLogEvent) error {
_, recordErr := store.RecordPortLog(ctx, event)
return recordErr
},
})
if gatewayErr != nil {
log.Fatalf("tera-api channel operation service setup failed: %v", gatewayErr)
}
defer gateway.Close()
portServiceController = gateway
startupTasks = append(startupTasks, func() {
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
}
servicePorts, credentialErr := store.ResolvePortServiceCredentials(reconcileContext, "running", snapshot.Running.Ports)
if credentialErr != nil {
log.Printf("tera-api channel operation credential reconciliation degraded: %v", credentialErr)
} else if applyErr := gateway.Apply(reconcileContext, servicePorts); applyErr != nil {
log.Printf("tera-api channel operation reconciliation degraded: %v", applyErr)
}
})
}
startupTasks = append(startupTasks, func() {
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() {
reconcileContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
reconcileErr := reconcileSerialRuntime(reconcileContext, store, serialController)
if reconcileErr != nil {
log.Printf("tera-api serial runtime reconciliation degraded: %v", reconcileErr)
}
})
logRuntimeContext, stopLogRuntime := context.WithCancel(context.Background())
defer stopLogRuntime()
if resetButton, ok := systemController.(resetButtonStateReader); ok {
go runFactoryResetButtonMonitor(
logRuntimeContext,
resetButton,
store,
getenvDurationMilliseconds("TERA_API_RESET_BUTTON_POLL_MILLISECONDS", 100*time.Millisecond),
getenvDurationMilliseconds("TERA_API_RESET_BUTTON_HOLD_MILLISECONDS", time.Second),
func(operation domain.Operation) {
log.Printf("tera-api hardware settings reset completed: %s", operation.OperationID)
},
func(err error) { log.Printf("tera-api hardware settings reset monitor degraded: %v", err) },
)
}
go runSerialRuntimeReconciler(
logRuntimeContext, store, serialController,
getenvDurationSeconds("TERA_API_SERIAL_RECONCILE_INTERVAL_SECONDS", 30*time.Second),
func(err error) {
if err != nil {
log.Printf("tera-api serial runtime reconciliation degraded: %v", err)
} else {
log.Printf("tera-api serial runtime reconciliation restored")
}
},
)
if logJournal != nil {
go runOperationalAlarmEvaluator(
logRuntimeContext, store,
getenvDurationSeconds("TERA_API_OPERATIONAL_ALARM_INTERVAL_SECONDS", time.Minute),
func(err error) { log.Printf("tera-api operational alarm evaluation degraded: %v", err) },
)
}
if getenvBool("TERA_API_LOG_SCHEDULER_ENABLED", logJournal != nil) {
interfaces, err := logruntime.ParseEthernetInterfaces(os.Getenv("TERA_API_LOG_ETHERNET_INTERFACES"))
if err != nil {
log.Fatalf("tera-api log observer setup failed: %v", err)
}
observers := logruntime.CompositeObserver{
logruntime.EthernetObserver{Interfaces: interfaces},
}
linkController, _ := serialController.(serial.LinkStateController)
probeController, _ := serialController.(serial.CRProbeStateController)
if linkController != nil || probeController != nil {
observers = append(observers, logruntime.SerialObserver{
Controller: linkController,
Probes: probeController,
})
}
scheduler, err := logruntime.NewScheduler(logruntime.Options{
Backend: store, Observer: observers,
OnError: func(err error) { log.Printf("tera-api log scheduler degraded: %v", err) },
})
if err != nil {
log.Fatalf("tera-api log scheduler setup failed: %v", err)
}
go scheduler.Run(logRuntimeContext)
}
var ticketListener net.Listener
var ticketSocketPath string
var ticketErrors chan error
if ticketSocketPath = os.Getenv("TERA_API_DIRECT_TICKET_SOCKET"); ticketSocketPath != "" {
listener, err := directstream.ListenTicketSocket(ticketSocketPath)
if err != nil {
log.Fatalf("tera-api direct stream ticket socket setup failed: %v", err)
}
ticketListener = listener
defer ticketListener.Close()
defer os.Remove(ticketSocketPath)
ticketServer := &directstream.TicketSocketServer{Authority: mock.NewDirectTicketAuthority(store)}
ticketErrors = make(chan error, 1)
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,
DisableMockActors: !allowMockActors,
GreenLEDController: greenledsocket.NewClient(greenLEDSocket),
SerialController: serialController,
PortServiceController: portServiceController,
SessionController: sessionController,
NetworkController: networkController,
FirmwareController: firmwareController,
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,
})
server := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: readHeaderTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
}
log.Printf("tera-api listening on http://%s", addr)
serverErrors := make(chan error, 1)
go func() { serverErrors <- server.ListenAndServe() }()
go func() {
runStartupTasks(startupTasks)
startupReady.Store(true)
log.Printf("tera-api runtime synchronization completed")
}()
signalContext, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stopSignals()
select {
case <-signalContext.Done():
log.Printf("tera-api stopping")
case err := <-serverErrors:
if err != nil && err != http.ErrServerClosed {
log.Printf("tera-api failed: %v", err)
}
return
case err := <-ticketErrors:
if err != nil {
log.Printf("tera-api direct stream ticket service failed: %v", err)
} else {
log.Printf("tera-api direct stream ticket service stopped unexpectedly")
}
return
}
if ticketListener != nil {
_ = ticketListener.Close()
}
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownContext); err != nil {
log.Printf("tera-api HTTP shutdown failed: %v", err)
}
}
func runStartupTasks(tasks []func()) {
var wait sync.WaitGroup
wait.Add(len(tasks))
for _, task := range tasks {
task := task
go func() {
defer wait.Done()
task()
}()
}
wait.Wait()
}
func unnotifiedKernelBoot(bootIDPath, markerPath string) (string, bool, error) {
bootContent, err := os.ReadFile(bootIDPath)
if err != nil {
return "", false, fmt.Errorf("read kernel boot id: %w", err)
}
bootID := strings.TrimSpace(string(bootContent))
if bootID == "" || len(bootID) > 128 || strings.ContainsAny(bootID, "\r\n\x00") {
return "", false, errors.New("kernel boot id is invalid")
}
markerContent, err := os.ReadFile(markerPath)
if err == nil {
return bootID, strings.TrimSpace(string(markerContent)) != bootID, nil
}
if !os.IsNotExist(err) {
return "", false, fmt.Errorf("read kernel boot marker: %w", err)
}
return bootID, true, nil
}
type serialRuntimeConfigStore interface {
ConfigExport(context.Context) (domain.ConfigExport, error)
}
type operationalAlarmEvaluator interface {
EvaluateOperationalAlarms(context.Context) error
}
func runOperationalAlarmEvaluator(ctx context.Context, evaluator operationalAlarmEvaluator, interval time.Duration, onError func(error)) {
if evaluator == nil {
return
}
if interval <= 0 {
interval = time.Minute
}
evaluate := func() {
evaluationContext, cancel := context.WithTimeout(ctx, min(interval, 30*time.Second))
defer cancel()
if err := evaluator.EvaluateOperationalAlarms(evaluationContext); err != nil && onError != nil {
onError(err)
}
}
evaluate()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
evaluate()
}
}
}
func reconcileManagementRuntime(ctx context.Context, store serialRuntimeConfigStore, controller managementaccess.Controller) error {
snapshot, err := store.ConfigExport(ctx)
if err != nil {
return fmt.Errorf("load running management configuration: %w", err)
}
if err := controller.Apply(ctx, snapshot.Running.Management); err != nil {
return fmt.Errorf("apply running management configuration: %w", err)
}
return nil
}
// reconcileSerialRuntime makes persisted line settings real after every
// seriald/API restart. seriald remains the sole owner that opens raw TTYs.
func reconcileSerialRuntime(ctx context.Context, store serialRuntimeConfigStore, controller serial.Controller) error {
snapshot, err := store.ConfigExport(ctx)
if err != nil {
return fmt.Errorf("load running serial configuration: %w", err)
}
lineConfigs := make([]serial.ChannelConfig, 0, len(snapshot.Running.Ports))
captureConfigs := make([]serial.CaptureConfig, 0, len(snapshot.Running.Ports))
for _, port := range snapshot.Running.Ports {
lineConfigs = append(lineConfigs, serial.ChannelConfig{
Channel: port.Channel, BaudRate: port.BaudRate, DataBits: port.DataBits,
Parity: port.Parity, StopBits: port.StopBits, StopBitsMode: port.StopBitsMode,
FlowControl: port.FlowControl, RTSControl: port.RTSControl, DTRControl: port.DTRControl,
IgnoreJammed: port.IgnoreJammed,
})
captureConfigs = append(captureConfigs, captureConfigForPort(port))
}
if len(lineConfigs) != serial.ChannelCount {
return fmt.Errorf("running serial configuration contains %d channels, want %d", len(lineConfigs), serial.ChannelCount)
}
_, lineErr := controller.Apply(ctx, lineConfigs)
var captureErr error
if captureController, ok := controller.(serial.CaptureController); ok {
captureErr = captureController.ApplyCapture(ctx, captureConfigs)
}
return errors.Join(lineErr, captureErr)
}
func captureConfigForPort(port domain.Port) serial.CaptureConfig {
return serial.CaptureConfig{
Channel: port.Channel, Mode: port.DataLogMode,
DataPackage: serial.CapturePackageConfig{
Mode: port.DataPackage.Mode, Length: port.DataPackage.Length, TimeoutMillis: port.DataPackage.TimeoutMillis,
DelimiterProcess: port.DataPackage.DelimiterProcess, Delimiter1: port.DataPackage.Delimiter1, Delimiter2: port.DataPackage.Delimiter2,
},
DataLog: serial.CaptureLogConfig{
Read: port.DataLog.Read, Send: port.DataLog.Send, ShowTimestamp: port.DataLog.ShowTimestamp,
ShowDirection: port.DataLog.ShowDirection, ShowUsername: port.DataLog.ShowUsername,
ShowIPAddress: port.DataLog.ShowIPAddress, ShowPort: port.DataLog.ShowPort, Format: port.DataLog.Format,
ReadTrigger: serial.CaptureTriggerConfig{
Length: port.DataLog.ReadTrigger.Length, TimeoutMillis: port.DataLog.ReadTrigger.TimeoutMillis,
Process: port.DataLog.ReadTrigger.Process, Character1: port.DataLog.ReadTrigger.Character1, Character2: port.DataLog.ReadTrigger.Character2,
},
SendTrigger: serial.CaptureTriggerConfig{
Length: port.DataLog.SendTrigger.Length, TimeoutMillis: port.DataLog.SendTrigger.TimeoutMillis,
Process: port.DataLog.SendTrigger.Process, Character1: port.DataLog.SendTrigger.Character1, Character2: port.DataLog.SendTrigger.Character2,
},
},
}
}
func serialRuntimeHealthy(states []serial.State, err error) bool {
if err != nil || len(states) != serial.ChannelCount {
return false
}
for _, state := range states {
if !state.Available || !state.ProfileApplied || state.LineSettings == nil {
return false
}
}
return true
}
func runSerialRuntimeReconciler(ctx context.Context, store serialRuntimeConfigStore, controller serial.Controller, interval time.Duration, onChange func(error)) {
if interval <= 0 {
interval = 30 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
wasDegraded := false
lastError := ""
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
probeContext, cancelProbe := context.WithTimeout(ctx, 3*time.Second)
states, stateErr := controller.States(probeContext)
cancelProbe()
if serialRuntimeHealthy(states, stateErr) {
if wasDegraded && onChange != nil {
onChange(nil)
}
wasDegraded = false
lastError = ""
continue
}
reconcileContext, cancelReconcile := context.WithTimeout(ctx, 15*time.Second)
reconcileErr := reconcileSerialRuntime(reconcileContext, store, controller)
cancelReconcile()
if reconcileErr == nil {
if wasDegraded && onChange != nil {
onChange(nil)
}
wasDegraded = false
lastError = ""
continue
}
if !wasDegraded || lastError != reconcileErr.Error() {
if onChange != nil {
onChange(reconcileErr)
}
}
wasDegraded = true
lastError = reconcileErr.Error()
}
}
}
func listenAddrConfig() string {
if value := getenv("TERA_API_ADDR", ""); value != "" {
return value
}
return getenv("TERA_API_LISTEN_ADDR", "127.0.0.1:8080")
}
func getenv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func getenvInt(key string, fallback int) int {
value := os.Getenv(key)
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func getenvDurationSeconds(key string, fallback time.Duration) time.Duration {
value := getenvInt(key, int(fallback/time.Second))
return time.Duration(value) * time.Second
}
func getenvDurationMilliseconds(key string, fallback time.Duration) time.Duration {
value := getenvInt(key, int(fallback/time.Millisecond))
return time.Duration(value) * time.Millisecond
}
func getenvBool(key string, fallback bool) bool {
value := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
if value == "" {
return fallback
}
switch value {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}
func getenvList(key string, fallback []string) []string {
value := os.Getenv(key)
if value == "" {
return fallback
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
item := strings.TrimSpace(part)
if item != "" {
result = append(result, item)
}
}
if len(result) == 0 {
return fallback
}
return result
}
func allowMockActorsConfig() bool {
return getenvBool("TERA_API_ALLOW_MOCK_ACTORS", false)
}
func diagnosticsRunnerConfig(enabled bool) (diagnostics.Runner, error) {
if !enabled {
return nil, nil
}
return diagnostics.NewProcessRunner(diagnostics.ProcessRunnerOptions{
PingPath: getenv("TERA_API_DIAGNOSTICS_PING_PATH", "/bin/ping"),
TraceroutePath: getenv("TERA_API_DIAGNOSTICS_TRACEROUTE_PATH", "/usr/bin/traceroute"),
})
}
func supportBundleServiceConfig(directory string) (supportbundle.Service, error) {
if strings.TrimSpace(directory) == "" {
return nil, nil
}
return supportbundle.NewManager(supportbundle.Options{
Directory: directory,
MaxArtifacts: getenvInt("TERA_API_SUPPORT_BUNDLE_MAX_ARTIFACTS", 4),
MaxBytes: getenvInt("TERA_API_SUPPORT_BUNDLE_MAX_BYTES", 8*1024*1024),
MaxSection: getenvInt("TERA_API_SUPPORT_BUNDLE_MAX_SECTION_BYTES", 2*1024*1024),
})
}
func passwordKDFConfig(value string) (password.KDF, password.Registry, error) {
mode := strings.TrimSpace(strings.ToLower(value))
switch mode {
case "", password.AlgorithmArgon2id:
return password.NewArgon2id(password.Argon2idDefaultParams()), password.NewProductionRegistry(), nil
case password.AlgorithmPBKDF2SHA256, "pbkdf2", "fips":
kdf := password.NewPBKDF2SHA256(password.PBKDF2SHA256DefaultParams())
return kdf, password.NewRegistry(kdf, password.PrototypeSHA256{}), nil
default:
return nil, password.Registry{}, fmt.Errorf("unsupported password KDF %q", value)
}
}

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

@ -0,0 +1,311 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"tera-clone/services/tera-api/internal/diagnostics"
"tera-clone/services/tera-api/internal/domain"
"tera-clone/services/tera-api/internal/security/password"
"tera-clone/services/tera-api/internal/serial"
"tera-clone/services/tera-api/internal/supportbundle"
)
type serialRuntimeStoreStub struct {
snapshot domain.ConfigExport
err error
}
func (s serialRuntimeStoreStub) ConfigExport(context.Context) (domain.ConfigExport, error) {
return s.snapshot, s.err
}
type serialRuntimeControllerStub struct {
line []serial.ChannelConfig
capture []serial.CaptureConfig
lineErr error
}
func (*serialRuntimeControllerStub) States(context.Context) ([]serial.State, error) { return nil, nil }
func (s *serialRuntimeControllerStub) Apply(_ context.Context, configs []serial.ChannelConfig) ([]serial.State, error) {
s.line = append([]serial.ChannelConfig(nil), configs...)
return nil, s.lineErr
}
func (s *serialRuntimeControllerStub) ApplyCapture(_ context.Context, configs []serial.CaptureConfig) error {
s.capture = append([]serial.CaptureConfig(nil), configs...)
return nil
}
func TestReconcileSerialRuntimeAppliesAllLineAndCaptureProfiles(t *testing.T) {
ports := make([]domain.Port, 0, serial.ChannelCount)
for index := 1; index <= serial.ChannelCount; index++ {
ports = append(ports, domain.Port{
Channel: serialChannel(index), BaudRate: 115200, DataBits: 8,
Parity: "none", StopBits: 1, FlowControl: "none", DataLogMode: "rx_tx",
})
}
controller := &serialRuntimeControllerStub{}
err := reconcileSerialRuntime(context.Background(), serialRuntimeStoreStub{
snapshot: domain.ConfigExport{Running: domain.ConfigExportState{Ports: ports}},
}, controller)
if err != nil {
t.Fatal(err)
}
if len(controller.line) != serial.ChannelCount || len(controller.capture) != serial.ChannelCount {
t.Fatalf("line=%d capture=%d", len(controller.line), len(controller.capture))
}
if controller.line[0].Channel != "CH01" || controller.line[0].BaudRate != 115200 || controller.capture[0].Mode != "rx_tx" {
t.Fatalf("CH01 line=%#v capture=%#v", controller.line[0], controller.capture[0])
}
}
func TestReconcileSerialRuntimeRejectsIncompleteSnapshotAndPropagatesApplyFailure(t *testing.T) {
if err := reconcileSerialRuntime(context.Background(), serialRuntimeStoreStub{}, &serialRuntimeControllerStub{}); err == nil {
t.Fatal("incomplete serial snapshot must fail")
}
ports := make([]domain.Port, serial.ChannelCount)
for index := range ports {
ports[index] = domain.Port{Channel: serialChannel(index + 1), BaudRate: 9600, DataBits: 8, Parity: "none", StopBits: 1, FlowControl: "none", DataLogMode: "rx_tx"}
}
want := errors.New("line apply failed")
err := reconcileSerialRuntime(context.Background(), serialRuntimeStoreStub{snapshot: domain.ConfigExport{Running: domain.ConfigExportState{Ports: ports}}}, &serialRuntimeControllerStub{lineErr: want})
if !errors.Is(err, want) {
t.Fatalf("apply error = %v", err)
}
}
func TestSerialRuntimeHealthyRequiresAllVerifiedProfiles(t *testing.T) {
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"}
states = append(states, serial.State{Channel: serialChannel(index), Available: true, ProfileApplied: true, LineSettings: settings})
}
if !serialRuntimeHealthy(states, nil) {
t.Fatal("complete verified profiles should be healthy")
}
states[0].ProfileApplied = false
if serialRuntimeHealthy(states, nil) {
t.Fatal("a missing applied profile must require reconciliation")
}
}
func serialChannel(index int) string {
return fmt.Sprintf("CH%02d", index)
}
func TestGetenvDurationSeconds(t *testing.T) {
t.Setenv("TERA_TEST_TIMEOUT_SECONDS", "7")
got := getenvDurationSeconds("TERA_TEST_TIMEOUT_SECONDS", 5*time.Second)
if got != 7*time.Second {
t.Fatalf("duration = %s, want 7s", got)
}
}
func TestGetenvDurationSecondsFallsBackForInvalidValues(t *testing.T) {
for _, value := range []string{"", "0", "-1", "abc"} {
t.Setenv("TERA_TEST_TIMEOUT_SECONDS", value)
got := getenvDurationSeconds("TERA_TEST_TIMEOUT_SECONDS", 5*time.Second)
if got != 5*time.Second {
t.Fatalf("duration for %q = %s, want 5s", value, got)
}
}
}
func TestMinimumPasswordLengthEnvironment(t *testing.T) {
t.Setenv("TERA_API_MIN_PASSWORD_LENGTH", "8")
if got := getenvInt("TERA_API_MIN_PASSWORD_LENGTH", 12); got != 8 {
t.Fatalf("minimum password length = %d, want 8", got)
}
}
func TestGetenvBool(t *testing.T) {
for _, item := range []struct {
value string
want bool
}{
{value: "true", want: true},
{value: "1", want: true},
{value: "yes", want: true},
{value: "on", want: true},
{value: "false", want: false},
{value: "0", want: false},
{value: "no", want: false},
{value: "off", want: false},
} {
t.Setenv("TERA_TEST_BOOL", item.value)
if got := getenvBool("TERA_TEST_BOOL", !item.want); got != item.want {
t.Fatalf("bool for %q = %v, want %v", item.value, got, item.want)
}
}
}
func TestGetenvBoolFallsBackForMissingOrInvalidValues(t *testing.T) {
for _, value := range []string{"", "maybe"} {
t.Setenv("TERA_TEST_BOOL", value)
if got := getenvBool("TERA_TEST_BOOL", true); !got {
t.Fatalf("bool for %q = false, want fallback true", value)
}
}
}
func TestListenAddrConfigUsesTargetEnvironmentName(t *testing.T) {
t.Setenv("TERA_API_ADDR", "")
t.Setenv("TERA_API_LISTEN_ADDR", "0.0.0.0:8080")
if got := listenAddrConfig(); got != "0.0.0.0:8080" {
t.Fatalf("listen addr = %q, want target env value", got)
}
}
func TestListenAddrConfigKeepsLegacyEnvironmentName(t *testing.T) {
t.Setenv("TERA_API_ADDR", "127.0.0.1:18080")
t.Setenv("TERA_API_LISTEN_ADDR", "0.0.0.0:8080")
if got := listenAddrConfig(); got != "127.0.0.1:18080" {
t.Fatalf("listen addr = %q, want legacy env value", got)
}
}
func TestAllowMockActorsConfigDefaultsToDisabled(t *testing.T) {
t.Setenv("TERA_API_ALLOW_MOCK_ACTORS", "")
if allowMockActorsConfig() {
t.Fatalf("mock actors should be disabled unless explicitly enabled")
}
}
func TestAllowMockActorsConfigCanBeEnabled(t *testing.T) {
t.Setenv("TERA_API_ALLOW_MOCK_ACTORS", "true")
if !allowMockActorsConfig() {
t.Fatalf("mock actors should be enabled when explicitly configured")
}
}
func TestDiagnosticsRunnerConfigIsExplicitAndRejectsRelativePaths(t *testing.T) {
runner, err := diagnosticsRunnerConfig(false)
if err != nil || runner != nil {
t.Fatalf("disabled diagnostics runner = %#v, %v", runner, err)
}
t.Setenv("TERA_API_DIAGNOSTICS_PING_PATH", "relative-ping")
if _, err := diagnosticsRunnerConfig(true); err == nil {
t.Fatal("diagnosticsRunnerConfig accepted a relative executable path")
}
t.Setenv("TERA_API_DIAGNOSTICS_PING_PATH", "/bin/ping")
t.Setenv("TERA_API_DIAGNOSTICS_TRACEROUTE_PATH", "/usr/bin/traceroute")
runner, err = diagnosticsRunnerConfig(true)
if err != nil {
t.Fatal(err)
}
if _, ok := runner.(*diagnostics.ProcessRunner); !ok {
t.Fatalf("runner type = %T, want *diagnostics.ProcessRunner", runner)
}
}
func TestSupportBundleServiceConfigIsExplicitAndBounded(t *testing.T) {
service, err := supportBundleServiceConfig("")
if err != nil || service != nil {
t.Fatalf("disabled support bundle service = %#v, %v", service, err)
}
if _, err := supportBundleServiceConfig("relative/bundles"); err == nil {
t.Fatal("support bundle service accepted a relative directory")
}
t.Setenv("TERA_API_SUPPORT_BUNDLE_MAX_ARTIFACTS", "2")
t.Setenv("TERA_API_SUPPORT_BUNDLE_MAX_BYTES", "65536")
t.Setenv("TERA_API_SUPPORT_BUNDLE_MAX_SECTION_BYTES", "4096")
service, err = supportBundleServiceConfig(t.TempDir())
if err != nil {
t.Fatal(err)
}
if _, ok := service.(*supportbundle.Manager); !ok {
t.Fatalf("support bundle service type = %T, want *supportbundle.Manager", service)
}
}
func TestPasswordKDFConfigDefaultsToArgon2id(t *testing.T) {
kdf, registry, err := passwordKDFConfig("")
if err != nil {
t.Fatalf("password KDF config: %v", err)
}
if kdf.Algorithm() != password.AlgorithmArgon2id {
t.Fatalf("kdf algorithm = %q, want argon2id", kdf.Algorithm())
}
if !registry.Supports(password.AlgorithmArgon2id) {
t.Fatalf("registry should support argon2id")
}
if registry.Supports(password.AlgorithmPBKDF2SHA256) {
t.Fatalf("default registry should not enable pbkdf2")
}
}
func TestPasswordKDFConfigSupportsFIPSCompatiblePBKDF2(t *testing.T) {
for _, mode := range []string{password.AlgorithmPBKDF2SHA256, "pbkdf2", "fips"} {
kdf, registry, err := passwordKDFConfig(mode)
if err != nil {
t.Fatalf("password KDF config for %q: %v", mode, err)
}
if kdf.Algorithm() != password.AlgorithmPBKDF2SHA256 {
t.Fatalf("kdf algorithm for %q = %q, want pbkdf2-hmac-sha256", mode, kdf.Algorithm())
}
if !registry.Supports(password.AlgorithmPBKDF2SHA256) {
t.Fatalf("registry for %q should support pbkdf2", mode)
}
if registry.Supports(password.AlgorithmArgon2id) {
t.Fatalf("fips-compatible registry for %q should not enable argon2id", mode)
}
}
}
func TestPasswordKDFConfigRejectsUnknownMode(t *testing.T) {
if _, _, err := passwordKDFConfig("scrypt"); err == nil {
t.Fatalf("unknown password KDF should fail")
}
}
func TestUnnotifiedKernelBootOnlyClaimsNewBootID(t *testing.T) {
directory := t.TempDir()
bootPath := filepath.Join(directory, "boot-id")
markerPath := filepath.Join(directory, "marker")
if err := os.WriteFile(bootPath, []byte("boot-a\n"), 0o600); err != nil {
t.Fatal(err)
}
bootID, unnotified, err := unnotifiedKernelBoot(bootPath, markerPath)
if err != nil || bootID != "boot-a" || !unnotified {
t.Fatalf("first detection = %q, %v, %v", bootID, unnotified, err)
}
if err := os.WriteFile(markerPath, []byte("boot-a\n"), 0o600); err != nil {
t.Fatal(err)
}
_, unnotified, err = unnotifiedKernelBoot(bootPath, markerPath)
if err != nil || unnotified {
t.Fatalf("same-boot detection = %v, %v", unnotified, err)
}
if err := os.WriteFile(bootPath, []byte("boot-b\n"), 0o600); err != nil {
t.Fatal(err)
}
bootID, unnotified, err = unnotifiedKernelBoot(bootPath, markerPath)
if err != nil || bootID != "boot-b" || !unnotified {
t.Fatalf("new-boot detection = %q, %v, %v", bootID, unnotified, err)
}
}
func TestUnnotifiedKernelBootRejectsInvalidOrUnreadableState(t *testing.T) {
directory := t.TempDir()
bootPath := filepath.Join(directory, "boot-id")
markerPath := filepath.Join(directory, "marker")
if _, _, err := unnotifiedKernelBoot(bootPath, markerPath); err == nil {
t.Fatal("missing boot id was accepted")
}
if err := os.WriteFile(bootPath, []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := unnotifiedKernelBoot(bootPath, markerPath); err == nil {
t.Fatal("empty boot id was accepted")
}
}

83
backend/cmd/tera-api/reset_button_monitor.go

@ -0,0 +1,83 @@
package main
import (
"context"
"time"
"tera-clone/services/tera-api/internal/domain"
)
type resetButtonStateReader interface {
ResetButtonPressed(context.Context) (bool, error)
}
type factoryResetStore interface {
FactoryReset(context.Context) (domain.Operation, error)
}
func runFactoryResetButtonMonitor(
ctx context.Context,
reader resetButtonStateReader,
store factoryResetStore,
pollInterval time.Duration,
holdDuration time.Duration,
onReset func(domain.Operation),
onError func(error),
) {
if pollInterval <= 0 {
pollInterval = 100 * time.Millisecond
}
if holdDuration <= 0 {
holdDuration = time.Second
}
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
var pressedAt time.Time
handled := false
unavailable := false
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
probeContext, cancel := context.WithTimeout(ctx, pollInterval)
pressed, err := reader.ResetButtonPressed(probeContext)
cancel()
if err != nil {
if !unavailable && onError != nil {
onError(err)
}
unavailable = true
pressedAt = time.Time{}
handled = false
continue
}
unavailable = false
if !pressed {
pressedAt = time.Time{}
handled = false
continue
}
if pressedAt.IsZero() {
pressedAt = time.Now()
continue
}
if handled || time.Since(pressedAt) < holdDuration {
continue
}
resetContext, resetCancel := context.WithTimeout(ctx, 15*time.Second)
operation, resetErr := store.FactoryReset(resetContext)
resetCancel()
handled = true
if resetErr != nil {
if onError != nil {
onError(resetErr)
}
continue
}
if onReset != nil {
onReset(operation)
}
}
}
}

41
backend/cmd/tera-api/reset_button_monitor_test.go

@ -0,0 +1,41 @@
package main
import (
"context"
"sync/atomic"
"testing"
"time"
"tera-clone/services/tera-api/internal/domain"
)
type pressedResetButton struct{}
func (pressedResetButton) ResetButtonPressed(context.Context) (bool, error) { return true, nil }
type countingFactoryResetStore struct{ calls atomic.Int32 }
func (s *countingFactoryResetStore) FactoryReset(context.Context) (domain.Operation, error) {
s.calls.Add(1)
return domain.Operation{OperationID: "factory-reset-test"}, nil
}
func TestFactoryResetButtonMonitorTriggersOnceWhileHeld(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
store := &countingFactoryResetStore{}
done := make(chan struct{})
go func() {
defer close(done)
runFactoryResetButtonMonitor(ctx, pressedResetButton{}, store, 2*time.Millisecond, 5*time.Millisecond, nil, func(err error) { t.Error(err) })
}()
deadline := time.Now().Add(250 * time.Millisecond)
for store.calls.Load() == 0 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
time.Sleep(20 * time.Millisecond)
cancel()
<-done
if store.calls.Load() != 1 {
t.Fatalf("factory reset calls = %d, want 1", store.calls.Load())
}
}

291
backend/cmd/tera-direct-load/main.go

@ -0,0 +1,291 @@
// tera-direct-load records repeatable in-process Direct Mode broker resource evidence.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"runtime/metrics"
"strconv"
"strings"
"sync"
"time"
"tera-clone/services/tera-api/internal/directstream"
)
type loadEvidence struct {
AttachmentsRequested int `json:"attachments_requested"`
ActiveAttachments int `json:"active_attachments"`
ActiveChannels int `json:"active_channels"`
QueuedBytes uint64 `json:"queued_bytes"`
QueueCapacityBytes uint64 `json:"queue_capacity_bytes"`
ResidentBytes uint64 `json:"resident_bytes"`
MemorySource string `json:"memory_source"`
HeapAllocBytes uint64 `json:"heap_alloc_bytes"`
GoSystemBytes uint64 `json:"go_system_bytes"`
CPUSeconds float64 `json:"cpu_seconds"`
WallMilliseconds float64 `json:"wall_milliseconds"`
RXBytes uint64 `json:"rx_bytes"`
FanoutBytes uint64 `json:"fanout_bytes"`
SlowConsumerDrops uint64 `json:"slow_consumer_drops"`
RejectedAttachments uint64 `json:"rejected_attachments"`
TransportReconnects uint64 `json:"transport_reconnects"`
GeneratedAt time.Time `json:"generated_at"`
}
type loadReport struct {
SchemaVersion int `json:"schema_version"`
Runtime string `json:"runtime"`
Profiles []loadEvidence `json:"profiles"`
}
func main() {
output := flag.String("output", "", "optional JSON evidence output path")
payloadBytes := flag.Int("payload-bytes", 4096, "queued RX bytes per active channel")
flag.Parse()
if *payloadBytes < 1 || *payloadBytes > 64*1024 {
fmt.Fprintln(os.Stderr, "payload-bytes must be between 1 and 65536")
os.Exit(2)
}
report := loadReport{SchemaVersion: 1, Runtime: runtime.GOOS + "/" + runtime.GOARCH}
for _, attachments := range []int{1, 48, 96, 192} {
evidence, err := runLoadProfile(attachments, *payloadBytes)
if err != nil {
fmt.Fprintf(os.Stderr, "load profile %d failed: %v\n", attachments, err)
os.Exit(1)
}
report.Profiles = append(report.Profiles, evidence)
}
payload, err := json.MarshalIndent(report, "", " ")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if *output != "" {
if err := os.MkdirAll(filepath.Dir(*output), 0o750); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err := os.WriteFile(*output, append(payload, '\n'), 0o640); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
fmt.Println(string(payload))
}
func runLoadProfile(attachments, payloadBytes int) (loadEvidence, error) {
if attachments != 1 && attachments != 48 && attachments != 96 && attachments != 192 {
return loadEvidence{}, errors.New("attachment profile must be 1, 48, 96, or 192")
}
if payloadBytes < 1 || payloadBytes > 64*1024 {
return loadEvidence{}, errors.New("payload size is outside the broker queue budget")
}
runtime.GC()
cpuBefore := processCPUSeconds()
started := time.Now()
factory := newLoadTransportFactory()
broker, err := directstream.NewBroker(factory, directstream.BrokerOptions{})
if err != nil {
return loadEvidence{}, err
}
defer broker.Close()
warmup, err := broker.Attach(context.Background(), directstream.AttachRequest{
SessionID: "load-warmup", Channel: "CH01", Actor: "load-test", Access: directstream.AccessViewer,
})
if err != nil {
return loadEvidence{}, err
}
if err := warmup.Close(); err != nil {
return loadEvidence{}, err
}
activeChannels := 48
if attachments == 1 {
activeChannels = 1
}
attached := make([]*directstream.Attachment, 0, attachments)
defer func() {
for _, attachment := range attached {
_ = attachment.Close()
}
}()
for index := 0; index < attachments; index++ {
channel := fmt.Sprintf("CH%02d", index%activeChannels+1)
attachment, attachErr := broker.Attach(context.Background(), directstream.AttachRequest{
SessionID: fmt.Sprintf("load-%03d-%03d", attachments, index),
Channel: channel, Actor: "load-test", Access: directstream.AccessViewer,
})
if attachErr != nil {
return loadEvidence{}, attachErr
}
attached = append(attached, attachment)
}
payload := make([]byte, payloadBytes)
for index := range payload {
payload[index] = byte(index)
}
for channel := 1; channel <= activeChannels; channel++ {
if err := factory.emit(fmt.Sprintf("CH%02d", channel), payload); err != nil {
return loadEvidence{}, err
}
}
expectedRX := uint64(activeChannels * payloadBytes)
expectedQueued := uint64(attachments * payloadBytes)
deadline := time.Now().Add(3 * time.Second)
var snapshot directstream.BrokerSnapshot
for {
snapshot = broker.Snapshot()
queued := queuedBytes(snapshot)
if snapshot.RXBytes == expectedRX && queued == expectedQueued {
break
}
if time.Now().After(deadline) {
return loadEvidence{}, fmt.Errorf("load did not settle: rx=%d/%d queued=%d/%d", snapshot.RXBytes, expectedRX, queued, expectedQueued)
}
time.Sleep(time.Millisecond)
}
// Exercise the same bounded snapshot path used by health polling so the CPU
// measurement has useful resolution even for the one-attachment profile.
observationSamples := 2000 * 48 / activeChannels
for sample := 0; sample < observationSamples; sample++ {
snapshot = broker.Snapshot()
}
var memory runtime.MemStats
runtime.ReadMemStats(&memory)
resident, source := residentBytes(memory.Sys)
return loadEvidence{
AttachmentsRequested: attachments,
ActiveAttachments: snapshot.TotalAttachments, ActiveChannels: snapshot.ActiveChannels,
QueuedBytes: queuedBytes(snapshot), QueueCapacityBytes: uint64(attachments * 64 * 1024),
ResidentBytes: resident, MemorySource: source,
HeapAllocBytes: memory.HeapAlloc, GoSystemBytes: memory.Sys,
CPUSeconds: processCPUSeconds() - cpuBefore,
WallMilliseconds: float64(time.Since(started).Microseconds()) / 1000,
RXBytes: snapshot.RXBytes, FanoutBytes: snapshot.FanoutBytes,
SlowConsumerDrops: snapshot.SlowConsumerDisconnects,
RejectedAttachments: snapshot.RejectedAttachments,
TransportReconnects: snapshot.TransportReconnects,
GeneratedAt: time.Now().UTC(),
}, nil
}
func queuedBytes(snapshot directstream.BrokerSnapshot) uint64 {
var total uint64
for _, channel := range snapshot.Channels {
total += uint64(channel.QueuedBytes)
}
return total
}
func processCPUSeconds() float64 {
samples := []metrics.Sample{{Name: "/cpu/classes/total:cpu-seconds"}}
metrics.Read(samples)
if samples[0].Value.Kind() != metrics.KindFloat64 {
return 0
}
return samples[0].Value.Float64()
}
func residentBytes(fallback uint64) (uint64, string) {
if runtime.GOOS != "linux" {
return fallback, "go_runtime_sys"
}
payload, err := os.ReadFile("/proc/self/statm")
if err != nil {
return fallback, "go_runtime_sys"
}
fields := strings.Fields(string(payload))
if len(fields) < 2 {
return fallback, "go_runtime_sys"
}
pages, err := strconv.ParseUint(fields[1], 10, 64)
if err != nil {
return fallback, "go_runtime_sys"
}
return pages * uint64(os.Getpagesize()), "proc_statm_rss"
}
type loadTransportFactory struct {
mu sync.Mutex
transports map[string]*loadTransport
}
func newLoadTransportFactory() *loadTransportFactory {
return &loadTransportFactory{transports: make(map[string]*loadTransport)}
}
func (f *loadTransportFactory) Open(_ context.Context, channel string) (io.ReadWriteCloser, error) {
transport := newLoadTransport()
f.mu.Lock()
f.transports[channel] = transport
f.mu.Unlock()
return transport, nil
}
func (f *loadTransportFactory) emit(channel string, payload []byte) error {
f.mu.Lock()
transport := f.transports[channel]
f.mu.Unlock()
if transport == nil {
return errors.New("load transport is missing for " + channel)
}
return transport.emit(payload)
}
type loadTransport struct {
rx chan []byte
closed chan struct{}
closeOnce sync.Once
pending []byte
}
func newLoadTransport() *loadTransport {
return &loadTransport{rx: make(chan []byte, 1), closed: make(chan struct{})}
}
func (t *loadTransport) Read(buffer []byte) (int, error) {
for len(t.pending) == 0 {
select {
case payload := <-t.rx:
t.pending = payload
case <-t.closed:
return 0, io.EOF
}
}
count := copy(buffer, t.pending)
t.pending = t.pending[count:]
return count, nil
}
func (t *loadTransport) Write(payload []byte) (int, error) {
select {
case <-t.closed:
return 0, io.ErrClosedPipe
default:
return len(payload), nil
}
}
func (t *loadTransport) Close() error {
t.closeOnce.Do(func() { close(t.closed) })
return nil
}
func (t *loadTransport) emit(payload []byte) error {
copyOfPayload := append([]byte(nil), payload...)
select {
case t.rx <- copyOfPayload:
return nil
case <-t.closed:
return io.ErrClosedPipe
}
}

25
backend/cmd/tera-direct-load/main_test.go

@ -0,0 +1,25 @@
package main
import "testing"
func TestRunLoadProfileCoversProductAttachmentBudgets(t *testing.T) {
for _, attachments := range []int{1, 48, 96, 192} {
evidence, err := runLoadProfile(attachments, 32)
if err != nil {
t.Fatalf("runLoadProfile(%d): %v", attachments, err)
}
expectedChannels := 48
if attachments == 1 {
expectedChannels = 1
}
if evidence.ActiveAttachments != attachments || evidence.ActiveChannels != expectedChannels {
t.Fatalf("profile %d evidence = %#v", attachments, evidence)
}
if evidence.QueuedBytes != uint64(attachments*32) || evidence.TransportReconnects < 1 {
t.Fatalf("profile %d queue/reconnect evidence = %#v", attachments, evidence)
}
if evidence.ResidentBytes == 0 || evidence.CPUSeconds < 0 || evidence.WallMilliseconds < 0 {
t.Fatalf("profile %d resource evidence = %#v", attachments, evidence)
}
}
}

105
backend/cmd/tera-hwd/main.go

@ -0,0 +1,105 @@
// tera-hwd is the minimal privileged hardware adapter for TERA board controls.
package main
import (
"errors"
"log"
"net"
"os"
"os/signal"
"path/filepath"
"syscall"
"tera-clone/services/tera-api/internal/hardware/greenled"
"tera-clone/services/tera-api/internal/hardware/greenledsocket"
"tera-clone/services/tera-api/internal/hardware/systemcontrol"
)
func main() {
greenSocketPath := getenv("TERA_HWD_GREEN_LED_SOCKET", greenledsocket.DefaultSocketPath)
systemSocketPath := getenv("TERA_HWD_SYSTEM_SOCKET", systemcontrol.DefaultSocketPath)
sysfsRoot := getenv("TERA_HWD_GREEN_LED_SYSFS_ROOT", greenled.DefaultSysfsRoot)
resetButtonPath := getenv("TERA_HWD_RESET_BUTTON_VALUE_PATH", "/sys/class/gpio/manual_KeyReset/value")
greenListener, err := listen(greenSocketPath, "TERA_HWD_GREEN_LED_SOCKET")
if err != nil {
log.Fatalf("tera-hwd green LED socket setup failed: %v", err)
}
systemListener, err := listen(systemSocketPath, "TERA_HWD_SYSTEM_SOCKET")
if err != nil {
_ = greenListener.Close()
_ = os.Remove(greenSocketPath)
log.Fatalf("tera-hwd system socket setup failed: %v", err)
}
defer func() {
_ = greenListener.Close()
_ = systemListener.Close()
_ = os.Remove(greenSocketPath)
_ = os.Remove(systemSocketPath)
}()
greenServer := greenledsocket.Server{Controller: greenled.NewSysfsController(sysfsRoot)}
systemServer := systemcontrol.Server{
Controller: systemcontrol.CommandController{RebootCommand: "/sbin/reboot"},
ResetButton: systemcontrol.SysfsResetButton{ValuePath: resetButtonPath, ActiveLow: true},
}
done := make(chan error, 2)
go func() { done <- greenServer.Serve(greenListener) }()
go func() { done <- systemServer.Serve(systemListener) }()
log.Printf("tera-hwd serving green LED control on %s and system control on %s", greenSocketPath, systemSocketPath)
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
remaining := 2
select {
case signal := <-signals:
log.Printf("tera-hwd stopping after %s", signal)
case err := <-done:
remaining--
if err != nil {
log.Printf("tera-hwd service failed: %v", err)
}
}
_ = greenListener.Close()
_ = systemListener.Close()
for remaining > 0 {
if err := <-done; err != nil {
log.Printf("tera-hwd stop error: %v", err)
}
remaining--
}
}
func listen(socketPath, variable string) (net.Listener, error) {
if socketPath == "" || !filepath.IsAbs(socketPath) {
return nil, errors.New(variable + " must be an absolute path")
}
if err := os.MkdirAll(filepath.Dir(socketPath), 0o750); err != nil {
return nil, err
}
if info, err := os.Lstat(socketPath); err == nil {
if info.Mode()&os.ModeSocket == 0 {
return nil, errors.New("refusing to replace non-socket hardware control path")
}
if err := os.Remove(socketPath); err != nil {
return nil, err
}
} else if !os.IsNotExist(err) {
return nil, err
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
return nil, err
}
if err := os.Chmod(socketPath, 0o660); err != nil {
_ = listener.Close()
return nil, err
}
return listener, nil
}
func getenv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}

125
backend/cmd/tera-initramfs-tool/archive.go

@ -0,0 +1,125 @@
package main
import (
"bufio"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path"
"sort"
"strings"
"time"
)
type archiveEntry struct {
name string
mode uint32
data []byte
}
func writeInitramfs(output string, initBinary []byte) error {
return writeInitramfsWithEntries(output, initBinary, nil)
}
func writeInitramfsWithEntries(output string, initBinary []byte, extra []archiveEntry) error {
if len(initBinary) == 0 {
return errors.New("init binary is empty")
}
entries := []archiveEntry{
{name: "dev", mode: 0o040755},
{name: "etc", mode: 0o040755},
{name: "etc/tera", mode: 0o040755},
{name: "etc/tera/update-trust", mode: 0o040555},
{name: "proc", mode: 0o040555},
{name: "sys", mode: 0o040555},
{name: "userdata", mode: 0o040755},
{name: "init", mode: 0o100755, data: initBinary},
}
entries = append(entries, extra...)
seen := make(map[string]struct{}, len(entries))
for _, entry := range entries {
if _, duplicate := seen[entry.name]; duplicate {
return fmt.Errorf("duplicate initramfs path %q", entry.name)
}
seen[entry.name] = struct{}{}
}
sort.Slice(entries, func(i, j int) bool { return entries[i].name < entries[j].name })
file, err := os.OpenFile(output, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
committed := false
defer func() {
_ = file.Close()
if !committed {
_ = os.Remove(output)
}
}()
gzipWriter, err := gzip.NewWriterLevel(file, gzip.BestCompression)
if err != nil {
return err
}
gzipWriter.Header.ModTime = time.Unix(0, 0).UTC()
gzipWriter.Header.OS = 255
buffered := bufio.NewWriterSize(gzipWriter, 1024*1024)
for index, entry := range entries {
if err := writeNewcEntry(buffered, uint32(index+1), entry); err != nil {
return err
}
}
if err := writeNewcEntry(buffered, uint32(len(entries)+1), archiveEntry{name: "TRAILER!!!"}); err != nil {
return err
}
if err := buffered.Flush(); err != nil {
return err
}
if err := gzipWriter.Close(); err != nil {
return err
}
if err := file.Sync(); err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
committed = true
return nil
}
func writeNewcEntry(writer io.Writer, inode uint32, entry archiveEntry) error {
clean := path.Clean(strings.TrimPrefix(entry.name, "/"))
if clean == "." || clean != entry.name || strings.Contains(clean, "..") {
return fmt.Errorf("unsafe initramfs path %q", entry.name)
}
name := append([]byte(clean), 0)
header := fmt.Sprintf("070701%08x%08x%08x%08x%08x%08x%08x%08x%08x%08x%08x%08x%08x",
inode, entry.mode, 0, 0, 1, 0, len(entry.data), 0, 0, 0, 0, len(name), 0)
if len(header) != 110 {
return errors.New("internal newc header length error")
}
if _, err := io.WriteString(writer, header); err != nil {
return err
}
if _, err := writer.Write(name); err != nil {
return err
}
if err := pad4(writer, len(header)+len(name)); err != nil {
return err
}
if _, err := writer.Write(entry.data); err != nil {
return err
}
return pad4(writer, len(entry.data))
}
func pad4(writer io.Writer, length int) error {
padding := (4 - length%4) % 4
if padding == 0 {
return nil
}
_, err := writer.Write(make([]byte, padding))
return err
}

95
backend/cmd/tera-initramfs-tool/archive_test.go

@ -0,0 +1,95 @@
package main
import (
"bytes"
"compress/gzip"
"io"
"os"
"path/filepath"
"strconv"
"testing"
)
func TestWriteInitramfsIsDeterministicAndContainsExecutableInit(t *testing.T) {
first := filepath.Join(t.TempDir(), "first.cpio.gz")
second := filepath.Join(t.TempDir(), "second.cpio.gz")
for _, output := range []string{first, second} {
if err := writeInitramfs(output, []byte("static-arm64-init")); err != nil {
t.Fatal(err)
}
}
one, _ := os.ReadFile(first)
two, _ := os.ReadFile(second)
if !bytes.Equal(one, two) {
t.Fatal("initramfs output is not deterministic")
}
entries := readNewc(t, one)
if got := string(entries["init"].data); got != "static-arm64-init" {
t.Fatalf("init contents = %q", got)
}
if entries["init"].mode != 0o100755 {
t.Fatalf("init mode = %#o", entries["init"].mode)
}
for _, directory := range []string{"dev", "proc", "sys", "userdata"} {
if entries[directory].mode&0o170000 != 0o040000 {
t.Fatalf("%s is not a directory", directory)
}
}
}
func TestWriteInitramfsIncludesReadOnlyTrustAnchor(t *testing.T) {
output := filepath.Join(t.TempDir(), "recovery.cpio.gz")
if err := writeInitramfsWithEntries(output, []byte("init"), []archiveEntry{{name: "etc/tera/update-trust/production.json", mode: 0o100444, data: []byte(`{"format_version":1}`)}}); err != nil {
t.Fatal(err)
}
compressed, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
entries := readNewc(t, compressed)
anchor := entries["etc/tera/update-trust/production.json"]
if anchor.mode != 0o100444 || string(anchor.data) != `{"format_version":1}` {
t.Fatalf("trust anchor = %+v", anchor)
}
}
func readNewc(t *testing.T, compressed []byte) map[string]archiveEntry {
t.Helper()
reader, err := gzip.NewReader(bytes.NewReader(compressed))
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatal(err)
}
entries := map[string]archiveEntry{}
for offset := 0; ; {
if offset+110 > len(data) || string(data[offset:offset+6]) != "070701" {
t.Fatalf("invalid newc header at %d", offset)
}
mode := parseHex(t, data[offset+14:offset+22])
size := int(parseHex(t, data[offset+54:offset+62]))
nameSize := int(parseHex(t, data[offset+94:offset+102]))
nameStart := offset + 110
name := string(data[nameStart : nameStart+nameSize-1])
dataStart := align4(nameStart + nameSize)
if name == "TRAILER!!!" {
break
}
entries[name] = archiveEntry{name: name, mode: mode, data: append([]byte(nil), data[dataStart:dataStart+size]...)}
offset = align4(dataStart + size)
}
return entries
}
func parseHex(t *testing.T, value []byte) uint32 {
t.Helper()
parsed, err := strconv.ParseUint(string(value), 16, 32)
if err != nil {
t.Fatal(err)
}
return uint32(parsed)
}
func align4(value int) int { return (value + 3) &^ 3 }

79
backend/cmd/tera-initramfs-tool/main.go

@ -0,0 +1,79 @@
// tera-initramfs-tool creates a deterministic, minimal newc+gzip initramfs.
// The supplied static executable becomes PID 1; no shell or shared libraries
// are added to the recovery trust boundary.
package main
import (
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
)
func main() {
flags := flag.NewFlagSet("tera-initramfs-tool", flag.ExitOnError)
initPath := flags.String("init", "", "static Linux arm64 recovery init binary")
output := flags.String("output", "", "new .cpio.gz output path")
var trustAnchors stringList
flags.Var(&trustAnchors, "trust-anchor", "public update trust-anchor JSON to include (repeatable)")
flags.Parse(os.Args[1:])
if err := runWithTrustAnchors(*initPath, *output, trustAnchors); err != nil {
fmt.Fprintln(os.Stderr, "tera-initramfs-tool:", err)
os.Exit(1)
}
}
type stringList []string
func (values *stringList) String() string { return fmt.Sprint([]string(*values)) }
func (values *stringList) Set(value string) error {
*values = append(*values, value)
return nil
}
func run(initPath, output string) error {
return runWithTrustAnchors(initPath, output, nil)
}
func runWithTrustAnchors(initPath, output string, trustAnchors []string) error {
if initPath == "" || output == "" {
return errors.New("init and output are required")
}
info, err := os.Lstat(initPath)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("init must be a regular non-symlink file")
}
initBinary, err := os.ReadFile(initPath)
if err != nil {
return err
}
extra := make([]archiveEntry, 0, len(trustAnchors))
seen := make(map[string]struct{}, len(trustAnchors))
for _, trustPath := range trustAnchors {
info, err := os.Lstat(trustPath)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() <= 0 || info.Size() > 64*1024 {
return errors.New("trust anchor must be a non-empty regular non-symlink JSON file no larger than 64 KiB")
}
name := filepath.Base(trustPath)
if filepath.Ext(name) != ".json" || name == ".json" || strings.ContainsAny(name, "/\\") {
return errors.New("trust anchor must have a safe JSON basename")
}
if _, duplicate := seen[name]; duplicate {
return errors.New("duplicate trust-anchor basename")
}
data, err := os.ReadFile(trustPath)
if err != nil {
return err
}
seen[name] = struct{}{}
extra = append(extra, archiveEntry{name: "etc/tera/update-trust/" + name, mode: 0o100444, data: data})
}
if err := writeInitramfsWithEntries(output, initBinary, extra); err != nil {
return err
}
fmt.Printf("created minimal recovery initramfs: %s\n", output)
return nil
}

110
backend/cmd/tera-logd/main.go

@ -0,0 +1,110 @@
// tera-logd owns the authoritative userdata event journal.
package main
import (
"errors"
"log"
"net"
"os"
"os/signal"
"path/filepath"
"strconv"
"syscall"
"tera-clone/services/tera-api/internal/logservice"
"tera-clone/services/tera-api/internal/logstorage"
"tera-clone/services/tera-api/internal/storage/capacity"
)
func main() {
policyPath := getenv("TERA_LOGD_STORAGE_POLICY", "/userdata/storage-layout.json")
policy, err := capacity.LoadPolicy(policyPath)
if err != nil {
log.Fatalf("tera-logd storage policy setup failed: %v", err)
}
store, err := logstorage.Open(logstorage.Options{
Root: getenv("TERA_LOGD_ROOT", "/userdata/log"),
SegmentBytes: getenvInt64("TERA_LOGD_SEGMENT_BYTES", 4<<20),
AuditCapBytes: policy.CapsBytes.AuditLogs,
SystemCapBytes: policy.CapsBytes.SystemEvents,
PortCapBytes: policy.CapsBytes.PortEvents,
IndexCapBytes: policy.CapsBytes.StateAndIndexes,
GlobalFreeFloor: policy.CapsBytes.GlobalFreeFloor,
})
if err != nil {
log.Fatalf("tera-logd store setup failed: %v", err)
}
socketPath := getenv("TERA_LOGD_SOCKET", logservice.DefaultSocketPath)
listener, err := listen(socketPath)
if err != nil {
log.Fatalf("tera-logd socket setup failed: %v", err)
}
defer func() {
_ = listener.Close()
_ = os.Remove(socketPath)
}()
server := logservice.Server{Store: store}
done := make(chan error, 1)
go func() { done <- server.Serve(listener) }()
log.Printf("tera-logd serving authoritative log storage on %s", socketPath)
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
select {
case signal := <-signals:
log.Printf("tera-logd stopping after %s", signal)
_ = listener.Close()
if err := <-done; err != nil {
log.Printf("tera-logd stop error: %v", err)
}
case err := <-done:
if err != nil {
log.Fatalf("tera-logd failed: %v", err)
}
}
}
func listen(socketPath string) (net.Listener, error) {
if socketPath == "" || !filepath.IsAbs(socketPath) {
return nil, errors.New("TERA_LOGD_SOCKET must be an absolute path")
}
if err := os.MkdirAll(filepath.Dir(socketPath), 0o750); err != nil {
return nil, err
}
if info, err := os.Lstat(socketPath); err == nil {
if info.Mode()&os.ModeSocket == 0 {
return nil, errors.New("refusing to replace non-socket log service path")
}
if err := os.Remove(socketPath); err != nil {
return nil, err
}
} else if !os.IsNotExist(err) {
return nil, err
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
return nil, err
}
if err := os.Chmod(socketPath, 0o660); err != nil {
_ = listener.Close()
return nil, err
}
return listener, nil
}
func getenv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func getenvInt64(key string, fallback int64) int64 {
value, err := strconv.ParseInt(os.Getenv(key), 10, 64)
if err != nil || value <= 0 {
return fallback
}
return value
}

110
backend/cmd/tera-netd/main.go

@ -0,0 +1,110 @@
// tera-netd is the minimal privileged dual-Ethernet configuration adapter.
package main
import (
"context"
"errors"
"log"
"net"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"tera-clone/services/tera-api/internal/networkconfig"
"tera-clone/services/tera-api/internal/networkconfig/netsocket"
)
func main() {
controller, err := networkconfig.NewOSController(networkconfig.OSOptions{
InterfacesPath: getenv("TERA_NETD_INTERFACES", networkconfig.DefaultInterfacesPath),
RoutingPath: getenv("TERA_NETD_ROUTING_SCRIPT", networkconfig.DefaultRoutingPath),
ConfigPath: getenv("TERA_NETD_CONFIG", networkconfig.DefaultConfigPath),
PendingPath: getenv("TERA_NETD_PENDING", networkconfig.DefaultPendingPath),
HostnamePath: getenv("TERA_NETD_HOSTNAME_FILE", networkconfig.DefaultHostnamePath),
HostnameCommandPath: getenv("TERA_NETD_HOSTNAME_COMMAND", "/bin/hostname"),
IfdownPath: getenv("TERA_NETD_IFDOWN", "/sbin/ifdown"),
IfupPath: getenv("TERA_NETD_IFUP", "/sbin/ifup"),
IPPath: getenv("TERA_NETD_IP", "/sbin/ip"),
PingPath: getenv("TERA_NETD_PING", "/bin/ping"),
ShellPath: getenv("TERA_NETD_SHELL", "/bin/sh"),
SysClassRoot: getenv("TERA_NETD_SYS_CLASS_ROOT", "/sys/class/net"),
})
if err != nil {
log.Fatalf("tera-netd setup failed: %v", err)
}
recoveryContext, cancelRecovery := context.WithTimeout(context.Background(), 30*time.Second)
if err := controller.Recover(recoveryContext); err != nil {
log.Printf("tera-netd recovery incomplete; starting control socket for operator retry: %v", err)
}
cancelRecovery()
monitorContext, stopMonitor := context.WithCancel(context.Background())
defer stopMonitor()
go controller.RunOOBMonitor(monitorContext)
socketPath := getenv("TERA_NETD_SOCKET", netsocket.DefaultSocketPath)
listener, err := listen(socketPath)
if err != nil {
log.Fatalf("tera-netd socket setup failed: %v", err)
}
defer func() {
_ = listener.Close()
_ = os.Remove(socketPath)
}()
server := netsocket.Server{Controller: controller}
done := make(chan error, 1)
go func() { done <- server.Serve(listener) }()
log.Printf("tera-netd serving network control on %s", socketPath)
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
select {
case signal := <-signals:
log.Printf("tera-netd stopping after %s", signal)
_ = listener.Close()
if err := <-done; err != nil {
log.Printf("tera-netd stop error: %v", err)
}
case err := <-done:
if err != nil {
log.Fatalf("tera-netd failed: %v", err)
}
}
}
func listen(socketPath string) (net.Listener, error) {
if socketPath == "" || !filepath.IsAbs(socketPath) {
return nil, errors.New("TERA_NETD_SOCKET must be an absolute path")
}
if err := os.MkdirAll(filepath.Dir(socketPath), 0o750); err != nil {
return nil, err
}
if info, err := os.Lstat(socketPath); err == nil {
if info.Mode()&os.ModeSocket == 0 {
return nil, errors.New("refusing to replace non-socket network control path")
}
if err := os.Remove(socketPath); err != nil {
return nil, err
}
} else if !os.IsNotExist(err) {
return nil, err
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
return nil, err
}
if err := os.Chmod(socketPath, 0o660); err != nil {
_ = listener.Close()
return nil, err
}
return listener, nil
}
func getenv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}

24
backend/cmd/tera-netd/main_test.go

@ -0,0 +1,24 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestListenRefusesRelativeAndNonSocketPaths(t *testing.T) {
if _, err := listen("relative.sock"); err == nil {
t.Fatal("listen accepted a relative path")
}
path := filepath.Join(t.TempDir(), "control.sock")
if err := os.WriteFile(path, []byte("preserve"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := listen(path); err == nil {
t.Fatal("listen replaced a non-socket path")
}
content, err := os.ReadFile(path)
if err != nil || string(content) != "preserve" {
t.Fatalf("protected path changed: %q, %v", content, err)
}
}

34
backend/cmd/tera-recovery-probe/journal.go

@ -0,0 +1,34 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
)
type journalIdentity struct {
OperationID string `json:"operation_id"`
State string `json:"state"`
}
func readJournalOperation(path string) (journalIdentity, error) {
file, err := os.Open(path)
if err != nil {
return journalIdentity{}, err
}
defer file.Close()
var journal journalIdentity
decoder := json.NewDecoder(io.LimitReader(file, 1024*1024))
if err := decoder.Decode(&journal); err != nil {
return journalIdentity{}, err
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return journalIdentity{}, fmt.Errorf("journal must contain exactly one JSON value")
}
if journal.OperationID == "" || journal.State == "" {
return journalIdentity{}, fmt.Errorf("journal identity or state is empty")
}
return journal, nil
}

36
backend/cmd/tera-recovery-probe/journal_test.go

@ -0,0 +1,36 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestReadJournalOperation(t *testing.T) {
path := filepath.Join(t.TempDir(), "status.json")
if err := os.WriteFile(path, []byte(`{"format_version":1,"operation_id":"fw-123","state":"validated","ignored":"allowed"}`), 0o600); err != nil {
t.Fatal(err)
}
identity, err := readJournalOperation(path)
if err != nil || identity.OperationID != "fw-123" || identity.State != "validated" {
t.Fatalf("identity = %+v, error = %v", identity, err)
}
}
func TestReadJournalOperationRejectsMissingOrTrailingIdentity(t *testing.T) {
for name, content := range map[string]string{
"missing-operation": `{"state":"validated"}`,
"missing-state": `{"operation_id":"fw-123"}`,
"trailing-json": `{"operation_id":"fw-123","state":"validated"}{}`,
} {
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "status.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
if _, err := readJournalOperation(path); err == nil {
t.Fatal("invalid journal was accepted")
}
})
}
}

128
backend/cmd/tera-recovery-probe/main_linux.go

@ -0,0 +1,128 @@
//go:build linux
// tera-recovery-probe is a deliberately read-only PID 1 used to prove the
// recovery boot contract before any partition-writing updater is introduced.
package main
import (
"bufio"
"fmt"
"io"
"os"
"strings"
"time"
"golang.org/x/sys/unix"
"tera-clone/services/tera-api/internal/update/recoveryrequest"
)
const (
miscDevice = "/dev/mmcblk0p2"
userdataDevice = "/dev/mmcblk0p8"
journalPath = "/userdata/update/journal/status.json"
)
func main() {
mustMount("devtmpfs", "/dev", "devtmpfs", unix.MS_NOSUID, "mode=0755")
mustMount("proc", "/proc", "proc", unix.MS_NOSUID|unix.MS_NOEXEC|unix.MS_NODEV, "")
mustMount("sysfs", "/sys", "sysfs", unix.MS_NOSUID|unix.MS_NOEXEC|unix.MS_NODEV, "")
console, err := os.OpenFile("/dev/ttyS2", os.O_WRONLY, 0)
if err != nil {
console = os.Stdout
}
log := func(format string, values ...any) {
fmt.Fprintf(console, "[tera-recovery-probe] "+format+"\r\n", values...)
}
log("START read-only probe; no partition payload writes are implemented")
cmdline, err := os.ReadFile("/proc/cmdline")
if err != nil || !containsWord(string(cmdline), "tera.recovery=probe") || !containsWord(string(cmdline), "rdinit=/init") {
hold(log, "FAIL unexpected recovery kernel command line: %q (%v)", strings.TrimSpace(string(cmdline)), err)
return
}
log("PASS recovery kernel command line: %s", strings.TrimSpace(string(cmdline)))
if err := requirePartName("/sys/class/block/mmcblk0p2/uevent", "misc"); err != nil {
hold(log, "FAIL misc identity: %v", err)
return
}
if err := requirePartName("/sys/class/block/mmcblk0p8/uevent", "userdata"); err != nil {
hold(log, "FAIL userdata identity: %v", err)
return
}
misc, err := os.Open(miscDevice)
if err != nil {
hold(log, "FAIL open misc read-only: %v", err)
return
}
message, readErr := recoveryrequest.ReadAt(misc)
closeErr := misc.Close()
if readErr != nil || closeErr != nil {
hold(log, "FAIL recovery request: read=%v close=%v", readErr, closeErr)
return
}
log("PASS recovery request operation=%s", message.OperationID)
if err := unix.Mount(userdataDevice, "/userdata", "ext4", unix.MS_RDONLY|unix.MS_NODEV|unix.MS_NOSUID|unix.MS_NOEXEC|unix.MS_NOATIME, probeUserdataMountData); err != nil {
hold(log, "FAIL mount userdata read-only: %v", err)
return
}
journal, err := readJournalOperation(journalPath)
if err != nil || journal.OperationID != message.OperationID || journal.State != "validated" {
hold(log, "FAIL journal binding: request=%q journal=%q state=%q error=%v", message.OperationID, journal.OperationID, journal.State, err)
return
}
packagePath := "/userdata/update/validated/" + message.OperationID + ".pkg"
packageInfo, err := os.Lstat(packagePath)
if err != nil || !packageInfo.Mode().IsRegular() || packageInfo.Mode()&os.ModeSymlink != 0 || packageInfo.Size() <= 0 {
hold(log, "FAIL validated package identity: path=%s error=%v", packagePath, err)
return
}
log("PASS journal binding operation=%s state=%s package_bytes=%d", journal.OperationID, journal.State, packageInfo.Size())
log("READY boot selection, ttyS2, misc BCB, GPT identity, and read-only userdata journal are proven")
hold(log, "HOLD request intentionally remains set; use the service procedure to restore normal boot")
}
func mustMount(source, target, filesystem string, flags uintptr, data string) {
if err := unix.Mount(source, target, filesystem, flags, data); err != nil {
fmt.Fprintf(os.Stderr, "mount %s: %v\n", target, err)
for {
time.Sleep(time.Hour)
}
}
}
func containsWord(value, wanted string) bool {
for _, word := range strings.Fields(value) {
if word == wanted {
return true
}
}
return false
}
func requirePartName(path, wanted string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(io.LimitReader(file, 4096))
for scanner.Scan() {
if scanner.Text() == "PARTNAME="+wanted {
return nil
}
}
if err := scanner.Err(); err != nil {
return err
}
return fmt.Errorf("expected PARTNAME=%s", wanted)
}
func hold(log func(string, ...any), format string, values ...any) {
log(format, values...)
for {
time.Sleep(time.Hour)
}
}

9
backend/cmd/tera-recovery-probe/main_other.go

@ -0,0 +1,9 @@
//go:build !linux
package main
import "fmt"
func main() {
fmt.Println("tera-recovery-probe is a Linux arm64 recovery PID 1")
}

5
backend/cmd/tera-recovery-probe/mount_contract.go

@ -0,0 +1,5 @@
package main
// All probe mount behavior is expressed through mount(2) flags. Passing
// generic options through the ext4 data string is rejected by the target.
const probeUserdataMountData = ""

9
backend/cmd/tera-recovery-probe/mount_contract_test.go

@ -0,0 +1,9 @@
package main
import "testing"
func TestProbeUserdataMountDataIsEmpty(t *testing.T) {
if probeUserdataMountData != "" {
t.Fatalf("probe userdata mount data = %q, want no filesystem-specific options", probeUserdataMountData)
}
}

130
backend/cmd/tera-recovery-request-tool/main.go

@ -0,0 +1,130 @@
// tera-recovery-request-tool creates and inspects the exact 2 KiB BCB record
// used by the target recovery protocol. It never writes a target block device.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"tera-clone/services/tera-api/internal/update/recoveryrequest"
)
func main() {
if len(os.Args) < 2 {
usage()
}
var err error
switch os.Args[1] {
case "create":
err = create(os.Args[2:])
case "clear":
err = clear(os.Args[2:])
case "inspect":
err = inspect(os.Args[2:])
default:
usage()
}
if err != nil {
fmt.Fprintln(os.Stderr, "tera-recovery-request-tool:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: tera-recovery-request-tool {create|clear|inspect} [flags]")
os.Exit(2)
}
func clear(arguments []string) error {
flags := flag.NewFlagSet("clear", flag.ContinueOnError)
output := flags.String("output", "", "new 2048-byte cleared BCB record path")
if err := flags.Parse(arguments); err != nil {
return err
}
if *output == "" {
return errors.New("output is required")
}
file, err := os.OpenFile(*output, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
encoded := recoveryrequest.Clear()
written, writeErr := file.Write(encoded)
syncErr := file.Sync()
closeErr := file.Close()
if writeErr != nil || written != recoveryrequest.Size {
_ = os.Remove(*output)
return fmt.Errorf("write cleared BCB record: wrote %d of %d bytes: %w", written, recoveryrequest.Size, writeErr)
}
if syncErr != nil {
_ = os.Remove(*output)
return syncErr
}
if closeErr != nil {
_ = os.Remove(*output)
return closeErr
}
fmt.Printf("created cleared BCB record: %s (misc offset %d)\n", *output, recoveryrequest.Offset)
return nil
}
func create(arguments []string) error {
flags := flag.NewFlagSet("create", flag.ContinueOnError)
operationID := flags.String("operation-id", "", "validated update operation id")
output := flags.String("output", "", "new 2048-byte BCB record path")
if err := flags.Parse(arguments); err != nil {
return err
}
if *output == "" {
return errors.New("output is required")
}
encoded, err := recoveryrequest.Encode(*operationID)
if err != nil {
return err
}
file, err := os.OpenFile(*output, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
written, writeErr := file.Write(encoded)
syncErr := file.Sync()
closeErr := file.Close()
if writeErr != nil || written != recoveryrequest.Size {
_ = os.Remove(*output)
return fmt.Errorf("write BCB record: wrote %d of %d bytes: %w", written, recoveryrequest.Size, writeErr)
}
if syncErr != nil {
_ = os.Remove(*output)
return syncErr
}
if closeErr != nil {
_ = os.Remove(*output)
return closeErr
}
fmt.Printf("created recovery BCB record: %s (misc offset %d)\n", *output, recoveryrequest.Offset)
return nil
}
func inspect(arguments []string) error {
flags := flag.NewFlagSet("inspect", flag.ContinueOnError)
input := flags.String("input", "", "2048-byte BCB record path")
if err := flags.Parse(arguments); err != nil {
return err
}
data, err := os.ReadFile(*input)
if err != nil {
return err
}
message, err := recoveryrequest.Decode(data)
if err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(map[string]any{
"valid": true, "command": recoveryrequest.Command,
"operation_id": message.OperationID, "misc_offset": recoveryrequest.Offset,
"record_bytes": recoveryrequest.Size,
})
}

189
backend/cmd/tera-recovery-updater/main_linux.go

@ -0,0 +1,189 @@
//go:build linux
// tera-recovery-updater is the static PID 1 for authenticated offline updates.
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"golang.org/x/sys/unix"
"tera-clone/services/tera-api/internal/update"
"tera-clone/services/tera-api/internal/update/recoveryapply"
"tera-clone/services/tera-api/internal/update/recoveryrequest"
)
const (
updateRoot = "/userdata/update"
trustDirectory = "/etc/tera/update-trust"
product = "tera-core-x48-pro"
hardwareProfile = "fet3568j-c"
sectorBytes = 512
)
func main() {
mustMount("devtmpfs", "/dev", "devtmpfs", unix.MS_NOSUID, "mode=0755")
mustMount("proc", "/proc", "proc", unix.MS_NOSUID|unix.MS_NOEXEC|unix.MS_NODEV, "")
mustMount("sysfs", "/sys", "sysfs", unix.MS_NOSUID|unix.MS_NOEXEC|unix.MS_NODEV, "")
// ttyS2 is a best-effort development trace only. The final product does not
// expose COM5; durable status and journal files in userdata are authoritative.
console, err := os.OpenFile("/dev/ttyS2", os.O_WRONLY, 0)
if err != nil {
console = os.Stdout
}
log := func(format string, values ...any) {
fmt.Fprintf(console, "[tera-recovery-updater] "+format+"\r\n", values...)
}
log("START authenticated offline recovery updater")
cmdline, err := os.ReadFile("/proc/cmdline")
if err != nil || !containsWord(string(cmdline), "tera.recovery=update") || !containsWord(string(cmdline), "rdinit=/init") {
hold(log, "FAIL unexpected recovery kernel command line: %q (%v)", strings.TrimSpace(string(cmdline)), err)
}
for _, partition := range recoveryPartitionContract {
if err := requirePartName(partition.UEventPath, partition.Name); err != nil {
hold(log, "FAIL partition identity %s: %v", partition.Name, err)
}
}
misc, err := os.OpenFile(miscDevice, os.O_RDWR, 0)
if err != nil {
hold(log, "FAIL open misc: %v", err)
}
message, err := recoveryrequest.ReadAt(misc)
if err != nil {
hold(log, "FAIL read authenticated recovery request: %v", err)
}
log("PASS recovery request operation=%s", message.OperationID)
if err := unix.Mount(userdataDevice, "/userdata", "ext4", unix.MS_NODEV|unix.MS_NOSUID|unix.MS_NOEXEC|unix.MS_NOATIME, userdataMountData); err != nil {
hold(log, "FAIL mount userdata: %v", err)
}
bootBytes, err := blockBytes(bootSizePath)
if err != nil {
hold(log, "FAIL boot partition size: %v", err)
}
rootfsBytes, err := blockBytes(rootfsSizePath)
if err != nil {
hold(log, "FAIL rootfs partition size: %v", err)
}
packagePath := updateRoot + "/validated/" + message.OperationID + ".pkg"
statusPath := updateRoot + "/journal/status.json"
deviceVersion, err := readDeviceVersion(statusPath, message.OperationID)
if err != nil {
hold(log, "FAIL durable device-version binding: %v", err)
}
engine, err := recoveryapply.New(recoveryapply.Options{
OperationID: message.OperationID,
PackagePath: packagePath,
JournalPath: recoveryapply.JournalPath(updateRoot, message.OperationID),
StatusPath: statusPath,
TrustDirectory: trustDirectory,
Product: product,
Profile: hardwareProfile,
CurrentVersion: deviceVersion,
Targets: map[string]recoveryapply.Target{
"rootfs": {Path: rootfsDevice, MaxBytes: rootfsBytes},
"boot": {Path: bootDevice, MaxBytes: bootBytes},
},
ClearRecovery: func() error { return recoveryrequest.WriteAt(misc, recoveryrequest.Clear()) },
})
if err != nil {
hold(log, "FAIL recovery engine setup: %v", err)
}
if err := engine.Run(); err != nil {
hold(log, "FAIL recovery apply: %v", err)
}
log("PASS all selected payloads and readbacks verified; recovery request cleared")
_ = misc.Close()
unix.Sync()
log("REBOOT normal boot")
if err := unix.Reboot(unix.LINUX_REBOOT_CMD_RESTART); err != nil {
hold(log, "FAIL reboot normal system: %v", err)
}
for {
time.Sleep(time.Hour)
}
}
func mustMount(source, target, filesystem string, flags uintptr, data string) {
if err := unix.Mount(source, target, filesystem, flags, data); err != nil {
fmt.Fprintf(os.Stderr, "mount %s: %v\n", target, err)
for {
time.Sleep(time.Hour)
}
}
}
func containsWord(value, wanted string) bool {
for _, word := range strings.Fields(value) {
if word == wanted {
return true
}
}
return false
}
func requirePartName(path, wanted string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(io.LimitReader(file, 4096))
for scanner.Scan() {
if scanner.Text() == "PARTNAME="+wanted {
return nil
}
}
if err := scanner.Err(); err != nil {
return err
}
return fmt.Errorf("expected PARTNAME=%s", wanted)
}
func blockBytes(path string) (int64, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
sectors, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
if err != nil || sectors <= 0 || sectors > (1<<62)/sectorBytes {
return 0, errors.New("invalid block-sector count")
}
return sectors * sectorBytes, nil
}
func readDeviceVersion(path, operationID string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
var status update.Status
decoder := json.NewDecoder(io.LimitReader(file, 1024*1024))
if err := decoder.Decode(&status); err != nil {
return "", err
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return "", errors.New("status journal must contain exactly one JSON value")
}
if status.OperationID != operationID || strings.TrimSpace(status.DeviceVersion) == "" {
return "", errors.New("status journal operation or device version does not match recovery request")
}
return status.DeviceVersion, nil
}
func hold(log func(string, ...any), format string, values ...any) {
log(format, values...)
for {
time.Sleep(time.Hour)
}
}

5
backend/cmd/tera-recovery-updater/main_other.go

@ -0,0 +1,5 @@
//go:build !linux
package main
func main() {}

5
backend/cmd/tera-recovery-updater/mount_contract.go

@ -0,0 +1,5 @@
package main
// Generic mount options such as rw and noatime belong in the mount(2) flags.
// Only filesystem-specific ext4 options may be passed through the data string.
const userdataMountData = "errors=remount-ro"

18
backend/cmd/tera-recovery-updater/mount_contract_test.go

@ -0,0 +1,18 @@
package main
import (
"strings"
"testing"
)
func TestUserdataMountDataContainsOnlyExt4Options(t *testing.T) {
for _, option := range strings.Split(userdataMountData, ",") {
switch option {
case "rw", "ro", "noatime", "nodev", "nosuid", "noexec":
t.Fatalf("generic mount option %q must be represented by mount flags", option)
}
}
if userdataMountData != "errors=remount-ro" {
t.Fatalf("userdata mount data = %q, want ext4 error policy only", userdataMountData)
}
}

23
backend/cmd/tera-recovery-updater/partition_contract.go

@ -0,0 +1,23 @@
package main
const (
miscDevice = "/dev/mmcblk0p2"
bootDevice = "/dev/mmcblk0p3"
rootfsDevice = "/dev/mmcblk0p6"
userdataDevice = "/dev/mmcblk0p8"
bootSizePath = "/sys/class/block/mmcblk0p3/size"
rootfsSizePath = "/sys/class/block/mmcblk0p6/size"
)
type recoveryPartition struct {
DevicePath string
UEventPath string
Name string
}
var recoveryPartitionContract = []recoveryPartition{
{DevicePath: miscDevice, UEventPath: "/sys/class/block/mmcblk0p2/uevent", Name: "misc"},
{DevicePath: bootDevice, UEventPath: "/sys/class/block/mmcblk0p3/uevent", Name: "boot"},
{DevicePath: rootfsDevice, UEventPath: "/sys/class/block/mmcblk0p6/uevent", Name: "rootfs"},
{DevicePath: userdataDevice, UEventPath: "/sys/class/block/mmcblk0p8/uevent", Name: "userdata"},
}

23
backend/cmd/tera-recovery-updater/partition_contract_test.go

@ -0,0 +1,23 @@
package main
import "testing"
func TestRecoveryPartitionContractMatchesMeasuredTargetGPT(t *testing.T) {
want := []recoveryPartition{
{DevicePath: "/dev/mmcblk0p2", UEventPath: "/sys/class/block/mmcblk0p2/uevent", Name: "misc"},
{DevicePath: "/dev/mmcblk0p3", UEventPath: "/sys/class/block/mmcblk0p3/uevent", Name: "boot"},
{DevicePath: "/dev/mmcblk0p6", UEventPath: "/sys/class/block/mmcblk0p6/uevent", Name: "rootfs"},
{DevicePath: "/dev/mmcblk0p8", UEventPath: "/sys/class/block/mmcblk0p8/uevent", Name: "userdata"},
}
if len(recoveryPartitionContract) != len(want) {
t.Fatalf("partition contract count=%d want=%d", len(recoveryPartitionContract), len(want))
}
for index := range want {
if recoveryPartitionContract[index] != want[index] {
t.Fatalf("partition contract[%d]=%+v want=%+v", index, recoveryPartitionContract[index], want[index])
}
}
if bootSizePath != "/sys/class/block/mmcblk0p3/size" || rootfsSizePath != "/sys/class/block/mmcblk0p6/size" {
t.Fatalf("size paths boot=%q rootfs=%q", bootSizePath, rootfsSizePath)
}
}

144
backend/cmd/tera-serial-check/main.go

@ -0,0 +1,144 @@
// tera-serial-check performs a read-only end-to-end check of tera-seriald.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"tera-clone/services/tera-api/internal/serial"
)
const (
defaultHardwareProfile = "fet3568j-c"
defaultMappingVersion = "fet3568j-c-20260721-v1"
)
type finding struct {
Channel string `json:"channel,omitempty"`
Check string `json:"check"`
Expected string `json:"expected,omitempty"`
Actual string `json:"actual,omitempty"`
Message string `json:"message,omitempty"`
}
type checkReport struct {
Kind string `json:"kind"`
Status string `json:"status"`
CheckedAt string `json:"checked_at"`
HardwareProfile string `json:"hardware_profile"`
MappingVersion string `json:"mapping_version"`
ExpectedChannels int `json:"expected_channels"`
ObservedChannels int `json:"observed_channels"`
AvailableChannels int `json:"available_channels"`
Findings []finding `json:"findings,omitempty"`
States []serial.State `json:"states,omitempty"`
}
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, time.Now))
}
func run(args []string, stdout, stderr io.Writer, now func() time.Time) int {
flags := flag.NewFlagSet("tera-serial-check", flag.ContinueOnError)
flags.SetOutput(stderr)
socketPath := flags.String("socket", serial.DefaultSocketPath, "tera-seriald Unix socket")
hardwareProfile := flags.String("hardware-profile", defaultHardwareProfile, "expected hardware profile")
mappingVersion := flags.String("mapping-version", defaultMappingVersion, "expected mapping version")
timeout := flags.Duration("timeout", 10*time.Second, "overall read-only check timeout")
if err := flags.Parse(args); err != nil {
return 2
}
if flags.NArg() != 0 || !filepath.IsAbs(*socketPath) || strings.TrimSpace(*hardwareProfile) == "" || strings.TrimSpace(*mappingVersion) == "" || *timeout <= 0 {
fmt.Fprintln(stderr, "invalid arguments: require an absolute socket path, profile, mapping version, and positive timeout")
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
report := inspect(ctx, serial.NewSocketClient(*socketPath), *hardwareProfile, *mappingVersion, now().UTC())
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(report); err != nil {
fmt.Fprintln(stderr, "write serial check report:", err)
return 1
}
if report.Status != "pass" {
return 1
}
return 0
}
func inspect(ctx context.Context, controller serial.Controller, expectedProfile, expectedVersion string, checkedAt time.Time) checkReport {
report := checkReport{
Kind: "tera-serial-readonly-check",
Status: "fail",
CheckedAt: checkedAt.UTC().Format(time.RFC3339Nano),
HardwareProfile: expectedProfile,
MappingVersion: expectedVersion,
ExpectedChannels: serial.ChannelCount,
}
states, err := controller.States(ctx)
if err != nil {
report.Findings = append(report.Findings, finding{Check: "seriald_states", Message: err.Error()})
return report
}
report.States = states
report.ObservedChannels = len(states)
if len(states) != serial.ChannelCount {
report.Findings = append(report.Findings, finding{
Check: "channel_count", Expected: fmt.Sprint(serial.ChannelCount), Actual: fmt.Sprint(len(states)),
})
}
seenChannels := make(map[string]struct{}, len(states))
seenDevices := make(map[string]struct{}, len(states))
for index, state := range states {
channelNumber := index + 1
expectedChannel := fmt.Sprintf("CH%02d", channelNumber)
ttyNumber := channelNumber + 3
expectedBDF := "0000:01:00.0"
if channelNumber > 24 {
ttyNumber = channelNumber + 7
expectedBDF = "0002:21:00.0"
}
expectedDevice := fmt.Sprintf("/dev/ttyWCH%d", ttyNumber)
if _, duplicate := seenChannels[state.Channel]; duplicate {
report.Findings = append(report.Findings, finding{Channel: state.Channel, Check: "unique_channel", Message: "duplicate channel"})
}
seenChannels[state.Channel] = struct{}{}
if _, duplicate := seenDevices[state.Device]; duplicate {
report.Findings = append(report.Findings, finding{Channel: state.Channel, Check: "unique_device", Actual: state.Device, Message: "duplicate device"})
}
seenDevices[state.Device] = struct{}{}
checkEqual := func(check, expected, actual string) {
if actual != expected {
report.Findings = append(report.Findings, finding{Channel: expectedChannel, Check: check, Expected: expected, Actual: actual})
}
}
checkEqual("channel_order", expectedChannel, state.Channel)
checkEqual("device_mapping", expectedDevice, state.Device)
checkEqual("pci_ancestry", expectedBDF, state.PCIBDF)
checkEqual("hardware_profile", expectedProfile, state.HardwareProfile)
checkEqual("mapping_version", expectedVersion, state.MappingVersion)
if state.Available {
report.AvailableChannels++
} else {
report.Findings = append(report.Findings, finding{Channel: expectedChannel, Check: "availability", Expected: "available", Actual: "unavailable", Message: state.Message})
}
}
if len(report.Findings) == 0 && report.AvailableChannels == serial.ChannelCount {
report.Status = "pass"
}
return report
}

147
backend/cmd/tera-serial-check/main_test.go

@ -0,0 +1,147 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"path/filepath"
"testing"
"time"
"tera-clone/services/tera-api/internal/serial"
)
type stubController struct {
states []serial.State
err error
}
func (s stubController) States(context.Context) ([]serial.State, error) {
return s.states, s.err
}
func (stubController) Apply(context.Context, []serial.ChannelConfig) ([]serial.State, error) {
panic("read-only checker must not apply serial configuration")
}
func TestInspectPassesCanonicalAvailableInventory(t *testing.T) {
checkedAt := time.Date(2026, 7, 21, 8, 0, 0, 0, time.UTC)
report := inspect(context.Background(), stubController{states: canonicalStates()}, defaultHardwareProfile, defaultMappingVersion, checkedAt)
if report.Status != "pass" || report.ObservedChannels != 48 || report.AvailableChannels != 48 {
t.Fatalf("report = %#v", report)
}
if len(report.Findings) != 0 {
t.Fatalf("findings = %#v", report.Findings)
}
}
func TestRunChecksSerialdSocketEndToEnd(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "serial-check.sock")
listener, err := net.Listen("unix", socketPath)
if err != nil {
t.Skipf("unix sockets unavailable: %v", err)
}
server := serial.SocketServer{Controller: stubController{states: canonicalStates()}}
done := make(chan error, 1)
go func() { done <- server.Serve(listener) }()
t.Cleanup(func() {
_ = listener.Close()
select {
case err := <-done:
if err != nil {
t.Errorf("socket server: %v", err)
}
case <-time.After(time.Second):
t.Error("socket server did not stop")
}
})
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := run(
[]string{"-socket", socketPath},
&stdout,
&stderr,
func() time.Time { return time.Date(2026, 7, 21, 8, 0, 0, 0, time.UTC) },
)
if exitCode != 0 {
t.Fatalf("exit = %d, stderr = %q, stdout = %q", exitCode, stderr.String(), stdout.String())
}
var report checkReport
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
t.Fatalf("decode report: %v", err)
}
if report.Status != "pass" || report.AvailableChannels != serial.ChannelCount {
t.Fatalf("report = %#v", report)
}
}
func TestInspectFailsOnUnavailableChannelWithoutApplying(t *testing.T) {
states := canonicalStates()
states[47].Available = false
states[47].Message = "serial device PCI ancestry is unavailable"
report := inspect(context.Background(), stubController{states: states}, defaultHardwareProfile, defaultMappingVersion, time.Time{})
if report.Status != "fail" || report.AvailableChannels != 47 {
t.Fatalf("report = %#v", report)
}
if !hasFinding(report.Findings, "CH48", "availability") {
t.Fatalf("findings = %#v", report.Findings)
}
}
func TestInspectFailsOnNCNodeOrWrongPCIGroup(t *testing.T) {
states := canonicalStates()
states[0].Device = "/dev/ttyWCH0"
states[24].PCIBDF = "0000:01:00.0"
report := inspect(context.Background(), stubController{states: states}, defaultHardwareProfile, defaultMappingVersion, time.Time{})
if report.Status != "fail" {
t.Fatalf("report = %#v", report)
}
if !hasFinding(report.Findings, "CH01", "device_mapping") || !hasFinding(report.Findings, "CH25", "pci_ancestry") {
t.Fatalf("findings = %#v", report.Findings)
}
}
func TestInspectReportsSerialdFailure(t *testing.T) {
report := inspect(context.Background(), stubController{err: errors.New("socket unavailable")}, defaultHardwareProfile, defaultMappingVersion, time.Time{})
if report.Status != "fail" || !hasFinding(report.Findings, "", "seriald_states") {
t.Fatalf("report = %#v", report)
}
}
func canonicalStates() []serial.State {
states := make([]serial.State, 0, serial.ChannelCount)
for channelNumber := 1; channelNumber <= serial.ChannelCount; channelNumber++ {
ttyNumber := channelNumber + 3
bdf := "0000:01:00.0"
if channelNumber > 24 {
ttyNumber = channelNumber + 7
bdf = "0002:21:00.0"
}
states = append(states, serial.State{
Channel: fmt.Sprintf("CH%02d", channelNumber),
Device: fmt.Sprintf("/dev/ttyWCH%d", ttyNumber),
PCIBDF: bdf,
HardwareProfile: defaultHardwareProfile,
MappingVersion: defaultMappingVersion,
Available: true,
})
}
return states
}
func hasFinding(findings []finding, channel, check string) bool {
for _, item := range findings {
if item.Channel == channel && item.Check == check {
return true
}
}
return false
}

142
backend/cmd/tera-serial-lease-check/main.go

@ -0,0 +1,142 @@
// tera-serial-lease-check verifies that CH02-CH48 can acquire and release the
// production tera-seriald stream path. It sends no test payload and never opens
// a raw TTY directly.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"time"
"tera-clone/services/tera-api/internal/serial"
)
const (
firstCheckedChannel = 2
lastCheckedChannel = 48
)
type leaseController interface {
Open(context.Context, string) (io.ReadWriteCloser, error)
Status(context.Context) (serial.StreamSnapshot, error)
}
type channelResult struct {
Channel string `json:"channel"`
Status string `json:"status"`
OpenCloseMS int64 `json:"open_close_ms"`
ErrorMessage string `json:"error,omitempty"`
}
type report struct {
Kind string `json:"kind"`
Status string `json:"status"`
GeneratedAt time.Time `json:"generated_at"`
FirstChannel string `json:"first_channel"`
LastChannel string `json:"last_channel"`
ChannelsRequested int `json:"channels_requested"`
ChannelsPassed int `json:"channels_passed"`
ChannelsFailed int `json:"channels_failed"`
ActiveLeasesAfter int `json:"active_leases_after"`
IdleStatusError string `json:"idle_status_error,omitempty"`
TestPayloadSent bool `json:"test_payload_sent"`
DynamicDeviceScan bool `json:"dynamic_device_scan"`
FixedChannelList bool `json:"fixed_channel_list"`
Results []channelResult `json:"results"`
}
func main() {
controller := serial.NewStreamSocketClient(serial.DefaultStreamSocketPath, "tera-serial-lease-check")
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, controller))
}
func run(args []string, stdout, stderr io.Writer, controller leaseController) int {
flags := flag.NewFlagSet("tera-serial-lease-check", flag.ContinueOnError)
flags.SetOutput(stderr)
perChannelTimeout := flags.Duration("per-channel-timeout", 3*time.Second, "maximum time for one open/close")
confirmed := flags.Bool("operator-confirmed-ch02-ch48-open-close", false, "confirm a no-test-payload CH02-CH48 open/close check")
if err := flags.Parse(args); err != nil {
return 2
}
if flags.NArg() != 0 || !*confirmed || *perChannelTimeout <= 0 || *perChannelTimeout > 10*time.Second {
fmt.Fprintln(stderr, "require explicit CH02-CH48 open/close confirmation and a timeout up to 10s")
return 2
}
if controller == nil {
fmt.Fprintln(stderr, "serial lease controller is required")
return 2
}
result := report{
Kind: "tera-serial-lease-check", Status: "pass", GeneratedAt: time.Now().UTC(),
FirstChannel: "CH02", LastChannel: "CH48",
ChannelsRequested: lastCheckedChannel - firstCheckedChannel + 1,
TestPayloadSent: false, DynamicDeviceScan: false, FixedChannelList: true,
Results: make([]channelResult, 0, lastCheckedChannel-firstCheckedChannel+1),
}
for number := firstCheckedChannel; number <= lastCheckedChannel; number++ {
channel := fmt.Sprintf("CH%02d", number)
started := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), *perChannelTimeout)
stream, err := controller.Open(ctx, channel)
if err == nil {
err = stream.Close()
}
cancel()
entry := channelResult{Channel: channel, Status: "pass", OpenCloseMS: time.Since(started).Milliseconds()}
if err != nil {
entry.Status = "fail"
entry.ErrorMessage = err.Error()
result.ChannelsFailed++
} else {
result.ChannelsPassed++
}
result.Results = append(result.Results, entry)
}
statusContext, cancelStatus := context.WithTimeout(context.Background(), 3*time.Second)
status, statusErr := waitForIdle(statusContext, controller)
cancelStatus()
result.ActiveLeasesAfter = status.ActiveLeases
if statusErr != nil {
result.Status = "fail"
result.IdleStatusError = statusErr.Error()
fmt.Fprintln(stderr, statusErr)
}
if result.ChannelsFailed != 0 || result.ChannelsPassed != result.ChannelsRequested {
result.Status = "fail"
}
if err := json.NewEncoder(stdout).Encode(result); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if result.Status != "pass" {
return 1
}
return 0
}
func waitForIdle(ctx context.Context, controller leaseController) (serial.StreamSnapshot, error) {
ticker := time.NewTicker(25 * time.Millisecond)
defer ticker.Stop()
var latest serial.StreamSnapshot
for {
status, err := controller.Status(ctx)
if err != nil {
return status, err
}
latest = status
if status.ActiveLeases == 0 {
return status, nil
}
select {
case <-ctx.Done():
return latest, fmt.Errorf("serial leases did not return to idle: %w", ctx.Err())
case <-ticker.C:
}
}
}

69
backend/cmd/tera-serial-lease-check/main_test.go

@ -0,0 +1,69 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"testing"
"tera-clone/services/tera-api/internal/serial"
)
type fakeLeaseController struct {
opened []string
failChannel string
}
func (c *fakeLeaseController) Open(_ context.Context, channel string) (io.ReadWriteCloser, error) {
c.opened = append(c.opened, channel)
if channel == c.failChannel {
return nil, errors.New("open failed")
}
return noIOStream{}, nil
}
func (*fakeLeaseController) Status(context.Context) (serial.StreamSnapshot, error) {
return serial.StreamSnapshot{}, nil
}
type noIOStream struct{}
func (noIOStream) Read([]byte) (int, error) { return 0, errors.New("unexpected read") }
func (noIOStream) Write([]byte) (int, error) { return 0, errors.New("unexpected write") }
func (noIOStream) Close() error { return nil }
func TestRunRequiresExplicitConfirmation(t *testing.T) {
if code := run(nil, io.Discard, io.Discard, &fakeLeaseController{}); code != 2 {
t.Fatalf("code = %d, want 2", code)
}
}
func TestRunOpensEveryCH02ThroughCH48WithoutPayload(t *testing.T) {
controller := &fakeLeaseController{}
var stdout bytes.Buffer
code := run([]string{"-operator-confirmed-ch02-ch48-open-close"}, &stdout, io.Discard, controller)
if code != 0 {
t.Fatalf("code = %d; report = %s", code, stdout.String())
}
if len(controller.opened) != 47 || controller.opened[0] != "CH02" || controller.opened[46] != "CH48" {
t.Fatalf("opened = %v", controller.opened)
}
var result report
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Status != "pass" || result.ChannelsPassed != 47 || result.TestPayloadSent || result.DynamicDeviceScan || !result.FixedChannelList {
t.Fatalf("report = %#v", result)
}
}
func TestRunReportsAChannelFailure(t *testing.T) {
controller := &fakeLeaseController{failChannel: "CH24"}
var stdout bytes.Buffer
code := run([]string{"-operator-confirmed-ch02-ch48-open-close"}, &stdout, io.Discard, controller)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}

122
backend/cmd/tera-serial-line-check/main.go

@ -0,0 +1,122 @@
// tera-serial-line-check temporarily applies and restores CH01 line settings.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"tera-clone/services/tera-api/internal/serial"
)
const (
representativeChannel = "CH01"
representativeDevice = "/dev/ttyWCH4"
representativePCPort = "COM3"
)
type probeClient interface {
Probe(context.Context, serial.ChannelConfig) (serial.ProbeResult, error)
}
type checkReport struct {
Kind string `json:"kind"`
Status string `json:"status"`
CheckedAt string `json:"checked_at"`
Channel string `json:"channel"`
Device string `json:"device"`
PCPort string `json:"pc_port"`
Requested serial.LineSettings `json:"requested"`
Probe *serial.ProbeResult `json:"probe,omitempty"`
Findings []string `json:"findings,omitempty"`
DataSent bool `json:"data_sent"`
}
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, time.Now))
}
func run(args []string, stdout, stderr io.Writer, now func() time.Time) int {
flags := flag.NewFlagSet("tera-serial-line-check", flag.ContinueOnError)
flags.SetOutput(stderr)
socketPath := flags.String("socket", serial.DefaultSocketPath, "tera-seriald Unix socket")
baudRate := flags.Int("baud", 115200, "temporary baud rate")
dataBits := flags.Int("data-bits", 8, "temporary data bits")
parity := flags.String("parity", "none", "temporary parity")
stopBits := flags.Int("stop-bits", 1, "temporary stop bits")
flowControl := flags.String("flow-control", "none", "temporary flow control")
timeout := flags.Duration("timeout", 10*time.Second, "overall probe timeout")
if err := flags.Parse(args); err != nil {
return 2
}
if flags.NArg() != 0 || !filepath.IsAbs(*socketPath) || *timeout <= 0 {
fmt.Fprintln(stderr, "invalid arguments: require an absolute socket path and positive timeout")
return 2
}
requested := serial.ChannelConfig{
Channel: representativeChannel, BaudRate: *baudRate, DataBits: *dataBits,
Parity: strings.ToLower(strings.TrimSpace(*parity)), StopBits: *stopBits,
FlowControl: strings.ToLower(strings.TrimSpace(*flowControl)),
}
if _, err := serial.NormalizeLineConfig(serial.LineConfig{
Device: representativeDevice, BaudRate: requested.BaudRate, DataBits: requested.DataBits,
Parity: requested.Parity, StopBits: requested.StopBits, FlowControl: requested.FlowControl,
}); err != nil {
fmt.Fprintln(stderr, err)
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
report := inspect(ctx, serial.NewSocketClient(*socketPath), requested, now().UTC())
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(report); err != nil {
fmt.Fprintln(stderr, "write serial line check report:", err)
return 1
}
if report.Status != "pass" {
return 1
}
return 0
}
func inspect(ctx context.Context, client probeClient, requested serial.ChannelConfig, checkedAt time.Time) checkReport {
wanted := serial.LineSettings{
BaudRate: requested.BaudRate, DataBits: requested.DataBits, Parity: requested.Parity,
StopBits: requested.StopBits, FlowControl: requested.FlowControl,
}
report := checkReport{
Kind: "tera-serial-line-check", Status: "fail", CheckedAt: checkedAt.UTC().Format(time.RFC3339Nano),
Channel: representativeChannel, Device: representativeDevice, PCPort: representativePCPort,
Requested: wanted, DataSent: false,
}
result, err := client.Probe(ctx, requested)
if err != nil {
report.Findings = append(report.Findings, err.Error())
return report
}
report.Probe = &result
if result.Channel != representativeChannel {
report.Findings = append(report.Findings, "probe returned a non-representative channel")
}
if result.Device != representativeDevice {
report.Findings = append(report.Findings, "probe returned an unexpected target device")
}
if result.Applied != wanted {
report.Findings = append(report.Findings, "applied readback does not match requested line settings")
}
if !result.Restored {
report.Findings = append(report.Findings, "prior line settings were not restored")
}
if len(report.Findings) == 0 {
report.Status = "pass"
}
return report
}

64
backend/cmd/tera-serial-line-check/main_test.go

@ -0,0 +1,64 @@
package main
import (
"context"
"errors"
"testing"
"time"
"tera-clone/services/tera-api/internal/serial"
)
type stubProbeClient struct {
result serial.ProbeResult
err error
calls int
}
func (s *stubProbeClient) Probe(context.Context, serial.ChannelConfig) (serial.ProbeResult, error) {
s.calls++
return s.result, s.err
}
func TestInspectPassesAppliedReadbackAndRestore(t *testing.T) {
requested := testConfig()
client := &stubProbeClient{result: serial.ProbeResult{
Channel: representativeChannel, Device: representativeDevice,
Previous: serial.LineSettings{BaudRate: 9600, DataBits: 8, Parity: "none", StopBits: 1, FlowControl: "none"},
Applied: serial.LineSettings{BaudRate: 115200, DataBits: 8, Parity: "none", StopBits: 1, FlowControl: "none"},
Restored: true,
}}
report := inspect(context.Background(), client, requested, time.Time{})
if report.Status != "pass" || report.DataSent || client.calls != 1 {
t.Fatalf("report = %#v, calls = %d", report, client.calls)
}
}
func TestInspectFailsWhenRestoreIsNotConfirmed(t *testing.T) {
client := &stubProbeClient{result: serial.ProbeResult{
Channel: representativeChannel, Device: representativeDevice,
Applied: serial.LineSettings{BaudRate: 115200, DataBits: 8, Parity: "none", StopBits: 1, FlowControl: "none"},
}}
report := inspect(context.Background(), client, testConfig(), time.Time{})
if report.Status != "fail" || len(report.Findings) == 0 {
t.Fatalf("report = %#v", report)
}
}
func TestInspectFailsClosedOnProbeError(t *testing.T) {
client := &stubProbeClient{err: errors.New("injected probe failure")}
report := inspect(context.Background(), client, testConfig(), time.Time{})
if report.Status != "fail" || len(report.Findings) != 1 || report.Probe != nil {
t.Fatalf("report = %#v", report)
}
}
func testConfig() serial.ChannelConfig {
return serial.ChannelConfig{
Channel: representativeChannel, BaudRate: 115200, DataBits: 8,
Parity: "none", StopBits: 1, FlowControl: "none",
}
}

137
backend/cmd/tera-serial-stream-check/main.go

@ -0,0 +1,137 @@
// tera-serial-stream-check performs one bounded CH01/COM3 data-plane check.
// It never opens a raw TTY and requires an explicit operator confirmation.
package main
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"time"
"tera-clone/services/tera-api/internal/serial"
)
const maximumPayloadBytes = 64
type streamOpener interface {
Open(context.Context, string) (io.ReadWriteCloser, error)
}
type report struct {
Kind string `json:"kind"`
Status string `json:"status"`
Direction string `json:"direction"`
Channel string `json:"channel"`
Device string `json:"device"`
PCPort string `json:"pc_port"`
Bytes int `json:"bytes"`
SHA256 string `json:"sha256"`
DataSent bool `json:"data_sent"`
}
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, serial.NewStreamSocketClient(serial.DefaultStreamSocketPath, "tera-serial-stream-check")))
}
func run(args []string, stdout, stderr io.Writer, opener streamOpener) int {
flags := flag.NewFlagSet("tera-serial-stream-check", flag.ContinueOnError)
flags.SetOutput(stderr)
direction := flags.String("direction", "", "tx or rx relative to the target")
payloadBase64 := flags.String("payload-base64", "", "bounded payload for tx")
byteCount := flags.Int("bytes", 0, "expected byte count for rx")
timeout := flags.Duration("timeout", 10*time.Second, "bounded stream timeout")
confirmed := flags.Bool("operator-confirmed-ch01-com3", false, "confirm CH01 /dev/ttyWCH4 is connected to PC COM3")
if err := flags.Parse(args); err != nil {
return 2
}
if flags.NArg() != 0 || !*confirmed || (*direction != "tx" && *direction != "rx") || *timeout <= 0 {
fmt.Fprintln(stderr, "require tx/rx, a positive timeout, and explicit CH01-COM3 confirmation")
return 2
}
var payload []byte
var err error
if *direction == "tx" {
payload, err = base64.StdEncoding.Strict().DecodeString(*payloadBase64)
if err != nil || len(payload) < 1 || len(payload) > maximumPayloadBytes || *byteCount != 0 {
fmt.Fprintln(stderr, "tx requires a strict Base64 payload of 1 to 64 bytes")
return 2
}
} else if *payloadBase64 != "" || *byteCount < 1 || *byteCount > maximumPayloadBytes {
fmt.Fprintln(stderr, "rx requires an expected byte count of 1 to 64")
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
stream, err := opener.Open(ctx, "CH01")
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
defer stream.Close()
if *direction == "tx" {
if err := writeAll(stream, payload); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
} else {
payload, err = readExact(ctx, stream, *byteCount)
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
}
digest := sha256.Sum256(payload)
result := report{
Kind: "tera-serial-stream-check", Status: "pass", Direction: *direction,
Channel: "CH01", Device: "/dev/ttyWCH4", PCPort: "COM3",
Bytes: len(payload), SHA256: hex.EncodeToString(digest[:]), DataSent: true,
}
if err := json.NewEncoder(stdout).Encode(result); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
return 0
}
func writeAll(writer io.Writer, payload []byte) error {
for len(payload) > 0 {
written, err := writer.Write(payload)
if err != nil {
return err
}
if written <= 0 {
return io.ErrShortWrite
}
payload = payload[written:]
}
return nil
}
func readExact(ctx context.Context, stream io.ReadWriteCloser, count int) ([]byte, error) {
type outcome struct {
payload []byte
err error
}
done := make(chan outcome, 1)
go func() {
payload := make([]byte, count)
_, err := io.ReadFull(stream, payload)
done <- outcome{payload: payload, err: err}
}()
select {
case result := <-done:
return result.payload, result.err
case <-ctx.Done():
_ = stream.Close()
result := <-done
return result.payload, errors.Join(ctx.Err(), result.err)
}
}

57
backend/cmd/tera-serial-stream-check/main_test.go

@ -0,0 +1,57 @@
package main
import (
"bytes"
"context"
"io"
"net"
"strings"
"testing"
)
type openerFunc func(context.Context, string) (io.ReadWriteCloser, error)
func (f openerFunc) Open(ctx context.Context, channel string) (io.ReadWriteCloser, error) {
return f(ctx, channel)
}
func TestRunRequiresConfirmationAndBounds(t *testing.T) {
if code := run([]string{"-direction=tx", "-payload-base64=YQ=="}, io.Discard, io.Discard, nil); code != 2 {
t.Fatalf("unconfirmed code = %d", code)
}
oversized := strings.Repeat("YQ==", 65)
if code := run([]string{"-direction=tx", "-operator-confirmed-ch01-com3", "-payload-base64=" + oversized}, io.Discard, io.Discard, nil); code != 2 {
t.Fatalf("oversized code = %d", code)
}
}
func TestRunTXAndRXUseCanonicalStreamWithoutPrintingPayload(t *testing.T) {
for _, direction := range []string{"tx", "rx"} {
t.Run(direction, func(t *testing.T) {
service, peer := net.Pipe()
defer peer.Close()
opener := openerFunc(func(_ context.Context, channel string) (io.ReadWriteCloser, error) {
if channel != "CH01" {
t.Fatalf("channel = %s", channel)
}
return service, nil
})
payload := []byte("bounded-check")
var stdout bytes.Buffer
args := []string{"-direction=" + direction, "-operator-confirmed-ch01-com3"}
if direction == "tx" {
args = append(args, "-payload-base64=Ym91bmRlZC1jaGVjaw==")
go func() { _, _ = io.ReadFull(peer, make([]byte, len(payload))) }()
} else {
args = append(args, "-bytes=13")
go func() { _, _ = peer.Write(payload) }()
}
if code := run(args, &stdout, io.Discard, opener); code != 0 {
t.Fatalf("code = %d", code)
}
if stdout.String() == "" || strings.Contains(stdout.String(), string(payload)) {
t.Fatalf("unsafe report = %s", stdout.String())
}
})
}
}

280
backend/cmd/tera-seriald/main.go

@ -0,0 +1,280 @@
// tera-seriald owns validated CH01-CH48 line configuration for TERA boards.
package main
import (
"context"
"errors"
"fmt"
"log"
"net"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"tera-clone/services/tera-api/internal/domain"
"tera-clone/services/tera-api/internal/logservice"
"tera-clone/services/tera-api/internal/logstorage"
"tera-clone/services/tera-api/internal/serial"
)
func main() {
controlSocketPath := getenv("TERA_SERIALD_SOCKET", serial.DefaultSocketPath)
streamSocketPath := getenv("TERA_SERIALD_STREAM_SOCKET", serial.DefaultStreamSocketPath)
mappingPath := getenv("TERA_SERIALD_MAPPING", "/etc/tera/serial-map.json")
hardwareProfile := getenv("TERA_SERIALD_HARDWARE_PROFILE", "fet3568j-c")
controller, err := serial.LoadMappingController(mappingPath, hardwareProfile)
if err != nil {
log.Printf("tera-seriald is fail-closed: %v", err)
controller = nil
}
if controller != nil {
defer func() {
if err := controller.Close(); err != nil {
log.Printf("tera-seriald serial hold close failed: %v", err)
}
}()
}
controlListener, err := listen(controlSocketPath)
if err != nil {
log.Fatalf("tera-seriald control socket setup failed: %v", err)
}
streamListener, err := listen(streamSocketPath)
if err != nil {
_ = controlListener.Close()
_ = os.Remove(controlSocketPath)
log.Fatalf("tera-seriald stream socket setup failed: %v", err)
}
defer func() {
_ = controlListener.Close()
_ = streamListener.Close()
_ = os.Remove(controlSocketPath)
_ = os.Remove(streamSocketPath)
}()
unavailable := serial.UnavailableController{Reason: "validated serial mapping is not installed"}
var hardware serial.Controller = unavailable
var streamResolver serial.StreamResolver = unavailable
if controller != nil {
hardware = controller
streamResolver = controller
}
eventSink := serial.NewBoundedStreamEventSink(getenvInt("TERA_SERIALD_EVENT_QUEUE", 1024))
streamManager, err := serial.NewStreamLeaseManager(streamResolver, serial.StreamLeaseOptions{Events: eventSink})
if err != nil {
log.Fatalf("tera-seriald stream manager setup failed: %v", err)
}
reporterContext, stopReporter := context.WithCancel(context.Background())
defer stopReporter()
var linkMonitor serial.LinkStateController = serial.DisabledLinkStateController{
Reason: "CTS/DSR/DCD are excluded from unified RX/CR connection status",
}
if controller != nil && getenvBool("TERA_SERIALD_LINK_SCAN_ENABLED", false) {
controller.SetLinkWorkerCount(getenvInt("TERA_SERIALD_LINK_WORKERS", 8))
monitor, monitorErr := serial.NewLinkMonitor(controller, serial.LinkMonitorOptions{
Interval: time.Duration(getenvInt("TERA_SERIALD_LINK_SCAN_INTERVAL_MS", 1000)) * time.Millisecond,
})
if monitorErr != nil {
log.Fatalf("tera-seriald link monitor setup failed: %v", monitorErr)
}
linkMonitor = monitor
go monitor.Run(reporterContext)
}
crProbeEnabled := controller != nil && getenvBool("TERA_SERIALD_CR_PROBE_ENABLED", true)
var crProbeGroups []serial.CRProbeGroup
if controller != nil {
crProbeGroups = controller.CRProbeGroups()
}
crProbeManager, err := serial.NewCRProbeManager(streamManager, serial.CRProbeOptions{
Enabled: crProbeEnabled,
ResponseTimeout: time.Duration(getenvInt("TERA_SERIALD_CR_PROBE_TIMEOUT_MS", 100)) * time.Millisecond,
SweepInterval: time.Duration(getenvInt("TERA_SERIALD_CR_PROBE_SWEEP_SECONDS", 4)) * time.Second,
BetweenChannels: time.Duration(getenvInt("TERA_SERIALD_CR_PROBE_SPACING_MS", 20)) * time.Millisecond,
QuietPeriod: time.Duration(getenvInt("TERA_SERIALD_CR_PROBE_QUIET_SECONDS", 5)) * time.Second,
StartupDelay: time.Duration(getenvInt("TERA_SERIALD_CR_PROBE_STARTUP_DELAY_SECONDS", 30)) * time.Second,
Workers: getenvInt("TERA_SERIALD_CR_PROBE_WORKERS", 2),
Groups: crProbeGroups,
ConfirmResponses: getenvInt("TERA_SERIALD_CR_PROBE_CONFIRM_RESPONSES", 1),
DownMisses: getenvInt("TERA_SERIALD_CR_PROBE_DOWN_MISSES", 2),
})
if err != nil {
log.Fatalf("tera-seriald CR probe setup failed: %v", err)
}
if crProbeEnabled {
for _, group := range crProbeGroups {
log.Printf("tera-seriald CR probe group pci_bdf=%s channels=%d", group.ID, len(group.Channels))
}
}
go crProbeManager.Run(reporterContext)
logJournal := logservice.Journal(logservice.NewClient(getenv("TERA_SERIALD_LOG_SOCKET", logservice.DefaultSocketPath)))
go reportStreamEvents(reporterContext, streamManager, eventSink.Events(), logJournal, time.Duration(getenvInt("TERA_SERIALD_REPORT_INTERVAL_SECONDS", 10))*time.Second)
controlServer := serial.SocketServer{Controller: hardware, Capture: streamManager, CRProbe: crProbeManager, Links: linkMonitor}
streamServer := serial.StreamSocketServer{Manager: streamManager}
done := make(chan error, 2)
go func() { done <- controlServer.Serve(controlListener) }()
go func() { done <- streamServer.Serve(streamListener) }()
log.Printf("tera-seriald serving validated serial configuration on %s", controlSocketPath)
log.Printf("tera-seriald serving exclusive serial streams on %s", streamSocketPath)
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
select {
case signal := <-signals:
log.Printf("tera-seriald stopping after %s", signal)
stopReporter()
_ = controlListener.Close()
_ = streamListener.Close()
_ = streamManager.Close()
for range 2 {
if err := <-done; err != nil {
log.Printf("tera-seriald stop error: %v", err)
}
}
case err := <-done:
stopReporter()
_ = controlListener.Close()
_ = streamListener.Close()
_ = streamManager.Close()
if err != nil {
log.Fatalf("tera-seriald failed: %v", err)
}
}
}
func listen(socketPath string) (net.Listener, error) {
if socketPath == "" || !filepath.IsAbs(socketPath) {
return nil, errors.New("TERA_SERIALD_SOCKET must be an absolute path")
}
if err := os.MkdirAll(filepath.Dir(socketPath), 0o750); err != nil {
return nil, err
}
if info, err := os.Lstat(socketPath); err == nil {
if info.Mode()&os.ModeSocket == 0 {
return nil, errors.New("refusing to replace non-socket serial control path")
}
if err := os.Remove(socketPath); err != nil {
return nil, err
}
} else if !os.IsNotExist(err) {
return nil, err
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
return nil, err
}
if err := os.Chmod(socketPath, 0o660); err != nil {
_ = listener.Close()
return nil, err
}
return listener, nil
}
func getenv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func getenvInt(key string, fallback int) int {
value := os.Getenv(key)
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func getenvBool(key string, fallback bool) bool {
value := os.Getenv(key)
if value == "" {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return fallback
}
return parsed
}
type streamIntervalTotal struct {
rx uint64
tx uint64
}
func reportStreamEvents(ctx context.Context, manager *serial.StreamLeaseManager, events <-chan serial.StreamEvent, journal logservice.Journal, interval time.Duration) {
if interval <= 0 {
interval = 10 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
totals := make(map[string]streamIntervalTotal)
var storageFailures uint64
var lastStorageError error
flush := func() {
for key, total := range totals {
if total.rx != 0 || total.tx != 0 {
log.Printf("tera-seriald stream interval lease=%s rx_bytes=%d tx_bytes=%d", key, total.rx, total.tx)
}
delete(totals, key)
}
snapshot := manager.Snapshot()
log.Printf("tera-seriald stream status active_leases=%d rx_bytes=%d tx_bytes=%d dropped_events=%d storage_failures=%d", snapshot.ActiveLeases, snapshot.RXBytes, snapshot.TXBytes, snapshot.DroppedEvents, storageFailures)
if lastStorageError != nil {
log.Printf("tera-seriald payload logging degraded: %v", lastStorageError)
lastStorageError = nil
}
}
defer flush()
for {
select {
case event := <-events:
key := event.Channel + "/" + event.LeaseID
switch event.Direction {
case "open", "close":
log.Printf("tera-seriald stream lifecycle channel=%s lease=%s client=%s action=%s", event.Channel, event.LeaseID, event.ClientID, event.Direction)
case "rx":
total := totals[key]
total.rx += event.Bytes
totals[key] = total
if err := recordPayload(journal, event); err != nil {
storageFailures++
lastStorageError = err
}
case "tx":
total := totals[key]
total.tx += event.Bytes
totals[key] = total
if err := recordPayload(journal, event); err != nil {
storageFailures++
lastStorageError = err
}
}
case <-ticker.C:
flush()
case <-ctx.Done():
return
}
}
}
func recordPayload(journal logservice.Journal, event serial.StreamEvent) error {
if journal == nil || len(event.Payload) == 0 {
return nil
}
record := domain.PortLogEvent{
Timestamp: event.Timestamp, Channel: event.Channel, EventType: "traffic",
Direction: strings.ToUpper(event.Direction), Actor: event.ClientID,
Message: fmt.Sprintf("%s %d serial payload byte(s)", strings.ToUpper(event.Direction), len(event.Payload)),
Payload: append([]byte(nil), event.Payload...), PayloadBytes: len(event.Payload), PayloadEncoding: "base64",
DisplayFormat: event.LogFormat, ShowTimestamp: event.ShowTimestamp, ShowDirection: event.ShowDirection,
ShowUsername: event.ShowUsername, ShowIPAddress: event.ShowIPAddress, ShowPort: event.ShowPort,
}
return journal.Append(logstorage.ClassPort, event.Timestamp, record)
}

52
backend/cmd/tera-seriald/main_test.go

@ -0,0 +1,52 @@
package main
import (
"encoding/json"
"testing"
"time"
"tera-clone/services/tera-api/internal/domain"
"tera-clone/services/tera-api/internal/logstorage"
"tera-clone/services/tera-api/internal/serial"
)
type captureJournal struct {
class string
timestamp time.Time
payload json.RawMessage
}
func (j *captureJournal) Append(class string, timestamp time.Time, value any) error {
payload, err := json.Marshal(value)
if err != nil {
return err
}
j.class, j.timestamp, j.payload = class, timestamp, payload
return nil
}
func (*captureJournal) Read(string) ([]json.RawMessage, error) { return nil, nil }
func (*captureJournal) ApplyRetention(logstorage.Retention) error { return nil }
func (*captureJournal) Snapshot() (logstorage.Snapshot, error) { return logstorage.Snapshot{}, nil }
func TestRecordPayloadAppendsBoundedPortEvent(t *testing.T) {
now := time.Date(2026, 7, 31, 1, 2, 3, 0, time.UTC)
payload := []byte{0x00, 0xff, 'A'}
journal := &captureJournal{}
if err := recordPayload(journal, serial.StreamEvent{
Timestamp: now, Channel: "CH01", LeaseID: "lease-1", ClientID: "session-1",
Direction: "rx", Bytes: uint64(len(payload)), Payload: payload,
}); err != nil {
t.Fatal(err)
}
if journal.class != logstorage.ClassPort || !journal.timestamp.Equal(now) {
t.Fatalf("journal routing = %q %s", journal.class, journal.timestamp)
}
var event domain.PortLogEvent
if err := json.Unmarshal(journal.payload, &event); err != nil {
t.Fatal(err)
}
if event.Channel != "CH01" || event.Direction != "RX" || event.Actor != "session-1" || event.PayloadBytes != len(payload) || event.PayloadEncoding != "base64" || string(event.Payload) != string(payload) {
t.Fatalf("port event = %#v", event)
}
}

149
backend/cmd/tera-sessiond/main.go

@ -0,0 +1,149 @@
// tera-sessiond owns Direct Mode WebSocket attachments and bounded fan-out.
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"tera-clone/services/tera-api/internal/directstream"
"tera-clone/services/tera-api/internal/serial"
)
func main() {
listenAddress := sessiondGetenv("TERA_SESSIOND_LISTEN_ADDR", "127.0.0.1:8081")
ticketSockets := sessiondGetenvList("TERA_SESSIOND_TICKET_SOCKETS")
if len(ticketSockets) == 0 {
ticketSockets = []string{sessiondGetenv("TERA_SESSIOND_TICKET_SOCKET", directstream.DefaultTicketSocketPath)}
}
serialSocket := sessiondGetenv("TERA_SESSIOND_SERIAL_STREAM_SOCKET", serial.DefaultStreamSocketPath)
ticketClient := directstream.NewTicketSocketPool(ticketSockets)
transportFactory := serial.NewStreamSocketClient(serialSocket, "tera-sessiond")
broker, err := directstream.NewBroker(transportFactory, directstream.BrokerOptions{})
if err != nil {
log.Fatalf("tera-sessiond broker setup failed: %v", err)
}
defer broker.Close()
webSocketMetrics := &directstream.WebSocketMetrics{}
streamHandler := directstream.NewWebSocketHandler(broker, ticketClient, ticketClient, directstream.WebSocketOptions{
AllowedOrigins: sessiondGetenvList("TERA_SESSIOND_ALLOWED_ORIGINS"),
Metrics: webSocketMetrics,
})
mux := http.NewServeMux()
mux.Handle("/api/v1/direct/", streamHandler)
statusHandler := newSessiondStatusHandler(broker, webSocketMetrics, ticketClient, transportFactory)
mux.Handle("/healthz", statusHandler(false))
mux.Handle("/readyz", statusHandler(true))
server := &http.Server{
Addr: listenAddress,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("tera-sessiond listening on http://%s", listenAddress)
serverErrors := make(chan error, 1)
go func() { serverErrors <- server.ListenAndServe() }()
signalContext, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stopSignals()
select {
case <-signalContext.Done():
log.Printf("tera-sessiond stopping")
_ = broker.Close()
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownContext); err != nil {
log.Printf("tera-sessiond HTTP shutdown failed: %v", err)
}
case err := <-serverErrors:
if err != nil && err != http.ErrServerClosed {
log.Fatalf("tera-sessiond failed: %v", err)
}
}
}
type ticketStatusProvider interface {
Status(context.Context) (directstream.TicketServiceSnapshot, error)
}
type serialStatusProvider interface {
Status(context.Context) (serial.StreamSnapshot, error)
}
type sessiondStatus struct {
Status string `json:"status"`
Broker directstream.BrokerSnapshot `json:"broker"`
WebSocket directstream.WebSocketSnapshot `json:"websocket"`
Tickets directstream.TicketServiceSnapshot `json:"tickets"`
Serial serial.StreamSnapshot `json:"serial"`
TicketError string `json:"ticket_error,omitempty"`
SerialError string `json:"serial_error,omitempty"`
}
func newSessiondStatusHandler(broker *directstream.Broker, metrics *directstream.WebSocketMetrics, tickets ticketStatusProvider, seriald serialStatusProvider) func(bool) http.Handler {
return func(readiness bool) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet {
response.Header().Set("Allow", http.MethodGet)
response.WriteHeader(http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(request.Context(), time.Second)
defer cancel()
status := sessiondStatus{Status: "ok", Broker: broker.Snapshot(), WebSocket: metrics.Snapshot()}
var wait sync.WaitGroup
wait.Add(2)
go func() {
defer wait.Done()
value, err := tickets.Status(ctx)
status.Tickets = value
if err != nil {
status.TicketError = err.Error()
}
}()
go func() {
defer wait.Done()
value, err := seriald.Status(ctx)
status.Serial = value
if err != nil {
status.SerialError = err.Error()
}
}()
wait.Wait()
if status.TicketError != "" || status.SerialError != "" {
status.Status = "degraded"
}
response.Header().Set("Content-Type", "application/json")
if readiness && status.Status != "ok" {
response.WriteHeader(http.StatusServiceUnavailable)
}
_ = json.NewEncoder(response).Encode(status)
})
}
}
func sessiondGetenv(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func sessiondGetenvList(key string) []string {
value := os.Getenv(key)
result := make([]string, 0)
for _, item := range strings.Split(value, ",") {
item = strings.TrimSpace(item)
if item != "" {
result = append(result, item)
}
}
return result
}

76
backend/cmd/tera-sessiond/main_test.go

@ -0,0 +1,76 @@
package main
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"tera-clone/services/tera-api/internal/directstream"
"tera-clone/services/tera-api/internal/serial"
)
type ticketStatusStub struct {
status directstream.TicketServiceSnapshot
err error
}
func (s ticketStatusStub) Status(context.Context) (directstream.TicketServiceSnapshot, error) {
return s.status, s.err
}
type serialStatusStub struct {
status serial.StreamSnapshot
err error
}
func (s serialStatusStub) Status(context.Context) (serial.StreamSnapshot, error) {
return s.status, s.err
}
func TestSessiondHealthAndReadinessExposeDependencyFailures(t *testing.T) {
broker, err := directstream.NewBroker(unavailableTransportFactory{}, directstream.BrokerOptions{})
if err != nil {
t.Fatal(err)
}
defer broker.Close()
metrics := &directstream.WebSocketMetrics{}
handler := newSessiondStatusHandler(
broker, metrics,
ticketStatusStub{status: directstream.TicketServiceSnapshot{ActiveBindings: 1}},
serialStatusStub{err: errors.New("seriald unavailable")},
)
health := httptest.NewRecorder()
handler(false).ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if health.Code != http.StatusOK {
t.Fatalf("health status = %d", health.Code)
}
if body := health.Body.String(); !containsAll(body, `"status":"degraded"`, `"serial_error":"seriald unavailable"`, `"active_bindings":1`) {
t.Fatalf("health body = %s", body)
}
ready := httptest.NewRecorder()
handler(true).ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if ready.Code != http.StatusServiceUnavailable {
t.Fatalf("readiness status = %d, body = %s", ready.Code, ready.Body.String())
}
}
type unavailableTransportFactory struct{}
func (unavailableTransportFactory) Open(context.Context, string) (io.ReadWriteCloser, error) {
return nil, errors.New("not used")
}
func containsAll(value string, expected ...string) bool {
for _, item := range expected {
if !strings.Contains(value, item) {
return false
}
}
return true
}

88
backend/cmd/tera-snmpd/main.go

@ -0,0 +1,88 @@
// tera-snmpd is the minimal privileged Net-SNMP configuration adapter.
package main
import (
"errors"
"log"
"net"
"os"
"os/signal"
"path/filepath"
"syscall"
"tera-clone/services/tera-api/internal/snmpagent"
"tera-clone/services/tera-api/internal/snmpagent/snmpsocket"
)
func main() {
controller, err := snmpagent.NewOSController(snmpagent.OSOptions{
ConfigPath: getenv("TERA_SNMPD_CONFIG", snmpagent.DefaultConfigPath),
PersistentPath: getenv("TERA_SNMPD_PERSISTENT_CONFIG", snmpagent.DefaultPersistentPath),
InitScriptPath: getenv("TERA_SNMPD_INIT_SCRIPT", snmpagent.DefaultInitScriptPath),
ServiceName: os.Getenv("TERA_SNMPD_SERVICE_NAME"),
})
if err != nil {
log.Fatalf("tera-snmpd setup failed: %v", err)
}
socketPath := getenv("TERA_SNMPD_SOCKET", snmpsocket.DefaultSocketPath)
listener, err := listen(socketPath)
if err != nil {
log.Fatalf("tera-snmpd socket setup failed: %v", err)
}
defer func() {
_ = listener.Close()
_ = os.Remove(socketPath)
}()
done := make(chan error, 1)
go func() { done <- (snmpsocket.Server{Controller: controller}).Serve(listener) }()
log.Printf("tera-snmpd serving SNMP agent control on %s", socketPath)
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
select {
case signal := <-signals:
log.Printf("tera-snmpd stopping after %s", signal)
_ = listener.Close()
if err := <-done; err != nil {
log.Printf("tera-snmpd stop error: %v", err)
}
case err := <-done:
if err != nil {
log.Fatalf("tera-snmpd failed: %v", err)
}
}
}
func listen(socketPath string) (net.Listener, error) {
if socketPath == "" || !filepath.IsAbs(socketPath) {
return nil, errors.New("TERA_SNMPD_SOCKET must be an absolute path")
}
if err := os.MkdirAll(filepath.Dir(socketPath), 0o750); err != nil {
return nil, err
}
if info, err := os.Lstat(socketPath); err == nil {
if info.Mode()&os.ModeSocket == 0 {
return nil, errors.New("refusing to replace non-socket SNMP control path")
}
if err := os.Remove(socketPath); err != nil {
return nil, err
}
} else if !os.IsNotExist(err) {
return nil, err
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
return nil, err
}
if err := os.Chmod(socketPath, 0o660); err != nil {
_ = listener.Close()
return nil, err
}
return listener, nil
}
func getenv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}

506
backend/cmd/tera-target-session-load/main.go

@ -0,0 +1,506 @@
// tera-target-session-load exercises the deployed Direct Mode WebSocket path.
// It deliberately uses the same Origin and subprotocol requirements as the web UI.
package main
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gorilla/websocket"
"tera-clone/services/tera-api/internal/directstream"
"tera-clone/services/tera-api/internal/domain"
)
type options struct {
baseURL *url.URL
origin string
username string
password string
access string
attachments int
startChannel int
sendPayload []byte
hold time.Duration
tlsConfig *tls.Config
}
type client struct {
http *http.Client
base *url.URL
token string
}
type loadConnection struct {
session domain.DirectSession
socket *websocket.Conn
done chan error
}
type report struct {
State string `json:"state"`
Attachments int `json:"attachments"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
HoldSeconds int `json:"hold_seconds"`
SessionDetail string `json:"session_service_detail,omitempty"`
WelcomeFrames int `json:"welcome_frames"`
SessionLogs int `json:"session_logs"`
TXBytes int `json:"tx_bytes,omitempty"`
TXLogs int `json:"tx_logs,omitempty"`
}
type serviceStatus struct {
Service string `json:"service"`
Detail string `json:"detail"`
}
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "target Direct Mode load failed: %v\n", err)
os.Exit(1)
}
}
func run() error {
configuration, err := parseOptions()
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
api := &client{
http: &http.Client{Timeout: 15 * time.Second, Transport: &http.Transport{TLSClientConfig: configuration.tlsConfig}},
base: configuration.baseURL,
}
login := domain.AuthSession{}
if err := api.request(ctx, http.MethodPost, "/api/v1/auth/login", domain.AuthLoginRequest{
Username: configuration.username,
Password: configuration.password,
}, &login); err != nil {
return err
}
api.token = login.SessionToken
defer func() {
logoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = api.request(logoutCtx, http.MethodPost, "/api/v1/auth/logout", nil, nil)
}()
startedAt := time.Now().UTC()
connections := make([]loadConnection, 0, configuration.attachments)
defer func() { cleanup(connections) }()
for index := 0; index < configuration.attachments; index++ {
if err := ctx.Err(); err != nil {
return err
}
channel := fmt.Sprintf("CH%02d", (configuration.startChannel-1+index)%48+1)
session := domain.DirectSession{}
if err := api.request(ctx, http.MethodPost, "/api/v1/direct/"+channel+"/sessions", domain.DirectSessionRequest{
Access: configuration.access,
}, &session); err != nil {
return fmt.Errorf("create attachment %d/%d: %w", index+1, configuration.attachments, err)
}
socket, readDone, err := connect(ctx, configuration, session)
if err != nil {
return fmt.Errorf("connect attachment %d/%d (%s): %w", index+1, configuration.attachments, channel, err)
}
// The ticket is a one-use secret and is no longer needed after authentication.
session.StreamTicket = ""
connections = append(connections, loadConnection{session: session, socket: socket, done: readDone})
}
sessionLogs, err := validateSessionLogs(ctx, api, connections)
if err != nil {
return err
}
txLogs := 0
if len(configuration.sendPayload) > 0 {
if err := connections[0].socket.WriteMessage(websocket.BinaryMessage, configuration.sendPayload); err != nil {
return fmt.Errorf("send Direct Mode payload: %w", err)
}
txLogs, err = validateTrafficLog(ctx, api, connections[0].session.Channel, "TX", configuration.sendPayload, startedAt)
if err != nil {
return err
}
}
serviceDetail := api.sessionServiceDetail(ctx)
writeReport(report{
State: "holding", Attachments: len(connections), StartedAt: startedAt,
HoldSeconds: int(configuration.hold.Seconds()), SessionDetail: serviceDetail,
WelcomeFrames: len(connections), SessionLogs: sessionLogs,
TXBytes: len(configuration.sendPayload), TXLogs: txLogs,
})
timer := time.NewTimer(configuration.hold)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
validatedConnections := len(connections)
cleanup(connections)
connections = nil
if err := waitForSessionCleanup(ctx, api); err != nil {
return err
}
completedAt := time.Now().UTC()
writeReport(report{
State: "completed", Attachments: validatedConnections, StartedAt: startedAt,
CompletedAt: &completedAt, HoldSeconds: int(configuration.hold.Seconds()),
SessionDetail: api.sessionServiceDetail(context.Background()),
WelcomeFrames: validatedConnections, SessionLogs: sessionLogs,
TXBytes: len(configuration.sendPayload), TXLogs: txLogs,
})
return nil
default:
}
for index := range connections {
select {
case readErr := <-connections[index].done:
if readErr == nil {
readErr = errors.New("connection closed unexpectedly")
}
return fmt.Errorf("attachment %d/%d (%s) ended during hold: %w", index+1, len(connections), connections[index].session.Channel, readErr)
default:
}
}
time.Sleep(100 * time.Millisecond)
}
}
func parseOptions() (options, error) {
baseValue := flag.String("base-url", "http://192.168.0.223:8083", "target web base URL")
originValue := flag.String("origin", "", "WebSocket Origin (defaults to the base URL origin)")
username := flag.String("username", "terauser", "web username")
access := flag.String("access", directstream.AccessViewer, "Direct Mode access: viewer or writer")
attachments := flag.Int("attachments", 192, "number of viewer attachments: 1, 48, 96, or 192")
startChannel := flag.Int("start-channel", 1, "first Direct Mode channel: 1-48")
sendText := flag.String("send-text", "", "bounded UTF-8 payload sent by one writer attachment")
sendCR := flag.Bool("send-cr", false, "append carriage return to send-text")
holdSeconds := flag.Int("hold-seconds", 60, "hold time in seconds")
tlsCA := flag.String("tls-ca", "", "PEM CA certificate used to verify an HTTPS/WSS target")
flag.Parse()
password := os.Getenv("TERA_TARGET_PASSWORD")
if password == "" {
return options{}, errors.New("TERA_TARGET_PASSWORD is required")
}
allowedCount := *attachments == 1 || *attachments == 48 || *attachments == 96 || *attachments == 192
if !allowedCount {
return options{}, errors.New("attachments must be 1, 48, 96, or 192")
}
if *startChannel < 1 || *startChannel > 48 {
return options{}, errors.New("start-channel must be between 1 and 48")
}
if *access != directstream.AccessViewer && *access != directstream.AccessWriter {
return options{}, errors.New("access must be viewer or writer")
}
sendPayload := []byte(*sendText)
if *sendCR {
sendPayload = append(sendPayload, '\r')
}
if len(sendPayload) > directstream.MaxDataFrameBytes {
return options{}, fmt.Errorf("send-text payload must not exceed %d bytes", directstream.MaxDataFrameBytes)
}
if len(sendPayload) > 0 && (*access != directstream.AccessWriter || *attachments != 1) {
return options{}, errors.New("send-text requires access=writer and attachments=1")
}
if *holdSeconds < 1 || *holdSeconds > 3600 {
return options{}, errors.New("hold-seconds must be between 1 and 3600")
}
base, err := url.Parse(*baseValue)
if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" {
return options{}, errors.New("base-url must be an absolute HTTP or HTTPS URL")
}
base.Path, base.RawPath, base.RawQuery, base.Fragment = "", "", "", ""
origin := strings.TrimSpace(*originValue)
if origin == "" {
origin = base.Scheme + "://" + base.Host
}
tlsConfig, err := trustedTLSConfig(base, strings.TrimSpace(*tlsCA))
if err != nil {
return options{}, err
}
return options{
baseURL: base, origin: origin, username: *username, password: password, access: *access,
attachments: *attachments, startChannel: *startChannel, hold: time.Duration(*holdSeconds) * time.Second,
sendPayload: sendPayload, tlsConfig: tlsConfig,
}, nil
}
func trustedTLSConfig(base *url.URL, caPath string) (*tls.Config, error) {
if base.Scheme != "https" {
if caPath != "" {
return nil, errors.New("tls-ca is only valid with an HTTPS base URL")
}
return nil, nil
}
if caPath == "" {
return nil, nil
}
payload, err := os.ReadFile(caPath)
if err != nil {
return nil, fmt.Errorf("read tls-ca: %w", err)
}
roots, err := x509.SystemCertPool()
if err != nil || roots == nil {
roots = x509.NewCertPool()
}
if !roots.AppendCertsFromPEM(payload) {
return nil, errors.New("tls-ca does not contain a PEM certificate")
}
return &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}, nil
}
func (c *client) request(ctx context.Context, method, path string, requestBody, responseBody any) error {
var body io.Reader
if requestBody != nil {
encoded, err := json.Marshal(requestBody)
if err != nil {
return err
}
body = bytes.NewReader(encoded)
}
reference, err := url.Parse(path)
if err != nil {
return err
}
endpoint := c.base.ResolveReference(reference)
request, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body)
if err != nil {
return err
}
if requestBody != nil {
request.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
request.Header.Set("Authorization", "Bearer "+c.token)
}
response, err := c.http.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
payload, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return err
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("%s %s returned HTTP %d: %s", method, path, response.StatusCode, strings.TrimSpace(string(payload)))
}
if responseBody == nil || len(payload) == 0 {
return nil
}
if err := json.Unmarshal(payload, responseBody); err != nil {
return fmt.Errorf("decode %s %s response: %w", method, path, err)
}
return nil
}
func (c *client) sessionServiceDetail(ctx context.Context) string {
status := []serviceStatus{}
if err := c.request(ctx, http.MethodGet, "/api/v1/system/services?limit=100", nil, &status); err != nil {
return "unavailable: " + err.Error()
}
for _, service := range status {
if service.Service == "tera-sessiond" {
return service.Detail
}
}
return "tera-sessiond missing"
}
func waitForSessionCleanup(ctx context.Context, api *client) error {
deadline := time.Now().Add(10 * time.Second)
for {
detail := api.sessionServiceDetail(ctx)
if strings.Contains(detail, "attachments=0") && strings.Contains(detail, "active_channels=0") && strings.Contains(detail, "serial_leases=0") {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("Direct Mode resources did not return to zero: %s", detail)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
}
func connect(ctx context.Context, configuration options, session domain.DirectSession) (*websocket.Conn, chan error, error) {
streamURL, err := configuration.baseURL.Parse(session.StreamURL)
if err != nil {
return nil, nil, err
}
if streamURL.Scheme == "https" {
streamURL.Scheme = "wss"
} else {
streamURL.Scheme = "ws"
}
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
Subprotocols: []string{session.StreamProtocol},
TLSClientConfig: configuration.tlsConfig,
}
header := http.Header{"Origin": []string{configuration.origin}}
socket, response, err := dialer.DialContext(ctx, streamURL.String(), header)
if err != nil {
status := 0
if response != nil {
status = response.StatusCode
}
return nil, nil, fmt.Errorf("WebSocket upgrade returned HTTP %d: %w", status, err)
}
if socket.Subprotocol() != session.StreamProtocol {
socket.Close()
return nil, nil, fmt.Errorf("negotiated subprotocol %q, want %q", socket.Subprotocol(), session.StreamProtocol)
}
if err := socket.WriteJSON(directstream.ControlFrame{Type: directstream.ControlAuthenticate, Ticket: session.StreamTicket}); err != nil {
socket.Close()
return nil, nil, err
}
_ = socket.SetReadDeadline(time.Now().Add(10 * time.Second))
var status directstream.WebSocketStatusFrame
if err := socket.ReadJSON(&status); err != nil {
socket.Close()
return nil, nil, err
}
if status.Type != "session.status" || status.Channel != session.Channel || status.SessionID != session.SessionID {
socket.Close()
return nil, nil, fmt.Errorf("unexpected authentication status type=%q channel=%q", status.Type, status.Channel)
}
messageType, welcome, err := socket.ReadMessage()
if err != nil {
socket.Close()
return nil, nil, err
}
if messageType != websocket.BinaryMessage ||
!strings.Contains(string(welcome), "Welcome to TERA CORA-X48 PRO K") ||
!strings.Contains(string(welcome), "Serial channel: "+session.Channel) ||
!strings.Contains(string(welcome), "Session: "+session.SessionID) ||
!messageContainsSourceIP(string(welcome)) {
socket.Close()
return nil, nil, fmt.Errorf("invalid Direct Mode welcome frame for %s", session.Channel)
}
_ = socket.SetReadDeadline(time.Time{})
done := make(chan error, 1)
go func() {
for {
if _, _, err := socket.ReadMessage(); err != nil {
done <- err
return
}
}
}()
return socket, done, nil
}
func validateSessionLogs(ctx context.Context, api *client, connections []loadConnection) (int, error) {
expected := make(map[string]map[string]bool)
for _, connection := range connections {
if expected[connection.session.Channel] == nil {
expected[connection.session.Channel] = make(map[string]bool)
}
expected[connection.session.Channel][connection.session.SessionID] = false
}
matched := 0
for channel, sessions := range expected {
events := []domain.PortLogEvent{}
path := "/api/v1/logs/port?channel=" + url.QueryEscape(channel) + "&event_type=session&limit=100"
if err := api.request(ctx, http.MethodGet, path, nil, &events); err != nil {
return matched, fmt.Errorf("read %s session logs: %w", channel, err)
}
for _, event := range events {
if event.EventType != "session" || !strings.Contains(event.Message, "Welcome to TERA CORA-X48 PRO K") ||
!strings.Contains(event.Message, "Serial channel: "+channel) || !messageContainsSourceIP(event.Message) {
continue
}
for sessionID, found := range sessions {
if !found && strings.Contains(event.Message, "Session: "+sessionID) {
sessions[sessionID] = true
matched++
}
}
}
for sessionID, found := range sessions {
if !found {
return matched, fmt.Errorf("session log missing for %s %s", channel, sessionID)
}
}
}
return matched, nil
}
func validateTrafficLog(ctx context.Context, api *client, channel, direction string, payload []byte, since time.Time) (int, error) {
deadline := time.Now().Add(5 * time.Second)
for {
events := []domain.PortLogEvent{}
path := "/api/v1/logs/port?channel=" + url.QueryEscape(channel) + "&event_type=traffic&direction=" + url.QueryEscape(direction) + "&limit=100"
if err := api.request(ctx, http.MethodGet, path, nil, &events); err != nil {
return 0, fmt.Errorf("read %s traffic logs: %w", channel, err)
}
matched := 0
for _, event := range events {
if event.Channel == channel && event.EventType == "traffic" && event.Direction == direction &&
event.PayloadBytes == len(payload) && !event.Timestamp.Before(since.Add(-5*time.Second)) &&
(len(event.Payload) == 0 || bytes.Equal(event.Payload, payload)) {
matched++
}
}
if matched > 0 {
return matched, nil
}
if time.Now().After(deadline) {
return 0, fmt.Errorf("%s %s traffic log for %d bytes was not observed", channel, direction, len(payload))
}
select {
case <-ctx.Done():
return 0, ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
}
func messageContainsSourceIP(message string) bool {
const marker = "Connected from: "
index := strings.Index(message, marker)
if index < 0 {
return false
}
fields := strings.Fields(message[index+len(marker):])
return len(fields) > 0 && net.ParseIP(fields[0]) != nil
}
func cleanup(connections []loadConnection) {
for index := range connections {
connection := &connections[index]
_ = connection.socket.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "load test complete"), time.Now().Add(time.Second))
_ = connection.socket.Close()
}
}
func writeReport(value report) {
encoder := json.NewEncoder(os.Stdout)
encoder.SetEscapeHTML(false)
_ = encoder.Encode(value)
}

57
backend/cmd/tera-target-session-load/main_test.go

@ -0,0 +1,57 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"tera-clone/services/tera-api/internal/domain"
)
func TestMessageContainsSourceIP(t *testing.T) {
for _, message := range []string{
"Welcome\nConnected from: 192.168.0.10\nSerial channel: CH01",
"Welcome Connected from: 2001:db8::10 Serial channel: CH48",
} {
if !messageContainsSourceIP(message) {
t.Fatalf("valid source not detected in %q", message)
}
}
if messageContainsSourceIP("Connected from: workstation Serial channel: CH01") {
t.Fatal("non-IP source was accepted")
}
}
func TestValidateSessionLogsCoversEachRequestedChannelAndSession(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
channel := request.URL.Query().Get("channel")
if request.URL.Query().Get("event_type") != "session" {
http.Error(response, "missing session filter", http.StatusBadRequest)
return
}
sessionID := map[string]string{"CH01": "direct-CH01-1", "CH48": "direct-CH48-1"}[channel]
_ = json.NewEncoder(response).Encode([]domain.PortLogEvent{{
Timestamp: time.Now().UTC(), Channel: channel, EventType: "session",
Actor: "admin/" + sessionID,
Message: "Welcome to TERA CORA-X48 PRO K Connected from: 192.168.0.10 Serial channel: " + channel + " User: admin Access: viewer Session: " + sessionID,
}})
}))
defer server.Close()
base, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
api := &client{http: server.Client(), base: base, token: "test"}
connections := []loadConnection{
{session: domain.DirectSession{Channel: "CH01", SessionID: "direct-CH01-1"}},
{session: domain.DirectSession{Channel: "CH48", SessionID: "direct-CH48-1"}},
}
matched, err := validateSessionLogs(context.Background(), api, connections)
if err != nil || matched != len(connections) {
t.Fatalf("matched=%d err=%v", matched, err)
}
}

96
backend/cmd/tera-timed/main.go

@ -0,0 +1,96 @@
// tera-timed is the minimal privileged system clock and timezone adapter.
package main
import (
"errors"
"log"
"net"
"os"
"os/signal"
"path/filepath"
"syscall"
"tera-clone/services/tera-api/internal/systemtime"
"tera-clone/services/tera-api/internal/systemtime/timesocket"
)
func main() {
socketPath := getenv("TERA_TIMED_SOCKET", timesocket.DefaultSocketPath)
zoneinfoRoot := getenv("TERA_TIMED_ZONEINFO_ROOT", systemtime.DefaultZoneinfoRoot)
localtimePath := getenv("TERA_TIMED_LOCALTIME_PATH", systemtime.DefaultLocaltimePath)
applier, err := systemtime.NewOSApplier(zoneinfoRoot, localtimePath)
if err != nil {
log.Fatalf("tera-timed system time setup failed: %v", err)
}
ntpApplier, err := systemtime.NewOSNTPApplier(
getenv("TERA_TIMED_NTP_CONFIG", systemtime.DefaultNTPConfigPath),
getenv("TERA_TIMED_NTP_INIT", systemtime.DefaultNTPInitPath),
getenv("TERA_TIMED_NTP_DAEMON", systemtime.DefaultNTPDaemonPath),
)
if err != nil {
log.Fatalf("tera-timed NTP setup failed: %v", err)
}
listener, err := listen(socketPath)
if err != nil {
log.Fatalf("tera-timed socket setup failed: %v", err)
}
defer func() {
_ = listener.Close()
_ = os.Remove(socketPath)
}()
server := timesocket.Server{Controller: systemtime.NewTransactionalController(applier, ntpApplier)}
done := make(chan error, 1)
go func() { done <- server.Serve(listener) }()
log.Printf("tera-timed serving system time control on %s", socketPath)
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
select {
case signal := <-signals:
log.Printf("tera-timed stopping after %s", signal)
_ = listener.Close()
if err := <-done; err != nil {
log.Printf("tera-timed stop error: %v", err)
}
case err := <-done:
if err != nil {
log.Fatalf("tera-timed failed: %v", err)
}
}
}
func listen(socketPath string) (net.Listener, error) {
if socketPath == "" || !filepath.IsAbs(socketPath) {
return nil, errors.New("TERA_TIMED_SOCKET must be an absolute path")
}
if err := os.MkdirAll(filepath.Dir(socketPath), 0o750); err != nil {
return nil, err
}
if info, err := os.Lstat(socketPath); err == nil {
if info.Mode()&os.ModeSocket == 0 {
return nil, errors.New("refusing to replace non-socket system time control path")
}
if err := os.Remove(socketPath); err != nil {
return nil, err
}
} else if !os.IsNotExist(err) {
return nil, err
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
return nil, err
}
if err := os.Chmod(socketPath, 0o660); err != nil {
_ = listener.Close()
return nil, err
}
return listener, nil
}
func getenv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}

24
backend/cmd/tera-timed/main_test.go

@ -0,0 +1,24 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestListenRefusesRelativeAndNonSocketPaths(t *testing.T) {
if _, err := listen("relative.sock"); err == nil {
t.Fatal("listen accepted a relative path")
}
path := filepath.Join(t.TempDir(), "time.sock")
if err := os.WriteFile(path, []byte("preserve"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := listen(path); err == nil {
t.Fatal("listen replaced a non-socket path")
}
content, err := os.ReadFile(path)
if err != nil || string(content) != "preserve" {
t.Fatalf("protected path changed: %q, %v", content, err)
}
}

485
backend/cmd/tera-update-tool/main.go

@ -0,0 +1,485 @@
// tera-update-tool creates signing keys and deterministic, signed local update packages.
// Private keys are host-only artifacts and are never installed on a target device.
package main
import (
"archive/tar"
"compress/gzip"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"tera-clone/services/tera-api/internal/update"
)
type privateKeyEnvelope struct {
FormatVersion int `json:"format_version"`
KeyID string `json:"key_id"`
Algorithm string `json:"algorithm"`
PrivateKey string `json:"private_key"`
}
type publicKeyEnvelope struct {
FormatVersion int `json:"format_version"`
KeyID string `json:"key_id"`
Algorithm string `json:"algorithm"`
PublicKey string `json:"public_key"`
AllowedProducts []string `json:"allowed_products"`
AllowedProfiles []string `json:"allowed_profiles"`
}
type signatureEnvelope struct {
Algorithm string `json:"algorithm"`
KeyID string `json:"key_id"`
Signature string `json:"signature"`
}
func main() {
if len(os.Args) < 2 {
usage()
}
var err error
switch os.Args[1] {
case "keygen":
err = keygen(os.Args[2:])
case "pack":
err = pack(os.Args[2:])
case "pack-recovery":
err = packRecovery(os.Args[2:])
case "inspect":
err = inspect(os.Args[2:])
default:
usage()
}
if err != nil {
fmt.Fprintln(os.Stderr, "tera-update-tool:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: tera-update-tool {keygen|pack|pack-recovery|inspect} [flags]")
os.Exit(2)
}
func keygen(arguments []string) error {
flags := flag.NewFlagSet("keygen", flag.ContinueOnError)
outputDirectory := flags.String("output-dir", "", "directory for the host private key and target public anchor")
keyID := flags.String("key-id", "", "bounded trust anchor id")
product := flags.String("product", "tera-core-x48-pro", "allowed product")
profile := flags.String("profile", "fet3568j-c", "allowed hardware profile")
if err := flags.Parse(arguments); err != nil {
return err
}
if !safeName(*keyID) || strings.TrimSpace(*outputDirectory) == "" || strings.TrimSpace(*product) == "" || strings.TrimSpace(*profile) == "" {
return errors.New("output-dir, a safe key-id, product, and profile are required")
}
if err := os.MkdirAll(*outputDirectory, 0o700); err != nil {
return err
}
privatePath := filepath.Join(*outputDirectory, *keyID+".private.json")
publicPath := filepath.Join(*outputDirectory, *keyID+".json")
if exists(privatePath) || exists(publicPath) {
return errors.New("refusing to overwrite an existing key")
}
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return err
}
privateValue := privateKeyEnvelope{1, *keyID, "ed25519", base64.StdEncoding.EncodeToString(privateKey)}
publicValue := publicKeyEnvelope{1, *keyID, "ed25519", base64.StdEncoding.EncodeToString(publicKey), []string{*product}, []string{*profile}}
if err := writeExclusiveJSON(privatePath, privateValue, 0o600); err != nil {
return err
}
if err := writeExclusiveJSON(publicPath, publicValue, 0o644); err != nil {
_ = os.Remove(privatePath)
return err
}
fmt.Printf("host private key: %s\ntarget public anchor: %s\n", privatePath, publicPath)
return nil
}
func pack(arguments []string) error {
flags := flag.NewFlagSet("pack", flag.ContinueOnError)
privatePath := flags.String("private-key", "", "host private key JSON")
outputPath := flags.String("output", "", "new package path")
payloadPath := flags.String("payload", "", "payload file")
payloadClass := flags.String("payload-class", "application", "config, application, boot, or full-rootfs")
packageID := flags.String("package-id", "", "package id")
version := flags.String("version", "", "package semantic version")
minimumVersion := flags.String("minimum-current-version", "", "minimum device version")
product := flags.String("product", "tera-core-x48-pro", "target product")
profile := flags.String("profile", "fet3568j-c", "target hardware profile")
createdAtValue := flags.String("created-at", "", "RFC3339 creation time (defaults to now)")
recovery := flags.String("recovery", "Upload a known-good signed package; running firmware remains unchanged.", "operator recovery guidance")
if err := flags.Parse(arguments); err != nil {
return err
}
if !safeName(*packageID) || *version == "" || *privatePath == "" || *outputPath == "" || *payloadPath == "" {
return errors.New("private-key, output, payload, package-id, and version are required")
}
if *payloadClass != "config" && *payloadClass != "application" && *payloadClass != "boot" && *payloadClass != "full-rootfs" {
return errors.New("payload-class is invalid")
}
if exists(*outputPath) {
return errors.New("refusing to overwrite an existing package")
}
createdAt := time.Now().UTC()
if *createdAtValue != "" {
parsed, err := time.Parse(time.RFC3339, *createdAtValue)
if err != nil {
return fmt.Errorf("created-at: %w", err)
}
createdAt = parsed.UTC()
}
privateKeyValue, err := readPrivateKey(*privatePath)
if err != nil {
return err
}
payloadInfo, err := safeRegularFile(*payloadPath)
if err != nil {
return err
}
payloadDigest, err := fileSHA256(*payloadPath)
if err != nil {
return err
}
payloadArchivePath := "payload/" + filepath.Base(*payloadPath)
manifest := update.Manifest{
FormatVersion: 1, PackageID: *packageID, Product: *product, HardwareProfile: *profile,
Version: *version, MinimumCurrentVersion: *minimumVersion, CreatedAt: createdAt, SigningKeyID: privateKeyValue.KeyID,
Payloads: []update.Payload{{Name: "primary", Path: payloadArchivePath, Class: *payloadClass, Size: payloadInfo.Size(), SHA256: payloadDigest}},
}
manifest.Apply.Capability = "validation-only"
manifest.Apply.RebootRequired = false
manifest.Recovery.Capability = "external-service"
manifest.Recovery.Instructions = strings.TrimSpace(*recovery)
manifestBytes, err := json.Marshal(manifest)
if err != nil {
return err
}
privateKeyBytes, _ := base64.StdEncoding.DecodeString(privateKeyValue.PrivateKey)
signatureBytes, err := json.Marshal(signatureEnvelope{Algorithm: "ed25519", KeyID: privateKeyValue.KeyID, Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(ed25519.PrivateKey(privateKeyBytes), manifestBytes))})
if err != nil {
return err
}
return writePackage(*outputPath, []packagePayload{{sourcePath: *payloadPath, archivePath: payloadArchivePath, info: payloadInfo}}, manifestBytes, signatureBytes, createdAt, "validation-only")
}
func packRecovery(arguments []string) error {
flags := flag.NewFlagSet("pack-recovery", flag.ContinueOnError)
privatePath := flags.String("private-key", "", "host private key JSON")
outputPath := flags.String("output", "", "new recovery package path")
rootfsPath := flags.String("rootfs-payload", "", "raw or gzip-compressed rootfs image")
rootfsEncoding := flags.String("rootfs-encoding", "gzip", "raw or gzip")
bootPath := flags.String("boot-payload", "", "optional raw signed normal boot FIT")
packageID := flags.String("package-id", "", "package id")
version := flags.String("version", "", "package semantic version")
minimumVersion := flags.String("minimum-current-version", "", "minimum device version")
product := flags.String("product", "tera-core-x48-pro", "target product")
profile := flags.String("profile", "fet3568j-c", "target hardware profile")
createdAtValue := flags.String("created-at", "", "RFC3339 creation time (defaults to now)")
recovery := flags.String("recovery", "Keep both power inputs connected. If recovery reports a failure, leave the device in recovery and follow the COM5 service procedure.", "operator recovery guidance")
if err := flags.Parse(arguments); err != nil {
return err
}
if !safeName(*packageID) || *version == "" || *privatePath == "" || *outputPath == "" || *rootfsPath == "" {
return errors.New("private-key, output, rootfs-payload, package-id, and version are required")
}
if *rootfsEncoding != "raw" && *rootfsEncoding != "gzip" {
return errors.New("rootfs-encoding must be raw or gzip")
}
if exists(*outputPath) {
return errors.New("refusing to overwrite an existing package")
}
createdAt := time.Now().UTC()
if *createdAtValue != "" {
parsed, err := time.Parse(time.RFC3339, *createdAtValue)
if err != nil {
return fmt.Errorf("created-at: %w", err)
}
createdAt = parsed.UTC()
}
privateKeyValue, err := readPrivateKey(*privatePath)
if err != nil {
return err
}
rootfsInfo, err := safeRegularFile(*rootfsPath)
if err != nil {
return err
}
rootfsDigest, err := fileSHA256(*rootfsPath)
if err != nil {
return err
}
expandedSize, expandedDigest, err := expandedMetadata(*rootfsPath, *rootfsEncoding)
if err != nil {
return err
}
rootfsArchivePath := "payload/" + filepath.Base(*rootfsPath)
manifest := update.Manifest{
FormatVersion: 1, PackageID: *packageID, Product: *product, HardwareProfile: *profile,
Version: *version, MinimumCurrentVersion: *minimumVersion, CreatedAt: createdAt, SigningKeyID: privateKeyValue.KeyID,
Payloads: []update.Payload{{
Name: "rootfs", Path: rootfsArchivePath, Class: "full-rootfs", Size: rootfsInfo.Size(), SHA256: rootfsDigest,
TargetPartition: "rootfs", Encoding: *rootfsEncoding, ExpandedSize: expandedSize, ExpandedSHA256: expandedDigest,
}},
}
payloads := []packagePayload{{sourcePath: *rootfsPath, archivePath: rootfsArchivePath, info: rootfsInfo}}
if *bootPath != "" {
bootInfo, err := safeRegularFile(*bootPath)
if err != nil {
return err
}
bootDigest, err := fileSHA256(*bootPath)
if err != nil {
return err
}
bootArchivePath := "payload/" + filepath.Base(*bootPath)
if bootArchivePath == rootfsArchivePath {
return errors.New("boot and rootfs payload basenames must differ")
}
manifest.Payloads = append(manifest.Payloads, update.Payload{
Name: "boot", Path: bootArchivePath, Class: "boot", Size: bootInfo.Size(), SHA256: bootDigest,
TargetPartition: "boot", Encoding: "raw", ExpandedSize: bootInfo.Size(), ExpandedSHA256: bootDigest,
})
payloads = append(payloads, packagePayload{sourcePath: *bootPath, archivePath: bootArchivePath, info: bootInfo})
}
manifest.Apply.Capability = update.ApplyCapabilityRecoveryV1
manifest.Apply.RebootRequired = true
manifest.Recovery.Capability = update.RecoveryCapabilityOfflineV1
manifest.Recovery.Instructions = strings.TrimSpace(*recovery)
manifestBytes, err := json.Marshal(manifest)
if err != nil {
return err
}
privateKeyBytes, _ := base64.StdEncoding.DecodeString(privateKeyValue.PrivateKey)
signatureBytes, err := json.Marshal(signatureEnvelope{Algorithm: "ed25519", KeyID: privateKeyValue.KeyID, Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(ed25519.PrivateKey(privateKeyBytes), manifestBytes))})
if err != nil {
return err
}
return writePackage(*outputPath, payloads, manifestBytes, signatureBytes, createdAt, "recovery-v1")
}
func inspect(arguments []string) error {
flags := flag.NewFlagSet("inspect", flag.ContinueOnError)
packagePath := flags.String("package", "", "package to verify")
trustDirectory := flags.String("trust-dir", "", "public trust anchor directory")
product := flags.String("product", "tera-core-x48-pro", "expected product")
profile := flags.String("profile", "fet3568j-c", "expected hardware profile")
currentVersion := flags.String("current-version", "0.1.0-dev", "current device version")
if err := flags.Parse(arguments); err != nil {
return err
}
manifest, signer, err := update.VerifyPackage(*packagePath, *trustDirectory, *product, *profile, *currentVersion)
if err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(map[string]any{"valid": true, "signer_key_id": signer, "manifest": manifest})
}
func readPrivateKey(path string) (privateKeyEnvelope, error) {
info, err := safeRegularFile(path)
if err != nil {
return privateKeyEnvelope{}, err
}
if info.Size() > 4096 {
return privateKeyEnvelope{}, errors.New("private key file is too large")
}
data, err := os.ReadFile(path)
if err != nil {
return privateKeyEnvelope{}, err
}
var value privateKeyEnvelope
if err := json.Unmarshal(data, &value); err != nil || value.FormatVersion != 1 || value.Algorithm != "ed25519" || !safeName(value.KeyID) {
return privateKeyEnvelope{}, errors.New("private key envelope is invalid")
}
decoded, err := base64.StdEncoding.DecodeString(value.PrivateKey)
if err != nil || len(decoded) != ed25519.PrivateKeySize {
return privateKeyEnvelope{}, errors.New("private key material is invalid")
}
return value, nil
}
type packagePayload struct {
sourcePath string
archivePath string
info os.FileInfo
}
func writePackage(outputPath string, payloads []packagePayload, manifest, signature []byte, createdAt time.Time, capability string) error {
parent := filepath.Dir(outputPath)
if _, err := safeRegularDirectory(parent); err != nil {
return err
}
temporary, err := os.CreateTemp(parent, ".tera-update-*.part")
if err != nil {
return err
}
temporaryPath := temporary.Name()
committed := false
defer func() {
_ = temporary.Close()
if !committed {
_ = os.Remove(temporaryPath)
}
}()
if err := temporary.Chmod(0o600); err != nil {
return err
}
writer := tar.NewWriter(temporary)
for _, entry := range []struct {
name string
data []byte
}{{"manifest.json", manifest}, {"manifest.sig", signature}} {
if err := writeTarBytes(writer, entry.name, entry.data, createdAt); err != nil {
return err
}
}
for _, item := range payloads {
if err := writer.WriteHeader(&tar.Header{Name: item.archivePath, Mode: 0o600, Size: item.info.Size(), ModTime: createdAt}); err != nil {
return err
}
payload, err := os.Open(item.sourcePath)
if err != nil {
return err
}
_, copyErr := io.Copy(writer, payload)
closePayloadErr := payload.Close()
if copyErr != nil {
return copyErr
}
if closePayloadErr != nil {
return closePayloadErr
}
}
if err := writer.Close(); err != nil {
return err
}
if err := temporary.Sync(); err != nil {
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Rename(temporaryPath, outputPath); err != nil {
return err
}
committed = true
fmt.Printf("signed %s package: %s\n", capability, outputPath)
return nil
}
func expandedMetadata(payloadPath, encoding string) (int64, string, error) {
file, err := os.Open(payloadPath)
if err != nil {
return 0, "", err
}
defer file.Close()
var reader io.Reader = file
if encoding == "gzip" {
compressed, err := gzip.NewReader(file)
if err != nil {
return 0, "", fmt.Errorf("open rootfs gzip payload: %w", err)
}
defer compressed.Close()
reader = compressed
} else if encoding != "raw" {
return 0, "", fmt.Errorf("unsupported payload encoding %q", encoding)
}
hash := sha256.New()
size, err := io.Copy(hash, reader)
if err != nil {
return 0, "", fmt.Errorf("read expanded payload: %w", err)
}
if size <= 0 {
return 0, "", errors.New("expanded payload is empty")
}
return size, hex.EncodeToString(hash.Sum(nil)), nil
}
func writeTarBytes(writer *tar.Writer, name string, data []byte, createdAt time.Time) error {
if err := writer.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), ModTime: createdAt}); err != nil {
return err
}
_, err := writer.Write(data)
return err
}
func fileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func safeRegularFile(path string) (os.FileInfo, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return nil, errors.New("path must be a regular non-symlink file")
}
return info, nil
}
func safeRegularDirectory(path string) (os.FileInfo, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, errors.New("path must be a non-symlink directory")
}
return info, nil
}
func writeExclusiveJSON(path string, value any, mode os.FileMode) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return err
}
encodeErr := json.NewEncoder(file).Encode(value)
syncErr := file.Sync()
closeErr := file.Close()
if encodeErr != nil {
return encodeErr
}
if syncErr != nil {
return syncErr
}
return closeErr
}
func exists(path string) bool { _, err := os.Lstat(path); return err == nil }
func safeName(value string) bool {
if len(value) < 1 || len(value) > 128 {
return false
}
for index, character := range value {
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' || index > 0 && (character == '.' || character == '_' || character == '-') {
continue
}
return false
}
return true
}

52
backend/cmd/tera-update-tool/main_test.go

@ -0,0 +1,52 @@
package main
import (
"compress/gzip"
"os"
"path/filepath"
"testing"
"tera-clone/services/tera-api/internal/update"
)
func TestPackRecoveryCreatesVerifiableOfflinePackage(t *testing.T) {
base := t.TempDir()
keys := filepath.Join(base, "keys")
if err := keygen([]string{"--output-dir", keys, "--key-id", "test-key", "--product", "tera-core-x48-pro", "--profile", "fet3568j-c"}); err != nil {
t.Fatal(err)
}
rootfsPath := filepath.Join(base, "rootfs.img.gz")
file, err := os.OpenFile(rootfsPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
t.Fatal(err)
}
compressed := gzip.NewWriter(file)
if _, err := compressed.Write([]byte("rootfs-image-fixture")); err != nil {
t.Fatal(err)
}
if err := compressed.Close(); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
bootPath := filepath.Join(base, "boot.img")
if err := os.WriteFile(bootPath, []byte("signed-boot-fit-fixture"), 0o600); err != nil {
t.Fatal(err)
}
packagePath := filepath.Join(base, "release.tera-update")
if err := packRecovery([]string{
"--private-key", filepath.Join(keys, "test-key.private.json"), "--output", packagePath,
"--rootfs-payload", rootfsPath, "--rootfs-encoding", "gzip", "--boot-payload", bootPath,
"--package-id", "release-1", "--version", "1.1.0", "--minimum-current-version", "1.0.0",
}); err != nil {
t.Fatal(err)
}
manifest, signer, err := update.VerifyPackage(packagePath, keys, "tera-core-x48-pro", "fet3568j-c", "1.0.0")
if err != nil {
t.Fatal(err)
}
if signer != "test-key" || manifest.Apply.Capability != update.ApplyCapabilityRecoveryV1 || !manifest.Apply.RebootRequired || len(manifest.Payloads) != 2 {
t.Fatalf("manifest=%+v signer=%q", manifest, signer)
}
}

189
backend/cmd/tera-updated/main.go

@ -0,0 +1,189 @@
package main
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net"
"os"
"os/exec"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"tera-clone/services/tera-api/internal/update"
"tera-clone/services/tera-api/internal/update/recoveryrequest"
"tera-clone/services/tera-api/internal/update/updatesocket"
)
func main() {
recoveryApplyEnabled := false
if getenvBool("TERA_UPDATED_RECOVERY_APPLY_ENABLED", false) {
gatePath := getenv("TERA_UPDATED_RECOVERY_GATE", "/etc/tera/recovery-apply-enabled.json")
if err := validateRecoveryGate(gatePath, getenv("TERA_UPDATED_PRODUCT", "tera-core-x48-pro"), getenv("TERA_UPDATED_PROFILE", "fet3568j-c")); err != nil {
log.Fatalf("tera-updated recovery apply gate failed closed: %v", err)
}
recoveryApplyEnabled = true
}
miscDevice := getenv("TERA_UPDATED_MISC_DEVICE", "/dev/mmcblk0p2")
manager, err := update.NewManager(update.ManagerOptions{
UpdateRoot: getenv("TERA_UPDATED_ROOT", "/userdata/update"),
PolicyPath: getenv("TERA_UPDATED_STORAGE_POLICY", "/userdata/storage-layout.json"),
TrustDirectory: getenv("TERA_UPDATED_TRUST_DIR", "/etc/tera/update-trust"),
Product: getenv("TERA_UPDATED_PRODUCT", "tera-core-x48-pro"),
Profile: getenv("TERA_UPDATED_PROFILE", "fet3568j-c"),
CurrentVersion: getenv("TERA_UPDATED_CURRENT_VERSION", "0.1.0-dev"),
RecoveryApplyEnabled: recoveryApplyEnabled,
RequestRecovery: func(operationID string) error {
return recoveryrequest.WriteRequestFile(miscDevice, operationID, false)
},
Reboot: func() error { return exec.Command("/sbin/reboot").Run() },
})
if err != nil {
log.Fatalf("tera-updated setup failed: %v", err)
}
socketPath := getenv("TERA_UPDATED_SOCKET", updatesocket.DefaultSocketPath)
if err := removeStaleSocket(socketPath); err != nil {
log.Fatalf("tera-updated socket setup failed: %v", err)
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
log.Fatalf("tera-updated listen failed: %v", err)
}
defer listener.Close()
defer os.Remove(socketPath)
if err := os.Chmod(socketPath, 0o660); err != nil {
log.Fatalf("tera-updated socket permissions failed: %v", err)
}
server := updatesocket.Server{Controller: manager, MaxBytes: managerMaxBytes(manager)}
serverErrors := make(chan error, 1)
go func() { serverErrors <- server.Serve(listener) }()
log.Printf("tera-updated serving protected update control on %s", socketPath)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
select {
case <-ctx.Done():
log.Printf("tera-updated stopping")
case err := <-serverErrors:
if err != nil {
log.Printf("tera-updated failed: %v", err)
}
}
_ = listener.Close()
select {
case <-serverErrors:
case <-time.After(3 * time.Second):
}
}
type recoveryGate struct {
FormatVersion int `json:"format_version"`
Product string `json:"product"`
HardwareProfile string `json:"hardware_profile"`
Authorized bool `json:"authorized"`
TargetEvidenceID string `json:"target_evidence_id"`
RecoveryFITSHA256 string `json:"recovery_fit_sha256"`
UBootSHA256 string `json:"uboot_sha256"`
}
func validateRecoveryGate(path, product, profile string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() <= 0 || info.Size() > 16*1024 {
return fmt.Errorf("gate must be a bounded regular non-symlink file")
}
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
decoder := json.NewDecoder(io.LimitReader(file, 16*1024))
decoder.DisallowUnknownFields()
var gate recoveryGate
if err := decoder.Decode(&gate); err != nil {
return err
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return fmt.Errorf("gate must contain exactly one JSON value")
}
if gate.FormatVersion != 1 || !gate.Authorized || gate.Product != product || gate.HardwareProfile != profile ||
!safeGateIdentity(gate.TargetEvidenceID) || !validHexSHA256(gate.RecoveryFITSHA256) || !validHexSHA256(gate.UBootSHA256) {
return fmt.Errorf("gate identity, authorization, or artifact hashes are invalid")
}
return nil
}
func safeGateIdentity(value string) bool {
if len(value) < 1 || len(value) > 128 {
return false
}
for index, character := range value {
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' || index > 0 && (character == '.' || character == '_' || character == '-') {
continue
}
return false
}
return true
}
func validHexSHA256(value string) bool {
if len(value) != 64 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func getenvBool(key string, fallback bool) bool {
value := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
if value == "" {
return fallback
}
return value == "1" || value == "true" || value == "yes" || value == "on"
}
func managerMaxBytes(manager *update.Manager) int64 {
status, err := manager.Status(context.Background())
if err != nil || status.Capacity.UpdatePackageMax <= 0 {
return 1280 << 20
}
return status.Capacity.UpdatePackageMax
}
func removeStaleSocket(path string) error {
info, err := os.Lstat(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSocket == 0 {
return fmt.Errorf("refusing to replace non-socket path %s", path)
}
return os.Remove(path)
}
func getenv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func getenvInt64(key string, fallback int64) int64 {
value, err := strconv.ParseInt(os.Getenv(key), 10, 64)
if err != nil || value <= 0 {
return fallback
}
return value
}

48
backend/cmd/tera-updated/main_test.go

@ -0,0 +1,48 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestValidateRecoveryGateFailsClosed(t *testing.T) {
valid := recoveryGate{
FormatVersion: 1, Product: "tera-core-x48-pro", HardwareProfile: "fet3568j-c", Authorized: true,
TargetEvidenceID: "target-gate-20260724", RecoveryFITSHA256: strings.Repeat("a", 64), UBootSHA256: strings.Repeat("b", 64),
}
path := filepath.Join(t.TempDir(), "gate.json")
writeGate(t, path, valid)
if err := validateRecoveryGate(path, valid.Product, valid.HardwareProfile); err != nil {
t.Fatal(err)
}
for name, mutate := range map[string]func(*recoveryGate){
"unauthorized": func(value *recoveryGate) { value.Authorized = false },
"wrong-profile": func(value *recoveryGate) { value.HardwareProfile = "other" },
"missing-evidence": func(value *recoveryGate) { value.TargetEvidenceID = "" },
"bad-fit-hash": func(value *recoveryGate) { value.RecoveryFITSHA256 = "bad" },
} {
t.Run(name, func(t *testing.T) {
candidate := valid
mutate(&candidate)
candidatePath := filepath.Join(t.TempDir(), "gate.json")
writeGate(t, candidatePath, candidate)
if err := validateRecoveryGate(candidatePath, valid.Product, valid.HardwareProfile); err == nil {
t.Fatal("invalid recovery gate was accepted")
}
})
}
}
func writeGate(t *testing.T, path string, value recoveryGate) {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}

57
backend/cmd/tera-web/main.go

@ -0,0 +1,57 @@
// tera-web serves static browser assets and the same-origin REST/stream proxy.
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"tera-clone/services/tera-api/internal/webgateway"
)
func main() {
listenAddress := webGetenv("TERA_WEB_LISTEN_ADDR", "127.0.0.1:8080")
handler, err := webgateway.NewHandler(webgateway.Options{
StaticRoot: webGetenv("TERA_WEB_STATIC_ROOT", "../../webapp"),
APIUpstream: webGetenv("TERA_WEB_API_UPSTREAM", "http://127.0.0.1:8082"),
SessionUpstream: webGetenv("TERA_WEB_SESSION_UPSTREAM", "http://127.0.0.1:8081"),
})
if err != nil {
log.Fatalf("tera-web setup failed: %v", err)
}
server := &http.Server{
Addr: listenAddress,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("tera-web listening on http://%s", listenAddress)
serverErrors := make(chan error, 1)
go func() { serverErrors <- server.ListenAndServe() }()
signalContext, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stopSignals()
select {
case <-signalContext.Done():
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownContext); err != nil {
log.Printf("tera-web HTTP shutdown failed: %v", err)
}
case err := <-serverErrors:
if err != nil && err != http.ErrServerClosed {
log.Fatalf("tera-web failed: %v", err)
}
}
}
func webGetenv(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}

118
backend/deploy/buildroot/etc/init.d/S40tera-seriald

@ -0,0 +1,118 @@
#!/bin/sh
DAEMON=/usr/bin/tera-seriald
PIDFILE=/run/tera-seriald/tera-seriald.pid
ENVFILE=/etc/tera/tera-seriald.env
RUN_AS=tera:tera
ensure_account() {
if ! grep -q '^tera:' /etc/group; then
addgroup -S tera || return 1
fi
if ! id tera >/dev/null 2>&1; then
adduser -S -D -H -s /bin/false -G tera tera || return 1
fi
normalize_service_account_password || return 1
}
normalize_service_account_password() {
shadow_value="$(awk -F: '$1 == "tera" { print $2 }' /etc/shadow)" || return 1
case "$shadow_value" in
""|!*) ;;
\**) ;;
*) return 0 ;;
esac
command -v mkpasswd >/dev/null 2>&1 || return 1
random_secret="$(dd if=/dev/urandom bs=48 count=1 2>/dev/null | sha256sum | cut -d' ' -f1)" || return 1
password_hash="$(mkpasswd -m sha-256 "$random_secret")" || return 1
unset random_secret
temporary_shadow="/etc/.shadow.tera.$$"
trap 'rm -f "$temporary_shadow"' EXIT HUP INT TERM
awk -F: -v OFS=: -v replacement="$password_hash" '
$1 == "tera" { $2 = replacement; found = 1 }
{ print }
END { if (!found) exit 1 }
' /etc/shadow >"$temporary_shadow" || return 1
chown 0:0 "$temporary_shadow" || return 1
chmod 0600 "$temporary_shadow" || return 1
mv -f "$temporary_shadow" /etc/shadow || return 1
trap - EXIT HUP INT TERM
}
wait_for_mapped_devices() {
mapping="${TERA_SERIALD_MAPPING:-/etc/tera/serial-map.json}"
[ -r "$mapping" ] || { echo "missing serial mapping $mapping" >&2; return 1; }
devices="$(sed -n 's/.*"device":[[:space:]]*"\([^"]*\)".*/\1/p' "$mapping")" || return 1
[ "$(printf '%s\n' "$devices" | grep -c '^/dev/ttyWCH[0-9][0-9]*$')" -eq 48 ] || {
echo "serial mapping must contain exactly 48 WCH devices" >&2
return 1
}
attempt=0
while [ "$attempt" -lt 100 ]; do
missing=0
for device in $devices; do
[ -c "$device" ] || { missing=1; break; }
done
[ "$missing" -eq 0 ] && return 0
attempt=$((attempt + 1))
sleep 0.1
done
echo "mapped WCH devices did not appear within 10 seconds" >&2
return 1
}
prepare_runtime() {
ensure_account || return 1
wait_for_mapped_devices || return 1
mkdir -p /run/tera-seriald /var/log/tera || return 1
chown tera:tera /run/tera-seriald /var/log/tera || return 1
for device in /dev/ttyWCH*; do
[ -c "$device" ] || continue
chgrp tera "$device" || return 1
chmod 0660 "$device" || return 1
done
}
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
if is_running; then
echo "tera-seriald is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
load_environment
prepare_runtime || return 1
start-stop-daemon -S -q -b -m -p "$PIDFILE" -c "$RUN_AS" -x "$DAEMON"
}
stop_service() {
if ! is_running; then
rm -f "$PIDFILE"
return 0
fi
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON"
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

63
backend/deploy/buildroot/etc/init.d/S42tera-hwd

@ -0,0 +1,63 @@
#!/bin/sh
DAEMON=/usr/bin/tera-hwd
PIDFILE=/run/tera-hwd/tera-hwd.pid
ENVFILE=/etc/tera/tera-hwd.env
RUN_AS=root:tera
ensure_group() {
if ! grep -q '^tera:' /etc/group; then
addgroup -S tera || return 1
fi
}
prepare_runtime() {
ensure_group || return 1
mkdir -p /run/tera-hwd || return 1
chown root:tera /run/tera-hwd || return 1
chmod 0750 /run/tera-hwd || return 1
}
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
if is_running; then
echo "tera-hwd is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
prepare_runtime || return 1
load_environment
umask 0007
start-stop-daemon -S -q -b -m -p "$PIDFILE" -c "$RUN_AS" -x "$DAEMON"
}
stop_service() {
if ! is_running; then
rm -f "$PIDFILE"
return 0
fi
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON"
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

65
backend/deploy/buildroot/etc/init.d/S43tera-netd

@ -0,0 +1,65 @@
#!/bin/sh
DAEMON=/usr/bin/tera-netd
PIDFILE=/run/tera-netd.pid
ENVFILE=/etc/tera/tera-netd.env
RUN_AS=root:tera
ensure_group() {
if ! grep -q '^tera:' /etc/group; then
addgroup -S tera || return 1
fi
}
prepare_runtime() {
ensure_group || return 1
mkdir -p /run/tera-netd /var/lib/tera-network || return 1
chown root:tera /run/tera-netd || return 1
chown root:root /var/lib/tera-network || return 1
chmod 0750 /run/tera-netd || return 1
chmod 0700 /var/lib/tera-network || return 1
}
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
if is_running; then
echo "tera-netd is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
prepare_runtime || return 1
load_environment
umask 0007
start-stop-daemon -S -q -b -m -p "$PIDFILE" -c "$RUN_AS" -x "$DAEMON"
}
stop_service() {
if ! is_running; then
rm -f "$PIDFILE"
return 0
fi
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON"
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

69
backend/deploy/buildroot/etc/init.d/S44tera-logd

@ -0,0 +1,69 @@
#!/bin/sh
DAEMON=/usr/bin/tera-logd
PIDFILE=/run/tera-logd/tera-logd.pid
ENVFILE=/etc/tera/tera-logd.env
RUN_AS=tera-logd:tera
ensure_accounts() {
if ! grep -q '^tera:' /etc/group; then
addgroup -S tera || return 1
fi
if ! id tera-logd >/dev/null 2>&1; then
adduser -S -D -H -s /bin/false tera-logd || return 1
fi
addgroup tera-logd tera 2>/dev/null || true
}
prepare_runtime() {
ensure_accounts || return 1
mkdir -p /run/tera-logd || return 1
chown tera-logd:tera /run/tera-logd || return 1
chmod 0750 /run/tera-logd || return 1
/usr/libexec/tera-storage-init --check || return 1
[ -d /userdata/log ] || { echo "missing /userdata/log" >&2; return 1; }
[ -r /userdata/storage-layout.json ] || { echo "missing storage policy" >&2; return 1; }
}
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
if is_running; then
echo "tera-logd is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
prepare_runtime || return 1
load_environment
start-stop-daemon -S -q -b -m -p "$PIDFILE" -c "$RUN_AS" -x "$DAEMON"
}
stop_service() {
if ! is_running; then
rm -f "$PIDFILE"
return 0
fi
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON"
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

63
backend/deploy/buildroot/etc/init.d/S45tera-timed

@ -0,0 +1,63 @@
#!/bin/sh
DAEMON=/usr/bin/tera-timed
PIDFILE=/run/tera-timed.pid
ENVFILE=/etc/tera/tera-timed.env
RUN_AS=root:tera
ensure_group() {
if ! grep -q '^tera:' /etc/group; then
addgroup -S tera || return 1
fi
}
prepare_runtime() {
ensure_group || return 1
mkdir -p /run/tera-timed || return 1
chown root:tera /run/tera-timed || return 1
chmod 0750 /run/tera-timed || return 1
}
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
if is_running; then
echo "tera-timed is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
prepare_runtime || return 1
load_environment
umask 0007
start-stop-daemon -S -q -b -m -p "$PIDFILE" -c "$RUN_AS" -x "$DAEMON"
}
stop_service() {
if ! is_running; then
rm -f "$PIDFILE"
return 0
fi
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON"
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

67
backend/deploy/buildroot/etc/init.d/S46tera-snmpd

@ -0,0 +1,67 @@
#!/bin/sh
DAEMON=/usr/bin/tera-snmpd
PIDFILE=/run/tera-snmpd.pid
ENVFILE=/etc/tera/tera-snmpd.env
RUN_AS=root:tera
ensure_group() {
if ! grep -q '^tera:' /etc/group; then
addgroup -S tera || return 1
fi
}
prepare_runtime() {
ensure_group || return 1
mkdir -p /run/tera-snmpd /etc/snmp /var/lib/net-snmp || return 1
chown root:tera /run/tera-snmpd || return 1
chown root:root /etc/snmp /var/lib/net-snmp || return 1
chmod 0750 /run/tera-snmpd /etc/snmp || return 1
chmod 0700 /var/lib/net-snmp || return 1
}
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
if is_running; then
echo "tera-snmpd is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
[ -x /etc/init.d/S59snmpd ] || { echo "missing Net-SNMP service" >&2; return 1; }
prepare_runtime || return 1
load_environment
umask 0007
start-stop-daemon -S -q -b -m -p "$PIDFILE" -c "$RUN_AS" -x "$DAEMON"
}
stop_service() {
if ! is_running; then
rm -f "$PIDFILE"
return 0
fi
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON"
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

69
backend/deploy/buildroot/etc/init.d/S47tera-alarmd

@ -0,0 +1,69 @@
#!/bin/sh
DAEMON=/usr/bin/tera-alarmd
PIDFILE=/run/tera-alarmd.pid
ENVFILE=/etc/tera/tera-alarmd.env
ensure_group() {
if ! grep -q '^tera:' /etc/group; then
addgroup -S tera || return 1
fi
}
ensure_user() {
ensure_group || return 1
if ! grep -q '^tera:' /etc/passwd; then
adduser -S -D -H -s /sbin/nologin -G tera tera || return 1
fi
}
prepare_directories() {
ensure_user || return 1
mkdir -p /run/tera-alarmd /var/lib/tera-alarmd || return 1
chown tera:tera /run/tera-alarmd /var/lib/tera-alarmd || return 1
chmod 0750 /run/tera-alarmd /var/lib/tera-alarmd || return 1
}
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
if is_running; then
echo "tera-alarmd is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
prepare_directories || return 1
load_environment
umask 0007
start-stop-daemon -S -q -b -m -p "$PIDFILE" -c tera:tera -x "$DAEMON"
}
stop_service() {
if ! is_running; then
rm -f "$PIDFILE"
return 0
fi
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON"
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

70
backend/deploy/buildroot/etc/init.d/S48tera-updated

@ -0,0 +1,70 @@
#!/bin/sh
DAEMON=/usr/bin/tera-updated
PIDFILE=/run/tera-updated.pid
ENVFILE=/etc/tera/tera-updated.env
RUN_AS=tera-updated:tera
ensure_accounts() {
if ! grep -q '^tera:' /etc/group; then
addgroup -S tera || return 1
fi
if ! grep -q '^tera-updated:' /etc/group; then
addgroup -S tera-updated || return 1
fi
if ! id tera-updated >/dev/null 2>&1; then
adduser -S -D -H -s /bin/false -G tera-updated tera-updated || return 1
fi
}
prepare_runtime() {
ensure_accounts || return 1
/usr/libexec/tera-storage-init --check >/dev/null || return 1
mkdir -p /run/tera-updated || return 1
chown tera-updated:tera /run/tera-updated || return 1
chmod 0750 /run/tera-updated || return 1
}
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
if is_running; then
echo "tera-updated is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
prepare_runtime || return 1
load_environment
umask 0007
start-stop-daemon -S -q -b -m -p "$PIDFILE" -c "$RUN_AS" -x "$DAEMON"
}
stop_service() {
if ! is_running; then
rm -f "$PIDFILE"
return 0
fi
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON"
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

66
backend/deploy/buildroot/etc/init.d/S49tera-accessd

@ -0,0 +1,66 @@
#!/bin/sh
DAEMON=/usr/bin/tera-accessd
PIDFILE=/run/tera-accessd.pid
ENVFILE=/etc/tera/tera-accessd.env
SOCKET=/run/tera-accessd/control.sock
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
SOCKET=${TERA_ACCESSD_SOCKET:-$SOCKET}
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
if is_running; then
echo "tera-accessd is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
[ -r /usr/share/tera-web/index.html ] || { echo "missing tera-web assets" >&2; return 1; }
load_environment
mkdir -p /run/tera-accessd /var/lib/tera-api/tls/library
chown root:tera /run/tera-accessd
chmod 0750 /run/tera-accessd
chown -R tera:tera /var/lib/tera-api/tls
chmod 0700 /var/lib/tera-api/tls /var/lib/tera-api/tls/library
start-stop-daemon -S -q -b -m -p "$PIDFILE" -x "$DAEMON"
attempts=0
while [ "$attempts" -lt 50 ]; do
if [ -S "$SOCKET" ]; then
chown root:tera "$SOCKET" || return 1
chmod 0660 "$SOCKET" || return 1
return 0
fi
is_running || return 1
attempts=$((attempts + 1))
sleep 0.1
done
return 1
}
stop_service() {
load_environment
if is_running; then
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON" || return 1
fi
rm -f "$PIDFILE" "$SOCKET"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

83
backend/deploy/buildroot/etc/init.d/S50tera-api

@ -0,0 +1,83 @@
#!/bin/sh
DAEMON=/usr/bin/tera-api
PIDFILE=/run/tera-api.pid
ENVFILE=/etc/tera/tera-api.env
RUN_AS=tera:tera
ensure_account() {
if ! grep -q '^tera:' /etc/group; then
addgroup -S tera || return 1
fi
if ! id tera >/dev/null 2>&1; then
adduser -S -D -H -s /bin/false -G tera tera || return 1
fi
}
prepare_runtime() {
ensure_account || return 1
mkdir -p /run/tera-api /var/lib/tera-api /var/log/tera || return 1
chown tera:tera /run/tera-api || return 1
chown -R tera:tera /var/lib/tera-api /var/log/tera || return 1
chmod 0750 /run/tera-api /var/lib/tera-api /var/log/tera || return 1
}
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
wait_for_socket() {
socket_path=$1
attempts=0
while [ "$attempts" -lt 30 ]; do
[ -S "$socket_path" ] && return 0
attempts=$((attempts + 1))
sleep 1
done
return 1
}
start_service() {
if is_running; then
echo "tera-api is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
prepare_runtime || return 1
load_environment
if [ -n "${TERA_API_LOG_SOCKET:-}" ]; then
wait_for_socket "$TERA_API_LOG_SOCKET" || {
echo "tera-api dependency socket is not ready: $TERA_API_LOG_SOCKET" >&2
return 1
}
fi
start-stop-daemon -S -q -b -m -p "$PIDFILE" -c "$RUN_AS" -x "$DAEMON"
}
stop_service() {
if ! is_running; then
rm -f "$PIDFILE"
return 0
fi
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON"
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

48
backend/deploy/buildroot/etc/init.d/S55tera-sessiond

@ -0,0 +1,48 @@
#!/bin/sh
DAEMON=/usr/bin/tera-sessiond
PIDFILE=/run/tera-sessiond.pid
ENVFILE=/etc/tera/tera-sessiond.env
RUN_AS=tera:tera
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
if is_running; then
echo "tera-sessiond is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
load_environment
start-stop-daemon -S -q -b -m -p "$PIDFILE" -c "$RUN_AS" -x "$DAEMON"
}
stop_service() {
if ! is_running; then
rm -f "$PIDFILE"
return 0
fi
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON"
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

57
backend/deploy/buildroot/etc/init.d/S60tera-web

@ -0,0 +1,57 @@
#!/bin/sh
DAEMON=/usr/bin/tera-web
PIDFILE=/run/tera-web.pid
ENVFILE=/etc/tera/tera-web.env
RUN_AS=tera:tera
STATE_DIR=/var/lib/tera-web
prepare_runtime() {
mkdir -p "$STATE_DIR" || return 1
chown tera:tera "$STATE_DIR" || return 1
chmod 0750 "$STATE_DIR" || return 1
}
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
if is_running; then
echo "tera-web is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
[ -r /usr/share/tera-web/index.html ] || { echo "missing tera-web assets" >&2; return 1; }
prepare_runtime || { echo "failed to prepare $STATE_DIR" >&2; return 1; }
load_environment
start-stop-daemon -S -q -b -m -p "$PIDFILE" -c "$RUN_AS" -x "$DAEMON"
}
stop_service() {
if ! is_running; then
rm -f "$PIDFILE"
return 0
fi
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON"
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

56
backend/deploy/buildroot/etc/init.d/S61tera-clone-demo-web

@ -0,0 +1,56 @@
#!/bin/sh
DAEMON=/usr/bin/tera-clone-demo-web
PIDFILE=/run/tera-clone-demo-web.pid
ENVFILE=/etc/tera/tera-clone-demo-web.env
RUN_AS=tera:tera
STATE_DIR=/var/lib/tera-clone-demo-web
prepare_runtime() {
mkdir -p "$STATE_DIR" || return 1
chown tera:tera "$STATE_DIR" || return 1
chmod 0750 "$STATE_DIR" || return 1
}
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
if is_running; then
echo "tera-clone-demo-web is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
prepare_runtime || { echo "failed to prepare $STATE_DIR" >&2; return 1; }
load_environment
start-stop-daemon -S -q -b -m -p "$PIDFILE" -c "$RUN_AS" -x "$DAEMON"
}
stop_service() {
if ! is_running; then
rm -f "$PIDFILE"
return 0
fi
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON"
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

2
backend/deploy/buildroot/etc/udev/rules.d/99-tera-serial.rules

@ -0,0 +1,2 @@
# Restrict WCH multi-port serial devices to the tera service group.
KERNEL=="ttyWCH[0-9]*", GROUP="tera", MODE="0660"

112
backend/deploy/clone-demo/etc/init.d/S39tera-ethernet-map

@ -0,0 +1,112 @@
#!/bin/sh
PATH=/sbin:/bin:/usr/sbin:/usr/bin
TARGET_ETH0_NODE="ethernet@fe010000"
TARGET_ETH1_NODE="ethernet@fe2a0000"
TEMP_NAME="tera-swap0"
log_message() {
echo "tera-ethernet-map: $*"
}
node_for_interface() {
node_path=$(readlink -f "/sys/class/net/$1/device/of_node" 2>/dev/null) || return 1
echo "${node_path##*/}"
}
wait_for_interfaces() {
attempt=0
while [ "$attempt" -lt 5 ]; do
if [ -e /sys/class/net/eth0 ] && [ -e /sys/class/net/eth1 ]; then
return 0
fi
attempt=$((attempt + 1))
sleep 1
done
return 1
}
start_mapping() {
if ! wait_for_interfaces; then
log_message "eth0/eth1 did not appear"
return 1
fi
eth0_node=$(node_for_interface eth0) || {
log_message "cannot resolve eth0 device-tree node"
return 1
}
eth1_node=$(node_for_interface eth1) || {
log_message "cannot resolve eth1 device-tree node"
return 1
}
if [ "$eth0_node" = "$TARGET_ETH0_NODE" ] && \
[ "$eth1_node" = "$TARGET_ETH1_NODE" ]; then
log_message "target mapping is already active"
return 0
fi
if [ "$eth0_node" != "$TARGET_ETH1_NODE" ] || \
[ "$eth1_node" != "$TARGET_ETH0_NODE" ]; then
log_message "unexpected mapping: eth0=$eth0_node eth1=$eth1_node"
return 1
fi
if ip link show dev "$TEMP_NAME" >/dev/null 2>&1; then
log_message "temporary interface $TEMP_NAME already exists"
return 1
fi
ip link set dev eth0 down || return 1
ip link set dev eth1 down || return 1
ip link set dev eth0 name "$TEMP_NAME" || return 1
if ! ip link set dev eth1 name eth0; then
ip link set dev "$TEMP_NAME" name eth0 >/dev/null 2>&1
log_message "failed to rename original eth1 to eth0; rolled back"
return 1
fi
if ! ip link set dev "$TEMP_NAME" name eth1; then
ip link set dev eth0 name eth1 >/dev/null 2>&1
ip link set dev "$TEMP_NAME" name eth0 >/dev/null 2>&1
log_message "failed to finish interface swap; rolled back"
return 1
fi
eth0_node=$(node_for_interface eth0)
eth1_node=$(node_for_interface eth1)
if [ "$eth0_node" != "$TARGET_ETH0_NODE" ] || \
[ "$eth1_node" != "$TARGET_ETH1_NODE" ]; then
log_message "post-swap verification failed: eth0=$eth0_node eth1=$eth1_node"
return 1
fi
log_message "mapping applied: eth0=$eth0_node eth1=$eth1_node"
return 0
}
case "${1:-start}" in
start|restart)
start_mapping
;;
stop)
;;
status)
eth0_node=$(node_for_interface eth0 2>/dev/null || echo missing)
eth1_node=$(node_for_interface eth1 2>/dev/null || echo missing)
echo "eth0=$eth0_node"
echo "eth1=$eth1_node"
if [ "$eth0_node" = "$TARGET_ETH0_NODE" ] && \
[ "$eth1_node" = "$TARGET_ETH1_NODE" ]; then
exit 0
fi
exit 1
;;
*)
echo "Usage: $0 {start|stop|restart|status}" >&2
exit 2
;;
esac

58
backend/deploy/clone-demo/etc/init.d/S48tera-clone-demo-alarmd

@ -0,0 +1,58 @@
#!/bin/sh
DAEMON=/usr/bin/tera-clone-demo-alarmd
PIDFILE=/run/tera-clone-demo-alarmd.pid
ENVFILE=/etc/tera/tera-clone-demo-alarmd.env
RUN_AS=tera:tera
STATE_DIR=/var/lib/tera-clone-demo-alarmd
RUNTIME_DIR=/run/tera-clone-demo-alarmd
prepare_runtime() {
mkdir -p "$STATE_DIR" "$RUNTIME_DIR" || return 1
chown -R "$RUN_AS" "$STATE_DIR" "$RUNTIME_DIR" || return 1
chmod 0750 "$STATE_DIR" "$RUNTIME_DIR" || return 1
}
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
if is_running; then
echo "tera-clone-demo-alarmd is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
prepare_runtime || return 1
load_environment
umask 0007
start-stop-daemon -S -q -b -m -p "$PIDFILE" -c "$RUN_AS" -x "$DAEMON"
}
stop_service() {
if ! is_running; then
rm -f "$PIDFILE"
return 0
fi
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON"
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

57
backend/deploy/clone-demo/etc/init.d/S51tera-clone-demo-api

@ -0,0 +1,57 @@
#!/bin/sh
DAEMON=/usr/bin/tera-clone-demo-api
PIDFILE=/run/tera-clone-demo-api.pid
ENVFILE=/etc/tera/tera-clone-demo-api.env
RUN_AS=tera:tera
STATE_DIR=/var/lib/tera-clone-demo-api
RUNTIME_DIR=/run/tera-clone-demo-api
prepare_runtime() {
mkdir -p "$STATE_DIR" "$RUNTIME_DIR" /var/log/tera || return 1
chown -R tera:tera "$STATE_DIR" "$RUNTIME_DIR" || return 1
chmod 0750 "$STATE_DIR" "$RUNTIME_DIR" || return 1
}
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
if is_running; then
echo "tera-clone-demo-api is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
prepare_runtime || return 1
load_environment
start-stop-daemon -S -q -b -m -p "$PIDFILE" -c "$RUN_AS" -x "$DAEMON"
}
stop_service() {
if ! is_running; then
rm -f "$PIDFILE"
return 0
fi
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON"
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

56
backend/deploy/clone-demo/etc/init.d/S55tera-sessiond-shared-session

@ -0,0 +1,56 @@
#!/bin/sh
DAEMON=/usr/bin/tera-sessiond
PIDFILE=/run/tera-sessiond.pid
ENVFILE=/etc/tera/tera-sessiond.env
RUN_AS=tera:tera
RUNTIME_DIR=/run/tera-sessiond
prepare_runtime() {
mkdir -p "$RUNTIME_DIR" || return 1
chown tera:tera "$RUNTIME_DIR" || return 1
chmod 0750 "$RUNTIME_DIR" || return 1
}
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
if is_running; then
echo "tera-sessiond is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
prepare_runtime || return 1
load_environment
start-stop-daemon -S -q -b -m -p "$PIDFILE" -c "$RUN_AS" -x "$DAEMON"
}
stop_service() {
if ! is_running; then
rm -f "$PIDFILE"
return 0
fi
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON"
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

56
backend/deploy/clone-demo/etc/init.d/S61tera-clone-demo-web

@ -0,0 +1,56 @@
#!/bin/sh
DAEMON=/usr/bin/tera-clone-demo-web
PIDFILE=/run/tera-clone-demo-web.pid
ENVFILE=/etc/tera/tera-clone-demo-web.env
RUN_AS=tera:tera
STATE_DIR=/var/lib/tera-clone-demo-web
prepare_runtime() {
mkdir -p "$STATE_DIR" || return 1
chown tera:tera "$STATE_DIR" || return 1
chmod 0750 "$STATE_DIR" || return 1
}
load_environment() {
if [ -r "$ENVFILE" ]; then
set -a
. "$ENVFILE"
set +a
fi
}
is_running() {
[ -r "$PIDFILE" ] || return 1
pid="$(cat "$PIDFILE" 2>/dev/null)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
start_service() {
if is_running; then
echo "tera-clone-demo-web is already running"
return 0
fi
rm -f "$PIDFILE"
[ -x "$DAEMON" ] || { echo "missing $DAEMON" >&2; return 1; }
prepare_runtime || return 1
load_environment
start-stop-daemon -S -q -b -m -p "$PIDFILE" -c "$RUN_AS" -x "$DAEMON"
}
stop_service() {
if ! is_running; then
rm -f "$PIDFILE"
return 0
fi
start-stop-daemon -K -q -p "$PIDFILE" -x "$DAEMON"
rm -f "$PIDFILE"
}
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service && start_service ;;
status) is_running ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac

96
backend/deploy/docker-compose.yml

@ -0,0 +1,96 @@
name: tera-direct
x-tera-service: &tera-service
image: tera/direct-mode-dev:local
restart: "no"
services:
seriald:
<<: *tera-service
command: ["tera-seriald"]
environment:
TERA_SERIALD_SOCKET: /run/tera-seriald/serial-ports.sock
TERA_SERIALD_STREAM_SOCKET: /run/tera-seriald/serial-stream.sock
TERA_SERIALD_MAPPING: /etc/tera/serial-map.json
TERA_SERIALD_HARDWARE_PROFILE: fet3568j-c
TERA_SERIALD_EVENT_QUEUE: "128"
TERA_SERIALD_REPORT_INTERVAL_SECONDS: "5"
volumes:
- serial-run:/run/tera-seriald
healthcheck:
test: ["CMD-SHELL", "test -S /run/tera-seriald/serial-ports.sock && test -S /run/tera-seriald/serial-stream.sock"]
interval: 2s
timeout: 1s
retries: 20
api:
<<: *tera-service
build:
context: ../../..
dockerfile: services/tera-api/deploy/docker/Dockerfile
command: ["tera-api"]
environment:
TERA_API_LISTEN_ADDR: 0.0.0.0:8082
TERA_API_SERIAL_SOCKET: /run/tera-seriald/serial-ports.sock
TERA_API_DIRECT_TICKET_SOCKET: /run/tera-api/direct-tickets.sock
TERA_API_ALLOW_MOCK_ACTORS: "true"
TERA_API_PASSWORD_KDF: pbkdf2-hmac-sha256
ports:
- "18080:8080"
volumes:
- api-run:/run/tera-api
- serial-run:/run/tera-seriald
depends_on:
seriald:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "test -S /run/tera-api/direct-tickets.sock"]
interval: 2s
timeout: 1s
retries: 20
sessiond:
<<: *tera-service
command: ["tera-sessiond"]
network_mode: "service:api"
environment:
TERA_SESSIOND_LISTEN_ADDR: 0.0.0.0:8081
TERA_SESSIOND_TICKET_SOCKET: /run/tera-api/direct-tickets.sock
TERA_SESSIOND_SERIAL_STREAM_SOCKET: /run/tera-seriald/serial-stream.sock
volumes:
- api-run:/run/tera-api
- serial-run:/run/tera-seriald
depends_on:
api:
condition: service_healthy
seriald:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8081/readyz"]
interval: 2s
timeout: 2s
retries: 20
web:
<<: *tera-service
command: ["tera-web"]
network_mode: "service:api"
environment:
TERA_WEB_LISTEN_ADDR: 0.0.0.0:8080
TERA_WEB_STATIC_ROOT: /usr/share/tera-web
TERA_WEB_API_UPSTREAM: http://127.0.0.1:8082
TERA_WEB_SESSION_UPSTREAM: http://127.0.0.1:8081
depends_on:
api:
condition: service_healthy
sessiond:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8080/"]
interval: 2s
timeout: 2s
retries: 20
volumes:
api-run:
serial-run:

21
backend/deploy/docker/Dockerfile

@ -0,0 +1,21 @@
FROM golang:1.25-bookworm AS build
WORKDIR /src/services/tera-api
COPY services/tera-api/ ./
RUN CGO_ENABLED=0 GOOS=linux go build -mod=vendor -trimpath -o /out/tera-api ./cmd/tera-api \
&& CGO_ENABLED=0 GOOS=linux go build -mod=vendor -trimpath -o /out/tera-seriald ./cmd/tera-seriald \
&& CGO_ENABLED=0 GOOS=linux go build -mod=vendor -trimpath -o /out/tera-sessiond ./cmd/tera-sessiond \
&& CGO_ENABLED=0 GOOS=linux go build -mod=vendor -trimpath -o /out/tera-web ./cmd/tera-web
FROM alpine:3.22
RUN addgroup -S tera && adduser -S -D -H -s /sbin/nologin -G tera tera \
&& mkdir -p /etc/tera /usr/share/tera-web /var/lib/tera-api /run/tera-api /run/tera-seriald \
&& chown -R tera:tera /var/lib/tera-api /run/tera-api /run/tera-seriald
COPY --from=build /out/tera-api /out/tera-seriald /out/tera-sessiond /out/tera-web /usr/bin/
COPY services/tera-api/deploy/etc/tera/serial-map.json /etc/tera/serial-map.json
COPY webapp/ /usr/share/tera-web/
COPY services/tera-api/deploy/docker/entrypoint.sh /usr/local/bin/tera-entrypoint
RUN chmod 0755 /usr/local/bin/tera-entrypoint /usr/bin/tera-api /usr/bin/tera-seriald /usr/bin/tera-sessiond /usr/bin/tera-web
ENTRYPOINT ["/usr/local/bin/tera-entrypoint"]

16
backend/deploy/docker/entrypoint.sh

@ -0,0 +1,16 @@
#!/bin/sh
set -eu
mkdir -p /run/tera-api /run/tera-seriald /var/lib/tera-api
chown tera:tera /run/tera-api /run/tera-seriald /var/lib/tera-api
case "${1:-}" in
tera-api|tera-seriald|tera-sessiond|tera-web)
daemon="$1"
exec su -s /bin/sh tera -c "exec /usr/bin/$daemon"
;;
*)
echo "unsupported Direct Mode service: ${1:-missing}" >&2
exit 2
;;
esac

57
backend/deploy/etc/tera/serial-map.json

@ -0,0 +1,57 @@
{
"format_version": 1,
"hardware_profile": "fet3568j-c",
"mapping_version": "fet3568j-c-20260721-v1",
"evidence": "target-bsp/docs/snapshots/20260721-wch-legacy-intx-loopback/manifest.json; operator-confirmed linear 24+24 channel mapping",
"validated": true,
"channels": [
{"channel": "CH01", "device": "/dev/ttyWCH4", "pci_bdf": "0000:01:00.0"},
{"channel": "CH02", "device": "/dev/ttyWCH5", "pci_bdf": "0000:01:00.0"},
{"channel": "CH03", "device": "/dev/ttyWCH6", "pci_bdf": "0000:01:00.0"},
{"channel": "CH04", "device": "/dev/ttyWCH7", "pci_bdf": "0000:01:00.0"},
{"channel": "CH05", "device": "/dev/ttyWCH8", "pci_bdf": "0000:01:00.0"},
{"channel": "CH06", "device": "/dev/ttyWCH9", "pci_bdf": "0000:01:00.0"},
{"channel": "CH07", "device": "/dev/ttyWCH10", "pci_bdf": "0000:01:00.0"},
{"channel": "CH08", "device": "/dev/ttyWCH11", "pci_bdf": "0000:01:00.0"},
{"channel": "CH09", "device": "/dev/ttyWCH12", "pci_bdf": "0000:01:00.0"},
{"channel": "CH10", "device": "/dev/ttyWCH13", "pci_bdf": "0000:01:00.0"},
{"channel": "CH11", "device": "/dev/ttyWCH14", "pci_bdf": "0000:01:00.0"},
{"channel": "CH12", "device": "/dev/ttyWCH15", "pci_bdf": "0000:01:00.0"},
{"channel": "CH13", "device": "/dev/ttyWCH16", "pci_bdf": "0000:01:00.0"},
{"channel": "CH14", "device": "/dev/ttyWCH17", "pci_bdf": "0000:01:00.0"},
{"channel": "CH15", "device": "/dev/ttyWCH18", "pci_bdf": "0000:01:00.0"},
{"channel": "CH16", "device": "/dev/ttyWCH19", "pci_bdf": "0000:01:00.0"},
{"channel": "CH17", "device": "/dev/ttyWCH20", "pci_bdf": "0000:01:00.0"},
{"channel": "CH18", "device": "/dev/ttyWCH21", "pci_bdf": "0000:01:00.0"},
{"channel": "CH19", "device": "/dev/ttyWCH22", "pci_bdf": "0000:01:00.0"},
{"channel": "CH20", "device": "/dev/ttyWCH23", "pci_bdf": "0000:01:00.0"},
{"channel": "CH21", "device": "/dev/ttyWCH24", "pci_bdf": "0000:01:00.0"},
{"channel": "CH22", "device": "/dev/ttyWCH25", "pci_bdf": "0000:01:00.0"},
{"channel": "CH23", "device": "/dev/ttyWCH26", "pci_bdf": "0000:01:00.0"},
{"channel": "CH24", "device": "/dev/ttyWCH27", "pci_bdf": "0000:01:00.0"},
{"channel": "CH25", "device": "/dev/ttyWCH32", "pci_bdf": "0002:21:00.0"},
{"channel": "CH26", "device": "/dev/ttyWCH33", "pci_bdf": "0002:21:00.0"},
{"channel": "CH27", "device": "/dev/ttyWCH34", "pci_bdf": "0002:21:00.0"},
{"channel": "CH28", "device": "/dev/ttyWCH35", "pci_bdf": "0002:21:00.0"},
{"channel": "CH29", "device": "/dev/ttyWCH36", "pci_bdf": "0002:21:00.0"},
{"channel": "CH30", "device": "/dev/ttyWCH37", "pci_bdf": "0002:21:00.0"},
{"channel": "CH31", "device": "/dev/ttyWCH38", "pci_bdf": "0002:21:00.0"},
{"channel": "CH32", "device": "/dev/ttyWCH39", "pci_bdf": "0002:21:00.0"},
{"channel": "CH33", "device": "/dev/ttyWCH40", "pci_bdf": "0002:21:00.0"},
{"channel": "CH34", "device": "/dev/ttyWCH41", "pci_bdf": "0002:21:00.0"},
{"channel": "CH35", "device": "/dev/ttyWCH42", "pci_bdf": "0002:21:00.0"},
{"channel": "CH36", "device": "/dev/ttyWCH43", "pci_bdf": "0002:21:00.0"},
{"channel": "CH37", "device": "/dev/ttyWCH44", "pci_bdf": "0002:21:00.0"},
{"channel": "CH38", "device": "/dev/ttyWCH45", "pci_bdf": "0002:21:00.0"},
{"channel": "CH39", "device": "/dev/ttyWCH46", "pci_bdf": "0002:21:00.0"},
{"channel": "CH40", "device": "/dev/ttyWCH47", "pci_bdf": "0002:21:00.0"},
{"channel": "CH41", "device": "/dev/ttyWCH48", "pci_bdf": "0002:21:00.0"},
{"channel": "CH42", "device": "/dev/ttyWCH49", "pci_bdf": "0002:21:00.0"},
{"channel": "CH43", "device": "/dev/ttyWCH50", "pci_bdf": "0002:21:00.0"},
{"channel": "CH44", "device": "/dev/ttyWCH51", "pci_bdf": "0002:21:00.0"},
{"channel": "CH45", "device": "/dev/ttyWCH52", "pci_bdf": "0002:21:00.0"},
{"channel": "CH46", "device": "/dev/ttyWCH53", "pci_bdf": "0002:21:00.0"},
{"channel": "CH47", "device": "/dev/ttyWCH54", "pci_bdf": "0002:21:00.0"},
{"channel": "CH48", "device": "/dev/ttyWCH55", "pci_bdf": "0002:21:00.0"}
]
}

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

@ -0,0 +1,8 @@
TERA_ACCESSD_SOCKET=/run/tera-accessd/control.sock
TERA_ACCESSD_BIND_HOST=0.0.0.0
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_HOSTS=192.168.0.223,localhost,127.0.0.1

3
backend/deploy/etc/tera/tera-alarmd.env

@ -0,0 +1,3 @@
TERA_ALARMD_SOCKET=/run/tera-alarmd/alarm-delivery.sock
TERA_ALARMD_STATE=/var/lib/tera-alarmd/deliveries.json
TERA_ALARMD_MAX_JOBS=256

49
backend/deploy/etc/tera/tera-api.env

@ -0,0 +1,49 @@
TERA_API_LISTEN_ADDR=127.0.0.1:8082
TERA_API_LOG_MAX_EVENTS=10000
TERA_API_CONFIG_STORE=/var/lib/tera-api/config-store.json
TERA_API_OPERATION_STORE=/var/lib/tera-api/operations.json
TERA_API_AUTH_STORE=/var/lib/tera-api/auth.json
TERA_API_SESSION_STORE=/var/lib/tera-api/sessions.json
TERA_API_LOGIN_FAILURE_STORE=/var/lib/tera-api/login-failures.json
TERA_API_SECRET_STORE=/var/lib/tera-api/secrets.json
TERA_API_BOOT_ID_PATH=/proc/sys/kernel/random/boot_id
TERA_API_BOOT_MARKER_PATH=/var/lib/tera-api/last-notified-boot-id
TERA_API_MIN_PASSWORD_LENGTH=8
TERA_API_GREEN_LED_SOCKET=/run/tera-hwd/green-leds.sock
TERA_API_SYSTEM_SOCKET=/run/tera-hwd/system-control.sock
TERA_API_RESET_BUTTON_POLL_MILLISECONDS=100
TERA_API_RESET_BUTTON_HOLD_MILLISECONDS=1000
TERA_API_SERIAL_SOCKET=/run/tera-seriald/serial-ports.sock
TERA_API_SERIAL_RECONCILE_INTERVAL_SECONDS=30
TERA_API_PORT_SERVICES_ENABLED=true
TERA_API_PORT_LISTEN_HOST=0.0.0.0
TERA_API_PORT_SSH_HOST_KEY=/var/lib/tera-api/port-service-ed25519
TERA_API_PORT_SSH_KNOWN_HOSTS=/etc/tera/ssh_known_hosts
TERA_API_TIME_SOCKET=/run/tera-timed/time.sock
TERA_API_NETWORK_SOCKET=/run/tera-netd/control.sock
TERA_API_MANAGEMENT_SOCKET=/run/tera-accessd/control.sock
TERA_API_ALARM_SOCKET=/run/tera-alarmd/alarm-delivery.sock
TERA_API_SNMP_SOCKET=/run/tera-snmpd/control.sock
TERA_API_TLS_STORE=/var/lib/tera-api/tls/library
TERA_API_TLS_ACTIVE_CERT=/var/lib/tera-api/tls/active.crt
TERA_API_TLS_ACTIVE_KEY=/var/lib/tera-api/tls/active.key
TERA_API_LOG_SOCKET=/run/tera-logd/log-store.sock
TERA_API_LOG_SCHEDULER_ENABLED=true
TERA_API_OPERATIONAL_ALARM_INTERVAL_SECONDS=60
TERA_API_LOG_ETHERNET_INTERFACES=Interface-1=eth0,Interface-2=eth1
TERA_API_UPDATE_SOCKET=/run/tera-updated/control.sock
TERA_API_UPDATE_MAX_BYTES=1342177280
TERA_API_FIRMWARE_TIMEOUT_SECONDS=1800
TERA_API_DIRECT_TICKET_SOCKET=/run/tera-api/direct-tickets.sock
TERA_API_SESSIOND_STATUS_URL=http://127.0.0.1:8081/readyz
TERA_API_DIAGNOSTICS_ENABLED=true
TERA_API_DIAGNOSTICS_PING_PATH=/bin/ping
TERA_API_DIAGNOSTICS_WORKERS=2
TERA_API_DIAGNOSTICS_QUEUE=16
TERA_API_SUPPORT_BUNDLE_DIR=/var/lib/tera-api/support-bundles
TERA_API_SUPPORT_BUNDLE_MAX_ARTIFACTS=4
TERA_API_SUPPORT_BUNDLE_MAX_BYTES=8388608
TERA_API_SUPPORT_BUNDLE_MAX_SECTION_BYTES=2097152
TERA_API_PASSWORD_KDF=argon2id
TERA_API_CORS_ORIGINS=
TERA_API_ALLOW_MOCK_ACTORS=false

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

@ -0,0 +1,4 @@
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

4
backend/deploy/etc/tera/tera-hwd.env

@ -0,0 +1,4 @@
TERA_HWD_GREEN_LED_SOCKET=/run/tera-hwd/green-leds.sock
TERA_HWD_GREEN_LED_SYSFS_ROOT=/sys/class/leds
TERA_HWD_SYSTEM_SOCKET=/run/tera-hwd/system-control.sock
TERA_HWD_RESET_BUTTON_VALUE_PATH=/sys/class/gpio/manual_KeyReset/value

4
backend/deploy/etc/tera/tera-logd.env

@ -0,0 +1,4 @@
TERA_LOGD_ROOT=/userdata/log
TERA_LOGD_STORAGE_POLICY=/userdata/storage-layout.json
TERA_LOGD_SOCKET=/run/tera-logd/log-store.sock
TERA_LOGD_SEGMENT_BYTES=4194304

10
backend/deploy/etc/tera/tera-netd.env

@ -0,0 +1,10 @@
TERA_NETD_SOCKET=/run/tera-netd/control.sock
TERA_NETD_INTERFACES=/etc/network/interfaces
TERA_NETD_ROUTING_SCRIPT=/etc/network/if-up.d/20-tera-active-active
TERA_NETD_CONFIG=/var/lib/tera-network/config.json
TERA_NETD_PENDING=/var/lib/tera-network/pending.json
TERA_NETD_IFDOWN=/sbin/ifdown
TERA_NETD_IFUP=/sbin/ifup
TERA_NETD_IP=/sbin/ip
TERA_NETD_SHELL=/bin/sh
TERA_NETD_SYS_CLASS_ROOT=/sys/class/net

19
backend/deploy/etc/tera/tera-seriald.env

@ -0,0 +1,19 @@
TERA_SERIALD_SOCKET=/run/tera-seriald/serial-ports.sock
TERA_SERIALD_STREAM_SOCKET=/run/tera-seriald/serial-stream.sock
TERA_SERIALD_MAPPING=/etc/tera/serial-map.json
TERA_SERIALD_HARDWARE_PROFILE=fet3568j-c
TERA_SERIALD_EVENT_QUEUE=1024
TERA_SERIALD_REPORT_INTERVAL_SECONDS=10
TERA_SERIALD_LOG_SOCKET=/run/tera-logd/log-store.sock
TERA_SERIALD_LINK_SCAN_ENABLED=false
TERA_SERIALD_LINK_SCAN_INTERVAL_MS=1000
TERA_SERIALD_LINK_WORKERS=8
TERA_SERIALD_CR_PROBE_ENABLED=true
TERA_SERIALD_CR_PROBE_TIMEOUT_MS=150
TERA_SERIALD_CR_PROBE_SWEEP_SECONDS=5
TERA_SERIALD_CR_PROBE_SPACING_MS=20
TERA_SERIALD_CR_PROBE_QUIET_SECONDS=5
TERA_SERIALD_CR_PROBE_STARTUP_DELAY_SECONDS=30
TERA_SERIALD_CR_PROBE_WORKERS=2
TERA_SERIALD_CR_PROBE_CONFIRM_RESPONSES=1
TERA_SERIALD_CR_PROBE_DOWN_MISSES=2

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

@ -0,0 +1,5 @@
TERA_SESSIOND_LISTEN_ADDR=127.0.0.1:8081
TERA_SESSIOND_TICKET_SOCKET=/run/tera-api/direct-tickets.sock
TERA_SESSIOND_TICKET_SOCKETS=/run/tera-api/direct-tickets.sock,/run/tera-clone-demo-api/direct-tickets.sock
TERA_SESSIOND_SERIAL_STREAM_SOCKET=/run/tera-seriald/serial-stream.sock
TERA_SESSIOND_ALLOWED_ORIGINS=

6
backend/deploy/etc/tera/tera-snmpd-systemd.env

@ -0,0 +1,6 @@
TERA_SNMPD_SOCKET=/run/tera-snmpd/control.sock
TERA_SNMPD_CONFIG=/etc/snmp/snmpd.conf
TERA_SNMPD_PERSISTENT_CONFIG=/var/lib/net-snmp/snmpd.conf
TERA_SNMPD_INIT_SCRIPT=/usr/bin/systemctl
TERA_SNMPD_SERVICE_NAME=snmpd.service

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

@ -0,0 +1,5 @@
TERA_SNMPD_SOCKET=/run/tera-snmpd/control.sock
TERA_SNMPD_CONFIG=/etc/snmp/snmpd.conf
TERA_SNMPD_PERSISTENT_CONFIG=/var/lib/net-snmp/snmpd.conf
TERA_SNMPD_INIT_SCRIPT=/etc/init.d/S59snmpd

6
backend/deploy/etc/tera/tera-timed.env

@ -0,0 +1,6 @@
TERA_TIMED_SOCKET=/run/tera-timed/time.sock
TERA_TIMED_ZONEINFO_ROOT=/usr/share/zoneinfo
TERA_TIMED_LOCALTIME_PATH=/etc/localtime
TERA_TIMED_NTP_CONFIG=/etc/ntp.conf
TERA_TIMED_NTP_INIT=/etc/init.d/S49ntp
TERA_TIMED_NTP_DAEMON=/usr/sbin/ntpd

10
backend/deploy/etc/tera/tera-updated.env

@ -0,0 +1,10 @@
TERA_UPDATED_ROOT=/userdata/update
TERA_UPDATED_STORAGE_POLICY=/userdata/storage-layout.json
TERA_UPDATED_TRUST_DIR=/etc/tera/update-trust
TERA_UPDATED_PRODUCT=tera-core-x48-pro
TERA_UPDATED_PROFILE=fet3568j-c
TERA_UPDATED_CURRENT_VERSION=0.1.0-dev
TERA_UPDATED_SOCKET=/run/tera-updated/control.sock
TERA_UPDATED_RECOVERY_APPLY_ENABLED=false
TERA_UPDATED_RECOVERY_GATE=/etc/tera/recovery-apply-enabled.json
TERA_UPDATED_MISC_DEVICE=/dev/mmcblk0p2

4
backend/deploy/etc/tera/tera-web.env

@ -0,0 +1,4 @@
TERA_WEB_LISTEN_ADDR=0.0.0.0:8080
TERA_WEB_STATIC_ROOT=/usr/share/tera-web
TERA_WEB_API_UPSTREAM=http://127.0.0.1:8082
TERA_WEB_SESSION_UPSTREAM=http://127.0.0.1:8081

2
backend/deploy/etc/tera/update-trust/README

@ -0,0 +1,2 @@
Install only reviewed Ed25519 public trust-anchor JSON files here.
Private signing keys must never be placed on the target device.

1
backend/deploy/etc/tera/update-trust/tera-prod-2026-01.json

@ -0,0 +1 @@
{"format_version":1,"key_id":"tera-prod-2026-01","algorithm":"ed25519","public_key":"SHfr4ME/rmfVKPRjFhk72EYFl9NIrVpbH9Jod7fnZck=","allowed_products":["tera-core-x48-pro"],"allowed_profiles":["fet3568j-c"]}

29
backend/deploy/systemd/tera-accessd.service

@ -0,0 +1,29 @@
[Unit]
Description=TERA management access listeners
After=network.target
Before=tera-api.service
[Service]
Type=simple
User=tera
Group=tera
EnvironmentFile=-/etc/tera/tera-accessd.env
ExecStart=/usr/bin/tera-accessd
Restart=on-failure
RestartSec=2s
UMask=0007
RuntimeDirectory=tera-accessd
RuntimeDirectoryMode=0750
StateDirectory=tera-accessd
StateDirectoryMode=0700
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadOnlyPaths=/usr/share/tera-web
ReadWritePaths=/run/tera-accessd /var/lib/tera-api/tls
[Install]
WantedBy=multi-user.target

31
backend/deploy/systemd/tera-alarmd.service

@ -0,0 +1,31 @@
[Unit]
Description=TERA bounded alarm delivery service
After=network-online.target
Wants=network-online.target
Before=tera-api.service
[Service]
Type=simple
User=tera
Group=tera
EnvironmentFile=-/etc/tera/tera-alarmd.env
ExecStart=/usr/bin/tera-alarmd
Restart=on-failure
RestartSec=2s
UMask=0007
StateDirectory=tera-alarmd
RuntimeDirectory=tera-alarmd
RuntimeDirectoryMode=0750
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ProtectControlGroups=true
ProtectKernelModules=true
ProtectKernelTunables=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=true
ReadWritePaths=/var/lib/tera-alarmd /run/tera-alarmd
[Install]
WantedBy=multi-user.target

26
backend/deploy/systemd/tera-api.service

@ -0,0 +1,26 @@
[Unit]
Description=TERA management API
After=network-online.target tera-hwd.service tera-seriald.service tera-netd.service tera-logd.service tera-timed.service tera-alarmd.service tera-snmpd.service tera-updated.service tera-accessd.service
Wants=network-online.target tera-hwd.service tera-seriald.service tera-netd.service tera-logd.service tera-timed.service tera-alarmd.service tera-snmpd.service tera-updated.service tera-accessd.service
[Service]
Type=simple
User=tera
Group=tera
EnvironmentFile=-/etc/tera/tera-api.env
ExecStart=/usr/bin/tera-api
Restart=on-failure
RestartSec=2s
UMask=0007
StateDirectory=tera-api
LogsDirectory=tera
RuntimeDirectory=tera-api
RuntimeDirectoryMode=0750
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/var/lib/tera-api /var/log/tera /run/tera-api
[Install]
WantedBy=multi-user.target

24
backend/deploy/systemd/tera-clone-demo-web.service

@ -0,0 +1,24 @@
[Unit]
Description=TERA Clone Demo Web UI
After=network.target tera-api.service tera-sessiond.service
Wants=tera-api.service tera-sessiond.service
[Service]
Type=simple
User=tera
Group=tera
EnvironmentFile=-/etc/tera/tera-clone-demo-web.env
ExecStart=/usr/bin/tera-clone-demo-web
Restart=on-failure
RestartSec=2s
UMask=0027
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
StateDirectory=tera-clone-demo-web
StateDirectoryMode=0750
ReadWritePaths=/var/lib/tera-clone-demo-web
[Install]
WantedBy=multi-user.target

24
backend/deploy/systemd/tera-hwd.service

@ -0,0 +1,24 @@
[Unit]
Description=TERA privileged hardware and system control adapter
After=local-fs.target
[Service]
Type=simple
User=root
Group=tera
EnvironmentFile=-/etc/tera/tera-hwd.env
ExecStart=/usr/bin/tera-hwd
Restart=on-failure
RestartSec=2s
RuntimeDirectory=tera-hwd
RuntimeDirectoryMode=0750
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_SYS_BOOT
AmbientCapabilities=CAP_DAC_OVERRIDE CAP_SYS_BOOT
ReadWritePaths=/run/tera-hwd /sys/class/leds
[Install]
WantedBy=multi-user.target

31
backend/deploy/systemd/tera-logd.service

@ -0,0 +1,31 @@
[Unit]
Description=TERA authoritative bounded log service
After=userdata.mount
Requires=userdata.mount
Before=tera-api.service
[Service]
Type=simple
User=tera-logd
Group=tera
SupplementaryGroups=tera-logd
EnvironmentFile=-/etc/tera/tera-logd.env
ExecStart=/usr/bin/tera-logd
Restart=on-failure
RestartSec=2s
UMask=0007
RuntimeDirectory=tera-logd
RuntimeDirectoryMode=0750
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ProtectControlGroups=true
ProtectKernelModules=true
ProtectKernelTunables=true
RestrictAddressFamilies=AF_UNIX
RestrictSUIDSGID=true
ReadWritePaths=/userdata/log /run/tera-logd
[Install]
WantedBy=multi-user.target

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

Loading…
Cancel
Save