diff --git a/Makefile b/Makefile index b292374..3dfa0b9 100644 --- a/Makefile +++ b/Makefile @@ -10,19 +10,21 @@ BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) BUILD_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) PKG := github.com/sdoque/mbaigo/components -SYSTEMS := beehive beekeeper busdriver clerk collector democrat \ +SYSTEMS := beehive beekeeper busdriver ca clerk collector democrat \ drafter ds18b20 emulator esr ethermostat filmer flattener kgrapher \ - leveler messenger meteorologue modeler modboss nurse orchestrator \ - parallax photographer recognizer revolutionary sapper sailor \ - telegrapher thermostat tracker uaclient weatherman + leveler maitreD messenger meteorologue modeler modboss nurse \ + orchestrator parallax photographer recognizer revolutionary sapper \ + sailor telegrapher thermostat tracker uaclient weatherman -.PHONY: all ci release rpi test lint clean $(SYSTEMS) +.PHONY: all ci release rpi test lint clean whitelist $(SYSTEMS) # Default target: build everything all: rpi # Clean rebuild with version stamp: make release VERSION=1.2.3 -release: clean rpi +# Produces both the cross-compiled binaries and the matching whitelist.json +# that authorises exactly those binaries for certificate issuance. +release: clean rpi whitelist # Full pipeline: tests and lint must pass before building ci: lint test rpi @@ -77,6 +79,66 @@ endef $(foreach sys,$(SYSTEMS),$(eval $(call build_system,$(sys)))) +# --- Whitelist generation ----------------------------------------------------- +# +# A release of mbaigo systems must be paired with a whitelist that authorises +# exactly the binaries in that release. The security/ca Certificate Authority +# reads `whitelist.json` (a flat JSON array of SHA-256 hex strings) at runtime +# and serves it to maitreDs on every host; the maitreDs deny attestation for +# any process whose hash is not on that list. +# +# This section walks the just-built binaries in $(STAGING) and writes both +# files into $(STAGING)/ca/, alongside the CA binary they belong to: +# +# $(STAGING)/ca/whitelist.json — flat array of hashes; the wire +# format the CA reads at runtime. +# $(STAGING)/ca/whitelist-manifest.txt — annotated `system → hash` map +# with VERSION and BUILD_DATE, +# for human review and audit. +# +# Co-locating with the CA binary lets a single `rsync $(STAGING)/ca/` deploy +# both the executable and the authorisation file as one atomic operation. +# +# Deployment: rsync the CA's directory to its host, e.g. +# rsync -av $(STAGING)/ca/ ca-host:/path/to/ca/ +# Every maitreD picks up the new list on its next sync (≤5 min by default). +# +# `release` depends on `whitelist`, so a single `make release VERSION=1.2.3` +# produces binaries and the matching authorisation file in one shot. +# +# Note: uses `shasum -a 256`, which is present on macOS and on most Linux +# distros. If your build host has only `sha256sum`, swap it in below. + +whitelist: $(STAGING)/ca/whitelist.json $(STAGING)/ca/whitelist-manifest.txt + +# Flat JSON array — the wire format expected by the CA's loadWhitelist(). +# Depends on every staged binary, so editing any system's source and +# re-running `make rpi` causes the whitelist to regenerate automatically. +$(STAGING)/ca/whitelist.json: $(foreach sys,$(SYSTEMS),$(STAGING)/$(sys)/$(sys)_rpi64) + @mkdir -p $(STAGING)/ca + @printf '[\n' > $@ + @first=1; for sys in $(SYSTEMS); do \ + bin=$(STAGING)/$$sys/$${sys}_rpi64; \ + hash=$$(shasum -a 256 $$bin | cut -d' ' -f1); \ + if [ $$first -eq 1 ]; then first=0; else printf ',\n' >> $@; fi; \ + printf ' "%s"' "$$hash" >> $@; \ + done + @printf '\n]\n' >> $@ + @echo "Wrote $@" + +# Human-readable manifest — never read by code, always read by people. +# Use this to answer "what binary is hash e3b0c44…?" during ops review. +$(STAGING)/ca/whitelist-manifest.txt: $(foreach sys,$(SYSTEMS),$(STAGING)/$(sys)/$(sys)_rpi64) + @mkdir -p $(STAGING)/ca + @printf 'Whitelist manifest — VERSION=%s built %s\n\n' \ + "$(VERSION)" "$(BUILD_DATE)" > $@ + @for sys in $(SYSTEMS); do \ + bin=$(STAGING)/$$sys/$${sys}_rpi64; \ + hash=$$(shasum -a 256 $$bin | cut -d' ' -f1); \ + printf '%-20s %s\n' "$$sys" "$$hash" >> $@; \ + done + @echo "Wrote $@" + # --- Housekeeping ------------------------------------------------------------- clean: diff --git a/authorizer/MISSIONS.md b/authorizer/MISSIONS.md new file mode 100644 index 0000000..b69940f --- /dev/null +++ b/authorizer/MISSIONS.md @@ -0,0 +1,141 @@ +# Standard Mission Taxonomy + +**Status:** Working specification. Pre-implementation. Subject to refinement once a +testbed deployment exercises it. + +## Purpose + +A *mission* is a coarse-grained classification of what a unit asset *is for*. It is +the primary axis along which the authorizer evaluates policies. Missions are +declared by each asset (in the system's `systemconfig.json`) and travel with the +asset's service-registration record, so the authorizer can read missions from the +service registrar rather than from every system's local file. + +The mission taxonomy is intentionally small. Too many missions becomes +indistinguishable from per-asset enumeration; too few cannot express real +distinctions. The eight missions below are the working set. Additions require a +deliberate revision of this document. + +## The taxonomy + +### `measurement` + +Assets that observe physical or digital state without changing it. + +- **Examples:** temperature sensor, position encoder, voltage probe, packet counter. +- **Typical actions:** `read` for any consumer with a legitimate use; `write` only + rarely (calibration parameters, set-points for the sensor's own operation). +- **Pairing:** typically location-bound. A bathroom temperature sensor is paired + to bathroom-class consumers via `functional_location`. + +### `actuation` + +Assets that change physical or digital state. + +- **Examples:** servo position setter, valve open/close, heater plug, pump speed + command. +- **Typical actions:** `write` only by authorised controllers; `read` permitted + for status display and audit. +- **Pairing:** location-bound. A kitchen heater plug is paired to kitchen-class + controllers. + +### `state` + +Internal mode, schedule, or configuration of a system or asset. + +- **Examples:** thermostat target temperature, scheduler entries, operating mode + (auto/manual/off). +- **Typical actions:** `read` widely; `write` by commissioning or maintenance role. +- **Pairing:** typically per-system, not location-bound. + +### `event` + +Ephemeral notifications, alarms, transitions. + +- **Examples:** door-opened event, threshold-crossed alarm, mode-change announcement. +- **Typical actions:** `read` (subscribe) widely; `write` (publish) only by the + asset that owns the event source. +- **Pairing:** event-stream-bound, occasionally location-bound. + +### `aggregation` + +Derived or computed values built from other assets' outputs. + +- **Examples:** rolling-window mean, hourly average, count over a tag set. +- **Typical actions:** `read` widely; `write` only by the aggregator producing the value. +- **Pairing:** typically not location-bound (aggregations span locations by design). + +### `logging` + +Write-only sinks for audit trails or data. + +- **Examples:** audit log, time-series database ingestion endpoint, alarm history. +- **Typical actions:** `write` widely; `read` only by audit and analytics roles. +- **Pairing:** typically not location-bound (logs are cloud-wide by design). + +### `control` + +Bidirectional control loops that both observe and act on physical state. + +- **Examples:** PID controller, feedback loop, servo position-and-feedback combined. +- **Typical actions:** `read` and `write` together; the consumer expects both as a + paired use. +- **Pairing:** location-bound, like `actuation`. + +### `core` + +Framework infrastructure: service registrar, orchestrator, certificate authority, +authorizer itself, maitreD. + +- **Examples:** the four core systems of an Arrowhead local cloud. +- **Typical actions:** restricted; framework-only roles. +- **Pairing:** never location-bound — core systems serve the whole cloud. + +## When the taxonomy doesn't fit cleanly + +Most assets land in exactly one mission. Two situations require care: + +### A service that is genuinely both measurement and actuation + +The parallax `position` service is the canonical example: GET reads the current +position; PUT sets a new one. Two design choices, in increasing complexity: + +1. **Split into two services** within the same asset: `position-read` (mission + `measurement`) and `position-write` (mission `actuation`). Cleanest but + requires the implementation to expose two endpoints. +2. **Mark the asset as `actuation`** and let read access be granted to broader + subjects via policy (the collector's policy in the example below). Pragmatic + for the common case where read is permissive but write is tight. + +For mbaigo today, option 2 is operationally simpler. Option 1 is the right move +if the read and write semantics ever need to be authorised differently for +different consumers. + +### Multi-mission assets + +If an asset honestly serves two missions (e.g. a controller that is both +`actuation` and `state`), declare both in the systemconfig: + +```json +"unit_assets": [ + { + "name": "servo1", + "missions": ["actuation", "state"], + ... + } +] +``` + +Policy matching uses *any* match (the asset's mission set ∩ the policy's mission +set must be non-empty). + +## Versioning + +This taxonomy is part of the authorization contract. Changes — adding a mission, +splitting one, deprecating one — must be propagated to every system's +configuration. We treat changes here as a versioned event, with a corresponding +note in the paper (or the journal-paper revision history once that is published). + +| Date | Change | +|------|--------| +| 2026-04-30 | Initial taxonomy: measurement, actuation, state, event, aggregation, logging, control, core | diff --git a/authorizer/POLICY.md b/authorizer/POLICY.md new file mode 100644 index 0000000..5de773b --- /dev/null +++ b/authorizer/POLICY.md @@ -0,0 +1,196 @@ +# Authorizer Policy Schema + +**Status:** Working specification. Pre-implementation. + +This document defines the policy file format read by the authorizer service, the +evaluation semantics, and the wire shape of the tokens the authorizer issues. +It is the contract every other piece of code in the security/authorizer system +will touch; getting it right before implementation prevents rework. + +## The file: `policies.json` + +Operator-edited, version-controlled, lives in the authorizer's working directory. +A flat JSON object with two top-level keys: + +```json +{ + "policies": [ ... ], // explicit allow rules (deny by default) + "denials": [ ... ] // optional, narrow exceptions to allow rules +} +``` + +A missing or empty file means *deny everything* — the authorizer issues no +tokens and every authenticated system is functionally inert. This is fail-closed +by construction, mirroring the security/ca's whitelist semantics. + +## Policy entries + +Each entry in the `policies` array is an allow rule: + +```json +{ + "subject": "thermostat", + "missions": ["actuation", "measurement"], + "actions": ["read", "write"], + "must_match_attribute": "functional_location", + "ttl": "10m" +} +``` + +| Field | Type | Required | Meaning | +|-------|------|----------|---------| +| `subject` | string | yes | The CN of the consumer's mTLS certificate. `"*"` matches any authenticated subject. | +| `missions` | string[] | yes | Mission names from MISSIONS.md the policy authorises. `["*"]` matches any mission. | +| `actions` | string[] | yes | One or more of `read`, `write`, `invoke`, or `*`. | +| `must_match_attribute` | string | no | If set, an additional ABAC constraint: the named attribute must match between subject and asset (see *Pairing semantics* below). | +| `ttl` | duration string | no | Token lifetime if this policy authorises the request. Defaults to `5m`. | + +A request is authorised iff at least one policy entry matches AND no `denials` +entry matches. + +## Denials (the escape hatch) + +For the rare case where a broad policy must carve out a specific exception: + +```json +{ + "denials": [ + {"subject": "thermostat", "asset": "parallax/basement-servo"} + ] +} +``` + +Each denial blocks one (subject, asset) pair regardless of any matching policy. +Denials should be kept few; if they multiply, the corresponding policy is +likely too broad and should be tightened instead. + +## Action vocabulary + +Three abstract actions, each mapped to mbaigo cervice modes and HTTP semantics: + +| Action | Cervice mode | HTTP method | Meaning | +|--------|--------------|-------------|---------| +| `read` | `get` | GET | Observe state without changing it | +| `write` | `set` | PUT, PATCH | Change asset state | +| `invoke` | `do` | POST | Trigger an ephemeral action (e.g. publish event, fire alarm) | + +`*` matches any of the three. + +## Pairing semantics (`must_match_attribute`) + +A policy may declare `must_match_attribute` to require that the named attribute +match between the subject and the asset. The match algorithm: + +1. Look up the named attribute on the subject (from its registration record). +2. Look up the named attribute on the asset (from the service-registrar entry). +3. If the asset has no value for the attribute → **constraint satisfied** + (asset is "unpaired" and universally accessible to subjects of the right mission). +4. Else if the asset has a value AND the subject has no value → **constraint violated**. +5. Else: at least one of the subject's values must equal at least one of the + asset's values (multi-valued match by intersection non-empty). + +Rationale for step 3: in OT plants, many sensors and actuators are not associated +with a specific location/zone — they're cloud-wide utilities (audit logs, +aggregations, framework infrastructure). Forcing every consumer to declare a +match key for these would be over-engineering. + +Rationale for step 4: a subject *with* a defined location/zone consuming an +asset *with* a defined location/zone is the security-relevant case; a missing +subject side is an operator misconfiguration that should fail closed. + +## Worked examples + +The eThermostat scenario, expressed in policy form. Setup: + +- Asset `bathroom-sensor` has mission `measurement`, attribute `functional_location: ["Bathroom"]`. +- Asset `bathroom-heater` has mission `actuation`, attribute `functional_location: ["Bathroom"]`. +- Asset `cloud-aggregator` has mission `aggregation`, no `functional_location`. +- Subject `thermostat-bathroom` has attribute `functional_location: ["Bathroom"]`. +- Subject `thermostat-kitchen` has attribute `functional_location: ["Kitchen"]`. +- Subject `collector` has no `functional_location`. + +Policies: + +```json +{ + "policies": [ + { + "subject": "thermostat-*", + "missions": ["measurement", "actuation"], + "actions": ["read", "write"], + "must_match_attribute": "functional_location" + }, + { + "subject": "collector", + "missions": ["measurement", "actuation", "aggregation"], + "actions": ["read"] + } + ] +} +``` + +Resolution: + +| Request | Match? | Reason | +|---------|--------|--------| +| `thermostat-bathroom` reads `bathroom-sensor` | allow | mission `measurement` ∈ policy; locations match | +| `thermostat-bathroom` writes `bathroom-heater` | allow | mission `actuation` ∈ policy; locations match | +| `thermostat-kitchen` writes `bathroom-heater` | deny | locations don't match | +| `thermostat-bathroom` reads `cloud-aggregator` | deny | policy missions don't include `aggregation` | +| `collector` reads `bathroom-sensor` | allow | no `must_match_attribute`; mission and action allowed | +| `collector` writes `bathroom-heater` | deny | `write` not in collector's actions | + +## Token format (issued by the authorizer) + +When a request is authorised, the authorizer returns a signed token the consumer +attaches to its provider request. JWT-style payload: + +```json +{ + "sub": "thermostat-bathroom", // CN of the requester's cert + "provider": "ethermostat-bathroom", // target system + "asset": "bathroom-heater", + "service": "plug-state", + "action": "write", + "iat": "2026-04-30T14:23:00Z", + "exp": "2026-04-30T14:33:00Z", + "iss": "authorizer", + "sig": "" +} +``` + +The provider verifies the signature locally using the authorizer's public key +(distributed at startup, same trust chain as the CA), checks expiry, and confirms +that the token's claimed `provider`/`asset`/`service`/`action` match the request +being made. No network round-trip to the authorizer is required at request time. + +## Revocation + +Revocation latency is bounded by token TTL. The authorizer will not issue a new +token for a deauthorised request the moment `policies.json` is edited; existing +tokens remain valid until they expire (default 5 minutes; tunable per policy). + +For revocation-sensitive deployments, set short TTLs (1–5 min). For low-frequency +control loops where renewal cost matters, longer TTLs are acceptable. Trade-off +explicit in the operator's hands. + +## Composition with the security/ca whitelist + +The authorizer is the *second* gate in a two-gate chain: + +1. **Authentication (security/ca):** the binary's hash is on `whitelist.json` → + issue mTLS certificate. The system *exists* in the cloud. +2. **Authorization (security/authorizer):** the system's CN matches a policy → + issue tokens for specific (provider, asset, service, action). The system + *acts* in the cloud. + +A binary that is whitelisted but not policy-authorised has cryptographic identity +but no permissions. A binary that has a token but whose certificate is revoked +fails at the mTLS handshake before any policy check runs. Both files, +operator-edited, version-controlled, fail-closed. + +## Versioning + +| Date | Change | +|------|--------| +| 2026-04-30 | Initial schema: subject, missions, actions, must_match_attribute, ttl, denials | diff --git a/beehive/beehive.go b/beehive/beehive.go index 4ce584e..9f4b652 100644 --- a/beehive/beehive.go +++ b/beehive/beehive.go @@ -37,6 +37,9 @@ func main() { sys := components.NewSystem("beehive", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + sys.Husk = &components.Husk{ Description: "web dashboard with on/off toggle switches for all ZigBee devices in the local cloud", Details: map[string][]string{"Developer": {"Synecdoque"}}, @@ -79,9 +82,8 @@ func main() { usecases.RegisterServices(&sys) go usecases.SetoutServers(&sys) - <-sys.Sigs - fmt.Println("\nshutting down system", sys.Name) - cancel() + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) } diff --git a/beehive/go.mod b/beehive/go.mod index df8b70b..fe2744a 100644 --- a/beehive/go.mod +++ b/beehive/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/beehive go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/beehive/go.sum b/beehive/go.sum index 6d3cabc..6113ffb 100644 --- a/beehive/go.sum +++ b/beehive/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/beekeeper/beekeeper.go b/beekeeper/beekeeper.go index d5aca46..7a96187 100644 --- a/beekeeper/beekeeper.go +++ b/beekeeper/beekeeper.go @@ -37,6 +37,9 @@ func main() { sys := components.NewSystem("beekeeper", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + sys.Husk = &components.Husk{ Description: "exposes ZigBee devices paired to a RaspBee II / deCONZ gateway as Arrowhead services", Details: map[string][]string{"Developer": {"Synecdoque"}}, @@ -82,9 +85,8 @@ func main() { usecases.RegisterServices(&sys) go usecases.SetoutServers(&sys) - <-sys.Sigs - fmt.Println("\nshutting down system", sys.Name) - cancel() + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) } diff --git a/beekeeper/go.mod b/beekeeper/go.mod index b5a8292..9bc2ef0 100644 --- a/beekeeper/go.mod +++ b/beekeeper/go.mod @@ -4,5 +4,5 @@ go 1.26.2 require ( github.com/gorilla/websocket v1.5.3 - github.com/sdoque/mbaigo v0.1.0-alpha.4 + github.com/sdoque/mbaigo v0.1.0-alpha.6 ) diff --git a/beekeeper/go.sum b/beekeeper/go.sum index ed4dae3..f01d56a 100644 --- a/beekeeper/go.sum +++ b/beekeeper/go.sum @@ -1,4 +1,4 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/busdriver/busdriver.go b/busdriver/busdriver.go index ff2e873..a4c0bd3 100644 --- a/busdriver/busdriver.go +++ b/busdriver/busdriver.go @@ -31,7 +31,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -45,6 +44,9 @@ func main() { defer cancel() sys := components.NewSystem("busdriver", ctx) + + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) sys.Husk = &components.Husk{ Description: "OBD-II CAN bus gateway — exposes vehicle signals as Arrowhead services", Details: map[string][]string{"Developer": {"Synecdoque"}}, @@ -87,9 +89,8 @@ func main() { usecases.RegisterServices(&sys) go usecases.SetoutServers(&sys) - <-sys.Sigs - fmt.Println("\nshutting down system", sys.Name) - cancel() + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) } diff --git a/busdriver/go.mod b/busdriver/go.mod index bde6b19..e9c91d0 100644 --- a/busdriver/go.mod +++ b/busdriver/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/busdriver go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/busdriver/go.sum b/busdriver/go.sum index 6d3cabc..6113ffb 100644 --- a/busdriver/go.sum +++ b/busdriver/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/ca/2factIDlogin.md b/ca/2factIDlogin.md new file mode 100644 index 0000000..bae8386 --- /dev/null +++ b/ca/2factIDlogin.md @@ -0,0 +1,86 @@ +# Two factor identification login + +One can use Microsoft Authenticator for two-factor authentication (2FA) on your Linux machine. Microsoft Authenticator supports TOTP, which is the same standard used by Google Authenticator and other similar apps. Here’s how you can set it up: + +### Step 1: Install the Necessary Packages +First, install the required packages on your Linux machine. Open a terminal and run the following commands: + +``` +sudo apt update +sudo apt install libpam-google-authenticator +``` + +### Step 2: Configure Google Authenticator +Run the Google Authenticator setup for your user account. This will generate a QR code and provide you with secret keys. You can then use the Microsoft Authenticator app on your phone to scan this QR code. + +``` +google-authenticator +``` + +During the setup, you will be prompted with several questions. Here are the recommended responses: +- **Do you want authentication tokens to be time-based? (Y/n)**: `Y` +- Your new secret key is: `[your-secret-key]` +- **Do you want me to update your "~/.google_authenticator" file? (Y/n)**: `Y` +- **Do you want to disallow multiple uses of the same authentication token? (y/N)**: `Y` +- **By default, tokens are good for 30 seconds. Do you want to increase the time skew to compensate for possible time discrepancies between the client and the server? (y/n)**: `N` +- **If the computer that you are logging into isn't hardened against brute-force login attempts, you can enable rate-limiting for the authentication module. Do you want to do so? (y/n)**: `Y` + +### Step 3: Configure PAM to Use Google Authenticator +Edit the PAM configuration for SSH and login. You need to add the Google Authenticator PAM module to the appropriate files. Open the SSH PAM configuration file with: + +``` +sudo nano /etc/pam.d/sshd +``` + +Add the following line at the top: + +``` +auth required pam_google_authenticator.so +``` + +Next, open the login PAM configuration file: + +``` +sudo nano /etc/pam.d/common-auth +``` + +Add the same line: + +``` +auth required pam_google_authenticator.so +``` + +### Step 4: Configure SSH +Ensure that your SSH server is configured to require both password and 2FA. Open the SSH daemon configuration file: + +``` +sudo nano /etc/ssh/sshd_config +``` + +Ensure the following lines are set: + +``` +ChallengeResponseAuthentication yes +UsePAM yes +AuthenticationMethods password,keyboard-interactive +``` + +### Step 5: Restart the SSH Service +Restart the SSH service to apply the changes: + +``` +sudo systemctl restart ssh +``` + +### Step 6: Add Account to Microsoft Authenticator +1. Open the Microsoft Authenticator app on your phone. +2. Tap the "+" icon to add a new account. +3. Select "Other account (Google, Facebook, etc.)". +4. Scan the QR code displayed during the `google-authenticator` setup on your Linux machine. + +### Step 7: Test Your Configuration +Try logging in via SSH from another device. You should be prompted for your password and then for a verification code from your Microsoft Authenticator app. + +By following these steps, you can enable two-factor authentication on your Linux machine using the Microsoft Authenticator app. + +#mbaigo \ No newline at end of file diff --git a/ca/README.md b/ca/README.md new file mode 100644 index 0000000..1bdd436 --- /dev/null +++ b/ca/README.md @@ -0,0 +1,187 @@ +# mbaigo System: Certificate Authority (ca) + +## Purpose + +The Certificate Authority (CA) is the trust anchor for a local cloud of mbaigo systems. It: + +- Generates its own self-signed X.509 certificate on first run (stored in `ca_certificate.pem` and `ca_private_key.pem`) +- Signs certificate signing requests (CSRs) from other systems so they can use mutual TLS (mTLS) +- Exposes the CA certificate at `GET /ca/certification` so systems can build their trust store +- Enforces IP-based pre-authorization for maitreD enrollment +- Delegates executable verification to the maitreD before signing any other system's CSR +- **Owns the cloud's approved-binary whitelist** at `whitelist.json` and serves it to maitreDs on demand + +Because the CA certificate is the root of trust for the entire local cloud, `ca_certificate.pem` and `ca_private_key.pem` must be kept secure and backed up. The same applies to `whitelist.json`: anyone who can edit it can authorise a binary to run anywhere in the cloud. + +## Whitelist file (`whitelist.json`) + +A flat JSON array of hex-encoded SHA-256 hashes of approved executables, kept next to `ca_certificate.pem`: + +```json +[ + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "abc123..." +] +``` + +A missing file is a deliberate "no binaries approved yet" — the CA serves an empty list and every maitreD denies every attestation request until the file appears. The on-disk file's modification time becomes the wire-format `version`; bumping the file (any edit, or `touch`) signals every maitreD to refresh on its next sync (5 min by default). + +To approve a new binary: +1. Compute its hash: `shasum -a 256 path/to/binary | cut -d' ' -f1` +2. Add the line to `whitelist.json` +3. Within 5 minutes every maitreD will pick it up. + +## Certificate issuance flow + +### maitreD enrollment (IP-based authorization) + +```mermaid +sequenceDiagram + participant MD as maitreD + participant CA as Certificate Authority + + Note over MD: Startup — generate key pair + CSR + MD->>CA: POST /ca/certification/certify
Body: CSR PEM (CommonName="maitreD")
Header: X-Process-PID: <pid> + CA->>CA: Extract client IP from remote address + CA->>CA: Check IP against maitreDHosts list + alt IP is authorized + CA->>CA: Sign CSR with CA private key + CA-->>MD: 200 OK — signed certificate PEM + MD->>CA: GET /ca/certification (fetch CA cert) + CA-->>MD: CA certificate PEM + Note over MD: Save cert + key to disk
Install mTLS on http.DefaultClient + else IP not in maitreDHosts + CA-->>MD: 403 Forbidden + end +``` + +### General system enrollment (PID-based attestation) + +```mermaid +sequenceDiagram + participant S as System (any) + participant CA as Certificate Authority + participant MD as maitreD + + Note over S: Startup — generate key pair + CSR + S->>CA: POST /ca/certification/certify
Body: CSR PEM
Header: X-Process-PID: <pid> + CA->>CA: Extract client IP and PID + alt maitreDPort != 0 (attestation enabled) + CA->>MD: POST /maitreD/maitreD/attest
Body: {"pid": <pid>} + MD->>MD: readlink /proc/<pid>/exe + MD->>MD: SHA-256 hash of executable + MD->>MD: Check hash against whitelist + alt hash is approved + MD-->>CA: 200 OK + else hash not in whitelist + MD-->>CA: 403 Forbidden + CA-->>S: 403 Forbidden — attestation failed + end + end + CA->>CA: Sign CSR with CA private key + CA-->>S: 200 OK — signed certificate PEM + S->>CA: GET /ca/certification (fetch CA cert) + CA-->>S: CA certificate PEM + Note over S: Save cert + key to disk
Install mTLS on http.DefaultClient +``` + +On subsequent startups, a system reuses its saved certificate if it has not expired (with a 24-hour renewal buffer), skipping the CA entirely. + +## Starting order + +The CA must start **before** any other system. Systems that request a certificate retry every minute until the CA is reachable, so order matters but strict timing does not. + +## Configuration (`systemconfig.json`) + +On first run the CA generates a `systemconfig.json` and then exits so you can review it. The key fields are: + +```json +{ + "systemname": "ca", + "unit_assets": [ + { + "name": "certification", + "details": { + "Location": ["LocalCloud"], + "PKI": ["X.509"] + }, + "safeSWare": false, + "maitreDHosts": ["192.168.1.10", "192.168.1.11"], + "maitreDPort": 20101 + } + ], + "protocolsNports": { + "http": 20100, + "https": 0, + "coap": 0 + }, + "coreSystems": [ + { "coreSystem": "serviceregistrar", "url": "http://localhost:20102/serviceregistrar/registry" }, + { "coreSystem": "orchestrator", "url": "http://localhost:20103/orchestrator/orchestration" }, + { "coreSystem": "ca", "url": "http://localhost:20100/ca/certification" }, + { "coreSystem": "maitreD", "url": "http://localhost:20101/maitreD/maitreD" } + ] +} +``` + +### Enabling mTLS + +Set `"https"` to a non-zero port (it can be the same value as `"http"`): + +```json +"protocolsNports": { "http": 20100, "https": 20100, "coap": 0 } +``` + +All systems that also have a non-zero https port will use mTLS for their outbound calls. + +### Authorizing maitreD hosts + +`maitreDHosts` is the list of IPs from which a maitreD system is permitted to enroll. Any CSR with `CommonName = "maitreD"` from an unlisted IP is rejected with `403 Forbidden`. + +```json +"maitreDHosts": ["192.168.1.10", "192.168.1.11"] +``` + +### Enabling PID-based attestation + +Set `maitreDPort` to the port the maitreD listens on (default 20101). When non-zero, every non-maitreD CSR triggers an attestation call to the maitreD on the requester's host before the CSR is signed. Set to `0` to disable attestation (development mode — all CSRs are signed without verification). + +```json +"maitreDPort": 20101 +``` + +## Building and running + +```bash +# Run in place (for development) +go run . + +# Build for the current machine +go build -o ca_local + +# Cross-compile for Raspberry Pi 64-bit +GOOS=linux GOARCH=arm64 go build -o ca_rpi64 + +# Copy to a Raspberry Pi +scp ca_rpi64 user@192.168.1.6:mbaigo/ca/ +``` + +Run the binary from **inside its own directory** so it can find (or create) `systemconfig.json`, `ca_certificate.pem`, and `ca_private_key.pem`. + +A full list of supported platforms: `go tool dist list` + +## Development with a local mbaigo clone + +Add a `replace` directive to `go.mod`: + +``` +require github.com/sdoque/mbaigo v0.x.x +replace github.com/sdoque/mbaigo => ../../mbaigo +``` + +Or add both modules to the workspace `go.work` at the repository root: + +``` +use ./mbaigo +use ./security/ca +``` diff --git a/ca/ca.go b/ca/ca.go new file mode 100644 index 0000000..8951d38 --- /dev/null +++ b/ca/ca.go @@ -0,0 +1,118 @@ +/******************************************************************************* + * Copyright (c) 2024 Synecdoque + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, subject to the following conditions: + * + * The software is licensed under the MIT License. See the LICENSE file in this repository for details. + * + * Contributors: + * Jan A. van Deventer, Luleå - initial implementation + * Thomas Hedeler, Hamburg - initial implementation + ***************************************************************************SDG*/ + +package main + +import ( + "context" + "crypto/x509/pkix" + "encoding/json" + "log" + "net/http" + "time" + + "github.com/sdoque/mbaigo/components" + "github.com/sdoque/mbaigo/usecases" +) + +func main() { + // prepare for graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) // create a context that can be cancelled + defer cancel() // make sure all paths cancel the context to avoid context leak + + // instantiate the System + sys := components.NewSystem("ca", ctx) + + // Watch for SIGINT immediately so that Ctrl+C interrupts blocking + // startup steps (currently none in the CA, but kept consistent with + // every other system so future changes do not introduce un-killable + // states). + usecases.WatchShutdown(&sys, cancel) + + // Instantiate the husk + sys.Husk = &components.Husk{ + Description: "handles X.509 certification for its local cloud", + Details: map[string][]string{"Developer": {"Synecdoque"}}, + Host: components.NewDevice(), + ProtoPort: map[string]int{"https": 30100, "http": 20100, "coap": 0}, + InfoLink: "https://github.com/sdoque/systems/tree/main/ca", + DName: pkix.Name{ + CommonName: "ca", + Country: []string{"SE"}, + Province: []string{"Norrbotten"}, + Locality: []string{"Luleaa"}, + Organization: []string{"Synecdoque"}, + OrganizationalUnit: []string{"Research"}, + }, + RegistrarChan: make(chan *components.CoreSystem, 1), + Messengers: make(map[string]int), + } + + // instantiate a template unit asset + assetTemplate := initTemplate() + sys.UAssets[assetTemplate.GetName()] = assetTemplate + + // Configure the system + rawResources, err := usecases.Configure(&sys) + if err != nil { + log.Fatalf("Configuration error: %v\n", err) + } + sys.UAssets = make(map[string]*components.UnitAsset) // clear the unit asset map (from the template) + for _, raw := range rawResources { + var uac usecases.ConfigurableAsset + if err := json.Unmarshal(raw, &uac); err != nil { + log.Fatalf("Resource configuration error: %+v\n", err) + } + ua, cleanup := newResource(uac, &sys) + defer cleanup() + sys.UAssets[ua.GetName()] = ua + } + + // Register the (system) and its services + usecases.RegisterServices(&sys) + + // start the http handler and server + go usecases.SetoutServers(&sys) + + // Wait for shutdown. WatchShutdown's goroutine cancels ctx on SIGINT; + // goroutines that respect ctx.Done() exit; the brief sleep covers + // in-flight HTTP handlers and other non-cancellable cleanup. + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) + time.Sleep(2 * time.Second) +} + +// serving handles the resources services. NOTE: it expects those names from the request URL path +func serving(t *Traits, w http.ResponseWriter, r *http.Request, servicePath string) { + switch servicePath { + case "certify": + t.certify(w, r) + case "whitelist": + t.whitelisting(w, r) + default: + http.Error(w, "Invalid service request [Do not modify the services subpath in the configuration file]", http.StatusBadRequest) + } +} + +// certify processes certificate signing request +func (t *Traits) certify(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "POST": + t.certifying(w, r) + default: + http.Error(w, "Method is not supported.", http.StatusNotFound) + } +} diff --git a/ca/go.mod b/ca/go.mod new file mode 100644 index 0000000..e89b54a --- /dev/null +++ b/ca/go.mod @@ -0,0 +1,5 @@ +module github.com/sdoque/systems/ca + +go 1.26.2 + +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/ca/go.sum b/ca/go.sum new file mode 100644 index 0000000..6113ffb --- /dev/null +++ b/ca/go.sum @@ -0,0 +1,2 @@ +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/ca/hostkeygen b/ca/hostkeygen new file mode 100755 index 0000000..e872fa9 Binary files /dev/null and b/ca/hostkeygen differ diff --git a/ca/thing.go b/ca/thing.go new file mode 100644 index 0000000..15e59af --- /dev/null +++ b/ca/thing.go @@ -0,0 +1,400 @@ +/******************************************************************************* + * Copyright (c) 2024 Synecdoque + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, subject to the following conditions: + * + * The software is licensed under the MIT License. See the LICENSE file in this repository for details. + * + * Contributors: + * Jan A. van Deventer, Luleå - initial implementation + * Thomas Hedeler, Hamburg - initial implementation + ***************************************************************************SDG*/ + +package main + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "log" + "math/big" + "net" + "net/http" + "os" + "strconv" + "time" + + "github.com/sdoque/mbaigo/components" + "github.com/sdoque/mbaigo/usecases" +) + +// isMaitreDAuthorized reports whether ip is in the configured list of permitted maitreD hosts. +func (t *Traits) isMaitreDAuthorized(ip string) bool { + for _, h := range t.MaitreDHosts { + if h == ip { + return true + } + } + return false +} + +// requestAttestation contacts the maitreD on hostIP and asks it to verify the executable +// identified by pid. Returns nil if the maitreD approves, an error otherwise. +// +// hostIP comes from net.SplitHostPort on the requester's RemoteAddr, which strips +// the IPv6 brackets. We use net.JoinHostPort to put them back, otherwise a same-host +// request from the IPv6 loopback (::1) would build the malformed URL +// "http://::1:20101/..." that http.Post cannot parse. +func (t *Traits) requestAttestation(hostIP string, pid int) error { + host := net.JoinHostPort(hostIP, strconv.Itoa(t.MaitreDPort)) + url := "http://" + host + "/maitreD/maitreD/attest" + body, _ := json.Marshal(map[string]int{"pid": pid}) + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("cannot reach maitreD at %s: %w", hostIP, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + msg, _ := io.ReadAll(resp.Body) + return fmt.Errorf("maitreD rejected attestation: %s", string(msg)) + } + return nil +} + +//-------------------------------------Define the unit asset + +// Traits holds the configurable parameters for the certificate authority. +type Traits struct { + privateKey *ecdsa.PrivateKey `json:"-"` + certificate *x509.Certificate `json:"-"` + SafeSWare bool `json:"safeSWare"` + MaitreDHosts []string `json:"maitreDHosts"` // IPs permitted to enroll a maitreD + MaitreDPort int `json:"maitreDPort"` // port of the maitreD attest endpoint (0 = skip attestation) + WhitelistPath string `json:"-"` // path to whitelist.json; defaults to "whitelist.json" + owner *components.System `json:"-"` + name string `json:"-"` +} + +//-------------------------------------Instantiate a unit asset template + +// initTemplate initializes a UnitAsset with default values. +func initTemplate() *components.UnitAsset { + certify := components.Service{ + Definition: "certify", + SubPath: "certify", + Details: map[string][]string{"Forms": {"csr.pem"}}, + RegPeriod: 30, + Description: "signs a certificate signing request (POST) from authenticated systems in its local cloud", + } + whitelist := components.Service{ + Definition: "whitelist", + SubPath: "whitelist", + Details: map[string][]string{"Forms": {"application/json"}}, + RegPeriod: 30, + Description: "serves the cloud's approved-executable hash list (GET) to authenticated maitreD hosts", + } + + return &components.UnitAsset{ + Name: "certification", + Details: map[string][]string{"PKI": {"X.509"}, "Location": {"LocalCloud"}}, + ServicesMap: map[string]*components.Service{ + certify.SubPath: &certify, + whitelist.SubPath: &whitelist, + }, + // Defaults are secure-by-default: + // - MaitreDHosts includes both loopback addresses so a CA and a + // maitreD running on the same host work out-of-the-box (the + // resolver may pick either IPv4 or IPv6 for "localhost"). + // Operators add real LAN IPs as the deployment extends. + // - MaitreDPort is set to the standard mbaigo maitreD port (20101). + // Setting it to 0 silently disables attestation entirely; we + // refuse to ship that as the default. Operators who genuinely + // want to bypass attestation must set it to 0 deliberately. + Traits: &Traits{ + MaitreDHosts: []string{"127.0.0.1", "::1"}, + MaitreDPort: 20101, + }, + } +} + +//-------------------------------------Instantiate unit asset(s) based on configuration + +// newResource creates the unit asset with its pointers and channels based on the configuration. +func newResource(configuredAsset usecases.ConfigurableAsset, sys *components.System) (*components.UnitAsset, func()) { + t := &Traits{ + owner: sys, + name: configuredAsset.Name, + WhitelistPath: "whitelist.json", + } + + if len(configuredAsset.Traits) > 0 { + if err := json.Unmarshal(configuredAsset.Traits[0], t); err != nil { + log.Println("Warning: could not unmarshal traits:", err) + } + } + + certFile := "ca_certificate.pem" + keyFile := "ca_private_key.pem" + + var err error + t.certificate, t.privateKey, err = ensureCertificate(sys, certFile, keyFile) + if err != nil { + log.Fatalf("Failed to ensure CA certificate and key: %v", err) + } + + // Convert the certificate to PEM format and store in the system husk + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: t.certificate.Raw, + }) + if certPEM == nil { + log.Fatalf("failed to encode certificate to PEM format") + } + sys.Husk.Certificate = string(certPEM) + + ua := &components.UnitAsset{ + Name: configuredAsset.Name, + Mission: configuredAsset.Mission, + Owner: sys, + Details: configuredAsset.Details, + ServicesMap: usecases.MakeServiceMap(configuredAsset.Services), + Traits: t, + } + ua.ServingFunc = func(w http.ResponseWriter, r *http.Request, servicePath string) { + serving(t, w, r, servicePath) + } + + return ua, func() { + log.Println("shutting down certificate authority") + } +} + +//-------------------------------------Unit asset's function methods + +// signCSR creates a certificate as an answer to the certificate signing request +func signCSR(csr *x509.CertificateRequest, caCert *x509.Certificate, caPrivateKey interface{}) ([]byte, error) { + if err := csr.CheckSignature(); err != nil { + return nil, fmt.Errorf("invalid CSR signature: %v", err) + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, err + } + + certTemplate := x509.Certificate{ + SerialNumber: serialNumber, + Subject: csr.Subject, + DNSNames: csr.DNSNames, + IPAddresses: csr.IPAddresses, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + IsCA: false, + } + + certBytes, err := x509.CreateCertificate(rand.Reader, &certTemplate, caCert, csr.PublicKey, caPrivateKey) + if err != nil { + return nil, err + } + + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}), nil +} + +func (t *Traits) certifying(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Invalid request method", http.StatusMethodNotAllowed) + return + } + + csrPEM, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read CSR", http.StatusBadRequest) + return + } + + block, _ := pem.Decode(csrPEM) + if block == nil || block.Type != "CERTIFICATE REQUEST" { + http.Error(w, "Failed to decode CSR", http.StatusBadRequest) + return + } + + csr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + http.Error(w, "Failed to parse CSR", http.StatusBadRequest) + return + } + + clientIP, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + http.Error(w, "Failed to determine client IP", http.StatusInternalServerError) + return + } + + // maitreD systems may only enroll from pre-authorized host IPs. + if csr.Subject.CommonName == "maitreD" { + if !t.isMaitreDAuthorized(clientIP) { + log.Printf("certify: denied maitreD enrollment from %q (maitreDHosts=%v)", clientIP, t.MaitreDHosts) + http.Error(w, "Unauthorized maitreD host", http.StatusForbidden) + return + } + } else if t.MaitreDPort != 0 { + // All other systems require attestation from the maitreD on their host. + pidStr := r.Header.Get("X-Process-PID") + pid, err := strconv.Atoi(pidStr) + if err != nil || pid <= 0 { + http.Error(w, "Missing or invalid X-Process-PID header", http.StatusBadRequest) + return + } + if err := t.requestAttestation(clientIP, pid); err != nil { + log.Printf("certify: attestation failed for CN=%q from %q (pid=%d): %v", + csr.Subject.CommonName, clientIP, pid, err) + http.Error(w, "Attestation failed: "+err.Error(), http.StatusForbidden) + return + } + } else { + // MaitreDPort == 0: attestation is disabled. The CSR will be signed + // without contacting the maitreD. This is convenient for early-stage + // development and isolated testing, but it leaves the CA in a + // "trust everything" mode that bypasses the security model + // described in the paper. Log every such issuance so a post-incident + // review can identify deauthorised binaries that nevertheless + // obtained certs because attestation was disabled. + log.Printf("WARNING: certify: attestation disabled (maitreDPort=0); signing CN=%q from %q without verification", + csr.Subject.CommonName, clientIP) + } + + signedCert, err := signCSR(csr, t.certificate, t.privateKey) + if err != nil { + http.Error(w, "Failed to sign CSR", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/x-pem-file") + w.Write(signedCert) +} + +func generateSelfSignedCert(sys *components.System) ([]byte, []byte, error) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, err + } + + notBefore := time.Now() + notAfter := notBefore.Add(365 * 24 * time.Hour) + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, nil, err + } + + dnsNames := []string{"localhost"} + var ipAddrs []net.IP + for _, ipStr := range sys.Husk.Host.IPAddresses { + ip := net.ParseIP(ipStr) + if ip != nil { + ipAddrs = append(ipAddrs, ip) + } + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Synecdoque"}, + CommonName: "synecdoque.com", + }, + DNSNames: dnsNames, + IPAddresses: ipAddrs, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return nil, nil, err + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}) + + keyBytes, err := x509.MarshalECPrivateKey(privateKey) + if err != nil { + return nil, nil, err + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}) + + return certPEM, keyPEM, nil +} + +// ensureCertificate ensures that the CA certificate and key exist and are loaded. +func ensureCertificate(sys *components.System, certFile, keyFile string) (*x509.Certificate, *ecdsa.PrivateKey, error) { + if _, err := os.Stat(certFile); err == nil { + if _, err := os.Stat(keyFile); err == nil { + return loadCACertificate(certFile, keyFile) + } + } + + certPEM, keyPEM, err := generateSelfSignedCert(sys) + if err != nil { + return nil, nil, err + } + + if err = os.WriteFile(certFile, certPEM, 0644); err != nil { + return nil, nil, err + } + if err = os.WriteFile(keyFile, keyPEM, 0644); err != nil { + return nil, nil, err + } + log.Println("CA certificate and private key have been created") + return loadCACertificate(certFile, keyFile) +} + +// loadCACertificate attempts to load the CA's certificate and private key from files. +func loadCACertificate(certFile, keyFile string) (*x509.Certificate, *ecdsa.PrivateKey, error) { + certPEMBlock, err := os.ReadFile(certFile) + if err != nil { + return nil, nil, err + } + + keyPEMBlock, err := os.ReadFile(keyFile) + if err != nil { + return nil, nil, err + } + + certBlock, _ := pem.Decode(certPEMBlock) + if certBlock == nil { + return nil, nil, fmt.Errorf("failed to parse certificate PEM") + } + caCert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return nil, nil, err + } + + keyBlock, _ := pem.Decode(keyPEMBlock) + if keyBlock == nil { + return nil, nil, fmt.Errorf("failed to parse key PEM") + } + caPrivateKey, err := x509.ParseECPrivateKey(keyBlock.Bytes) + if err != nil { + return nil, nil, err + } + + log.Println("CA certificate and private key have been loaded") + return caCert, caPrivateKey, nil +} diff --git a/ca/thing_test.go b/ca/thing_test.go new file mode 100644 index 0000000..80a0284 --- /dev/null +++ b/ca/thing_test.go @@ -0,0 +1,502 @@ +/******************************************************************************* + * Copyright (c) 2024 Synecdoque + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, subject to the following conditions: + * + * The software is licensed under the MIT License. See the LICENSE file in this repository for details. + * + * Contributors: + * Jan A. van Deventer, Luleå - initial implementation + ***************************************************************************SDG*/ + +package main + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "math/big" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/sdoque/mbaigo/components" + "github.com/sdoque/mbaigo/usecases" +) + +// ── helpers ─────────────────────────────────────────────────────────────────── + +// newTestSystem builds a minimal System for use in tests. +func newTestSystem() *components.System { + ctx := context.Background() + sys := components.NewSystem("ca", ctx) + sys.Husk = &components.Husk{ + Description: "test ca", + Details: map[string][]string{"Developer": {"Synecdoque"}}, + Host: components.NewDevice(), + ProtoPort: map[string]int{"http": 20100}, + } + return &sys +} + +// makeTestCA generates a self-signed CA certificate and private key. +func makeTestCA(t *testing.T) (*x509.Certificate, *ecdsa.PrivateKey) { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + t.Fatalf("generate serial: %v", err) + } + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{CommonName: "test-ca"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + } + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + t.Fatalf("create cert: %v", err) + } + cert, err := x509.ParseCertificate(certBytes) + if err != nil { + t.Fatalf("parse cert: %v", err) + } + return cert, privateKey +} + +// writeCAFiles saves PEM-encoded cert and key to the named files inside dir. +func writeCAFiles(t *testing.T, dir, certFilename, keyFilename string, cert *x509.Certificate, key *ecdsa.PrivateKey) { + t.Helper() + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) + if err := os.WriteFile(filepath.Join(dir, certFilename), certPEM, 0644); err != nil { + t.Fatalf("write cert: %v", err) + } + keyBytes, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}) + if err := os.WriteFile(filepath.Join(dir, keyFilename), keyPEM, 0644); err != nil { + t.Fatalf("write key: %v", err) + } +} + +// makeCSRPEM generates and returns a PEM-encoded certificate signing request. +func makeCSRPEM(t *testing.T) []byte { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key for CSR: %v", err) + } + template := x509.CertificateRequest{ + Subject: pkix.Name{CommonName: "test-client"}, + } + csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &template, privateKey) + if err != nil { + t.Fatalf("create CSR: %v", err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrBytes}) +} + +// ── initTemplate ────────────────────────────────────────────────────────────── + +func TestInitTemplate(t *testing.T) { + ua := initTemplate() + + if ua.GetName() != "certification" { + t.Errorf("name = %q, want %q", ua.GetName(), "certification") + } + services := ua.GetServices() + svc, ok := services["certify"] + if !ok { + t.Fatal("expected 'certify' entry in ServicesMap") + } + if svc.Definition != "certify" { + t.Errorf("service definition = %q, want %q", svc.Definition, "certify") + } + if ua.GetTraits() == nil { + t.Error("Traits should be non-nil") + } +} + +// ── Traits serialisation ────────────────────────────────────────────────────── + +func TestTraitsSerialization(t *testing.T) { + original := Traits{SafeSWare: true} + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + var decoded Traits + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if decoded.SafeSWare != original.SafeSWare { + t.Errorf("SafeSWare = %v, want %v", decoded.SafeSWare, original.SafeSWare) + } + // Private fields must not leak into JSON; public fields must round-trip. + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("re-unmarshal: %v", err) + } + if _, ok := raw["privateKey"]; ok { + t.Error("privateKey must not be exported to JSON") + } + if _, ok := raw["certificate"]; ok { + t.Error("certificate must not be exported to JSON") + } + if v, ok := raw["safeSWare"].(bool); !ok || !v { + t.Error("safeSWare must be true in JSON") + } +} + +// ── signCSR ─────────────────────────────────────────────────────────────────── + +func TestSignCSR(t *testing.T) { + caCert, caKey := makeTestCA(t) + csrPEM := makeCSRPEM(t) + + block, _ := pem.Decode(csrPEM) + csr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + t.Fatalf("parse CSR: %v", err) + } + + t.Run("valid CSR produces a verifiable certificate", func(t *testing.T) { + certPEM, err := signCSR(csr, caCert, caKey) + if err != nil { + t.Fatalf("signCSR: %v", err) + } + blk, _ := pem.Decode(certPEM) + if blk == nil || blk.Type != "CERTIFICATE" { + t.Fatal("output is not a valid CERTIFICATE PEM block") + } + cert, err := x509.ParseCertificate(blk.Bytes) + if err != nil { + t.Fatalf("parse signed cert: %v", err) + } + pool := x509.NewCertPool() + pool.AddCert(caCert) + if _, err := cert.Verify(x509.VerifyOptions{Roots: pool}); err != nil { + t.Errorf("certificate does not verify against CA: %v", err) + } + if cert.IsCA { + t.Error("signed certificate should not be a CA certificate") + } + }) + + t.Run("nil CA private key returns error", func(t *testing.T) { + _, err := signCSR(csr, caCert, nil) + if err == nil { + t.Error("expected error when CA private key is nil") + } + }) +} + +// ── generateSelfSignedCert ──────────────────────────────────────────────────── + +func TestGenerateSelfSignedCert(t *testing.T) { + sys := newTestSystem() + + certPEM, keyPEM, err := generateSelfSignedCert(sys) + if err != nil { + t.Fatalf("generateSelfSignedCert: %v", err) + } + + certBlock, _ := pem.Decode(certPEM) + if certBlock == nil || certBlock.Type != "CERTIFICATE" { + t.Fatal("certPEM is not a valid CERTIFICATE block") + } + cert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + t.Fatalf("parse certificate: %v", err) + } + if !cert.IsCA { + t.Error("expected IsCA = true for self-signed CA cert") + } + if cert.NotAfter.Before(time.Now().Add(364 * 24 * time.Hour)) { + t.Error("certificate validity is shorter than expected") + } + + keyBlock, _ := pem.Decode(keyPEM) + if keyBlock == nil || keyBlock.Type != "EC PRIVATE KEY" { + t.Fatal("keyPEM is not a valid EC PRIVATE KEY block") + } + if _, err := x509.ParseECPrivateKey(keyBlock.Bytes); err != nil { + t.Fatalf("parse EC private key: %v", err) + } +} + +// ── loadCACertificate ───────────────────────────────────────────────────────── + +func TestLoadCACertificate(t *testing.T) { + dir := t.TempDir() + caCert, caKey := makeTestCA(t) + writeCAFiles(t, dir, "ca_cert.pem", "ca_key.pem", caCert, caKey) + certFile := filepath.Join(dir, "ca_cert.pem") + keyFile := filepath.Join(dir, "ca_key.pem") + + t.Run("loads valid files successfully", func(t *testing.T) { + cert, key, err := loadCACertificate(certFile, keyFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cert.SerialNumber.Cmp(caCert.SerialNumber) != 0 { + t.Error("loaded certificate serial number does not match original") + } + if key == nil { + t.Error("expected non-nil private key") + } + }) + + t.Run("missing cert file returns error", func(t *testing.T) { + _, _, err := loadCACertificate(filepath.Join(dir, "no_such.pem"), keyFile) + if err == nil { + t.Error("expected error for missing cert file") + } + }) + + t.Run("missing key file returns error", func(t *testing.T) { + _, _, err := loadCACertificate(certFile, filepath.Join(dir, "no_such.pem")) + if err == nil { + t.Error("expected error for missing key file") + } + }) + + t.Run("invalid cert PEM returns error", func(t *testing.T) { + badCert := filepath.Join(dir, "bad_cert.pem") + os.WriteFile(badCert, []byte("this is not PEM"), 0644) + _, _, err := loadCACertificate(badCert, keyFile) + if err == nil { + t.Error("expected error for invalid cert PEM") + } + }) + + t.Run("invalid key PEM returns error", func(t *testing.T) { + badKey := filepath.Join(dir, "bad_key.pem") + os.WriteFile(badKey, []byte("this is not PEM"), 0644) + _, _, err := loadCACertificate(certFile, badKey) + if err == nil { + t.Error("expected error for invalid key PEM") + } + }) +} + +// ── ensureCertificate ───────────────────────────────────────────────────────── + +func TestEnsureCertificate(t *testing.T) { + sys := newTestSystem() + + t.Run("generates files when none exist", func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + cert, key, err := ensureCertificate(sys, "ca_certificate.pem", "ca_private_key.pem") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cert == nil || key == nil { + t.Error("expected non-nil cert and key") + } + if _, err := os.Stat("ca_certificate.pem"); err != nil { + t.Error("cert file was not created") + } + if _, err := os.Stat("ca_private_key.pem"); err != nil { + t.Error("key file was not created") + } + }) + + t.Run("loads existing files without overwriting", func(t *testing.T) { + dir := t.TempDir() + existing, existingKey := makeTestCA(t) + writeCAFiles(t, dir, "ca_certificate.pem", "ca_private_key.pem", existing, existingKey) + t.Chdir(dir) + cert, _, err := ensureCertificate(sys, "ca_certificate.pem", "ca_private_key.pem") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cert.SerialNumber.Cmp(existing.SerialNumber) != 0 { + t.Error("loaded a different certificate than the one that was present") + } + }) +} + +// ── newResource ─────────────────────────────────────────────────────────────── + +func TestNewResource(t *testing.T) { + t.Run("creates unit asset with correct fields", func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + sys := newTestSystem() + certifySvc := components.Service{ + Definition: "certify", + SubPath: "certify", + Details: map[string][]string{"Forms": {"csr.pem"}}, + RegPeriod: 30, + Description: "signs CSRs", + } + cfgAsset := usecases.ConfigurableAsset{ + Name: "certification", + Mission: "sign_csrs", + Details: map[string][]string{"PKI": {"X.509"}}, + Services: []components.Service{certifySvc}, + } + + ua, cleanup := newResource(cfgAsset, sys) + defer cleanup() + + if ua.GetName() != "certification" { + t.Errorf("name = %q, want %q", ua.GetName(), "certification") + } + if ua.Mission != "sign_csrs" { + t.Errorf("mission = %q, want %q", ua.Mission, "sign_csrs") + } + if ua.ServingFunc == nil { + t.Error("ServingFunc must be set") + } + if _, ok := ua.GetServices()["certify"]; !ok { + t.Error("expected 'certify' service in map") + } + if sys.Husk.Certificate == "" { + t.Error("sys.Husk.Certificate was not populated") + } + }) + + t.Run("unmarshals SafeSWare trait from config", func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + sys := newTestSystem() + traitJSON, _ := json.Marshal(Traits{SafeSWare: true}) + cfgAsset := usecases.ConfigurableAsset{ + Name: "certification", + Traits: []json.RawMessage{traitJSON}, + Services: []components.Service{{Definition: "certify", SubPath: "certify"}}, + } + + ua, cleanup := newResource(cfgAsset, sys) + defer cleanup() + + traits, ok := ua.GetTraits().(*Traits) + if !ok { + t.Fatal("traits are not of type *Traits") + } + if !traits.SafeSWare { + t.Error("SafeSWare should be true after unmarshaling") + } + }) +} + +// ── serving ─────────────────────────────────────────────────────────────────── + +func TestServing(t *testing.T) { + caCert, caKey := makeTestCA(t) + traits := &Traits{certificate: caCert, privateKey: caKey} + + t.Run("certify path dispatches correctly", func(t *testing.T) { + csrPEM := makeCSRPEM(t) + req := httptest.NewRequest(http.MethodPost, "/ca/certification/certify", bytes.NewReader(csrPEM)) + w := httptest.NewRecorder() + serving(traits, w, req, "certify") + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + }) + + t.Run("unknown path returns 400", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/ca/certification/unknown", nil) + w := httptest.NewRecorder() + serving(traits, w, req, "unknown") + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } + }) +} + +// ── certifying ──────────────────────────────────────────────────────────────── + +func TestCertifying(t *testing.T) { + caCert, caKey := makeTestCA(t) + traits := &Traits{certificate: caCert, privateKey: caKey} + + t.Run("POST with valid CSR returns signed certificate", func(t *testing.T) { + csrPEM := makeCSRPEM(t) + req := httptest.NewRequest(http.MethodPost, "/certify", bytes.NewReader(csrPEM)) + w := httptest.NewRecorder() + traits.certifying(w, req) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + block, _ := pem.Decode(w.Body.Bytes()) + if block == nil || block.Type != "CERTIFICATE" { + t.Error("response body is not a valid CERTIFICATE PEM block") + } + // The signed certificate should verify against the test CA. + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("parse returned cert: %v", err) + } + pool := x509.NewCertPool() + pool.AddCert(caCert) + if _, err := cert.Verify(x509.VerifyOptions{Roots: pool}); err != nil { + t.Errorf("returned certificate does not verify: %v", err) + } + }) + + t.Run("GET returns 405", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/certify", nil) + w := httptest.NewRecorder() + traits.certifying(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", w.Code) + } + }) + + t.Run("POST with non-PEM body returns 400", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/certify", bytes.NewReader([]byte("not a pem"))) + w := httptest.NewRecorder() + traits.certifying(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } + }) + + t.Run("POST with wrong PEM type returns 400", func(t *testing.T) { + wrongPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("not a csr")}) + req := httptest.NewRequest(http.MethodPost, "/certify", bytes.NewReader(wrongPEM)) + w := httptest.NewRecorder() + traits.certifying(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } + }) + + t.Run("POST with invalid ASN.1 inside CSR block returns 400", func(t *testing.T) { + garbledPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: []byte("garbage")}) + req := httptest.NewRequest(http.MethodPost, "/certify", bytes.NewReader(garbledPEM)) + w := httptest.NewRecorder() + traits.certifying(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } + }) +} diff --git a/ca/whitelist.go b/ca/whitelist.go new file mode 100644 index 0000000..a711945 --- /dev/null +++ b/ca/whitelist.go @@ -0,0 +1,112 @@ +/******************************************************************************* + * Copyright (c) 2026 Synecdoque + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, subject to the following conditions: + * + * The software is licensed under the MIT License. See the LICENSE file in this repository for details. + * + * Contributors: + * Jan A. van Deventer, Luleå - initial implementation + ***************************************************************************SDG*/ + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "strconv" + "time" +) + +// Whitelist is the wire format served by the CA at /ca/certification/whitelist. +// +// Version is the Unix-second mtime of whitelist.json; bumping the file's mtime +// (any edit, or `touch`) advances the version automatically — operators do not +// hand-maintain a counter. UpdatedAt is the same timestamp in RFC3339 for +// human readers. +type Whitelist struct { + Version int64 `json:"version"` + UpdatedAt string `json:"updatedAt"` + Hashes []string `json:"hashes"` +} + +// loadWhitelist reads the operator-edited whitelist file, which is a flat JSON +// array of SHA-256 hex strings. The wrapper struct adds version metadata +// derived from the file's modification time. +// +// A missing file is not an error: it represents the deliberate state "operator +// has not approved any binaries yet". The CA serves an empty whitelist and the +// maitreD enforces fail-closed (no hash matches an empty list), so accidentally +// deleting the file does not silently approve every binary — it denies them. +func loadWhitelist(path string) (Whitelist, error) { + info, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return Whitelist{Hashes: []string{}}, nil + } + if err != nil { + return Whitelist{}, err + } + data, err := os.ReadFile(path) + if err != nil { + return Whitelist{}, err + } + hashes := []string{} + if err := json.Unmarshal(data, &hashes); err != nil { + return Whitelist{}, fmt.Errorf("parse %s: %w", path, err) + } + mt := info.ModTime() + return Whitelist{ + Version: mt.Unix(), + UpdatedAt: mt.UTC().Format(time.RFC3339), + Hashes: hashes, + }, nil +} + +// whitelisting handles GET /ca/certification/whitelist. +// +// Source IP must be in MaitreDHosts (same gating as maitreD enrollment) so +// that only authenticated host sentinels can pull the list. ?since=N +// short-circuits with 304 Not Modified when the on-disk version has not +// advanced past N, so an unchanged whitelist costs the maitreD a single +// HEAD-equivalent round trip. +func (t *Traits) whitelisting(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not supported", http.StatusMethodNotAllowed) + return + } + clientIP, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + http.Error(w, "Failed to determine client IP", http.StatusInternalServerError) + return + } + if !t.isMaitreDAuthorized(clientIP) { + log.Printf("whitelist: denied source IP %q (maitreDHosts=%v)", clientIP, t.MaitreDHosts) + http.Error(w, "Unauthorized maitreD host", http.StatusForbidden) + return + } + + wl, err := loadWhitelist(t.WhitelistPath) + if err != nil { + http.Error(w, "Cannot load whitelist", http.StatusInternalServerError) + return + } + + if since := r.URL.Query().Get("since"); since != "" { + if n, err := strconv.ParseInt(since, 10, 64); err == nil && wl.Version <= n { + w.WriteHeader(http.StatusNotModified) + return + } + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(wl) +} diff --git a/ca/whitelist_test.go b/ca/whitelist_test.go new file mode 100644 index 0000000..753899e --- /dev/null +++ b/ca/whitelist_test.go @@ -0,0 +1,235 @@ +/******************************************************************************* + * Copyright (c) 2026 Synecdoque + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, subject to the following conditions: + * + * The software is licensed under the MIT License. See the LICENSE file in this repository for details. + * + * Contributors: + * Jan A. van Deventer, Luleå - initial implementation + ***************************************************************************SDG*/ + +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "testing" +) + +// ── loadWhitelist ───────────────────────────────────────────────────────────── + +func TestLoadWhitelist(t *testing.T) { + t.Run("missing file returns empty whitelist with version 0", func(t *testing.T) { + dir := t.TempDir() + wl, err := loadWhitelist(filepath.Join(dir, "nope.json")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(wl.Hashes) != 0 { + t.Errorf("expected empty hashes, got %v", wl.Hashes) + } + if wl.Version != 0 { + t.Errorf("expected version 0 for missing file, got %d", wl.Version) + } + }) + + t.Run("flat array of hashes parses, version is mtime", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "whitelist.json") + if err := os.WriteFile(path, []byte(`["abc","def"]`), 0644); err != nil { + t.Fatalf("write: %v", err) + } + wl, err := loadWhitelist(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(wl.Hashes, []string{"abc", "def"}) { + t.Errorf("hashes = %v, want [abc def]", wl.Hashes) + } + if wl.Version <= 0 { + t.Errorf("version should be positive for an existing file, got %d", wl.Version) + } + if wl.UpdatedAt == "" { + t.Error("UpdatedAt must be set for an existing file") + } + }) + + t.Run("empty array yields zero hashes (no panic, no error)", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "whitelist.json") + os.WriteFile(path, []byte(`[]`), 0644) + wl, err := loadWhitelist(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(wl.Hashes) != 0 { + t.Errorf("expected empty hashes, got %v", wl.Hashes) + } + }) + + t.Run("malformed JSON returns parse error", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "whitelist.json") + os.WriteFile(path, []byte(`not json`), 0644) + if _, err := loadWhitelist(path); err == nil { + t.Error("expected parse error for malformed JSON") + } + }) +} + +// ── whitelisting (HTTP handler) ─────────────────────────────────────────────── + +// newWhitelistTraits returns a Traits configured for the whitelist HTTP tests. +// It writes the supplied hashes to a temp file and points Traits at it, so +// each subtest is fully isolated. +func newWhitelistTraits(t *testing.T, hashes []string) *Traits { + t.Helper() + path := filepath.Join(t.TempDir(), "whitelist.json") + if hashes != nil { + data, _ := json.Marshal(hashes) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatalf("write whitelist: %v", err) + } + } + return &Traits{ + MaitreDHosts: []string{"127.0.0.1"}, + WhitelistPath: path, + } +} + +func TestWhitelisting(t *testing.T) { + t.Run("authorized GET returns the whitelist", func(t *testing.T) { + traits := newWhitelistTraits(t, []string{"abc", "def"}) + req := httptest.NewRequest(http.MethodGet, "/ca/certification/whitelist", nil) + req.RemoteAddr = "127.0.0.1:54321" + w := httptest.NewRecorder() + + traits.whitelisting(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var wl Whitelist + if err := json.Unmarshal(w.Body.Bytes(), &wl); err != nil { + t.Fatalf("decode body: %v", err) + } + if !reflect.DeepEqual(wl.Hashes, []string{"abc", "def"}) { + t.Errorf("hashes = %v, want [abc def]", wl.Hashes) + } + if wl.Version <= 0 { + t.Errorf("version = %d, want > 0", wl.Version) + } + }) + + t.Run("unauthorized source IP returns 403", func(t *testing.T) { + traits := newWhitelistTraits(t, []string{"abc"}) + req := httptest.NewRequest(http.MethodGet, "/ca/certification/whitelist", nil) + req.RemoteAddr = "10.0.0.99:54321" + w := httptest.NewRecorder() + + traits.whitelisting(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", w.Code) + } + }) + + t.Run("non-GET methods return 405", func(t *testing.T) { + traits := newWhitelistTraits(t, []string{"abc"}) + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} { + req := httptest.NewRequest(method, "/ca/certification/whitelist", nil) + req.RemoteAddr = "127.0.0.1:54321" + w := httptest.NewRecorder() + + traits.whitelisting(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("%s: status = %d, want 405", method, w.Code) + } + } + }) + + t.Run("?since at current version returns 304", func(t *testing.T) { + traits := newWhitelistTraits(t, []string{"abc"}) + + // First fetch to learn the current version. + req := httptest.NewRequest(http.MethodGet, "/ca/certification/whitelist", nil) + req.RemoteAddr = "127.0.0.1:54321" + w := httptest.NewRecorder() + traits.whitelisting(w, req) + var wl Whitelist + if err := json.Unmarshal(w.Body.Bytes(), &wl); err != nil { + t.Fatalf("decode body: %v", err) + } + + // Re-request with since == current version → must be 304. + req2 := httptest.NewRequest(http.MethodGet, + fmt.Sprintf("/ca/certification/whitelist?since=%d", wl.Version), nil) + req2.RemoteAddr = "127.0.0.1:54321" + w2 := httptest.NewRecorder() + traits.whitelisting(w2, req2) + + if w2.Code != http.StatusNotModified { + t.Errorf("status = %d, want 304", w2.Code) + } + }) + + t.Run("?since older than version returns 200 with body", func(t *testing.T) { + traits := newWhitelistTraits(t, []string{"abc"}) + req := httptest.NewRequest(http.MethodGet, "/ca/certification/whitelist?since=0", nil) + req.RemoteAddr = "127.0.0.1:54321" + w := httptest.NewRecorder() + + traits.whitelisting(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + }) + + t.Run("missing whitelist file serves empty list (fail-closed by maitreD)", func(t *testing.T) { + traits := newWhitelistTraits(t, nil) // hashes==nil ⇒ no file written + req := httptest.NewRequest(http.MethodGet, "/ca/certification/whitelist", nil) + req.RemoteAddr = "127.0.0.1:54321" + w := httptest.NewRecorder() + + traits.whitelisting(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var wl Whitelist + json.Unmarshal(w.Body.Bytes(), &wl) + if len(wl.Hashes) != 0 { + t.Errorf("expected empty hashes for missing file, got %v", wl.Hashes) + } + if wl.Version != 0 { + t.Errorf("expected version 0 for missing file, got %d", wl.Version) + } + }) +} + +// ── serving routing ────────────────────────────────────────────────────────── + +func TestServingWhitelistDispatch(t *testing.T) { + traits := newWhitelistTraits(t, []string{"abc"}) + req := httptest.NewRequest(http.MethodGet, "/ca/certification/whitelist", nil) + req.RemoteAddr = "127.0.0.1:54321" + w := httptest.NewRecorder() + + serving(traits, w, req, "whitelist") + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } +} diff --git a/clerk/clerk.go b/clerk/clerk.go index d463d81..591306b 100644 --- a/clerk/clerk.go +++ b/clerk/clerk.go @@ -20,7 +20,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -35,6 +34,9 @@ func main() { sys := components.NewSystem("clerk", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + sys.Husk = &components.Husk{ Description: "browser-based order entry form for pen holder orders", Details: map[string][]string{"Developer": {"Synecdoque"}}, @@ -75,9 +77,8 @@ func main() { usecases.RegisterServices(&sys) go usecases.SetoutServers(&sys) - <-sys.Sigs - fmt.Println("\nshutting down system", sys.Name) - cancel() + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) } diff --git a/clerk/go.mod b/clerk/go.mod index 3f2a29c..801e56a 100644 --- a/clerk/go.mod +++ b/clerk/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/clerk go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/clerk/go.sum b/clerk/go.sum index 6d3cabc..6113ffb 100644 --- a/clerk/go.sum +++ b/clerk/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/collector/collector.go b/collector/collector.go index 0d415c7..53d2710 100644 --- a/collector/collector.go +++ b/collector/collector.go @@ -20,7 +20,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -37,6 +36,9 @@ func main() { // instantiate the System sys := components.NewSystem("Collector", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // Instantiate the husk sys.Husk = &components.Husk{ Description: " is a system that ingests time signals into an Influx database", @@ -86,9 +88,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal - fmt.Println("\nshuting down system", sys.Name) - cancel() // cancel the context, signaling the goroutines to stop + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end } diff --git a/collector/go.mod b/collector/go.mod index 5df9a0b..3d8e0b6 100644 --- a/collector/go.mod +++ b/collector/go.mod @@ -4,7 +4,7 @@ go 1.26.2 require ( github.com/influxdata/influxdb-client-go/v2 v2.14.0 - github.com/sdoque/mbaigo v0.1.0-alpha.4 + github.com/sdoque/mbaigo v0.1.0-alpha.6 ) require ( diff --git a/collector/go.sum b/collector/go.sum index ab8d096..33bd688 100644 --- a/collector/go.sum +++ b/collector/go.sum @@ -16,8 +16,8 @@ github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7o github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/democrat/democrat.go b/democrat/democrat.go index ffae853..454bc20 100644 --- a/democrat/democrat.go +++ b/democrat/democrat.go @@ -44,7 +44,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -58,6 +57,9 @@ func main() { defer cancel() sys := components.NewSystem("democrat", ctx) + + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) sys.Husk = &components.Husk{ Description: "bridges the Arrowhead local cloud knowledge graph to FA³ST Asset Administration Shells", Details: map[string][]string{"Developer": {"Synecdoque"}}, @@ -98,9 +100,8 @@ func main() { usecases.RegisterServices(&sys) go usecases.SetoutServers(&sys) - <-sys.Sigs - fmt.Println("\nshutting down system", sys.Name) - cancel() + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) } diff --git a/democrat/go.mod b/democrat/go.mod index c088329..c981efa 100644 --- a/democrat/go.mod +++ b/democrat/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/democrat go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/democrat/go.sum b/democrat/go.sum index 6d3cabc..6113ffb 100644 --- a/democrat/go.sum +++ b/democrat/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/drafter/drafter.go b/drafter/drafter.go index c4544df..5b48576 100644 --- a/drafter/drafter.go +++ b/drafter/drafter.go @@ -31,7 +31,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -48,6 +47,9 @@ func main() { // ── system instantiation ─────────────────────────────────────────────────── sys := components.NewSystem("drafter", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + sys.Husk = &components.Husk{ Description: "skeleton system for learning the mbaigo architecture", Details: map[string][]string{"Developer": {"Synecdoque"}}, @@ -89,9 +91,8 @@ func main() { go usecases.SetoutServers(&sys) // ── wait for Ctrl-C ──────────────────────────────────────────────────────── - <-sys.Sigs - fmt.Println("\nshutting down system", sys.Name) - cancel() + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) } diff --git a/drafter/go.mod b/drafter/go.mod index 99b36d1..31ec861 100644 --- a/drafter/go.mod +++ b/drafter/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/drafter go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/drafter/go.sum b/drafter/go.sum index 6d3cabc..6113ffb 100644 --- a/drafter/go.sum +++ b/drafter/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/ds18b20/ds18b20.go b/ds18b20/ds18b20.go index a065909..eae7d2d 100644 --- a/ds18b20/ds18b20.go +++ b/ds18b20/ds18b20.go @@ -36,6 +36,9 @@ func main() { // instantiate the System sys := components.NewSystem("ds18b20", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // instantiate the husk sys.Husk = &components.Husk{ Description: "reads the temperature from 1-wire sensors", @@ -85,9 +88,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal - log.Println("\nshuting down system", sys.Name) - cancel() // cancel the context, signaling the goroutines to stop + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end } diff --git a/ds18b20/go.mod b/ds18b20/go.mod index 5ede630..0b913e2 100644 --- a/ds18b20/go.mod +++ b/ds18b20/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/ds18b20 go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/ds18b20/go.sum b/ds18b20/go.sum index 6d3cabc..6113ffb 100644 --- a/ds18b20/go.sum +++ b/ds18b20/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/emulator/emulator.go b/emulator/emulator.go index f849f29..aa6cf3d 100644 --- a/emulator/emulator.go +++ b/emulator/emulator.go @@ -36,6 +36,9 @@ func main() { // instantiate the System sys := components.NewSystem("emulator", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // instantiate the husk sys.Husk = &components.Husk{ Description: "replays signals stored in JSON, XML or CSV files", @@ -85,9 +88,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal - log.Println("\nshuting down system", sys.Name) - cancel() // cancel the context, signaling the goroutines to stop + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end } diff --git a/emulator/go.mod b/emulator/go.mod index 71d1577..1afc2c2 100644 --- a/emulator/go.mod +++ b/emulator/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/emulator go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/emulator/go.sum b/emulator/go.sum index 6d3cabc..6113ffb 100644 --- a/emulator/go.sum +++ b/emulator/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/esr/esr.go b/esr/esr.go index b56d227..9316283 100644 --- a/esr/esr.go +++ b/esr/esr.go @@ -42,6 +42,9 @@ func main() { // instantiate the System sys := components.NewSystem("serviceregistrar", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // Instantiate the Capsule sys.Husk = &components.Husk{ Description: "is an Arrowhead mandatory core system that keeps track of the currently available services.", @@ -91,9 +94,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs - fmt.Println("\nShutting down system", sys.Name) - cancel() + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(3 * time.Second) } @@ -118,24 +120,62 @@ func serving(t *Traits, w http.ResponseWriter, r *http.Request, servicePath stri } // renderListItems builds the sorted
  • HTML fragment sent to SSE subscribers. +// +// Each service shows one endpoint per configured protocol. Plain-HTTP links +// are clickable for browser-driven inspection by deployment technicians. +// HTTPS endpoints are rendered as labels rather than active links because the +// framework's TLS server requires mTLS (`tls.RequireAndVerifyClientCert`), +// which a regular browser cannot satisfy without an installed client cert +// signed by this cloud's CA. Programmatic peers using `http.DefaultClient` +// configured by `installTLSConfig` reach those endpoints; humans cannot. func renderListItems(servicesList []forms.ServiceRecord_v1) string { sort.Slice(servicesList, func(i, j int) bool { return servicesList[i].Id < servicesList[j].Id }) + + // Protocol render order: HTTP first because it is the browser-clickable + // link; HTTPS afterwards as an mTLS-labelled endpoint. + protoOrder := []string{"http", "https", "coap"} + var sb strings.Builder for _, servRec := range servicesList { - metaservice := "" + var details strings.Builder for key, values := range servRec.Details { - metaservice += key + ": " + fmt.Sprintf("%v", values) + " " + fmt.Fprintf(&details, "%s: %v ", key, values) } - hyperlink := "http://" + servRec.IPAddresses[0] + ":" + strconv.Itoa(int(servRec.ProtoPort["http"])) + "/" + servRec.SystemName + "/" + servRec.SubPath + parts := strings.Split(servRec.SubPath, "/") uaName := parts[0] - sb.WriteString("
  • Service ID: " + strconv.Itoa(int(servRec.Id)) + - " with definition " + servRec.ServiceDefinition + "" + - " from the " + servRec.SystemName + "/" + uaName + "" + - " with details " + metaservice + - " will expire at: " + servRec.EndOfValidity + "

  • ") + + var endpoints strings.Builder + for _, proto := range protoOrder { + port, ok := servRec.ProtoPort[proto] + if !ok || port == 0 { + continue + } + url := proto + "://" + servRec.IPAddresses[0] + ":" + strconv.Itoa(port) + + "/" + servRec.SystemName + "/" + servRec.SubPath + if proto == "http" { + // Browser-clickable plain-HTTP link. + fmt.Fprintf(&endpoints, ` %s`, url, proto) + } else { + // mTLS endpoint (HTTPS) or non-HTTP protocols (CoAP): + // shown as a labelled span so the URL is visible but not + // clickable into a regular browser session. + fmt.Fprintf(&endpoints, ` [%s: %s]`, proto, url) + } + } + + fmt.Fprintf(&sb, + "
  • Service ID: %d with definition %s from the %s/%s"+ + " — endpoints:%s — with details %s — will expire at: %s

  • ", + servRec.Id, + servRec.ServiceDefinition, + servRec.SystemName, uaName, + endpoints.String(), + details.String(), + servRec.EndOfValidity, + ) } return sb.String() } diff --git a/esr/esr_test.go b/esr/esr_test.go index d561f80..6846fd5 100644 --- a/esr/esr_test.go +++ b/esr/esr_test.go @@ -500,6 +500,49 @@ func TestRenderListItems(t *testing.T) { } } +// renderListItems must show every configured protocol per service. HTTP is +// rendered as a clickable link (browsers can hit it); HTTPS is rendered as a +// non-clickable label because the framework's mTLS requirement excludes +// browser-only clients. Ports of 0 must be skipped entirely. +func TestRenderListItemsProtocols(t *testing.T) { + services := []forms.ServiceRecord_v1{ + { + Id: 1, SystemName: "ca", SubPath: "certification/certify", + IPAddresses: []string{"10.0.0.33"}, + ProtoPort: map[string]int{"http": 20100, "https": 30100, "coap": 0}, + ServiceDefinition: "certify", + }, + { + Id: 2, SystemName: "secret", SubPath: "vault/get", + IPAddresses: []string{"10.0.0.99"}, + ProtoPort: map[string]int{"http": 0, "https": 30200}, + ServiceDefinition: "fetch", + }, + } + + result := renderListItems(services) + + // Service 1 is reachable on both HTTP and HTTPS. + if !strings.Contains(result, `href="http://10.0.0.33:20100/ca/certification/certify"`) { + t.Error("HTTP endpoint for service 1 missing or malformed") + } + if !strings.Contains(result, `https://10.0.0.33:30100/ca/certification/certify`) { + t.Error("HTTPS endpoint for service 1 missing") + } + // HTTPS must NOT be inside an — clicking it would fail mTLS. + if strings.Contains(result, `href="https://`) { + t.Error("HTTPS endpoints must not be rendered as clickable links") + } + + // Service 2 is HTTPS-only; no HTTP link should be rendered. + if strings.Contains(result, "http://10.0.0.99:0") { + t.Error("Port-0 HTTP must be skipped, not rendered as :0") + } + if !strings.Contains(result, "https://10.0.0.99:30200/secret/vault/get") { + t.Error("HTTPS endpoint for service 2 missing") + } +} + // ----------------------------------------------- // // Tests for notify() // ----------------------------------------------- // diff --git a/esr/go.mod b/esr/go.mod index fda1aa1..fcefd2c 100644 --- a/esr/go.mod +++ b/esr/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/esr go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/esr/go.sum b/esr/go.sum index 6d3cabc..6113ffb 100644 --- a/esr/go.sum +++ b/esr/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/ethermostat/ethermostat.go b/ethermostat/ethermostat.go index d14420d..4d8bcaa 100644 --- a/ethermostat/ethermostat.go +++ b/ethermostat/ethermostat.go @@ -34,6 +34,9 @@ func main() { sys := components.NewSystem("ethermostat", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + sys.Husk = &components.Husk{ Description: "controls electrical heating plugs based on temperature readings from meteorologue", Details: map[string][]string{"Developer": {"Synecdoque"}}, @@ -71,11 +74,6 @@ func main() { } // Forward shutdown signals to the context immediately so that Ctrl+C // unblocks the discovery retry loop inside newResources. - go func() { - <-sys.Sigs - cancel() - }() - assets, cleanup := newResources(uac, &sys) defer cleanup() for _, ua := range assets { diff --git a/ethermostat/go.mod b/ethermostat/go.mod index 14e7eee..9c898c0 100644 --- a/ethermostat/go.mod +++ b/ethermostat/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/ethermostat go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/ethermostat/go.sum b/ethermostat/go.sum index 6d3cabc..6113ffb 100644 --- a/ethermostat/go.sum +++ b/ethermostat/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/filmer/filmer.go b/filmer/filmer.go index 5aa08f7..2760d12 100644 --- a/filmer/filmer.go +++ b/filmer/filmer.go @@ -37,6 +37,9 @@ func main() { // instantiate the System sys := components.NewSystem("filmer", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // instantiate the husk sys.Husk = &components.Husk{ Description: "streams live MJPEG video from a Raspberry Pi camera", @@ -86,9 +89,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal - fmt.Println("\nshuting down system", sys.Name) - cancel() // cancel the context, signaling the goroutines to stop + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(3 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end } diff --git a/filmer/go.mod b/filmer/go.mod index 1e964c4..e44b557 100644 --- a/filmer/go.mod +++ b/filmer/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/filmer go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/filmer/go.sum b/filmer/go.sum index 6d3cabc..6113ffb 100644 --- a/filmer/go.sum +++ b/filmer/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/flattener/flattener.go b/flattener/flattener.go index 2d02707..eda9754 100644 --- a/flattener/flattener.go +++ b/flattener/flattener.go @@ -22,7 +22,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -37,6 +36,9 @@ func main() { sys := components.NewSystem("flattener", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + sys.Husk = &components.Husk{ Description: "adjusts the thermostat setpoint inversely to the electricity spot price to flatten energy peak demand.", Details: map[string][]string{"Developer": {"Synecdoque"}}, @@ -77,9 +79,8 @@ func main() { usecases.RegisterServices(&sys) go usecases.SetoutServers(&sys) - <-sys.Sigs - fmt.Println("\nshuting down system", sys.Name) - cancel() + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) } diff --git a/flattener/go.mod b/flattener/go.mod index 19b5309..cce7b3a 100644 --- a/flattener/go.mod +++ b/flattener/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/flattener go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/flattener/go.sum b/flattener/go.sum index 6d3cabc..6113ffb 100644 --- a/flattener/go.sum +++ b/flattener/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/kgrapher/go.mod b/kgrapher/go.mod index d20c97d..3a2ad94 100644 --- a/kgrapher/go.mod +++ b/kgrapher/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/kgrapher go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/kgrapher/go.sum b/kgrapher/go.sum index 6d3cabc..6113ffb 100644 --- a/kgrapher/go.sum +++ b/kgrapher/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/kgrapher/kgrapher.go b/kgrapher/kgrapher.go index 69e960c..ef2479b 100644 --- a/kgrapher/kgrapher.go +++ b/kgrapher/kgrapher.go @@ -20,7 +20,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -38,6 +37,9 @@ func main() { // instantiate the System sys := components.NewSystem("kgrapher", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // instantiate the husk sys.Husk = &components.Husk{ Description: "assembles the ontologies of all systems in a local cloud", @@ -87,9 +89,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal - fmt.Println("\nshuting down system", sys.Name) - cancel() // cancel the context, signaling the goroutines to stop + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(3 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end } diff --git a/leveler/go.mod b/leveler/go.mod index 9ddefd2..c14d330 100644 --- a/leveler/go.mod +++ b/leveler/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/leveler go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/leveler/go.sum b/leveler/go.sum index 6d3cabc..6113ffb 100644 --- a/leveler/go.sum +++ b/leveler/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/leveler/leveler.go b/leveler/leveler.go index 2f989c7..f45b009 100644 --- a/leveler/leveler.go +++ b/leveler/leveler.go @@ -20,7 +20,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -37,6 +36,9 @@ func main() { // instantiate the System sys := components.NewSystem("Leveler", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // Instantiate the husk sys.Husk = &components.Husk{ Description: " is a controller for a consumed servo motor position based on a consumed temperature", @@ -86,9 +88,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal - fmt.Println("\nshuting down system", sys.Name) - cancel() // cancel the context, signaling the goroutines to stop + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end } diff --git a/maitreD/README.md b/maitreD/README.md new file mode 100644 index 0000000..f6e9c9c --- /dev/null +++ b/maitreD/README.md @@ -0,0 +1,164 @@ +# mbaigo System: maitreD (Maître d'hôtel) + +## Purpose + +The *Maître d'hôtel* system is a security sentinel that runs **once per host**. Its role is to vouch for the systems running on that host before the Certificate Authority (CA) will sign their CSRs. The name comes from the French *maître d'hôtel* — the host's trusted manager. + +It has three responsibilities: + +1. **Own enrollment** — the maitreD enrolls with the CA over the network using IP-based pre-authorization. The CA only signs its CSR if the request originates from a pre-configured host IP. +2. **Whitelist sync** — after enrollment, the maitreD pulls the cloud-wide whitelist from the CA's `/ca/certification/whitelist` endpoint and refreshes it every 5 minutes. The fetched list lives in memory and is mirrored to `whitelist.cache.json` so the maitreD survives a CA outage. **The whitelist is no longer hand-edited per host** — the CA owns it (see [ca/README.md](../ca/README.md)). +3. **Software attestation** — once a whitelist is loaded, the maitreD answers attestation requests from the CA. When any other system on the same host requests a certificate, the CA asks the maitreD to verify the SHA-256 hash of that system's running executable against the in-memory list. Until the first successful load, the maitreD returns `503 Service Unavailable` to every attestation request — fail-closed. + +## Startup order + +``` +CA → maitreD → all other systems on the host +``` + +The maitreD must be running and enrolled before it can vouch for other systems. It retries its own certificate request every minute until the CA is reachable. + +## Sequence diagrams + +### maitreD own enrollment + +```mermaid +sequenceDiagram + participant MD as maitreD + participant CA as Certificate Authority + + Note over MD: Startup — generate key pair + CSR + MD->>CA: POST /ca/certification/certify
    Body: CSR PEM (CommonName="maitreD")
    Header: X-Process-PID: <pid> + CA->>CA: Check source IP against maitreDHosts + alt IP authorized + CA-->>MD: 200 OK — signed certificate PEM + MD->>CA: GET /ca/certification + CA-->>MD: CA certificate PEM + Note over MD: Save cert + key to disk
    mTLS active on all outbound calls + else IP not authorized + CA-->>MD: 403 Forbidden + Note over MD: Retry in 1 minute + end +``` + +### Attesting another system's executable + +```mermaid +sequenceDiagram + participant S as System (any) + participant CA as Certificate Authority + participant MD as maitreD + + S->>CA: POST /ca/certification/certify
    Body: CSR PEM
    Header: X-Process-PID: <pid> + CA->>MD: POST /maitreD/maitreD/attest
    Body: {"pid": <pid>} + MD->>MD: readlink /proc/<pid>/exe → executable path + MD->>MD: SHA-256 hash of executable file + MD->>MD: Check hash against whitelist + alt hash approved + MD-->>CA: 200 OK + CA->>CA: Sign CSR + CA-->>S: 200 OK — signed certificate PEM + else hash not in whitelist + MD-->>CA: 403 Forbidden + CA-->>S: 403 Forbidden — attestation failed + end +``` + +## Configuration (`systemconfig.json`) + +On first run the maitreD generates a `systemconfig.json` and exits so you can review it. + +```json +{ + "systemname": "maitreD", + "unit_assets": [ + { + "name": "maitreD", + "details": { + "Role": ["host-attestation"] + } + } + ], + "protocolsNports": { + "http": 20101, + "https": 20101, + "coap": 0 + }, + "coreSystems": [ + { "coreSystem": "serviceregistrar", "url": "http://192.168.1.1:20102/serviceregistrar/registry" }, + { "coreSystem": "orchestrator", "url": "http://192.168.1.1:20103/orchestrator/orchestration" }, + { "coreSystem": "ca", "url": "http://192.168.1.1:20100/ca/certification" }, + { "coreSystem": "maitreD", "url": "http://192.168.1.10:20101/maitreD/maitreD" } + ] +} +``` + +### The whitelist (CA-mastered) + +The maitreD does **not** carry a hand-edited whitelist. It pulls the cloud's +approved-executable list from the CA on startup and every 5 minutes +afterwards, caching the last-good copy in `whitelist.cache.json` next to the +binary. Until the first successful load (cache or fetch), every attestation +request returns `503 Service Unavailable`. + +To approve a new binary, edit the CA's `whitelist.json`. See +[ca/README.md](../ca/README.md) for the CA-side instructions. + +| Failure mode | Behaviour | +|---|---| +| First-ever startup, CA reachable | Pull, cache, then start serving | +| First-ever startup, CA unreachable | Log fatal, exit (no cache to fall back on) | +| Subsequent startup, cache present | Use cache immediately, then refresh in background | +| CA unreachable mid-run | Keep using current in-memory list, log a warning per failed sync | + +### CA-side prerequisites + +Before the maitreD can enroll, two fields must be set in the **CA's** `systemconfig.json`: + +| Field | Purpose | +|-------|---------| +| `maitreDHosts` | List of host IPs permitted to enroll a maitreD | +| `maitreDPort` | Port the maitreD listens on (default 20101) | + +```json +"maitreDHosts": ["192.168.1.10"], +"maitreDPort": 20101 +``` + +## Building and running + +```bash +# Run in place (for development) +go run . + +# Build for the current machine +go build -o maitreD_local + +# Cross-compile for Raspberry Pi 64-bit +GOOS=linux GOARCH=arm64 go build -o maitreD_rpi64 + +# Copy to a Raspberry Pi +scp maitreD_rpi64 user@192.168.1.10:mbaigo/maitreD/ +``` + +Run the binary from **inside its own directory** so it can find (or create) `systemconfig.json`. + +The `attest` service uses `/proc//exe`, which is Linux-specific. The maitreD is designed to run on Linux hosts (e.g. Raspberry Pi). Running it on macOS is supported for development but attestation requests will fail because `/proc` does not exist. + +A full list of supported platforms: `go tool dist list` + +## Development with a local mbaigo clone + +Add a `replace` directive to `go.mod`: + +``` +require github.com/sdoque/mbaigo v0.x.x +replace github.com/sdoque/mbaigo => ../../mbaigo +``` + +Or add both modules to the workspace `go.work` at the repository root: + +``` +use ./mbaigo +use ./security/maitreD +``` diff --git a/maitreD/go.mod b/maitreD/go.mod new file mode 100644 index 0000000..e023283 --- /dev/null +++ b/maitreD/go.mod @@ -0,0 +1,5 @@ +module github.com/sdoque/systems/maitreD + +go 1.26.2 + +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/maitreD/go.sum b/maitreD/go.sum new file mode 100644 index 0000000..6113ffb --- /dev/null +++ b/maitreD/go.sum @@ -0,0 +1,2 @@ +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/maitreD/maitreD.go b/maitreD/maitreD.go new file mode 100644 index 0000000..7fee102 --- /dev/null +++ b/maitreD/maitreD.go @@ -0,0 +1,119 @@ +/******************************************************************************* + * Copyright (c) 2024 Synecdoque + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, subject to the following conditions: + * + * The software is licensed under the MIT License. See the LICENSE file in this repository for details. + * + * Contributors: + * Jan A. van Deventer, Luleå - initial implementation + * Thomas Hedeler, Hamburg - initial implementation + ***************************************************************************SDG*/ + +package main + +import ( + "context" + "crypto/x509/pkix" + "encoding/json" + "log" + "net/http" + "time" + + "github.com/sdoque/mbaigo/components" + "github.com/sdoque/mbaigo/usecases" +) + +// whitelistCachePath is the on-disk location of the maitreD's CA-synced +// whitelist cache. Hard-coded relative to the working directory because it +// is runtime state, not operator-tunable config. +const whitelistCachePath = "whitelist.cache.json" + +func main() { + // prepare for graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) // create a context that can be cancelled + defer cancel() // make sure all paths cancel the context to avoid context leak + + // instantiate the System + sys := components.NewSystem("maitreD", ctx) + + // Watch for SIGINT immediately so that Ctrl+C can interrupt blocking + // startup steps (RequestCertificate retry loop, whitelist bootstrap). + usecases.WatchShutdown(&sys, cancel) + + // Instantiate the husk + sys.Husk = &components.Husk{ + Description: "supports systems on local host computer to authenticate themselves towards the CA.", + Details: map[string][]string{"Developer": {"Synecdoque"}}, + Host: components.NewDevice(), + ProtoPort: map[string]int{"https": 30101, "http": 20101, "coap": 0}, + InfoLink: "https://github.com/sdoque/systems/tree/main/maitreD", + DName: pkix.Name{ + CommonName: "maitreD", + Country: []string{"SE"}, + Province: []string{"Norrbotten"}, + Locality: []string{"Luleaa"}, + Organization: []string{"Synecdoque"}, + OrganizationalUnit: []string{"Research"}, + }, + RegistrarChan: make(chan *components.CoreSystem, 1), + Messengers: make(map[string]int), + } + + // instantiate a template unit asset + assetTemplate := initTemplate() + sys.UAssets[assetTemplate.GetName()] = assetTemplate + + // Configure the system + rawResources, err := usecases.Configure(&sys) + if err != nil { + log.Fatalf("configuration error: %v\n", err) + } + sys.UAssets = make(map[string]*components.UnitAsset) // clear the unit asset map (from the template) + for _, raw := range rawResources { + var uac usecases.ConfigurableAsset + if err := json.Unmarshal(raw, &uac); err != nil { + log.Fatalf("resource configuration error: %+v\n", err) + } + ua, cleanup := newResource(uac, &sys) + defer cleanup() + sys.UAssets[ua.GetName()] = ua + } + + // Generate PKI keys and CSR to obtain a authentication certificate from the CA + usecases.RequestCertificate(&sys) + + // Bootstrap the whitelist from the CA. This must happen after enrollment + // (we need the CA URL and, eventually, mTLS) and before service registration + // so that attestation requests cannot arrive against an unloaded whitelist. + if err := bootstrapWhitelist(&sys); err != nil { + log.Fatalf("whitelist bootstrap failed: %v", err) + } + + // Register the (system) and its services + usecases.RegisterServices(&sys) + + // start the requests handlers and servers + go usecases.SetoutServers(&sys) + + // Wait for shutdown. WatchShutdown's goroutine cancels ctx on SIGINT; + // goroutines that respect ctx.Done() exit; the brief sleep covers + // in-flight HTTP handlers and other non-cancellable cleanup. + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) + time.Sleep(2 * time.Second) +} + +// serving handles the resources services. NOTE: it expects those names from the request URL path +func serving(t *Traits, w http.ResponseWriter, r *http.Request, servicePath string) { + switch servicePath { + case "attest": + t.attest(w, r) + default: + http.Error(w, "Invalid service request [Do not modify the services subpath in the configuration file]", http.StatusBadRequest) + } +} diff --git a/maitreD/sync.go b/maitreD/sync.go new file mode 100644 index 0000000..b13a8b1 --- /dev/null +++ b/maitreD/sync.go @@ -0,0 +1,226 @@ +/******************************************************************************* + * Copyright (c) 2026 Synecdoque + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, subject to the following conditions: + * + * The software is licensed under the MIT License. See the LICENSE file in this repository for details. + * + * Contributors: + * Jan A. van Deventer, Luleå - initial implementation + ***************************************************************************SDG*/ + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/sdoque/mbaigo/components" +) + +// whitelistResponse mirrors the wire format produced by the CA's +// /ca/certification/whitelist endpoint. Both endpoints define their own copy +// because the CA and maitreD are separate `package main` binaries with no +// shared package; the JSON contract on the wire is the source of truth. +type whitelistResponse struct { + Version int64 `json:"version"` + UpdatedAt string `json:"updatedAt"` + Hashes []string `json:"hashes"` +} + +// defaultSyncInterval is how often the maitreD re-checks the CA for whitelist +// changes after the initial load. Five minutes is the design's deliberate +// trade-off: deployments don't change minute-by-minute, but operators don't +// want to wait an hour after editing the whitelist. +const defaultSyncInterval = 5 * time.Minute + +// loadCache reads the previously-fetched whitelist from disk into Traits. +// A missing file is not an error — it represents "first ever run". +func (t *Traits) loadCache(path string) error { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var wl whitelistResponse + if err := json.Unmarshal(data, &wl); err != nil { + return fmt.Errorf("parse cache %s: %w", path, err) + } + t.mu.Lock() + t.Whitelist = wl.Hashes + t.version = wl.Version + t.loaded = true + t.mu.Unlock() + return nil +} + +// saveCache atomically writes the in-memory whitelist to disk so the next +// startup can fall back to it if the CA is unreachable. The atomic dance +// (write to a sibling file, then rename) keeps the cache file consistent +// even if the maitreD is killed mid-write. +func (t *Traits) saveCache(path string) error { + t.mu.RLock() + wl := whitelistResponse{ + Version: t.version, + UpdatedAt: time.Unix(t.version, 0).UTC().Format(time.RFC3339), + Hashes: append([]string{}, t.Whitelist...), + } + t.mu.RUnlock() + + data, err := json.MarshalIndent(wl, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// fetchFromCA performs a single GET against the CA's whitelist endpoint. It +// returns true if the CA's version is newer than ours (the body has been +// applied); false if the CA returned 304 Not Modified or the body is +// otherwise unchanged. caURL is the base URL of the CA's certification asset +// (e.g. "http://localhost:20100/ca/certification"), to which "/whitelist" is +// appended. +func (t *Traits) fetchFromCA(ctx context.Context, client *http.Client, caURL string) (bool, error) { + t.mu.RLock() + since := t.version + t.mu.RUnlock() + + url := strings.TrimRight(caURL, "/") + "/whitelist" + if since > 0 { + url = fmt.Sprintf("%s?since=%d", url, since) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, err + } + resp, err := client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotModified { + return false, nil + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return false, fmt.Errorf("CA returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var wl whitelistResponse + if err := json.NewDecoder(resp.Body).Decode(&wl); err != nil { + return false, fmt.Errorf("decode whitelist response: %w", err) + } + + t.mu.Lock() + t.Whitelist = wl.Hashes + t.version = wl.Version + t.loaded = true + t.mu.Unlock() + return true, nil +} + +// syncOnce performs one fetch+cache cycle. On success it returns nil; on +// failure it returns the error so the caller can decide whether to fail-fast +// (first-ever run, no cache) or fail-soft (continue on stale cache). +func (t *Traits) syncOnce(ctx context.Context, client *http.Client, caURL, cachePath string) error { + changed, err := t.fetchFromCA(ctx, client, caURL) + if err != nil { + return err + } + if changed { + if err := t.saveCache(cachePath); err != nil { + // Cache write failure is logged but does not invalidate the in-memory + // fetch — attestations will still work; only the next-startup fallback + // is degraded. + log.Printf("warning: could not persist whitelist cache to %s: %v", cachePath, err) + } + } + return nil +} + +// runSyncLoop bootstraps the whitelist (cache → first fetch) and then keeps +// it fresh on a ticker until ctx is cancelled. Failure semantics, by design: +// +// - cache present + fetch fails → log warning, continue with cache. +// - cache absent + fetch fails → return error (caller exits the process). +// - successful fetch → updates in-memory + cache. +// - subsequent fetch failures → log warning, keep current in-memory state. +func (t *Traits) runSyncLoop(ctx context.Context, client *http.Client, caURL, cachePath string, interval time.Duration) error { + if err := t.loadCache(cachePath); err != nil { + log.Printf("warning: could not read whitelist cache %s: %v", cachePath, err) + } + hadCache := t.IsLoaded() + + if err := t.syncOnce(ctx, client, caURL, cachePath); err != nil { + if !hadCache { + return fmt.Errorf("first whitelist fetch failed and no cache exists: %w", err) + } + log.Printf("warning: initial whitelist fetch failed, continuing with cache: %v", err) + } + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := t.syncOnce(ctx, client, caURL, cachePath); err != nil { + log.Printf("warning: whitelist sync failed, keeping current state: %v", err) + } + } + } + }() + return nil +} + +// IsLoaded reports whether the in-memory whitelist has been populated at least +// once (from cache or from a fetch). The attest handler will use this in +// step 3 to fail-closed before the first successful load. +func (t *Traits) IsLoaded() bool { + t.mu.RLock() + defer t.mu.RUnlock() + return t.loaded +} + +// bootstrapWhitelist resolves the CA URL from the system's core-system list, +// then drives runSyncLoop on every maitreD UnitAsset in the system. Returns +// an error if the maitreD asset is missing, the CA URL cannot be resolved, +// or the first sync fails fatally (no cache + CA unreachable). +func bootstrapWhitelist(sys *components.System) error { + caURL, err := components.GetRunningCoreSystemURL(sys, "ca") + if err != nil { + return fmt.Errorf("resolve CA URL: %w", err) + } + for name, ua := range sys.UAssets { + t, ok := ua.GetTraits().(*Traits) + if !ok { + continue // not a maitreD asset + } + if err := t.runSyncLoop(sys.Ctx, http.DefaultClient, caURL, whitelistCachePath, defaultSyncInterval); err != nil { + return fmt.Errorf("asset %s: %w", name, err) + } + } + return nil +} diff --git a/maitreD/sync_test.go b/maitreD/sync_test.go new file mode 100644 index 0000000..d71cd63 --- /dev/null +++ b/maitreD/sync_test.go @@ -0,0 +1,225 @@ +/******************************************************************************* + * Copyright (c) 2026 Synecdoque + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, subject to the following conditions: + * + * The software is licensed under the MIT License. See the LICENSE file in this repository for details. + * + * Contributors: + * Jan A. van Deventer, Luleå - initial implementation + ***************************************************************************SDG*/ + +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strconv" + "testing" + "time" +) + +// fakeCA returns an httptest server that mimics the CA's whitelist endpoint. +// Each call lets the test inspect the ?since query and choose 200 vs 304. +func fakeCA(t *testing.T, version int64, hashes []string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/whitelist" { + http.NotFound(w, r) + return + } + if since := r.URL.Query().Get("since"); since != "" { + sinceN, _ := strconv.ParseInt(since, 10, 64) + if sinceN >= version { + w.WriteHeader(http.StatusNotModified) + return + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(whitelistResponse{ + Version: version, + Hashes: hashes, + }) + })) +} + +// ── cache round-trip ────────────────────────────────────────────────────────── + +func TestSaveAndLoadCache(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "whitelist.cache.json") + + t1 := &Traits{Whitelist: []string{"abc", "def"}, version: 1700000000, loaded: true} + if err := t1.saveCache(path); err != nil { + t.Fatalf("saveCache: %v", err) + } + + t2 := &Traits{} + if err := t2.loadCache(path); err != nil { + t.Fatalf("loadCache: %v", err) + } + + if !reflect.DeepEqual(t2.Whitelist, t1.Whitelist) { + t.Errorf("hashes = %v, want %v", t2.Whitelist, t1.Whitelist) + } + if t2.version != t1.version { + t.Errorf("version = %d, want %d", t2.version, t1.version) + } + if !t2.IsLoaded() { + t.Error("loaded must be true after loadCache") + } +} + +func TestLoadCacheMissingFile(t *testing.T) { + dir := t.TempDir() + tr := &Traits{} + if err := tr.loadCache(filepath.Join(dir, "nope.json")); err != nil { + t.Fatalf("missing cache file should not be an error: %v", err) + } + if tr.IsLoaded() { + t.Error("loaded must remain false when no cache file existed") + } +} + +func TestLoadCacheCorrupt(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "whitelist.cache.json") + os.WriteFile(path, []byte("not json"), 0644) + + tr := &Traits{} + if err := tr.loadCache(path); err == nil { + t.Error("expected error for corrupt cache file") + } +} + +// ── fetchFromCA ─────────────────────────────────────────────────────────────── + +func TestFetchFromCA(t *testing.T) { + t.Run("empty Traits gets full whitelist", func(t *testing.T) { + srv := fakeCA(t, 100, []string{"a", "b"}) + defer srv.Close() + + tr := &Traits{} + changed, err := tr.fetchFromCA(context.Background(), http.DefaultClient, srv.URL) + if err != nil { + t.Fatalf("fetchFromCA: %v", err) + } + if !changed { + t.Error("expected changed=true on first fetch") + } + if !reflect.DeepEqual(tr.Whitelist, []string{"a", "b"}) { + t.Errorf("hashes = %v, want [a b]", tr.Whitelist) + } + if tr.version != 100 { + t.Errorf("version = %d, want 100", tr.version) + } + }) + + t.Run("up-to-date Traits gets 304 → changed=false", func(t *testing.T) { + srv := fakeCA(t, 100, []string{"a"}) + defer srv.Close() + + tr := &Traits{Whitelist: []string{"a"}, version: 100, loaded: true} + changed, err := tr.fetchFromCA(context.Background(), http.DefaultClient, srv.URL) + if err != nil { + t.Fatalf("fetchFromCA: %v", err) + } + if changed { + t.Error("expected changed=false on 304") + } + }) + + t.Run("CA error returns error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "down", http.StatusInternalServerError) + })) + defer srv.Close() + + tr := &Traits{} + _, err := tr.fetchFromCA(context.Background(), http.DefaultClient, srv.URL) + if err == nil { + t.Error("expected error when CA returns 500") + } + }) +} + +// ── runSyncLoop bootstrap behaviour ─────────────────────────────────────────── + +func TestRunSyncLoopFirstRun(t *testing.T) { + srv := fakeCA(t, 100, []string{"a", "b"}) + defer srv.Close() + + dir := t.TempDir() + cachePath := filepath.Join(dir, "whitelist.cache.json") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tr := &Traits{} + // Use a long interval so the ticker doesn't fire during this synchronous test. + if err := tr.runSyncLoop(ctx, http.DefaultClient, srv.URL, cachePath, time.Hour); err != nil { + t.Fatalf("runSyncLoop: %v", err) + } + + if !tr.IsLoaded() { + t.Fatal("loaded must be true after first successful fetch") + } + if !reflect.DeepEqual(tr.Whitelist, []string{"a", "b"}) { + t.Errorf("hashes = %v, want [a b]", tr.Whitelist) + } + // Cache must have been written. + if _, err := os.Stat(cachePath); err != nil { + t.Errorf("cache file not written: %v", err) + } +} + +func TestRunSyncLoopFirstRunCAUnreachable(t *testing.T) { + dir := t.TempDir() + cachePath := filepath.Join(dir, "whitelist.cache.json") + + tr := &Traits{} + // No cache + CA unreachable ⇒ must fail fatally. + err := tr.runSyncLoop(context.Background(), http.DefaultClient, + "http://127.0.0.1:1", // unreachable + cachePath, time.Hour) + if err == nil { + t.Error("expected fatal error when no cache exists and CA is unreachable") + } + if tr.IsLoaded() { + t.Error("loaded must remain false when first fetch failed without cache") + } +} + +func TestRunSyncLoopFallsBackToCache(t *testing.T) { + dir := t.TempDir() + cachePath := filepath.Join(dir, "whitelist.cache.json") + + // Pre-populate the cache as if a previous run had succeeded. + pre := &Traits{Whitelist: []string{"cached-hash"}, version: 50, loaded: true} + if err := pre.saveCache(cachePath); err != nil { + t.Fatalf("seed cache: %v", err) + } + + tr := &Traits{} + err := tr.runSyncLoop(context.Background(), http.DefaultClient, + "http://127.0.0.1:1", // unreachable + cachePath, time.Hour) + if err != nil { + t.Fatalf("expected nil error when cache is present, got: %v", err) + } + if !tr.IsLoaded() { + t.Error("loaded must be true after cache fallback") + } + if !reflect.DeepEqual(tr.Whitelist, []string{"cached-hash"}) { + t.Errorf("hashes = %v, want [cached-hash]", tr.Whitelist) + } +} diff --git a/maitreD/thing.go b/maitreD/thing.go new file mode 100644 index 0000000..c95020f --- /dev/null +++ b/maitreD/thing.go @@ -0,0 +1,186 @@ +/******************************************************************************* + * Copyright (c) 2024 Synecdoque + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, subject to the following conditions: + * + * The software is licensed under the MIT License. See the LICENSE file in this repository for details. + * + * Contributors: + * Jan A. van Deventer, Luleå - initial implementation + * Thomas Hedeler, Hamburg - initial implementation + ***************************************************************************SDG*/ + +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "sync" + + "github.com/sdoque/mbaigo/components" + "github.com/sdoque/mbaigo/usecases" +) + +//-------------------------------------Define the unit asset + +// Traits holds the runtime state of the maitreD unit asset. +// +// The Whitelist is fetched from the CA and refreshed periodically (see +// sync.go); it is not part of the operator-edited systemconfig.json schema. +// Any "whitelist" entry that an older systemconfig still carries is silently +// ignored by Go's json package because the field is tagged `json:"-"`. +type Traits struct { + Whitelist []string `json:"-"` // approved SHA-256 hashes (kept in sync with the CA) + version int64 `json:"-"` // current whitelist version (CA-issued) + loaded bool `json:"-"` // true after first successful cache load or fetch + mu sync.RWMutex `json:"-"` // protects Whitelist, version, loaded + owner *components.System `json:"-"` + name string `json:"-"` +} + +// resolveExecutable returns the filesystem path of the executable running as pid. +// It reads /proc//exe, which is Linux-specific. The variable form allows +// tests to substitute a different implementation without build tags. +var resolveExecutable = func(pid int) (string, error) { + return os.Readlink(fmt.Sprintf("/proc/%d/exe", pid)) +} + +//-------------------------------------Instantiate a unit asset template + +// initTemplate initializes a UnitAsset with default values. +func initTemplate() *components.UnitAsset { + attest := components.Service{ + Definition: "attest", + SubPath: "attest", + Details: map[string][]string{"Forms": {"application/json"}}, + RegPeriod: 0, + Description: "verifies (POST) the executable hash of the requesting system against the whitelist", + } + + return &components.UnitAsset{ + Name: "maitreD", + Details: map[string][]string{"Role": {"host-attestation"}}, + ServicesMap: map[string]*components.Service{attest.SubPath: &attest}, + Traits: &Traits{}, + } +} + +//-------------------------------------Instantiate unit asset(s) based on configuration + +// newResource creates the unit asset with its pointers and channels based on the configuration. +func newResource(configuredAsset usecases.ConfigurableAsset, sys *components.System) (*components.UnitAsset, func()) { + t := &Traits{ + owner: sys, + name: configuredAsset.Name, + } + + if len(configuredAsset.Traits) > 0 { + if err := json.Unmarshal(configuredAsset.Traits[0], t); err != nil { + log.Println("Warning: could not unmarshal traits:", err) + } + } + + ua := &components.UnitAsset{ + Name: configuredAsset.Name, + Mission: configuredAsset.Mission, + Owner: sys, + Details: configuredAsset.Details, + ServicesMap: usecases.MakeServiceMap(configuredAsset.Services), + Traits: t, + } + ua.ServingFunc = func(w http.ResponseWriter, r *http.Request, servicePath string) { + serving(t, w, r, servicePath) + } + + return ua, func() { + log.Printf("disconnecting from %s\n", ua.Name) + } +} + +//-------------------------------------Unit asset's function methods + +// attest handles a POST request from the CA. It resolves the executable of the given PID, +// hashes it, and returns 200 if the hash is on the whitelist or 403 if it is not. +// +// Returns 503 Service Unavailable until the maitreD has loaded a whitelist +// at least once (from cache or fresh fetch). This prevents the brief +// post-startup window in which attestation could otherwise run against an +// empty in-memory list and approve nothing legitimately, or — worse — be +// silently misconfigured into a permissive state. +func (t *Traits) attest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not supported", http.StatusMethodNotAllowed) + return + } + if !t.IsLoaded() { + http.Error(w, "Whitelist not yet loaded", http.StatusServiceUnavailable) + return + } + + var req struct { + PID int `json:"pid"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.PID <= 0 { + http.Error(w, "Invalid request body: expected {\"pid\": }", http.StatusBadRequest) + return + } + + exePath, err := resolveExecutable(req.PID) + if err != nil { + http.Error(w, "Cannot resolve executable for PID", http.StatusInternalServerError) + return + } + + hash, err := hashFile(exePath) + if err != nil { + http.Error(w, "Cannot hash executable", http.StatusInternalServerError) + return + } + + if !t.isApproved(hash) { + log.Printf("attestation denied: pid=%d exe=%s hash=%s\n", req.PID, exePath, hash) + http.Error(w, "Executable not in whitelist", http.StatusForbidden) + return + } + + log.Printf("attestation approved: pid=%d exe=%s\n", req.PID, exePath) + w.WriteHeader(http.StatusOK) +} + +// isApproved reports whether hash is present in the in-memory whitelist. +// The read lock keeps this safe against the sync loop concurrently swapping +// the slice during a refresh. +func (t *Traits) isApproved(hash string) bool { + t.mu.RLock() + defer t.mu.RUnlock() + for _, h := range t.Whitelist { + if h == hash { + return true + } + } + return false +} + +// hashFile returns the lowercase hex-encoded SHA-256 digest of the file at path. +func hashFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/maitreD/thing_test.go b/maitreD/thing_test.go new file mode 100644 index 0000000..18f7757 --- /dev/null +++ b/maitreD/thing_test.go @@ -0,0 +1,334 @@ +/******************************************************************************* + * Copyright (c) 2024 Synecdoque + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, subject to the following conditions: + * + * The software is licensed under the MIT License. See the LICENSE file in this repository for details. + * + * Contributors: + * Jan A. van Deventer, Luleå - initial implementation + ***************************************************************************SDG*/ + +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/sdoque/mbaigo/components" + "github.com/sdoque/mbaigo/usecases" +) + +// ── helpers ─────────────────────────────────────────────────────────────────── + +// writeTempFile creates a file with the given content in a temp dir and returns its path. +func writeTempFile(t *testing.T, content []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "testexe") + if err := os.WriteFile(path, content, 0755); err != nil { + t.Fatalf("write temp file: %v", err) + } + return path +} + +// sha256Hex returns the hex SHA-256 of data. +func sha256Hex(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} + +// withResolveExecutable temporarily replaces resolveExecutable for the duration of the test. +func withResolveExecutable(t *testing.T, fn func(int) (string, error)) { + t.Helper() + orig := resolveExecutable + resolveExecutable = fn + t.Cleanup(func() { resolveExecutable = orig }) +} + +// ── initTemplate ────────────────────────────────────────────────────────────── + +func TestInitTemplate(t *testing.T) { + ua := initTemplate() + + if ua.GetName() != "maitreD" { + t.Errorf("name = %q, want %q", ua.GetName(), "maitreD") + } + svc, ok := ua.GetServices()["attest"] + if !ok { + t.Fatal("expected 'attest' entry in ServicesMap") + } + if svc.Definition != "attest" { + t.Errorf("service definition = %q, want %q", svc.Definition, "attest") + } + if ua.GetTraits() == nil { + t.Error("Traits should be non-nil") + } +} + +// ── Traits serialisation ────────────────────────────────────────────────────── + +func TestTraitsSerialization(t *testing.T) { + // All Traits fields are runtime state, not config: marshalling must + // produce no operator-visible fields. A future schema addition that + // accidentally exposes one of these will fail this test. + original := &Traits{Whitelist: []string{"abc123"}, version: 42, loaded: true} + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("re-unmarshal: %v", err) + } + for _, field := range []string{"whitelist", "version", "loaded", "owner", "name"} { + if _, ok := raw[field]; ok { + t.Errorf("field %q must not appear in JSON", field) + } + } +} + +// ── newResource ─────────────────────────────────────────────────────────────── + +func TestNewResource(t *testing.T) { + t.Run("creates unit asset with correct fields", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sys := components.NewSystem("maitreD", ctx) + sys.Husk = &components.Husk{ + Host: components.NewDevice(), + ProtoPort: map[string]int{"http": 20101}, + } + + attestSvc := components.Service{ + Definition: "attest", + SubPath: "attest", + } + cfgAsset := usecases.ConfigurableAsset{ + Name: "maitreD", + Mission: "attest systems on this host", + Services: []components.Service{attestSvc}, + } + + ua, cleanup := newResource(cfgAsset, &sys) + defer cleanup() + + if ua.GetName() != "maitreD" { + t.Errorf("name = %q, want %q", ua.GetName(), "maitreD") + } + if ua.Mission != "attest systems on this host" { + t.Errorf("mission = %q, want %q", ua.Mission, "attest systems on this host") + } + if ua.ServingFunc == nil { + t.Error("ServingFunc must be set") + } + if _, ok := ua.GetServices()["attest"]; !ok { + t.Error("expected 'attest' service in map") + } + }) + + t.Run("ignores any 'whitelist' field carried by an older systemconfig", func(t *testing.T) { + // The whitelist is now CA-mastered. Operator-supplied whitelist entries + // in systemconfig.json must be silently ignored, not loaded as truth. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sys := components.NewSystem("maitreD", ctx) + sys.Husk = &components.Husk{ + Host: components.NewDevice(), + ProtoPort: map[string]int{"http": 20101}, + } + + // Hand-craft a traits payload using the legacy "whitelist" key. + traitJSON := json.RawMessage(`{"whitelist":["aabbcc"]}`) + cfgAsset := usecases.ConfigurableAsset{ + Name: "maitreD", + Traits: []json.RawMessage{traitJSON}, + Services: []components.Service{{Definition: "attest", SubPath: "attest"}}, + } + + ua, cleanup := newResource(cfgAsset, &sys) + defer cleanup() + + tr, ok := ua.GetTraits().(*Traits) + if !ok { + t.Fatal("traits are not of type *Traits") + } + if len(tr.Whitelist) != 0 { + t.Errorf("Whitelist must remain empty (CA is the source of truth); got %v", tr.Whitelist) + } + }) +} + +// ── serving ─────────────────────────────────────────────────────────────────── + +func TestServing(t *testing.T) { + exeData := []byte("fake-executable") + exePath := writeTempFile(t, exeData) + hash := sha256Hex(exeData) + tr := &Traits{Whitelist: []string{hash}, loaded: true} + + withResolveExecutable(t, func(pid int) (string, error) { return exePath, nil }) + + t.Run("attest path dispatches correctly", func(t *testing.T) { + body, _ := json.Marshal(map[string]int{"pid": 42}) + req := httptest.NewRequest(http.MethodPost, "/maitreD/maitreD/attest", bytes.NewReader(body)) + w := httptest.NewRecorder() + serving(tr, w, req, "attest") + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + }) + + t.Run("unknown path returns 400", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/unknown", nil) + w := httptest.NewRecorder() + serving(tr, w, req, "unknown") + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } + }) +} + +// ── attest ──────────────────────────────────────────────────────────────────── + +func TestAttest(t *testing.T) { + exeData := []byte("approved-binary-content") + exePath := writeTempFile(t, exeData) + approvedHash := sha256Hex(exeData) + + tr := &Traits{Whitelist: []string{approvedHash}, loaded: true} + + t.Run("approved executable returns 200", func(t *testing.T) { + withResolveExecutable(t, func(pid int) (string, error) { return exePath, nil }) + + body, _ := json.Marshal(map[string]int{"pid": 99}) + req := httptest.NewRequest(http.MethodPost, "/attest", bytes.NewReader(body)) + w := httptest.NewRecorder() + tr.attest(w, req) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + }) + + t.Run("unknown executable hash returns 403", func(t *testing.T) { + otherData := []byte("untrusted-binary") + otherPath := writeTempFile(t, otherData) + withResolveExecutable(t, func(pid int) (string, error) { return otherPath, nil }) + + body, _ := json.Marshal(map[string]int{"pid": 99}) + req := httptest.NewRequest(http.MethodPost, "/attest", bytes.NewReader(body)) + w := httptest.NewRecorder() + tr.attest(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", w.Code) + } + }) + + t.Run("unresolvable PID returns 500", func(t *testing.T) { + withResolveExecutable(t, func(pid int) (string, error) { + return "", fmt.Errorf("no such process") + }) + + body, _ := json.Marshal(map[string]int{"pid": 99}) + req := httptest.NewRequest(http.MethodPost, "/attest", bytes.NewReader(body)) + w := httptest.NewRecorder() + tr.attest(w, req) + if w.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", w.Code) + } + }) + + t.Run("non-POST returns 405", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/attest", nil) + w := httptest.NewRecorder() + tr.attest(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", w.Code) + } + }) + + t.Run("invalid JSON body returns 400", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/attest", bytes.NewReader([]byte("not json"))) + w := httptest.NewRecorder() + tr.attest(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } + }) + + t.Run("zero PID returns 400", func(t *testing.T) { + body, _ := json.Marshal(map[string]int{"pid": 0}) + req := httptest.NewRequest(http.MethodPost, "/attest", bytes.NewReader(body)) + w := httptest.NewRecorder() + tr.attest(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } + }) + + t.Run("returns 503 when whitelist not yet loaded", func(t *testing.T) { + // loaded=false ⇒ no successful sync yet ⇒ refuse to make a decision. + notReady := &Traits{Whitelist: []string{approvedHash}} + body, _ := json.Marshal(map[string]int{"pid": 99}) + req := httptest.NewRequest(http.MethodPost, "/attest", bytes.NewReader(body)) + w := httptest.NewRecorder() + notReady.attest(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503", w.Code) + } + }) +} + +// ── hashFile ────────────────────────────────────────────────────────────────── + +func TestHashFile(t *testing.T) { + content := []byte("hello maitreD") + path := writeTempFile(t, content) + + t.Run("produces correct SHA-256", func(t *testing.T) { + got, err := hashFile(path) + if err != nil { + t.Fatalf("hashFile: %v", err) + } + if want := sha256Hex(content); got != want { + t.Errorf("hash = %s, want %s", got, want) + } + }) + + t.Run("missing file returns error", func(t *testing.T) { + _, err := hashFile(filepath.Join(t.TempDir(), "no-such-file")) + if err == nil { + t.Error("expected error for missing file") + } + }) +} + +// ── isApproved ──────────────────────────────────────────────────────────────── + +func TestIsApproved(t *testing.T) { + tr := &Traits{Whitelist: []string{"aaa", "bbb"}} + + if !tr.isApproved("aaa") { + t.Error("expected aaa to be approved") + } + if tr.isApproved("ccc") { + t.Error("expected ccc to be rejected") + } + if (&Traits{}).isApproved("aaa") { + t.Error("empty whitelist should reject everything") + } +} diff --git a/messenger/go.mod b/messenger/go.mod index 1177f02..42cfa78 100644 --- a/messenger/go.mod +++ b/messenger/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/messenger go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/messenger/go.sum b/messenger/go.sum index 6d3cabc..6113ffb 100644 --- a/messenger/go.sum +++ b/messenger/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/messenger/messenger.go b/messenger/messenger.go index ff341ba..131f9a6 100644 --- a/messenger/messenger.go +++ b/messenger/messenger.go @@ -16,6 +16,9 @@ func main() { defer cancel() sys := components.NewSystem("messenger", ctx) + + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) sys.Husk = &components.Husk{ Description: "is a logging system that receives log messages from other systems.", Details: map[string][]string{"Developer": {"alex"}}, diff --git a/meteorologue/go.mod b/meteorologue/go.mod index 8668da7..c78a2e8 100644 --- a/meteorologue/go.mod +++ b/meteorologue/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/meteorologue go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/meteorologue/go.sum b/meteorologue/go.sum index 6d3cabc..6113ffb 100644 --- a/meteorologue/go.sum +++ b/meteorologue/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/meteorologue/meteorologue.go b/meteorologue/meteorologue.go index a7a2b69..7d250bd 100644 --- a/meteorologue/meteorologue.go +++ b/meteorologue/meteorologue.go @@ -20,7 +20,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -38,6 +37,9 @@ func main() { // instantiate the System sys := components.NewSystem("meteorologue", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // instantiate the husk sys.Husk = &components.Husk{ Description: "exposes Netatmo weather station modules as Arrowhead services", @@ -80,12 +82,6 @@ func main() { // Cancel the context if a shutdown signal arrives while newResources is blocking // (e.g. waiting for the one-time OAuth2 browser authorization). - go func() { - <-sys.Sigs - fmt.Println("\nshutting down system", sys.Name) - cancel() - }() - assets, cleanup := newResources(uac, &sys) defer cleanup() for _, ua := range assets { diff --git a/modboss/go.mod b/modboss/go.mod index 914f48b..6da911a 100644 --- a/modboss/go.mod +++ b/modboss/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/modboss go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/modboss/go.sum b/modboss/go.sum index 6d3cabc..6113ffb 100644 --- a/modboss/go.sum +++ b/modboss/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/modboss/modboss.go b/modboss/modboss.go index d588266..08533d8 100644 --- a/modboss/modboss.go +++ b/modboss/modboss.go @@ -20,7 +20,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -38,6 +37,9 @@ func main() { // instantiate the System sys := components.NewSystem("modboss", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // instantiate the husk sys.Husk = &components.Husk{ Description: "interacts with an Modbus slave or server", @@ -89,9 +91,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal - fmt.Println("\nshuting down system", sys.Name) - cancel() // cancel the context, signaling the goroutines to stop + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(3 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end } diff --git a/modeler/go.mod b/modeler/go.mod index a8ffa96..9d66334 100644 --- a/modeler/go.mod +++ b/modeler/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/modeler go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/modeler/go.sum b/modeler/go.sum index 6d3cabc..6113ffb 100644 --- a/modeler/go.sum +++ b/modeler/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/modeler/modeler.go b/modeler/modeler.go index 5b6fd65..b1dc553 100644 --- a/modeler/modeler.go +++ b/modeler/modeler.go @@ -19,7 +19,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -36,6 +35,9 @@ func main() { // instantiate the System sys := components.NewSystem("modeler", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // instantiate the husk sys.Husk = &components.Husk{ Description: "assembles the SysML v2 BDD/IBD models of all systems in a local cloud", @@ -85,9 +87,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs - fmt.Println("\nshutting down system", sys.Name) - cancel() + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) } diff --git a/nurse/go.mod b/nurse/go.mod index 15a6ea9..4b76104 100644 --- a/nurse/go.mod +++ b/nurse/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/nurse go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/nurse/go.sum b/nurse/go.sum index 6d3cabc..6113ffb 100644 --- a/nurse/go.sum +++ b/nurse/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/nurse/nurse.go b/nurse/nurse.go index bd30725..a589075 100644 --- a/nurse/nurse.go +++ b/nurse/nurse.go @@ -20,7 +20,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -37,6 +36,9 @@ func main() { // instantiate the System sys := components.NewSystem("nurse", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // Instantiate the husk sys.Husk = &components.Husk{ Description: " is a system that monitors an asset's measurements and reports to a SAP system in case of anomalies.", @@ -86,9 +88,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal - fmt.Println("\nshuting down system", sys.Name) - cancel() // cancel the context, signaling the goroutines to stop + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end } diff --git a/orchestrator/go.mod b/orchestrator/go.mod index dc0e7e5..c8d01fa 100644 --- a/orchestrator/go.mod +++ b/orchestrator/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/orchestrator go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/orchestrator/go.sum b/orchestrator/go.sum index 6d3cabc..6113ffb 100644 --- a/orchestrator/go.sum +++ b/orchestrator/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/orchestrator/orchestrator.go b/orchestrator/orchestrator.go index 8c32603..5e68416 100644 --- a/orchestrator/orchestrator.go +++ b/orchestrator/orchestrator.go @@ -33,6 +33,9 @@ func main() { // instantiate the System sys := components.NewSystem("orchestrator", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // Instantiate the husk sys.Husk = &components.Husk{ Description: "provides the URL of a currently available and authorized sought service", @@ -82,9 +85,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal + <-sys.Ctx.Done() log.Println("shutting down system", sys.Name) - cancel() // signal the goroutines to stop time.Sleep(2 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end } diff --git a/parallax/go.mod b/parallax/go.mod index 74f92fd..6111933 100644 --- a/parallax/go.mod +++ b/parallax/go.mod @@ -3,6 +3,6 @@ module github.com/sdoque/systems/parallax go 1.26.2 require ( - github.com/sdoque/mbaigo v0.1.0-alpha.4 + github.com/sdoque/mbaigo v0.1.0-alpha.6 github.com/stianeikeland/go-rpio/v4 v4.6.0 ) diff --git a/parallax/go.sum b/parallax/go.sum index 7954416..de2ef2f 100644 --- a/parallax/go.sum +++ b/parallax/go.sum @@ -1,4 +1,4 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= github.com/stianeikeland/go-rpio/v4 v4.6.0 h1:eAJgtw3jTtvn/CqwbC82ntcS+dtzUTgo5qlZKe677EY= github.com/stianeikeland/go-rpio/v4 v4.6.0/go.mod h1:A3GvHxC1Om5zaId+HqB3HKqx4K/AqeckxB7qRjxMK7o= diff --git a/parallax/parallax.go b/parallax/parallax.go index 7de5d07..7b5394b 100644 --- a/parallax/parallax.go +++ b/parallax/parallax.go @@ -20,7 +20,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -37,6 +36,9 @@ func main() { // instantiate the System sys := components.NewSystem("parallax", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // instantiate the husk sys.Husk = &components.Husk{ Description: " provides a rotation service using a standard servo motor driven with PWM", @@ -87,9 +89,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal - fmt.Println("\nshuting down system", sys.Name) - cancel() // cancel the context, signaling the goroutines to stop + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(3 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end } diff --git a/photographer/go.mod b/photographer/go.mod index 9cffc80..fa325d4 100644 --- a/photographer/go.mod +++ b/photographer/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/photographer go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/photographer/go.sum b/photographer/go.sum index 6d3cabc..6113ffb 100644 --- a/photographer/go.sum +++ b/photographer/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/photographer/photographer.go b/photographer/photographer.go index 0e557fd..9979f94 100644 --- a/photographer/photographer.go +++ b/photographer/photographer.go @@ -37,6 +37,9 @@ func main() { // instantiate the System sys := components.NewSystem("photographer", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // instantiate the husk sys.Husk = &components.Husk{ Description: " takes a picture using a camera and saves a file", @@ -84,9 +87,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal - fmt.Println("\nshuting down system", sys.Name) - cancel() // cancel the context, signaling the goroutines to stop + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(3 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end } diff --git a/recognizer/go.mod b/recognizer/go.mod index 7de7469..a295a43 100644 --- a/recognizer/go.mod +++ b/recognizer/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/recognizer go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/recognizer/go.sum b/recognizer/go.sum index 6d3cabc..6113ffb 100644 --- a/recognizer/go.sum +++ b/recognizer/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/recognizer/recognizer.go b/recognizer/recognizer.go index 4556a4b..3dd5f68 100644 --- a/recognizer/recognizer.go +++ b/recognizer/recognizer.go @@ -22,7 +22,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -38,6 +37,9 @@ func main() { sys := components.NewSystem("recognizer", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + sys.Husk = &components.Husk{ Description: "detects objects in camera images using YOLOv8 and returns annotated results", Details: map[string][]string{"Developer": {"Synecdoque"}}, @@ -78,9 +80,8 @@ func main() { usecases.RegisterServices(&sys) go usecases.SetoutServers(&sys) - <-sys.Sigs - fmt.Println("\nshutting down system", sys.Name) - cancel() + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) } diff --git a/revolutionary/go.mod b/revolutionary/go.mod index 142c90b..dec1551 100644 --- a/revolutionary/go.mod +++ b/revolutionary/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/revolutionary go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/revolutionary/go.sum b/revolutionary/go.sum index 6d3cabc..6113ffb 100644 --- a/revolutionary/go.sum +++ b/revolutionary/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/revolutionary/revolutionary.go b/revolutionary/revolutionary.go index 923900a..b7a265e 100644 --- a/revolutionary/revolutionary.go +++ b/revolutionary/revolutionary.go @@ -36,6 +36,9 @@ func main() { // instantiate the System sys := components.NewSystem("revolutionary", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // instantiate the husk sys.Husk = &components.Husk{ Description: "interacts with the RevPi Connect 4 PLC", diff --git a/sailor/go.mod b/sailor/go.mod index 458b539..41db033 100644 --- a/sailor/go.mod +++ b/sailor/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/sailor go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/sailor/go.sum b/sailor/go.sum index 6d3cabc..6113ffb 100644 --- a/sailor/go.sum +++ b/sailor/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/sailor/sailor.go b/sailor/sailor.go index 8e101cd..74a7744 100644 --- a/sailor/sailor.go +++ b/sailor/sailor.go @@ -31,7 +31,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -45,6 +44,9 @@ func main() { defer cancel() sys := components.NewSystem("sailor", ctx) + + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) sys.Husk = &components.Husk{ Description: "NMEA 2000 gateway — exposes vessel signals as Arrowhead services", Details: map[string][]string{"Developer": {"Synecdoque"}}, @@ -87,9 +89,8 @@ func main() { usecases.RegisterServices(&sys) go usecases.SetoutServers(&sys) - <-sys.Sigs - fmt.Println("\nshutting down system", sys.Name) - cancel() + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) } diff --git a/sapper/go.mod b/sapper/go.mod index df83a2d..b41d3be 100644 --- a/sapper/go.mod +++ b/sapper/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/sapper go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/sapper/go.sum b/sapper/go.sum index 6d3cabc..6113ffb 100644 --- a/sapper/go.sum +++ b/sapper/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/sapper/sapper.go b/sapper/sapper.go index 080bde2..155fb51 100644 --- a/sapper/sapper.go +++ b/sapper/sapper.go @@ -19,7 +19,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -36,6 +35,9 @@ func main() { // instantiate the System sys := components.NewSystem("sapper", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // Instantiate the husk sys.Husk = &components.Husk{ Description: "simulates a SAP maintenance order system, exposing order creation and status as Arrowhead services.", @@ -85,9 +87,8 @@ func main() { go usecases.SetoutServers(&sys) // Wait for shutdown signal - <-sys.Sigs - fmt.Println("\nshuting down system", sys.Name) - cancel() + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) } diff --git a/telegrapher/go.mod b/telegrapher/go.mod index 6953013..c62b557 100644 --- a/telegrapher/go.mod +++ b/telegrapher/go.mod @@ -4,7 +4,7 @@ go 1.26.2 require ( github.com/eclipse/paho.mqtt.golang v1.5.1 - github.com/sdoque/mbaigo v0.1.0-alpha.4 + github.com/sdoque/mbaigo v0.1.0-alpha.6 ) require ( diff --git a/telegrapher/go.sum b/telegrapher/go.sum index 99b2810..6e1cb05 100644 --- a/telegrapher/go.sum +++ b/telegrapher/go.sum @@ -2,8 +2,8 @@ github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2I github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= diff --git a/telegrapher/telegrapher.go b/telegrapher/telegrapher.go index 36d7dab..d469a20 100644 --- a/telegrapher/telegrapher.go +++ b/telegrapher/telegrapher.go @@ -20,7 +20,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -37,6 +36,9 @@ func main() { // instantiate the System sys := components.NewSystem("telegrapher", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // instantiate the husk sys.Husk = &components.Husk{ Description: " subscribes and publishes to an MQTT broker", @@ -86,9 +88,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal - fmt.Println("\nshuting down system", sys.Name) - cancel() // cancel the context, signaling the goroutines to stop + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(3 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end } diff --git a/thermostat/go.mod b/thermostat/go.mod index c5e6cef..261b1e5 100644 --- a/thermostat/go.mod +++ b/thermostat/go.mod @@ -2,4 +2,4 @@ module github.com/sdoque/systems/thermostat go 1.26.2 -require github.com/sdoque/mbaigo v0.1.0-alpha.4 +require github.com/sdoque/mbaigo v0.1.0-alpha.6 diff --git a/thermostat/go.sum b/thermostat/go.sum index 6d3cabc..6113ffb 100644 --- a/thermostat/go.sum +++ b/thermostat/go.sum @@ -1,2 +1,2 @@ -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= diff --git a/thermostat/thermostat.go b/thermostat/thermostat.go index a4733dd..70821e1 100644 --- a/thermostat/thermostat.go +++ b/thermostat/thermostat.go @@ -20,7 +20,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -37,6 +36,9 @@ func main() { // instantiate the System sys := components.NewSystem("thermostat", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // Instantiate the husk sys.Husk = &components.Husk{ Description: " is a controller for a consumed servo motor position based on a consumed temperature", @@ -87,9 +89,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal - fmt.Println("\nshuting down system", sys.Name) - cancel() // cancel the context, signaling the goroutines to stop + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end } diff --git a/tracker/go.mod b/tracker/go.mod index 13a4ced..ba34998 100644 --- a/tracker/go.mod +++ b/tracker/go.mod @@ -3,7 +3,7 @@ module github.com/sdoque/systems/tracker go 1.26.2 require ( - github.com/sdoque/mbaigo v0.1.0-alpha.4 + github.com/sdoque/mbaigo v0.1.0-alpha.6 modernc.org/sqlite v1.36.1 ) diff --git a/tracker/go.sum b/tracker/go.sum index 1290bc2..5e1901e 100644 --- a/tracker/go.sum +++ b/tracker/go.sum @@ -10,8 +10,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= diff --git a/tracker/tracker.go b/tracker/tracker.go index 9319774..36d0f46 100644 --- a/tracker/tracker.go +++ b/tracker/tracker.go @@ -20,7 +20,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -35,6 +34,9 @@ func main() { sys := components.NewSystem("tracker", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + sys.Husk = &components.Husk{ Description: "tracks pen holder orders in a SQLite database", Details: map[string][]string{"Developer": {"Synecdoque"}}, @@ -75,9 +77,8 @@ func main() { usecases.RegisterServices(&sys) go usecases.SetoutServers(&sys) - <-sys.Sigs - fmt.Println("\nshutting down system", sys.Name) - cancel() + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) } diff --git a/typos.toml b/typos.toml index 4c06cef..b342c3f 100644 --- a/typos.toml +++ b/typos.toml @@ -32,3 +32,15 @@ Mosquitto = "Mosquitto" # AAS — Asset Administration Shell (Industry 4.0 / Platform Industrie 4.0). # The three-letter acronym clashes with the dictionary word "ass". AAS = "AAS" + +# OT — Operational Technology. Standard industrial-automation term used in +# the authorizer specs and architectural prose. The dictionary otherwise +# flags it as a misspelling of TO/OF/OR/NOT. +OT = "OT" + +# "unmarshaling" — the canonical Go spelling. Go's standard library uses +# `json.Unmarshal` (single L), and the verb form follows: "unmarshaling". +# The typos dictionary prefers the British "unmarshalling" (double L), but +# changing our prose to disagree with `go doc encoding/json` would be more +# confusing than the spellchecker's preference. +unmarshaling = "unmarshaling" diff --git a/uaclient/go.mod b/uaclient/go.mod index 712627a..c663a11 100644 --- a/uaclient/go.mod +++ b/uaclient/go.mod @@ -5,5 +5,5 @@ go 1.26.2 require ( github.com/gopcua/opcua v0.6.5 github.com/pkg/errors v0.9.1 - github.com/sdoque/mbaigo v0.1.0-alpha.4 + github.com/sdoque/mbaigo v0.1.0-alpha.6 ) diff --git a/uaclient/go.sum b/uaclient/go.sum index 5b6da90..711fdb8 100644 --- a/uaclient/go.sum +++ b/uaclient/go.sum @@ -6,8 +6,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/uaclient/uaclient.go b/uaclient/uaclient.go index 645f1ad..2fc71fc 100644 --- a/uaclient/uaclient.go +++ b/uaclient/uaclient.go @@ -20,7 +20,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -38,6 +37,9 @@ func main() { // instantiate the System sys := components.NewSystem("opcuac", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // Instantiate the husk sys.Husk = &components.Husk{ Description: "interacts with an OPC UA server", @@ -88,9 +90,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal, and gracefully close properly goroutines with context - <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal - fmt.Println("\nshuting down system", sys.Name) - cancel() // cancel the context, signaling the goroutines to stop + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(3 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end } diff --git a/weatherman/go.mod b/weatherman/go.mod index c7aa084..cab6349 100644 --- a/weatherman/go.mod +++ b/weatherman/go.mod @@ -3,7 +3,7 @@ module github.com/sdoque/systems/weatherman go 1.26.2 require ( - github.com/sdoque/mbaigo v0.1.0-alpha.4 + github.com/sdoque/mbaigo v0.1.0-alpha.6 go.bug.st/serial v1.6.2 ) diff --git a/weatherman/go.sum b/weatherman/go.sum index 99ce6d2..6a83595 100644 --- a/weatherman/go.sum +++ b/weatherman/go.sum @@ -4,8 +4,8 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sdoque/mbaigo v0.1.0-alpha.4 h1:jFxIIP3+kSC3LwamzZFytxnO+K/5WY79YYekw7lEvyg= -github.com/sdoque/mbaigo v0.1.0-alpha.4/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= +github.com/sdoque/mbaigo v0.1.0-alpha.6 h1:kN+TrmJfV49r51KdWbI9Vv7RFm+TQU5ekb1GO0b3rHg= +github.com/sdoque/mbaigo v0.1.0-alpha.6/go.mod h1:IUaNyy+TmZOnjiaJlwaZYlhlx/X10zMQxttMBVv0Fv4= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8= diff --git a/weatherman/weatherman.go b/weatherman/weatherman.go index c81f0e4..f78aa95 100644 --- a/weatherman/weatherman.go +++ b/weatherman/weatherman.go @@ -20,7 +20,6 @@ import ( "context" "crypto/x509/pkix" "encoding/json" - "fmt" "log" "net/http" "time" @@ -38,6 +37,9 @@ func main() { // instantiate the System sys := components.NewSystem("weatherman", ctx) + // Watch for SIGINT immediately so Ctrl+C interrupts blocking startup steps. + usecases.WatchShutdown(&sys, cancel) + // instantiate the husk sys.Husk = &components.Husk{ Description: "exposes a Davis Vantage Pro2 weather station as Arrowhead services via USB serial", @@ -93,9 +95,8 @@ func main() { go usecases.SetoutServers(&sys) // wait for shutdown signal - <-sys.Sigs - fmt.Println("\nshutting down system", sys.Name) - cancel() + <-sys.Ctx.Done() + log.Println("shutting down system", sys.Name) time.Sleep(2 * time.Second) }