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