feat(cli): read secret-bearing option values from a file or stdin (#137)

Every CLI option that can carry a secret gets a `--<name>-file <path|->`
sibling ('-' reads stdin), so the value stays out of the process list and
shell history: --password-file (global), --primary-config-file /
--backup-config-file (data-connection create|update), --auth-config-file
(external-system create|update), --connection-string-file (db-connection
create|update), --credentials-file (notification smtp create|update) and
--auth-token-file (notification sms create|update). Follows the existing
--config-file convention; `@path` is avoided because System.CommandLine
expands @-tokens as response files.

The value is read at parse time; a missing/unreadable/empty source, or '-'
on a terminal stdin, is a parse error naming option + path only, never the
content. Inline and file forms are mutually exclusive, at most one option
may read stdin, and one trailing line break is stripped. Inline forms keep
working.

docker/seed-sites.sh and docker-env2/seed-sites.sh now write the MxGateway
config (windev gateway key) to a mode-600 temp file removed by an EXIT trap
and pass --primary-config-file; seed-flow-outbound-demo.sh pipes its rig
connection string via --connection-string-file -. CLI README (new "Secrets
from a file or stdin" section + per-command tables), Component-CLI.md and
both docker READMEs updated.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-13 00:46:17 -05:00
parent 1e98997ce1
commit e2f433e849
19 changed files with 724 additions and 52 deletions
+70 -14
View File
@@ -39,6 +39,7 @@ These options are accepted by the root command and inherited by all subcommands.
| `--url <value>` | Management API URL (e.g., `http://localhost:9001`) |
| `--username <value>` | LDAP username for authentication |
| `--password <value>` | LDAP password for authentication |
| `--password-file <path\|->` | Read the LDAP password from a file, or from stdin with `-` (see *Secrets from a file or stdin*). Not together with `--password`. |
| `--format <json\|table>` | Output format (default: `json`) |
## Configuration File
@@ -60,9 +61,54 @@ For the Docker test environment, see `docker/README.md` for a ready-to-use confi
| `SCADABRIDGE_MANAGEMENT_URL` | Management API URL (overrides config file) |
| `SCADABRIDGE_FORMAT` | Default output format (overrides config file) |
| `SCADABRIDGE_USERNAME` | LDAP username (fallback when `--username` is not supplied) |
| `SCADABRIDGE_PASSWORD` | LDAP password (fallback when `--password` is not supplied). Preferred over `--password` on the command line, which leaks into process listings and shell history. |
| `SCADABRIDGE_PASSWORD` | LDAP password (fallback when neither `--password` nor `--password-file` is supplied). Preferred over `--password` on the command line, which leaks into process listings and shell history. |
| `SCADABRIDGE_AUTH_MODE` | `token` (default) exchanges credentials for a bearer token once per process; `basic` sends HTTP Basic on every call (see *Authentication* below). |
## Secrets from a file or stdin
Every option that can carry a secret has a `-file` sibling that reads the value from a file, or from
stdin when the path is `-`, so the secret never appears on the command line. A value on the command
line can be read by any local user from the process list (`ps`) and lands in shell history.
| Inline option | File / stdin sibling | Commands |
|---------------|----------------------|----------|
| `--password` | `--password-file` | every command (global) |
| `--primary-config` (`--configuration`) | `--primary-config-file` | `data-connection create`, `data-connection update` |
| `--backup-config` | `--backup-config-file` | `data-connection create`, `data-connection update` |
| `--auth-config` | `--auth-config-file` | `external-system create`, `external-system update` |
| `--connection-string` | `--connection-string-file` | `db-connection create`, `db-connection update` |
| `--credentials` | `--credentials-file` | `notification smtp create`, `notification smtp update` |
| `--auth-token` | `--auth-token-file` | `notification sms create`, `notification sms update` |
Rules:
- `--x-file <path>` reads the whole file; `--x-file -` reads stdin. A file literally named `-` is `./-`.
- One trailing line break is removed, so a file written with `echo` works for a connection string or token.
- The inline form keeps working for non-secret values. Giving both forms of the same value is an error,
and so is more than one option reading stdin in one invocation.
- A missing, unreadable or empty file, or `-` with stdin attached to a terminal, is a parse error that
names the option and the path. No error message ever includes the file's content.
- The value is sent to the management API exactly as the inline form would send it — the file is only
a different way to hand it to the CLI.
- `@path` is **not** a file reference: System.CommandLine treats a token that starts with `@` as a
response file and expands it before any option sees it.
```sh
# MxGateway config with its API key, from a mode-600 file
(umask 077; printf '{"endpoint":"http://gw:5120","apiKey":"%s"}' "$MXGW_APIKEY" > /tmp/mxgw.json)
scadabridge data-connection create --site-id 1 --name "Gateway" --protocol MxGateway --primary-config-file /tmp/mxgw.json
rm -f /tmp/mxgw.json
# Connection string from stdin
printf '%s' "$CONN_STR" | scadabridge db-connection create --name PlantDb --connection-string-file -
# LDAP password from a file
scadabridge --password-file ~/.config/scadabridge/password site list
```
`printf` is a shell builtin in bash and zsh, so the secret is not in any process's argument list while
it is written either. Using `echo` or `cat <<<` is equally fine; `python3 -c ... "$SECRET"` is not.
## Output
All commands write JSON to stdout on success. Errors are written as JSON to stderr:
@@ -1201,7 +1247,7 @@ scadabridge --url <url> data-connection list [--site-id <int>]
Create a new data connection belonging to a specific site.
```sh
scadabridge --url <url> data-connection create --site-id <int> --name <string> --protocol <string> [--primary-config <json>] [--backup-config <json>] [--failover-retry-count <int>]
scadabridge --url <url> data-connection create --site-id <int> --name <string> --protocol <string> [--primary-config <json> | --primary-config-file <path|->] [--backup-config <json> | --backup-config-file <path|->] [--failover-retry-count <int>]
```
| Option | Required | Description |
@@ -1210,7 +1256,9 @@ scadabridge --url <url> data-connection create --site-id <int> --name <string> -
| `--name` | yes | Connection name |
| `--protocol` | yes | Protocol identifier (e.g. `OpcUa`) |
| `--primary-config` | no | Primary protocol-specific configuration as a JSON string (alias `--configuration`) |
| `--primary-config-file` | no | Read the primary configuration from a file, or stdin with `-` — use it when the config carries a secret (MxGateway `apiKey`, OPC UA password). Not together with `--primary-config`. |
| `--backup-config` | no | Backup protocol-specific configuration as a JSON string |
| `--backup-config-file` | no | Read the backup configuration from a file, or stdin with `-`. Not together with `--backup-config`. |
| `--failover-retry-count` | no | Retries before failover to the backup configuration (default: `3`) |
#### `data-connection update`
@@ -1219,7 +1267,7 @@ Update a data connection definition. An update **replaces** the whole entity —
required field below must be supplied, even if unchanged.
```sh
scadabridge --url <url> data-connection update --id <int> --name <string> --protocol <string> [--primary-config <json>] [--backup-config <json>] [--failover-retry-count <int>]
scadabridge --url <url> data-connection update --id <int> --name <string> --protocol <string> [--primary-config <json> | --primary-config-file <path|->] [--backup-config <json> | --backup-config-file <path|->] [--failover-retry-count <int>]
```
| Option | Required | Description |
@@ -1228,7 +1276,9 @@ scadabridge --url <url> data-connection update --id <int> --name <string> --prot
| `--name` | yes | Connection name |
| `--protocol` | yes | Protocol identifier |
| `--primary-config` | no | Primary protocol-specific configuration as a JSON string (alias `--configuration`) |
| `--primary-config-file` | no | Read the primary configuration from a file, or stdin with `-` — use it when the config carries a secret (MxGateway `apiKey`, OPC UA password). Not together with `--primary-config`. |
| `--backup-config` | no | Backup protocol-specific configuration as a JSON string |
| `--backup-config-file` | no | Read the backup configuration from a file, or stdin with `-`. Not together with `--backup-config`. |
| `--failover-retry-count` | no | Retries before failover to the backup configuration (default: `3`) |
#### `data-connection move`
@@ -1369,7 +1419,7 @@ scadabridge --url <url> external-system list
Register an external HTTP system that scripts can call.
```sh
scadabridge --url <url> external-system create --name <string> --endpoint-url <url> --auth-type <string> [--auth-config <json>] [--timeout-seconds <int>] [--max-retries <int>] [--retry-delay-seconds <int>]
scadabridge --url <url> external-system create --name <string> --endpoint-url <url> --auth-type <string> [--auth-config <json> | --auth-config-file <path|->] [--timeout-seconds <int>] [--max-retries <int>] [--retry-delay-seconds <int>]
```
| Option | Required | Description |
@@ -1378,6 +1428,7 @@ scadabridge --url <url> external-system create --name <string> --endpoint-url <u
| `--endpoint-url` | yes | Base URL of the external system |
| `--auth-type` | yes | Authentication type: `ApiKey` or `BasicAuth` |
| `--auth-config` | no | Auth credentials as a JSON string |
| `--auth-config-file` | no | Read the auth configuration from a file, or stdin with `-`. Not together with `--auth-config`. |
| `--timeout-seconds` | no | Per-system HTTP round-trip timeout. `0` (default) = use the gateway's `DefaultHttpTimeout` |
| `--max-retries` | no | Store-and-forward retry budget for this system's `CachedCall`s. `0` (default) = use the engine's `DefaultMaxRetries` (50) |
| `--retry-delay-seconds` | no | Fixed interval between store-and-forward retries. `0` (default) = use the engine's `DefaultRetryInterval` (30 s). Alias: `--retry-delay` |
@@ -1392,7 +1443,7 @@ Update an external system definition. An update **replaces** the whole entity
required field below must be supplied, even if unchanged.
```sh
scadabridge --url <url> external-system update --id <int> --name <string> --endpoint-url <url> --auth-type <string> [--auth-config <json>] [--timeout-seconds <int>] [--max-retries <int>] [--retry-delay-seconds <int>]
scadabridge --url <url> external-system update --id <int> --name <string> --endpoint-url <url> --auth-type <string> [--auth-config <json> | --auth-config-file <path|->] [--timeout-seconds <int>] [--max-retries <int>] [--retry-delay-seconds <int>]
```
| Option | Required | Description |
@@ -1402,6 +1453,7 @@ scadabridge --url <url> external-system update --id <int> --name <string> --endp
| `--endpoint-url` | yes | Base URL |
| `--auth-type` | yes | Authentication type |
| `--auth-config` | no | Updated auth credentials as a JSON string; omit to preserve the stored value |
| `--auth-config-file` | no | Read the auth configuration from a file, or stdin with `-`. Not together with `--auth-config`. |
| `--timeout-seconds` | no | Per-system HTTP round-trip timeout; omit to preserve |
| `--max-retries` | no | Store-and-forward retry budget for this system's `CachedCall`s; omit to preserve |
| `--retry-delay-seconds` | no | Fixed interval between store-and-forward retries; omit to preserve. Alias: `--retry-delay` |
@@ -1587,7 +1639,7 @@ scadabridge --url <url> notification smtp list
Create a new SMTP configuration. Requires Administrator access.
```sh
scadabridge notification smtp create --server <host> --port <int> --auth-mode <mode> --from-address <email> [--tls-mode <mode>] [--credentials <value>] [--oauth2-authority <url>] [--oauth2-scope <scope>] [--transport <Smtp|Ews>]
scadabridge notification smtp create --server <host> --port <int> --auth-mode <mode> --from-address <email> [--tls-mode <mode>] [--credentials <value> | --credentials-file <path|->] [--oauth2-authority <url>] [--oauth2-scope <scope>] [--transport <Smtp|Ews>]
```
Uses the same settings as `smtp update`, without `--id`. This sends the distinct create contract; update with ID zero is not a creation operation. The server validates transport/authentication compatibility.
@@ -1597,7 +1649,7 @@ Uses the same settings as `smtp update`, without `--id`. This sends the distinct
Update the SMTP configuration.
```sh
scadabridge --url <url> notification smtp update --id <int> --server <string> --port <int> --auth-mode <string> --from-address <string> [--tls-mode <string>] [--credentials <string>] [--oauth2-authority <string>] [--oauth2-scope <string>] [--transport <string>]
scadabridge --url <url> notification smtp update --id <int> --server <string> --port <int> --auth-mode <string> --from-address <string> [--tls-mode <string>] [--credentials <string> | --credentials-file <path|->] [--oauth2-authority <string>] [--oauth2-scope <string>] [--transport <string>]
```
| Option | Required | Description |
@@ -1609,6 +1661,7 @@ scadabridge --url <url> notification smtp update --id <int> --server <string> --
| `--from-address` | yes | Sender email address |
| `--tls-mode` | no | TLS mode: `None`, `StartTLS`, or `SSL` (preserves existing if omitted) |
| `--credentials` | no | SMTP credentials — `username:password` for Basic, or client secret for OAuth2 (preserves existing if omitted) |
| `--credentials-file` | no | Read the credentials from a file, or stdin with `-`. Not together with `--credentials`. |
| `--oauth2-authority` | no | OAuth2 token-endpoint URL — a `{tenant}` placeholder is substituted from the credential (preserves existing / Microsoft 365 default if omitted; OAuth2 auth mode only) |
| `--oauth2-scope` | no | OAuth2 scope requested from the token endpoint (preserves existing / Microsoft 365 default if omitted; OAuth2 auth mode only) |
| `--transport` | no | Email transport: `Smtp` or `Ews` (preserves existing if omitted) |
@@ -1630,17 +1683,17 @@ scadabridge --url <url> notification sms list
Create a new Twilio configuration. Requires Administrator access.
```sh
scadabridge notification sms create --account-sid <sid> --auth-token <token> [--from-number <E.164-number>] [--messaging-service-sid <sid>] [--api-base-url <url>] [--connection-timeout-seconds <n>] [--max-retries <n>] [--retry-delay-seconds <n>]
scadabridge notification sms create --account-sid <sid> (--auth-token <token> | --auth-token-file <path|->) [--from-number <E.164-number>] [--messaging-service-sid <sid>] [--api-base-url <url>] [--connection-timeout-seconds <n>] [--max-retries <n>] [--retry-delay-seconds <n>]
```
Account SID and auth token are required. Supply at least one sender: `--from-number` or `--messaging-service-sid`. Timeout must be positive; retries/delay must be nonnegative. Omitted policy settings use creation defaults. Explicit zero retry settings retain the outbox fallback semantics; they do not disable retries.
Account SID and auth token are required (the token inline, or from a file / stdin with `--auth-token-file`). Supply at least one sender: `--from-number` or `--messaging-service-sid`. Timeout must be positive; retries/delay must be nonnegative. Omitted policy settings use creation defaults. Explicit zero retry settings retain the outbox fallback semantics; they do not disable retries.
#### `notification sms update`
Update the SMS (Twilio) configuration.
```sh
scadabridge --url <url> notification sms update --id <int> --account-sid <string> [--from-number <string>] [--messaging-service-sid <string>] [--api-base-url <string>] [--auth-token <string>] [--connection-timeout-seconds <n>] [--max-retries <n>] [--retry-delay-seconds <n>]
scadabridge --url <url> notification sms update --id <int> --account-sid <string> [--from-number <string>] [--messaging-service-sid <string>] [--api-base-url <string>] [--auth-token <string> | --auth-token-file <path|->] [--connection-timeout-seconds <n>] [--max-retries <n>] [--retry-delay-seconds <n>]
```
| Option | Required | Description |
@@ -1651,6 +1704,7 @@ scadabridge --url <url> notification sms update --id <int> --account-sid <string
| `--messaging-service-sid` | no | Twilio Messaging Service SID. **Omitting it CLEARS the stored value** (the update overwrites it). |
| `--api-base-url` | no | API base URL override (preserves existing if omitted) |
| `--auth-token` | no | Twilio Auth Token. **Omitting it PRESERVES the stored token.** Never printed back. |
| `--auth-token-file` | no | Read the Auth Token from a file, or stdin with `-`. Not together with `--auth-token`. |
| `--connection-timeout-seconds` | no | Positive timeout; omission preserves the existing value |
| `--max-retries` | no | Nonnegative retry setting; omission preserves it; zero retains outbox fallback semantics |
| `--retry-delay-seconds` | no | Nonnegative delay; omission preserves it; zero retains outbox fallback semantics |
@@ -2385,13 +2439,14 @@ scadabridge --url <url> db-connection get --id <int>
Create a new database connection definition.
```sh
scadabridge --url <url> db-connection create --name <string> --connection-string <string>
scadabridge --url <url> db-connection create --name <string> (--connection-string <string> | --connection-string-file <path|->)
```
| Option | Required | Description |
|--------|----------|-------------|
| `--name` | yes | Connection name |
| `--connection-string` | yes | Database connection string |
| `--connection-string` | one of | Database connection string |
| `--connection-string-file` | one of | Read the connection string from a file, or stdin with `-`. Exactly one of the two is required. |
#### `db-connection update`
@@ -2399,14 +2454,15 @@ Update a database connection definition. An update **replaces** the whole entity
every required field below must be supplied, even if unchanged.
```sh
scadabridge --url <url> db-connection update --id <int> --name <string> --connection-string <string>
scadabridge --url <url> db-connection update --id <int> --name <string> (--connection-string <string> | --connection-string-file <path|->)
```
| Option | Required | Description |
|--------|----------|-------------|
| `--id` | yes | Database connection ID |
| `--name` | yes | Connection name |
| `--connection-string` | yes | Database connection string |
| `--connection-string` | one of | Database connection string |
| `--connection-string-file` | one of | Read the connection string from a file, or stdin with `-`. Exactly one of the two is required. |
#### `db-connection delete`