commit
b283a445c5
865 changed files with 330932 additions and 0 deletions
@ -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 |
|||
@ -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 |
|||
@ -0,0 +1,4 @@ |
|||
# 최종 빌드 파일 위치 |
|||
|
|||
- 프론트엔드: `build/linux-arm64/tera-clone-demo-web` |
|||
- 백엔드: `build/linux-arm64/tera-clone-demo-api` |
|||
@ -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. |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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()) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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") |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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()) |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 } |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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") |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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") |
|||
} |
|||
@ -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 = "" |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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, |
|||
}) |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -0,0 +1,5 @@ |
|||
//go:build !linux
|
|||
|
|||
package main |
|||
|
|||
func main() {} |
|||
@ -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" |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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"}, |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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: |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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", |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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()) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
|
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -0,0 +1,2 @@ |
|||
# Restrict WCH multi-port serial devices to the tera service group. |
|||
KERNEL=="ttyWCH[0-9]*", GROUP="tera", MODE="0660" |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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: |
|||
@ -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"] |
|||
@ -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 |
|||
@ -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"} |
|||
] |
|||
} |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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= |
|||
@ -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 |
|||
|
|||
@ -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 |
|||
|
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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. |
|||
@ -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"]} |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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…
Reference in new issue