fix(ca): honor --remote for ca export and info (#6)
Closes #1 ## Summary `gordon ca export` and `gordon ca info` now honor remote targeting (`--remote`, `GORDON_REMOTE`, or the active remote, same order as other commands) and fetch the CA through a new authenticated admin endpoint. - `GET /admin/ca` (scope `admin:status:read`) returns the root CA PEM, CN, fingerprint, and intermediate expiry. It answers `404` when internal TLS is disabled. Only public certificate data is exposed. - The CLI checks the fetched PEM locally: exactly one self-signed CA certificate. CN and fingerprint are computed from it; a fingerprint that differs from the server's fails the command. - `ca export` with a remote prints the fingerprint on stderr and asks to compare it with `gordon ca info` on the host, because the checks prove a well-formed CA, not its origin (`insecure_tls` / `http://` remotes). - `ca install` stays local-only and refuses when a remote is targeted, with a hint to `export --out` and install the file with OS tools. - The local error names the config file that was read and suggests `--remote` (except for `install`). ## Review Reviewed for correctness/tests and security/architecture. Deferred: the admin handler reads the pki adapter directly rather than through the pki use case (read-only public data; refactor out of scope). ## Checks - `go test ./...` - `golangci-lint run ./...` Reviewed-on: https://git.bnema.dev/bnema/gordon/pulls/6
- commit
- 598d46d1a425c7e5e69e1ecf364e9994ba57521a
- author
- brice · · 2026-10-02 13:16 UTC
- committer
- bnema · · 2026-10-02 13:16 UTC
- parent
- dcd34c9
- signature
- unsigned
21 files · +966 -35
M .mockery.yaml +1 -0
| @@ -54,6 +54,7 @@ packages: | |||
| 54 | 54 | VolumeService: | |
| 55 | 55 | PublicTLSService: | |
| 56 | 56 | TrafficStatusService: | |
| 57 | + | CAInfoService: | |
| 57 | 58 | AppService: | |
| 58 | 59 | AppReconciler: | |
| 59 | 60 | # Exception: pushImageOps is a CLI-local interface, not a boundary port. | |
| @@ -54,6 +54,7 @@ packages: | |||||
| 54 | VolumeService: | 54 | VolumeService: | ||
| 55 | PublicTLSService: | 55 | PublicTLSService: | ||
| 56 | TrafficStatusService: | 56 | TrafficStatusService: | ||
| 57 | + | CAInfoService: | |||
| 57 | AppService: | 58 | AppService: | ||
| 58 | AppReconciler: | 59 | AppReconciler: | ||
| 59 | # Exception: pushImageOps is a CLI-local interface, not a boundary port. | 60 | # Exception: pushImageOps is a CLI-local interface, not a boundary port. | ||
M docs/cli/ca.md +21 -2
| @@ -14,6 +14,16 @@ Manage Gordon's internal Certificate Authority. | |||
| 14 | 14 | ||
| 15 | 15 | All subcommands require Gordon to have TLS-capable HTTPS fallback configured on an entrypoint such as `entrypoints.edge` with `protocol = "smart_tcp"`. | |
| 16 | 16 | ||
| 17 | + | ### Local and remote | |
| 18 | + | ||
| 19 | + | `export` and `info` target a remote with the same rules as other commands: `--remote`, then `GORDON_REMOTE`, then the active remote saved with `gordon remotes`. When a remote is targeted, they fetch the CA through the authenticated admin API (`GET /admin/ca`, scope `admin:status:read`). The remote answers `404` when its internal TLS is disabled. | |
| 20 | + | ||
| 21 | + | The fetched certificate is checked locally: it must be a single self-signed CA certificate, and the displayed common name and fingerprint are computed from it. If the server reports a different fingerprint, the command fails. These checks prove the certificate is well formed, not where it came from: over an `insecure_tls` or `http://` remote the response can be replaced in transit. `export` prints the fingerprint on stderr; compare it with `gordon ca info` on the Gordon host before trusting the certificate. | |
| 22 | + | ||
| 23 | + | With no remote targeted, `export` and `info` read the local server config (`--config`, or `gordon.toml` in `/etc/gordon`, `~/.config/gordon`, or the current directory) and its data directory. If that config has no TLS-capable entrypoint, the error names the config file that was read. | |
| 24 | + | ||
| 25 | + | `install` is local-only and works on the Gordon host. It fails when a remote is targeted. | |
| 26 | + | ||
| 17 | 27 | --- | |
| 18 | 28 | ||
| 19 | 29 | ## gordon ca export | |
| @@ -23,6 +33,7 @@ Export the root CA certificate for manual trust installation on clients. | |||
| 23 | 33 | ```bash | |
| 24 | 34 | gordon ca export # Print PEM to stdout | |
| 25 | 35 | gordon ca export --out ca.pem # Write to file | |
| 36 | + | gordon ca export --remote prod # Fetch the CA from a remote Gordon | |
| 26 | 37 | ``` | |
| 27 | 38 | ||
| 28 | 39 | ### Options | |
| @@ -40,6 +51,7 @@ Show CA status information: root common name, SHA-256 fingerprint, and intermedi | |||
| 40 | 51 | ```bash | |
| 41 | 52 | gordon ca info | |
| 42 | 53 | gordon ca info --json | |
| 54 | + | gordon ca info --remote prod | |
| 43 | 55 | ``` | |
| 44 | 56 | ||
| 45 | 57 | ### Options | |
| @@ -63,13 +75,19 @@ gordon ca info --json | |||
| 63 | 75 | ||
| 64 | 76 | ## gordon ca install | |
| 65 | 77 | ||
| 66 | - | Install or uninstall the root CA certificate in system, Firefox, and Java trust stores on the **Gordon host machine**. Requires root privileges. | |
| 78 | + | Install or uninstall the root CA certificate in the system, Firefox, and Java trust stores of the **Gordon host machine**. Requires root privileges. The CA is read from the local Gordon data directory; the command never fetches it over the network. | |
| 67 | 79 | ||
| 68 | 80 | ```bash | |
| 69 | 81 | sudo gordon ca install | |
| 70 | 82 | sudo gordon ca install --uninstall | |
| 71 | 83 | ``` | |
| 72 | 84 | ||
| 85 | + | If a remote is targeted (`--remote`, `GORDON_REMOTE`, or an active remote), the command fails with: | |
| 86 | + | ||
| 87 | + | ``` | |
| 88 | + | ca install only works on the Gordon host; from another machine run `gordon ca export --remote <name> --out gordon-ca.pem` and install that file with your OS trust tools | |
| 89 | + | ``` | |
| 90 | + | ||
| 73 | 91 | ### Options | |
| 74 | 92 | ||
| 75 | 93 | | Option | Description | | |
| @@ -77,7 +95,7 @@ sudo gordon ca install --uninstall | |||
| 77 | 95 | | `--uninstall` | Remove the CA from trust stores instead of installing | | |
| 78 | 96 | | `--json` | Output as JSON | | |
| 79 | 97 | ||
| 80 | - | > **Important:** This command is for the machine running Gordon, not for arbitrary clients. Remote users should import the certificate through one of the methods below. | |
| 98 | + | > **Important:** This command is for the machine running Gordon, not for arbitrary clients. Other machines should export the certificate (`gordon ca export --remote <name> --out gordon-ca.pem`) or use one of the methods below. | |
| 81 | 99 | ||
| 82 | 100 | --- | |
| 83 | 101 | ||
| @@ -122,3 +140,4 @@ Visit `http://<gordon-host>/.well-known/gordon/ca` from the device browser. The | |||
| 122 | 140 | ||
| 123 | 141 | - [Server Configuration](../config/server.md) — `force_https_redirect`, smart TCP edge, and internal CA settings | |
| 124 | 142 | - [CLI Commands](./index.md) | |
| 143 | + | - [Remotes](./remotes.md) — saved remote Gordon instances for `--remote` | |
| @@ -14,6 +14,16 @@ Manage Gordon's internal Certificate Authority. | |||||
| 14 | 14 | ||||
| 15 | All subcommands require Gordon to have TLS-capable HTTPS fallback configured on an entrypoint such as `entrypoints.edge` with `protocol = "smart_tcp"`. | 15 | All subcommands require Gordon to have TLS-capable HTTPS fallback configured on an entrypoint such as `entrypoints.edge` with `protocol = "smart_tcp"`. | ||
| 16 | 16 | ||||
| 17 | + | ### Local and remote | |||
| 18 | + | ||||
| 19 | + | `export` and `info` target a remote with the same rules as other commands: `--remote`, then `GORDON_REMOTE`, then the active remote saved with `gordon remotes`. When a remote is targeted, they fetch the CA through the authenticated admin API (`GET /admin/ca`, scope `admin:status:read`). The remote answers `404` when its internal TLS is disabled. | |||
| 20 | + | ||||
| 21 | + | The fetched certificate is checked locally: it must be a single self-signed CA certificate, and the displayed common name and fingerprint are computed from it. If the server reports a different fingerprint, the command fails. These checks prove the certificate is well formed, not where it came from: over an `insecure_tls` or `http://` remote the response can be replaced in transit. `export` prints the fingerprint on stderr; compare it with `gordon ca info` on the Gordon host before trusting the certificate. | |||
| 22 | + | ||||
| 23 | + | With no remote targeted, `export` and `info` read the local server config (`--config`, or `gordon.toml` in `/etc/gordon`, `~/.config/gordon`, or the current directory) and its data directory. If that config has no TLS-capable entrypoint, the error names the config file that was read. | |||
| 24 | + | ||||
| 25 | + | `install` is local-only and works on the Gordon host. It fails when a remote is targeted. | |||
| 26 | + | ||||
| 17 | --- | 27 | --- | ||
| 18 | 28 | ||||
| 19 | ## gordon ca export | 29 | ## gordon ca export | ||
| @@ -23,6 +33,7 @@ Export the root CA certificate for manual trust installation on clients. | |||||
| 23 | ```bash | 33 | ```bash | ||
| 24 | gordon ca export # Print PEM to stdout | 34 | gordon ca export # Print PEM to stdout | ||
| 25 | gordon ca export --out ca.pem # Write to file | 35 | gordon ca export --out ca.pem # Write to file | ||
| 36 | + | gordon ca export --remote prod # Fetch the CA from a remote Gordon | |||
| 26 | ``` | 37 | ``` | ||
| 27 | 38 | ||||
| 28 | ### Options | 39 | ### Options | ||
| @@ -40,6 +51,7 @@ Show CA status information: root common name, SHA-256 fingerprint, and intermedi | |||||
| 40 | ```bash | 51 | ```bash | ||
| 41 | gordon ca info | 52 | gordon ca info | ||
| 42 | gordon ca info --json | 53 | gordon ca info --json | ||
| 54 | + | gordon ca info --remote prod | |||
| 43 | ``` | 55 | ``` | ||
| 44 | 56 | ||||
| 45 | ### Options | 57 | ### Options | ||
| @@ -63,13 +75,19 @@ gordon ca info --json | |||||
| 63 | 75 | ||||
| 64 | ## gordon ca install | 76 | ## gordon ca install | ||
| 65 | 77 | ||||
| 66 | - | Install or uninstall the root CA certificate in system, Firefox, and Java trust stores on the **Gordon host machine**. Requires root privileges. | 78 | + | Install or uninstall the root CA certificate in the system, Firefox, and Java trust stores of the **Gordon host machine**. Requires root privileges. The CA is read from the local Gordon data directory; the command never fetches it over the network. |
| 67 | 79 | ||||
| 68 | ```bash | 80 | ```bash | ||
| 69 | sudo gordon ca install | 81 | sudo gordon ca install | ||
| 70 | sudo gordon ca install --uninstall | 82 | sudo gordon ca install --uninstall | ||
| 71 | ``` | 83 | ``` | ||
| 72 | 84 | ||||
| 85 | + | If a remote is targeted (`--remote`, `GORDON_REMOTE`, or an active remote), the command fails with: | |||
| 86 | + | ||||
| 87 | + | ``` | |||
| 88 | + | ca install only works on the Gordon host; from another machine run `gordon ca export --remote <name> --out gordon-ca.pem` and install that file with your OS trust tools | |||
| 89 | + | ``` | |||
| 90 | + | ||||
| 73 | ### Options | 91 | ### Options | ||
| 74 | 92 | ||||
| 75 | | Option | Description | | 93 | | Option | Description | | ||
| @@ -77,7 +95,7 @@ sudo gordon ca install --uninstall | |||||
| 77 | | `--uninstall` | Remove the CA from trust stores instead of installing | | 95 | | `--uninstall` | Remove the CA from trust stores instead of installing | | ||
| 78 | | `--json` | Output as JSON | | 96 | | `--json` | Output as JSON | | ||
| 79 | 97 | ||||
| 80 | - | > **Important:** This command is for the machine running Gordon, not for arbitrary clients. Remote users should import the certificate through one of the methods below. | 98 | + | > **Important:** This command is for the machine running Gordon, not for arbitrary clients. Other machines should export the certificate (`gordon ca export --remote <name> --out gordon-ca.pem`) or use one of the methods below. |
| 81 | 99 | ||||
| 82 | --- | 100 | --- | ||
| 83 | 101 | ||||
| @@ -122,3 +140,4 @@ Visit `http://<gordon-host>/.well-known/gordon/ca` from the device browser. The | |||||
| 122 | 140 | ||||
| 123 | - [Server Configuration](../config/server.md) — `force_https_redirect`, smart TCP edge, and internal CA settings | 141 | - [Server Configuration](../config/server.md) — `force_https_redirect`, smart TCP edge, and internal CA settings | ||
| 124 | - [CLI Commands](./index.md) | 142 | - [CLI Commands](./index.md) | ||
| 143 | + | - [Remotes](./remotes.md) — saved remote Gordon instances for `--remote` | |||
M docs/cli/index.md +2 -1
| @@ -16,7 +16,7 @@ Commands are organized by where they run: | |||
| 16 | 16 | |---------|-------------|---------------| | |
| 17 | 17 | | `gordon serve` | Start the Gordon server | [serve](./serve.md) | | |
| 18 | 18 | | `gordon auth` | Manage Gordon server authentication | [auth](./auth.md) | | |
| 19 | - | | `gordon ca` | Manage the internal Certificate Authority | [ca](./ca.md) | | |
| 19 | + | | `gordon ca install` | Install/uninstall the internal CA in the host trust stores | [ca](./ca.md#gordon-ca-install) | | |
| 20 | 20 | ||
| 21 | 21 | ## Management Commands (local or remote) | |
| 22 | 22 | ||
| @@ -26,6 +26,7 @@ Management commands use the authenticated daemon API. By default they connect th | |||
| 26 | 26 | |---------|-------------|---------------| | |
| 27 | 27 | | `gordon apps` | Manage applications, operations, and app secrets | [apps](./apps.md) | | |
| 28 | 28 | | `gordon backups` | Manage declared app database and volume backups | [backup](./backup.md) | | |
| 29 | + | | `gordon ca export`, `gordon ca info` | Export or inspect the internal CA (read from the local config, or fetched with `--remote`) | [ca](./ca.md) | | |
| 29 | 30 | | `gordon daemon` | Daemon status, logs, reload, config, TLS, traffic, and networks | [daemon](./daemon.md) | | |
| 30 | 31 | | `gordon images` | Push, list, and prune images | [images](./images.md) | | |
| 31 | 32 | | `gordon volumes` | Manage volumes | [volumes](./volumes.md) | | |
| @@ -16,7 +16,7 @@ Commands are organized by where they run: | |||||
| 16 | |---------|-------------|---------------| | 16 | |---------|-------------|---------------| | ||
| 17 | | `gordon serve` | Start the Gordon server | [serve](./serve.md) | | 17 | | `gordon serve` | Start the Gordon server | [serve](./serve.md) | | ||
| 18 | | `gordon auth` | Manage Gordon server authentication | [auth](./auth.md) | | 18 | | `gordon auth` | Manage Gordon server authentication | [auth](./auth.md) | | ||
| 19 | - | | `gordon ca` | Manage the internal Certificate Authority | [ca](./ca.md) | | 19 | + | | `gordon ca install` | Install/uninstall the internal CA in the host trust stores | [ca](./ca.md#gordon-ca-install) | |
| 20 | 20 | ||||
| 21 | ## Management Commands (local or remote) | 21 | ## Management Commands (local or remote) | ||
| 22 | 22 | ||||
| @@ -26,6 +26,7 @@ Management commands use the authenticated daemon API. By default they connect th | |||||
| 26 | |---------|-------------|---------------| | 26 | |---------|-------------|---------------| | ||
| 27 | | `gordon apps` | Manage applications, operations, and app secrets | [apps](./apps.md) | | 27 | | `gordon apps` | Manage applications, operations, and app secrets | [apps](./apps.md) | | ||
| 28 | | `gordon backups` | Manage declared app database and volume backups | [backup](./backup.md) | | 28 | | `gordon backups` | Manage declared app database and volume backups | [backup](./backup.md) | | ||
| 29 | + | | `gordon ca export`, `gordon ca info` | Export or inspect the internal CA (read from the local config, or fetched with `--remote`) | [ca](./ca.md) | | |||
| 29 | | `gordon daemon` | Daemon status, logs, reload, config, TLS, traffic, and networks | [daemon](./daemon.md) | | 30 | | `gordon daemon` | Daemon status, logs, reload, config, TLS, traffic, and networks | [daemon](./daemon.md) | | ||
| 30 | | `gordon images` | Push, list, and prune images | [images](./images.md) | | 31 | | `gordon images` | Push, list, and prune images | [images](./images.md) | | ||
| 31 | | `gordon volumes` | Manage volumes | [volumes](./volumes.md) | | 32 | | `gordon volumes` | Manage volumes | [volumes](./volumes.md) | | ||
M docs/cli/remotes.md +2 -2
| @@ -337,8 +337,8 @@ token_env = "GORDON_TOKEN" | |||
| 337 | 337 | A common Tailscale setup is to point your domain's DNS at the machine's Tailscale IP (Cloudflare DNS-only / grey cloud). Since the server isn't publicly reachable, there is no external CA to issue a trusted certificate — Gordon's internal CA handles TLS automatically. Clients need to trust the internal CA: | |
| 338 | 338 | ||
| 339 | 339 | - `insecure_tls = true` on the remote — skips certificate verification entirely | |
| 340 | - | - `sudo gordon ca install` — installs the root CA into system, Firefox, and Java trust stores | |
| 341 | - | - `gordon ca export --out gordon-ca.crt` — exports the root CA PEM for manual installation | |
| 340 | + | - `sudo gordon ca install` — on the Gordon host only: installs the root CA into system, Firefox, and Java trust stores | |
| 341 | + | - `gordon ca export --remote <name> --out gordon-ca.crt` — fetches the root CA PEM from the remote for manual installation | |
| 342 | 342 | - Visit `https://<gordon-host>/.well-known/gordon/ca` (or your mapped edge address) in a browser — onboarding page with downloads for macOS, Linux, Windows, iOS, and Android | |
| 343 | 343 | ||
| 344 | 344 | If you have your own certificate for the domain (e.g. from a corporate CA), you can provide it via `tls_cert_file`/`tls_key_file` — see [Server Configuration](../config/server.md#custom-certificates). The static cert is served for SNI-matching domains; everything else falls through to the internal CA. | |
| @@ -337,8 +337,8 @@ token_env = "GORDON_TOKEN" | |||||
| 337 | A common Tailscale setup is to point your domain's DNS at the machine's Tailscale IP (Cloudflare DNS-only / grey cloud). Since the server isn't publicly reachable, there is no external CA to issue a trusted certificate — Gordon's internal CA handles TLS automatically. Clients need to trust the internal CA: | 337 | A common Tailscale setup is to point your domain's DNS at the machine's Tailscale IP (Cloudflare DNS-only / grey cloud). Since the server isn't publicly reachable, there is no external CA to issue a trusted certificate — Gordon's internal CA handles TLS automatically. Clients need to trust the internal CA: | ||
| 338 | 338 | ||||
| 339 | - `insecure_tls = true` on the remote — skips certificate verification entirely | 339 | - `insecure_tls = true` on the remote — skips certificate verification entirely | ||
| 340 | - | - `sudo gordon ca install` — installs the root CA into system, Firefox, and Java trust stores | 340 | + | - `sudo gordon ca install` — on the Gordon host only: installs the root CA into system, Firefox, and Java trust stores |
| 341 | - | - `gordon ca export --out gordon-ca.crt` — exports the root CA PEM for manual installation | 341 | + | - `gordon ca export --remote <name> --out gordon-ca.crt` — fetches the root CA PEM from the remote for manual installation |
| 342 | - Visit `https://<gordon-host>/.well-known/gordon/ca` (or your mapped edge address) in a browser — onboarding page with downloads for macOS, Linux, Windows, iOS, and Android | 342 | - Visit `https://<gordon-host>/.well-known/gordon/ca` (or your mapped edge address) in a browser — onboarding page with downloads for macOS, Linux, Windows, iOS, and Android | ||
| 343 | 343 | ||||
| 344 | If you have your own certificate for the domain (e.g. from a corporate CA), you can provide it via `tls_cert_file`/`tls_key_file` — see [Server Configuration](../config/server.md#custom-certificates). The static cert is served for SNI-matching domains; everything else falls through to the internal CA. | 344 | If you have your own certificate for the domain (e.g. from a corporate CA), you can provide it via `tls_cert_file`/`tls_key_file` — see [Server Configuration](../config/server.md#custom-certificates). The static cert is served for SNI-matching domains; everything else falls through to the internal CA. | ||
A internal/adapters/dto/ca.go +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // CAResponse describes the internal root CA for the admin API. | |
| 6 | + | type CAResponse struct { | |
| 7 | + | RootCN string `json:"root_cn"` | |
| 8 | + | Fingerprint string `json:"fingerprint"` | |
| 9 | + | IntermediateExpiry time.Time `json:"intermediate_expiry"` | |
| 10 | + | RootPEM string `json:"root_pem"` | |
| 11 | + | } | |
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | package dto | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // CAResponse describes the internal root CA for the admin API. | |||
| 6 | + | type CAResponse struct { | |||
| 7 | + | RootCN string `json:"root_cn"` | |||
| 8 | + | Fingerprint string `json:"fingerprint"` | |||
| 9 | + | IntermediateExpiry time.Time `json:"intermediate_expiry"` | |||
| 10 | + | RootPEM string `json:"root_pem"` | |||
| 11 | + | } | |||
M internal/adapters/in/cli/ca.go +162 -28
| @@ -4,10 +4,12 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/x509" | |
| 6 | 6 | "encoding/pem" | |
| 7 | + | "errors" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "io" | |
| 9 | 10 | "os" | |
| 10 | 11 | "path/filepath" | |
| 12 | + | "strings" | |
| 11 | 13 | "time" | |
| 12 | 14 | ||
| 13 | 15 | "github.com/spf13/cobra" | |
| @@ -29,19 +31,48 @@ func newCACmd() *cobra.Command { | |||
| 29 | 31 | return cmd | |
| 30 | 32 | } | |
| 31 | 33 | ||
| 34 | + | // caResolveRemote allows tests to override remote control-plane resolution. | |
| 35 | + | // Tests that swap it must not run in parallel. It returns a nil ControlPlane when no remote is targeted. | |
| 36 | + | var caResolveRemote = func() (ControlPlane, error) { | |
| 37 | + | client, isRemote, err := GetRemoteClient() | |
| 38 | + | if err != nil || !isRemote { | |
| 39 | + | return nil, err | |
| 40 | + | } | |
| 41 | + | return client, nil | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // caDetails is the CA data the ca commands operate on, whether it was read | |
| 45 | + | // from the local data directory or fetched from a remote Gordon. | |
| 46 | + | type caDetails struct { | |
| 47 | + | rootPEM []byte | |
| 48 | + | rootCN string | |
| 49 | + | fingerprint string | |
| 50 | + | intermediateExpiry time.Time | |
| 51 | + | // remote is true when the CA was fetched over the network. | |
| 52 | + | remote bool | |
| 53 | + | } | |
| 54 | + | ||
| 32 | 55 | func newCAExportCmd() *cobra.Command { | |
| 33 | 56 | var outPath string | |
| 34 | 57 | ||
| 35 | 58 | cmd := &cobra.Command{ | |
| 36 | 59 | Use: "export", | |
| 37 | 60 | Short: "Export the root CA certificate", | |
| 38 | - | Long: "Export Gordon's root CA certificate in PEM format for manual trust installation.", | |
| 61 | + | Long: "Export Gordon's root CA certificate in PEM format for manual trust installation. When a remote is targeted (--remote, GORDON_REMOTE, or the active remote), the certificate is fetched from that Gordon.", | |
| 39 | 62 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 40 | - | dataDir, err := resolveCADataDir() | |
| 63 | + | details, err := loadCADetails(cmd.Context()) | |
| 41 | 64 | if err != nil { | |
| 42 | 65 | return err | |
| 43 | 66 | } | |
| 44 | - | return runCAExport(cmd.Context(), cmd.OutOrStdout(), dataDir, outPath) | |
| 67 | + | if details.remote { | |
| 68 | + | // The checks prove a well-formed CA, not its origin: over an | |
| 69 | + | // insecure or plain-http remote the response can be swapped. | |
| 70 | + | if err := cliWriteLine(cmd.ErrOrStderr(), cliRenderWarning(fmt.Sprintf( | |
| 71 | + | "Before trusting this CA, check that fingerprint %s matches `gordon ca info` on the Gordon host", details.fingerprint))); err != nil { | |
| 72 | + | return err | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | return runCAExport(cmd.OutOrStdout(), details, outPath) | |
| 45 | 76 | }, | |
| 46 | 77 | } | |
| 47 | 78 | ||
| @@ -59,9 +90,12 @@ func newCAInstallCmd() *cobra.Command { | |||
| 59 | 90 | cmd := &cobra.Command{ | |
| 60 | 91 | Use: "install", | |
| 61 | 92 | Short: "Install/uninstall the root CA in the system trust store", | |
| 62 | - | Long: "Install Gordon's root CA certificate into the system, Firefox, and Java trust stores. Requires running as root.", | |
| 93 | + | Long: "Install Gordon's root CA certificate into the system, Firefox, and Java trust stores. Requires running as root on the Gordon host.", | |
| 63 | 94 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 64 | - | dataDir, err := resolveCADataDir() | |
| 95 | + | if err := rejectRemoteForCAInstall(); err != nil { | |
| 96 | + | return err | |
| 97 | + | } | |
| 98 | + | dataDir, err := resolveCADataDir(false) | |
| 65 | 99 | if err != nil { | |
| 66 | 100 | return err | |
| 67 | 101 | } | |
| @@ -75,6 +109,24 @@ func newCAInstallCmd() *cobra.Command { | |||
| 75 | 109 | return cmd | |
| 76 | 110 | } | |
| 77 | 111 | ||
| 112 | + | // rejectRemoteForCAInstall refuses to run install/uninstall while a remote is | |
| 113 | + | // targeted: trusting a network-fetched root is unsafe (insecure or plain-http | |
| 114 | + | // remotes), sudo drops the user's remotes config, and uninstall would depend | |
| 115 | + | // on the remote still being reachable. | |
| 116 | + | func rejectRemoteForCAInstall() error { | |
| 117 | + | cp, err := caResolveRemote() | |
| 118 | + | if err != nil { | |
| 119 | + | return err | |
| 120 | + | } | |
| 121 | + | if cp != nil { | |
| 122 | + | return errCAInstallRemote | |
| 123 | + | } | |
| 124 | + | return nil | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | var errCAInstallRemote = errors.New("ca install only works on the Gordon host; from another machine run " + | |
| 128 | + | "`gordon ca export --remote <name> --out gordon-ca.pem` and install that file with your OS trust tools") | |
| 129 | + | ||
| 78 | 130 | func newCAInfoCmd() *cobra.Command { | |
| 79 | 131 | var jsonOut bool | |
| 80 | 132 | ||
| @@ -82,11 +134,11 @@ func newCAInfoCmd() *cobra.Command { | |||
| 82 | 134 | Use: "info", | |
| 83 | 135 | Short: "Show CA status information", | |
| 84 | 136 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 85 | - | dataDir, err := resolveCADataDir() | |
| 137 | + | details, err := loadCADetails(cmd.Context()) | |
| 86 | 138 | if err != nil { | |
| 87 | 139 | return err | |
| 88 | 140 | } | |
| 89 | - | return runCAInfo(cmd.Context(), cmd.OutOrStdout(), dataDir, jsonOut) | |
| 141 | + | return runCAInfo(cmd.OutOrStdout(), details, jsonOut) | |
| 90 | 142 | }, | |
| 91 | 143 | } | |
| 92 | 144 | ||
| @@ -95,17 +147,99 @@ func newCAInfoCmd() *cobra.Command { | |||
| 95 | 147 | return cmd | |
| 96 | 148 | } | |
| 97 | 149 | ||
| 98 | - | func resolveCADataDir() (string, error) { | |
| 150 | + | // loadCADetails reads the CA from the remote Gordon when --remote targets one, | |
| 151 | + | // otherwise from the local server config and data directory. | |
| 152 | + | func loadCADetails(ctx context.Context) (*caDetails, error) { | |
| 153 | + | cp, err := caResolveRemote() | |
| 154 | + | if err != nil { | |
| 155 | + | return nil, err | |
| 156 | + | } | |
| 157 | + | if cp != nil { | |
| 158 | + | return fetchRemoteCADetails(ctx, cp) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | dataDir, err := resolveCADataDir(true) | |
| 162 | + | if err != nil { | |
| 163 | + | return nil, err | |
| 164 | + | } | |
| 165 | + | return loadLocalCADetails(dataDir) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // fetchRemoteCADetails fetches the CA from the remote and verifies it locally: | |
| 169 | + | // the PEM must hold exactly one self-signed CA certificate, and the CN and | |
| 170 | + | // fingerprint are computed from it rather than trusted from the response. | |
| 171 | + | func fetchRemoteCADetails(ctx context.Context, cp ControlPlane) (*caDetails, error) { | |
| 172 | + | resp, err := cp.GetCA(ctx) | |
| 173 | + | if err != nil { | |
| 174 | + | return nil, fmt.Errorf("failed to get CA from remote: %w", err) | |
| 175 | + | } | |
| 176 | + | if resp == nil || resp.RootPEM == "" { | |
| 177 | + | return nil, fmt.Errorf("failed to get CA from remote: empty response") | |
| 178 | + | } | |
| 179 | + | cert, err := parseRemoteRootCA([]byte(resp.RootPEM)) | |
| 180 | + | if err != nil { | |
| 181 | + | return nil, fmt.Errorf("remote returned an invalid root CA: %w", err) | |
| 182 | + | } | |
| 183 | + | fingerprint := pkiadapter.CertFingerprint(cert) | |
| 184 | + | if resp.Fingerprint != "" && !strings.EqualFold(resp.Fingerprint, fingerprint) { | |
| 185 | + | return nil, fmt.Errorf("remote returned an invalid root CA: reported fingerprint %s does not match certificate fingerprint %s", | |
| 186 | + | resp.Fingerprint, fingerprint) | |
| 187 | + | } | |
| 188 | + | return &caDetails{ | |
| 189 | + | rootPEM: []byte(resp.RootPEM), | |
| 190 | + | rootCN: cert.Subject.CommonName, | |
| 191 | + | fingerprint: fingerprint, | |
| 192 | + | intermediateExpiry: resp.IntermediateExpiry, | |
| 193 | + | remote: true, | |
| 194 | + | }, nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func parseRemoteRootCA(rootPEM []byte) (*x509.Certificate, error) { | |
| 198 | + | block, rest := pem.Decode(rootPEM) | |
| 199 | + | if block == nil || block.Type != "CERTIFICATE" { | |
| 200 | + | return nil, errors.New("no CERTIFICATE PEM block found") | |
| 201 | + | } | |
| 202 | + | if extra, _ := pem.Decode(rest); extra != nil { | |
| 203 | + | return nil, errors.New("expected exactly one certificate, found more") | |
| 204 | + | } | |
| 205 | + | cert, err := x509.ParseCertificate(block.Bytes) | |
| 206 | + | if err != nil { | |
| 207 | + | return nil, fmt.Errorf("parse certificate: %w", err) | |
| 208 | + | } | |
| 209 | + | if !cert.BasicConstraintsValid || !cert.IsCA { | |
| 210 | + | return nil, errors.New("certificate is not a CA") | |
| 211 | + | } | |
| 212 | + | if err := cert.CheckSignatureFrom(cert); err != nil { | |
| 213 | + | return nil, fmt.Errorf("certificate is not validly self-signed: %w", err) | |
| 214 | + | } | |
| 215 | + | return cert, nil | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // resolveCADataDir returns the local data directory. remoteHint adds the | |
| 219 | + | // --remote suggestion for commands that support remotes. | |
| 220 | + | func resolveCADataDir(remoteHint bool) (string, error) { | |
| 99 | 221 | local, err := GetLocalServices(cliConfigPath) | |
| 100 | 222 | if err != nil { | |
| 101 | 223 | return "", err | |
| 102 | 224 | } | |
| 103 | 225 | if !local.HasInternalTLS() { | |
| 104 | - | return "", fmt.Errorf("internal TLS is disabled (no TLS-capable entrypoint configured); CA commands are not available — configure a smart_tcp or tls_mux entrypoint") | |
| 226 | + | return "", errLocalInternalTLSDisabled(local.GetConfigFile(), remoteHint) | |
| 105 | 227 | } | |
| 106 | 228 | return local.GetDataDir(), nil | |
| 107 | 229 | } | |
| 108 | 230 | ||
| 231 | + | func errLocalInternalTLSDisabled(configFile string, remoteHint bool) error { | |
| 232 | + | source := "no local server config file was found (searched /etc/gordon, ~/.config/gordon and the current directory)" | |
| 233 | + | if configFile != "" { | |
| 234 | + | source = fmt.Sprintf("local server config %s was read", configFile) | |
| 235 | + | } | |
| 236 | + | hint := "" | |
| 237 | + | if remoteHint { | |
| 238 | + | hint = "; to use the CA of a remote Gordon, pass --remote <name|url>" | |
| 239 | + | } | |
| 240 | + | return fmt.Errorf("internal TLS is disabled: %s and it has no TLS-capable entrypoint (smart_tcp or tls_mux)%s", source, hint) | |
| 241 | + | } | |
| 242 | + | ||
| 109 | 243 | // loadCAFromDataDir creates a CA adapter directly (bypassing the usecase layer). | |
| 110 | 244 | // This is intentional: CLI commands like 'ca export' and 'ca info' are standalone | |
| 111 | 245 | // admin utilities that run outside the server lifecycle and don't need business logic. | |
| @@ -117,25 +251,31 @@ func loadCAFromDataDir(dataDir string) (boundaryout.CertificateAuthority, error) | |||
| 117 | 251 | return ca, nil | |
| 118 | 252 | } | |
| 119 | 253 | ||
| 120 | - | func runCAExport(_ context.Context, out io.Writer, dataDir, outPath string) error { | |
| 254 | + | func loadLocalCADetails(dataDir string) (*caDetails, error) { | |
| 121 | 255 | ca, err := loadCAFromDataDir(dataDir) | |
| 122 | 256 | if err != nil { | |
| 123 | - | return err | |
| 257 | + | return nil, err | |
| 124 | 258 | } | |
| 259 | + | return &caDetails{ | |
| 260 | + | rootPEM: ca.RootCertificate(), | |
| 261 | + | rootCN: ca.RootCommonName(), | |
| 262 | + | fingerprint: ca.RootFingerprint(), | |
| 263 | + | intermediateExpiry: ca.IntermediateExpiresAt(), | |
| 264 | + | }, nil | |
| 265 | + | } | |
| 125 | 266 | ||
| 126 | - | rootPEM := ca.RootCertificate() | |
| 127 | - | ||
| 267 | + | func runCAExport(out io.Writer, details *caDetails, outPath string) error { | |
| 128 | 268 | if outPath != "" { | |
| 129 | 269 | if err := os.MkdirAll(filepath.Dir(outPath), 0750); err != nil { | |
| 130 | 270 | return fmt.Errorf("create directory for %s: %w", outPath, err) | |
| 131 | 271 | } | |
| 132 | - | if err := os.WriteFile(outPath, rootPEM, 0600); err != nil { | |
| 272 | + | if err := os.WriteFile(outPath, details.rootPEM, 0600); err != nil { | |
| 133 | 273 | return fmt.Errorf("write certificate to %s: %w", outPath, err) | |
| 134 | 274 | } | |
| 135 | 275 | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Root CA certificate written to %s", outPath))) | |
| 136 | 276 | } | |
| 137 | 277 | ||
| 138 | - | _, err = out.Write(rootPEM) | |
| 278 | + | _, err := out.Write(details.rootPEM) | |
| 139 | 279 | return err | |
| 140 | 280 | } | |
| 141 | 281 | ||
| @@ -174,28 +314,22 @@ func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, j | |||
| 174 | 314 | return cliWriteLine(out, cliRenderSuccess("Root CA installed in system trust stores (system, Firefox, Java)")) | |
| 175 | 315 | } | |
| 176 | 316 | ||
| 177 | - | func runCAInfo(_ context.Context, out io.Writer, dataDir string, jsonOut bool) error { | |
| 178 | - | ca, err := loadCAFromDataDir(dataDir) | |
| 179 | - | if err != nil { | |
| 180 | - | return err | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | interExpiry := ca.IntermediateExpiresAt() | |
| 184 | - | remaining := time.Until(interExpiry).Truncate(time.Minute) | |
| 317 | + | func runCAInfo(out io.Writer, details *caDetails, jsonOut bool) error { | |
| 318 | + | remaining := time.Until(details.intermediateExpiry).Truncate(time.Minute) | |
| 185 | 319 | ||
| 186 | 320 | if jsonOut { | |
| 187 | 321 | return writeJSON(out, map[string]any{ | |
| 188 | - | "root_cn": ca.RootCommonName(), | |
| 189 | - | "fingerprint": "SHA256:" + ca.RootFingerprint(), | |
| 190 | - | "intermediate_expiry": interExpiry.Format(time.RFC3339), | |
| 322 | + | "root_cn": details.rootCN, | |
| 323 | + | "fingerprint": "SHA256:" + details.fingerprint, | |
| 324 | + | "intermediate_expiry": details.intermediateExpiry.Format(time.RFC3339), | |
| 191 | 325 | "intermediate_ttl": remaining.String(), | |
| 192 | 326 | }) | |
| 193 | 327 | } | |
| 194 | 328 | ||
| 195 | - | if err := cliWriteLine(out, cliRenderMeta("Root CA:", ca.RootCommonName())); err != nil { | |
| 329 | + | if err := cliWriteLine(out, cliRenderMeta("Root CA:", details.rootCN)); err != nil { | |
| 196 | 330 | return err | |
| 197 | 331 | } | |
| 198 | - | if err := cliWriteLine(out, cliRenderMeta("Fingerprint:", "SHA256:"+ca.RootFingerprint())); err != nil { | |
| 332 | + | if err := cliWriteLine(out, cliRenderMeta("Fingerprint:", "SHA256:"+details.fingerprint)); err != nil { | |
| 199 | 333 | return err | |
| 200 | 334 | } | |
| 201 | 335 | return cliWriteLine(out, cliRenderMeta("Intermediate:", fmt.Sprintf("expires in %s (auto-renews)", remaining))) | |
| @@ -4,10 +4,12 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "crypto/x509" | 5 | "crypto/x509" | ||
| 6 | "encoding/pem" | 6 | "encoding/pem" | ||
| 7 | + | "errors" | |||
| 7 | "fmt" | 8 | "fmt" | ||
| 8 | "io" | 9 | "io" | ||
| 9 | "os" | 10 | "os" | ||
| 10 | "path/filepath" | 11 | "path/filepath" | ||
| 12 | + | "strings" | |||
| 11 | "time" | 13 | "time" | ||
| 12 | 14 | ||||
| 13 | "github.com/spf13/cobra" | 15 | "github.com/spf13/cobra" | ||
| @@ -29,19 +31,48 @@ func newCACmd() *cobra.Command { | |||||
| 29 | return cmd | 31 | return cmd | ||
| 30 | } | 32 | } | ||
| 31 | 33 | ||||
| 34 | + | // caResolveRemote allows tests to override remote control-plane resolution. | |||
| 35 | + | // Tests that swap it must not run in parallel. It returns a nil ControlPlane when no remote is targeted. | |||
| 36 | + | var caResolveRemote = func() (ControlPlane, error) { | |||
| 37 | + | client, isRemote, err := GetRemoteClient() | |||
| 38 | + | if err != nil || !isRemote { | |||
| 39 | + | return nil, err | |||
| 40 | + | } | |||
| 41 | + | return client, nil | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // caDetails is the CA data the ca commands operate on, whether it was read | |||
| 45 | + | // from the local data directory or fetched from a remote Gordon. | |||
| 46 | + | type caDetails struct { | |||
| 47 | + | rootPEM []byte | |||
| 48 | + | rootCN string | |||
| 49 | + | fingerprint string | |||
| 50 | + | intermediateExpiry time.Time | |||
| 51 | + | // remote is true when the CA was fetched over the network. | |||
| 52 | + | remote bool | |||
| 53 | + | } | |||
| 54 | + | ||||
| 32 | func newCAExportCmd() *cobra.Command { | 55 | func newCAExportCmd() *cobra.Command { | ||
| 33 | var outPath string | 56 | var outPath string | ||
| 34 | 57 | ||||
| 35 | cmd := &cobra.Command{ | 58 | cmd := &cobra.Command{ | ||
| 36 | Use: "export", | 59 | Use: "export", | ||
| 37 | Short: "Export the root CA certificate", | 60 | Short: "Export the root CA certificate", | ||
| 38 | - | Long: "Export Gordon's root CA certificate in PEM format for manual trust installation.", | 61 | + | Long: "Export Gordon's root CA certificate in PEM format for manual trust installation. When a remote is targeted (--remote, GORDON_REMOTE, or the active remote), the certificate is fetched from that Gordon.", |
| 39 | RunE: func(cmd *cobra.Command, _ []string) error { | 62 | RunE: func(cmd *cobra.Command, _ []string) error { | ||
| 40 | - | dataDir, err := resolveCADataDir() | 63 | + | details, err := loadCADetails(cmd.Context()) |
| 41 | if err != nil { | 64 | if err != nil { | ||
| 42 | return err | 65 | return err | ||
| 43 | } | 66 | } | ||
| 44 | - | return runCAExport(cmd.Context(), cmd.OutOrStdout(), dataDir, outPath) | 67 | + | if details.remote { |
| 68 | + | // The checks prove a well-formed CA, not its origin: over an | |||
| 69 | + | // insecure or plain-http remote the response can be swapped. | |||
| 70 | + | if err := cliWriteLine(cmd.ErrOrStderr(), cliRenderWarning(fmt.Sprintf( | |||
| 71 | + | "Before trusting this CA, check that fingerprint %s matches `gordon ca info` on the Gordon host", details.fingerprint))); err != nil { | |||
| 72 | + | return err | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | return runCAExport(cmd.OutOrStdout(), details, outPath) | |||
| 45 | }, | 76 | }, | ||
| 46 | } | 77 | } | ||
| 47 | 78 | ||||
| @@ -59,9 +90,12 @@ func newCAInstallCmd() *cobra.Command { | |||||
| 59 | cmd := &cobra.Command{ | 90 | cmd := &cobra.Command{ | ||
| 60 | Use: "install", | 91 | Use: "install", | ||
| 61 | Short: "Install/uninstall the root CA in the system trust store", | 92 | Short: "Install/uninstall the root CA in the system trust store", | ||
| 62 | - | Long: "Install Gordon's root CA certificate into the system, Firefox, and Java trust stores. Requires running as root.", | 93 | + | Long: "Install Gordon's root CA certificate into the system, Firefox, and Java trust stores. Requires running as root on the Gordon host.", |
| 63 | RunE: func(cmd *cobra.Command, _ []string) error { | 94 | RunE: func(cmd *cobra.Command, _ []string) error { | ||
| 64 | - | dataDir, err := resolveCADataDir() | 95 | + | if err := rejectRemoteForCAInstall(); err != nil { |
| 96 | + | return err | |||
| 97 | + | } | |||
| 98 | + | dataDir, err := resolveCADataDir(false) | |||
| 65 | if err != nil { | 99 | if err != nil { | ||
| 66 | return err | 100 | return err | ||
| 67 | } | 101 | } | ||
| @@ -75,6 +109,24 @@ func newCAInstallCmd() *cobra.Command { | |||||
| 75 | return cmd | 109 | return cmd | ||
| 76 | } | 110 | } | ||
| 77 | 111 | ||||
| 112 | + | // rejectRemoteForCAInstall refuses to run install/uninstall while a remote is | |||
| 113 | + | // targeted: trusting a network-fetched root is unsafe (insecure or plain-http | |||
| 114 | + | // remotes), sudo drops the user's remotes config, and uninstall would depend | |||
| 115 | + | // on the remote still being reachable. | |||
| 116 | + | func rejectRemoteForCAInstall() error { | |||
| 117 | + | cp, err := caResolveRemote() | |||
| 118 | + | if err != nil { | |||
| 119 | + | return err | |||
| 120 | + | } | |||
| 121 | + | if cp != nil { | |||
| 122 | + | return errCAInstallRemote | |||
| 123 | + | } | |||
| 124 | + | return nil | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | var errCAInstallRemote = errors.New("ca install only works on the Gordon host; from another machine run " + | |||
| 128 | + | "`gordon ca export --remote <name> --out gordon-ca.pem` and install that file with your OS trust tools") | |||
| 129 | + | ||||
| 78 | func newCAInfoCmd() *cobra.Command { | 130 | func newCAInfoCmd() *cobra.Command { | ||
| 79 | var jsonOut bool | 131 | var jsonOut bool | ||
| 80 | 132 | ||||
| @@ -82,11 +134,11 @@ func newCAInfoCmd() *cobra.Command { | |||||
| 82 | Use: "info", | 134 | Use: "info", | ||
| 83 | Short: "Show CA status information", | 135 | Short: "Show CA status information", | ||
| 84 | RunE: func(cmd *cobra.Command, _ []string) error { | 136 | RunE: func(cmd *cobra.Command, _ []string) error { | ||
| 85 | - | dataDir, err := resolveCADataDir() | 137 | + | details, err := loadCADetails(cmd.Context()) |
| 86 | if err != nil { | 138 | if err != nil { | ||
| 87 | return err | 139 | return err | ||
| 88 | } | 140 | } | ||
| 89 | - | return runCAInfo(cmd.Context(), cmd.OutOrStdout(), dataDir, jsonOut) | 141 | + | return runCAInfo(cmd.OutOrStdout(), details, jsonOut) |
| 90 | }, | 142 | }, | ||
| 91 | } | 143 | } | ||
| 92 | 144 | ||||
| @@ -95,17 +147,99 @@ func newCAInfoCmd() *cobra.Command { | |||||
| 95 | return cmd | 147 | return cmd | ||
| 96 | } | 148 | } | ||
| 97 | 149 | ||||
| 98 | - | func resolveCADataDir() (string, error) { | 150 | + | // loadCADetails reads the CA from the remote Gordon when --remote targets one, |
| 151 | + | // otherwise from the local server config and data directory. | |||
| 152 | + | func loadCADetails(ctx context.Context) (*caDetails, error) { | |||
| 153 | + | cp, err := caResolveRemote() | |||
| 154 | + | if err != nil { | |||
| 155 | + | return nil, err | |||
| 156 | + | } | |||
| 157 | + | if cp != nil { | |||
| 158 | + | return fetchRemoteCADetails(ctx, cp) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | dataDir, err := resolveCADataDir(true) | |||
| 162 | + | if err != nil { | |||
| 163 | + | return nil, err | |||
| 164 | + | } | |||
| 165 | + | return loadLocalCADetails(dataDir) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // fetchRemoteCADetails fetches the CA from the remote and verifies it locally: | |||
| 169 | + | // the PEM must hold exactly one self-signed CA certificate, and the CN and | |||
| 170 | + | // fingerprint are computed from it rather than trusted from the response. | |||
| 171 | + | func fetchRemoteCADetails(ctx context.Context, cp ControlPlane) (*caDetails, error) { | |||
| 172 | + | resp, err := cp.GetCA(ctx) | |||
| 173 | + | if err != nil { | |||
| 174 | + | return nil, fmt.Errorf("failed to get CA from remote: %w", err) | |||
| 175 | + | } | |||
| 176 | + | if resp == nil || resp.RootPEM == "" { | |||
| 177 | + | return nil, fmt.Errorf("failed to get CA from remote: empty response") | |||
| 178 | + | } | |||
| 179 | + | cert, err := parseRemoteRootCA([]byte(resp.RootPEM)) | |||
| 180 | + | if err != nil { | |||
| 181 | + | return nil, fmt.Errorf("remote returned an invalid root CA: %w", err) | |||
| 182 | + | } | |||
| 183 | + | fingerprint := pkiadapter.CertFingerprint(cert) | |||
| 184 | + | if resp.Fingerprint != "" && !strings.EqualFold(resp.Fingerprint, fingerprint) { | |||
| 185 | + | return nil, fmt.Errorf("remote returned an invalid root CA: reported fingerprint %s does not match certificate fingerprint %s", | |||
| 186 | + | resp.Fingerprint, fingerprint) | |||
| 187 | + | } | |||
| 188 | + | return &caDetails{ | |||
| 189 | + | rootPEM: []byte(resp.RootPEM), | |||
| 190 | + | rootCN: cert.Subject.CommonName, | |||
| 191 | + | fingerprint: fingerprint, | |||
| 192 | + | intermediateExpiry: resp.IntermediateExpiry, | |||
| 193 | + | remote: true, | |||
| 194 | + | }, nil | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func parseRemoteRootCA(rootPEM []byte) (*x509.Certificate, error) { | |||
| 198 | + | block, rest := pem.Decode(rootPEM) | |||
| 199 | + | if block == nil || block.Type != "CERTIFICATE" { | |||
| 200 | + | return nil, errors.New("no CERTIFICATE PEM block found") | |||
| 201 | + | } | |||
| 202 | + | if extra, _ := pem.Decode(rest); extra != nil { | |||
| 203 | + | return nil, errors.New("expected exactly one certificate, found more") | |||
| 204 | + | } | |||
| 205 | + | cert, err := x509.ParseCertificate(block.Bytes) | |||
| 206 | + | if err != nil { | |||
| 207 | + | return nil, fmt.Errorf("parse certificate: %w", err) | |||
| 208 | + | } | |||
| 209 | + | if !cert.BasicConstraintsValid || !cert.IsCA { | |||
| 210 | + | return nil, errors.New("certificate is not a CA") | |||
| 211 | + | } | |||
| 212 | + | if err := cert.CheckSignatureFrom(cert); err != nil { | |||
| 213 | + | return nil, fmt.Errorf("certificate is not validly self-signed: %w", err) | |||
| 214 | + | } | |||
| 215 | + | return cert, nil | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // resolveCADataDir returns the local data directory. remoteHint adds the | |||
| 219 | + | // --remote suggestion for commands that support remotes. | |||
| 220 | + | func resolveCADataDir(remoteHint bool) (string, error) { | |||
| 99 | local, err := GetLocalServices(cliConfigPath) | 221 | local, err := GetLocalServices(cliConfigPath) | ||
| 100 | if err != nil { | 222 | if err != nil { | ||
| 101 | return "", err | 223 | return "", err | ||
| 102 | } | 224 | } | ||
| 103 | if !local.HasInternalTLS() { | 225 | if !local.HasInternalTLS() { | ||
| 104 | - | return "", fmt.Errorf("internal TLS is disabled (no TLS-capable entrypoint configured); CA commands are not available — configure a smart_tcp or tls_mux entrypoint") | 226 | + | return "", errLocalInternalTLSDisabled(local.GetConfigFile(), remoteHint) |
| 105 | } | 227 | } | ||
| 106 | return local.GetDataDir(), nil | 228 | return local.GetDataDir(), nil | ||
| 107 | } | 229 | } | ||
| 108 | 230 | ||||
| 231 | + | func errLocalInternalTLSDisabled(configFile string, remoteHint bool) error { | |||
| 232 | + | source := "no local server config file was found (searched /etc/gordon, ~/.config/gordon and the current directory)" | |||
| 233 | + | if configFile != "" { | |||
| 234 | + | source = fmt.Sprintf("local server config %s was read", configFile) | |||
| 235 | + | } | |||
| 236 | + | hint := "" | |||
| 237 | + | if remoteHint { | |||
| 238 | + | hint = "; to use the CA of a remote Gordon, pass --remote <name|url>" | |||
| 239 | + | } | |||
| 240 | + | return fmt.Errorf("internal TLS is disabled: %s and it has no TLS-capable entrypoint (smart_tcp or tls_mux)%s", source, hint) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 109 | // loadCAFromDataDir creates a CA adapter directly (bypassing the usecase layer). | 243 | // loadCAFromDataDir creates a CA adapter directly (bypassing the usecase layer). | ||
| 110 | // This is intentional: CLI commands like 'ca export' and 'ca info' are standalone | 244 | // This is intentional: CLI commands like 'ca export' and 'ca info' are standalone | ||
| 111 | // admin utilities that run outside the server lifecycle and don't need business logic. | 245 | // admin utilities that run outside the server lifecycle and don't need business logic. | ||
| @@ -117,25 +251,31 @@ func loadCAFromDataDir(dataDir string) (boundaryout.CertificateAuthority, error) | |||||
| 117 | return ca, nil | 251 | return ca, nil | ||
| 118 | } | 252 | } | ||
| 119 | 253 | ||||
| 120 | - | func runCAExport(_ context.Context, out io.Writer, dataDir, outPath string) error { | 254 | + | func loadLocalCADetails(dataDir string) (*caDetails, error) { |
| 121 | ca, err := loadCAFromDataDir(dataDir) | 255 | ca, err := loadCAFromDataDir(dataDir) | ||
| 122 | if err != nil { | 256 | if err != nil { | ||
| 123 | - | return err | 257 | + | return nil, err |
| 124 | } | 258 | } | ||
| 259 | + | return &caDetails{ | |||
| 260 | + | rootPEM: ca.RootCertificate(), | |||
| 261 | + | rootCN: ca.RootCommonName(), | |||
| 262 | + | fingerprint: ca.RootFingerprint(), | |||
| 263 | + | intermediateExpiry: ca.IntermediateExpiresAt(), | |||
| 264 | + | }, nil | |||
| 265 | + | } | |||
| 125 | 266 | ||||
| 126 | - | rootPEM := ca.RootCertificate() | 267 | + | func runCAExport(out io.Writer, details *caDetails, outPath string) error { |
| 127 | - | ||||
| 128 | if outPath != "" { | 268 | if outPath != "" { | ||
| 129 | if err := os.MkdirAll(filepath.Dir(outPath), 0750); err != nil { | 269 | if err := os.MkdirAll(filepath.Dir(outPath), 0750); err != nil { | ||
| 130 | return fmt.Errorf("create directory for %s: %w", outPath, err) | 270 | return fmt.Errorf("create directory for %s: %w", outPath, err) | ||
| 131 | } | 271 | } | ||
| 132 | - | if err := os.WriteFile(outPath, rootPEM, 0600); err != nil { | 272 | + | if err := os.WriteFile(outPath, details.rootPEM, 0600); err != nil { |
| 133 | return fmt.Errorf("write certificate to %s: %w", outPath, err) | 273 | return fmt.Errorf("write certificate to %s: %w", outPath, err) | ||
| 134 | } | 274 | } | ||
| 135 | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Root CA certificate written to %s", outPath))) | 275 | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Root CA certificate written to %s", outPath))) | ||
| 136 | } | 276 | } | ||
| 137 | 277 | ||||
| 138 | - | _, err = out.Write(rootPEM) | 278 | + | _, err := out.Write(details.rootPEM) |
| 139 | return err | 279 | return err | ||
| 140 | } | 280 | } | ||
| 141 | 281 | ||||
| @@ -174,28 +314,22 @@ func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, j | |||||
| 174 | return cliWriteLine(out, cliRenderSuccess("Root CA installed in system trust stores (system, Firefox, Java)")) | 314 | return cliWriteLine(out, cliRenderSuccess("Root CA installed in system trust stores (system, Firefox, Java)")) | ||
| 175 | } | 315 | } | ||
| 176 | 316 | ||||
| 177 | - | func runCAInfo(_ context.Context, out io.Writer, dataDir string, jsonOut bool) error { | 317 | + | func runCAInfo(out io.Writer, details *caDetails, jsonOut bool) error { |
| 178 | - | ca, err := loadCAFromDataDir(dataDir) | 318 | + | remaining := time.Until(details.intermediateExpiry).Truncate(time.Minute) |
| 179 | - | if err != nil { | |||
| 180 | - | return err | |||
| 181 | - | } | |||
| 182 | - | ||||
| 183 | - | interExpiry := ca.IntermediateExpiresAt() | |||
| 184 | - | remaining := time.Until(interExpiry).Truncate(time.Minute) | |||
| 185 | 319 | ||||
| 186 | if jsonOut { | 320 | if jsonOut { | ||
| 187 | return writeJSON(out, map[string]any{ | 321 | return writeJSON(out, map[string]any{ | ||
| 188 | - | "root_cn": ca.RootCommonName(), | 322 | + | "root_cn": details.rootCN, |
| 189 | - | "fingerprint": "SHA256:" + ca.RootFingerprint(), | 323 | + | "fingerprint": "SHA256:" + details.fingerprint, |
| 190 | - | "intermediate_expiry": interExpiry.Format(time.RFC3339), | 324 | + | "intermediate_expiry": details.intermediateExpiry.Format(time.RFC3339), |
| 191 | "intermediate_ttl": remaining.String(), | 325 | "intermediate_ttl": remaining.String(), | ||
| 192 | }) | 326 | }) | ||
| 193 | } | 327 | } | ||
| 194 | 328 | ||||
| 195 | - | if err := cliWriteLine(out, cliRenderMeta("Root CA:", ca.RootCommonName())); err != nil { | 329 | + | if err := cliWriteLine(out, cliRenderMeta("Root CA:", details.rootCN)); err != nil { |
| 196 | return err | 330 | return err | ||
| 197 | } | 331 | } | ||
| 198 | - | if err := cliWriteLine(out, cliRenderMeta("Fingerprint:", "SHA256:"+ca.RootFingerprint())); err != nil { | 332 | + | if err := cliWriteLine(out, cliRenderMeta("Fingerprint:", "SHA256:"+details.fingerprint)); err != nil { |
| 199 | return err | 333 | return err | ||
| 200 | } | 334 | } | ||
| 201 | return cliWriteLine(out, cliRenderMeta("Intermediate:", fmt.Sprintf("expires in %s (auto-renews)", remaining))) | 335 | return cliWriteLine(out, cliRenderMeta("Intermediate:", fmt.Sprintf("expires in %s (auto-renews)", remaining))) | ||
A internal/adapters/in/cli/ca_test.go +264 -0
| @@ -0,0 +1,264 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "crypto/ecdsa" | |
| 7 | + | "crypto/elliptic" | |
| 8 | + | "crypto/rand" | |
| 9 | + | "crypto/x509" | |
| 10 | + | "crypto/x509/pkix" | |
| 11 | + | "encoding/pem" | |
| 12 | + | "errors" | |
| 13 | + | "math/big" | |
| 14 | + | "os" | |
| 15 | + | "path/filepath" | |
| 16 | + | "testing" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "github.com/stretchr/testify/assert" | |
| 20 | + | "github.com/stretchr/testify/require" | |
| 21 | + | ||
| 22 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 23 | + | "github.com/bnema/gordon/internal/adapters/in/cli/mocks" | |
| 24 | + | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | func testCertPEM(t *testing.T, isCA bool) (pemStr, fingerprint string) { | |
| 28 | + | t.Helper() | |
| 29 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | tmpl := &x509.Certificate{ | |
| 32 | + | SerialNumber: big.NewInt(1), | |
| 33 | + | Subject: pkix.Name{CommonName: "Remote Root"}, | |
| 34 | + | NotBefore: time.Now().Add(-time.Hour), | |
| 35 | + | NotAfter: time.Now().Add(time.Hour), | |
| 36 | + | IsCA: isCA, | |
| 37 | + | BasicConstraintsValid: true, | |
| 38 | + | } | |
| 39 | + | der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | cert, err := x509.ParseCertificate(der) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), pkiadapter.CertFingerprint(cert) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func testRootPEM(t *testing.T) string { | |
| 47 | + | t.Helper() | |
| 48 | + | p, _ := testCertPEM(t, true) | |
| 49 | + | return p | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // stubCARemote swaps the package-level caResolveRemote. Tests using it must | |
| 53 | + | // not run in parallel. | |
| 54 | + | func stubCARemote(t *testing.T, cp ControlPlane) { | |
| 55 | + | t.Helper() | |
| 56 | + | prev := caResolveRemote | |
| 57 | + | caResolveRemote = func() (ControlPlane, error) { return cp, nil } | |
| 58 | + | t.Cleanup(func() { caResolveRemote = prev }) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestCAExportRemoteUsesControlPlane(t *testing.T) { | |
| 62 | + | rootPEM := testRootPEM(t) | |
| 63 | + | cp := mocks.NewMockControlPlane(t) | |
| 64 | + | cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{RootPEM: rootPEM, RootCN: "Remote Root"}, nil).Once() | |
| 65 | + | stubCARemote(t, cp) | |
| 66 | + | ||
| 67 | + | cmd := newCAExportCmd() | |
| 68 | + | var buf, errBuf bytes.Buffer | |
| 69 | + | cmd.SetOut(&buf) | |
| 70 | + | cmd.SetErr(&errBuf) | |
| 71 | + | cmd.SetContext(context.Background()) | |
| 72 | + | require.NoError(t, cmd.RunE(cmd, nil)) | |
| 73 | + | ||
| 74 | + | assert.Equal(t, rootPEM, buf.String(), "stdout carries only the PEM") | |
| 75 | + | assert.Contains(t, errBuf.String(), "fingerprint", "stderr asks to verify the fingerprint out of band") | |
| 76 | + | assert.Contains(t, errBuf.String(), "gordon ca info") | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestCAExportRemoteWritesFile(t *testing.T) { | |
| 80 | + | rootPEM := testRootPEM(t) | |
| 81 | + | cp := mocks.NewMockControlPlane(t) | |
| 82 | + | cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{RootPEM: rootPEM}, nil).Once() | |
| 83 | + | stubCARemote(t, cp) | |
| 84 | + | ||
| 85 | + | outPath := filepath.Join(t.TempDir(), "sub", "ca.pem") | |
| 86 | + | cmd := newCAExportCmd() | |
| 87 | + | require.NoError(t, cmd.Flags().Set("out", outPath)) | |
| 88 | + | var buf bytes.Buffer | |
| 89 | + | cmd.SetOut(&buf) | |
| 90 | + | cmd.SetContext(context.Background()) | |
| 91 | + | require.NoError(t, cmd.RunE(cmd, nil)) | |
| 92 | + | ||
| 93 | + | data, err := os.ReadFile(outPath) | |
| 94 | + | require.NoError(t, err) | |
| 95 | + | assert.Equal(t, rootPEM, string(data)) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestCAInfoRemoteUsesControlPlane(t *testing.T) { | |
| 99 | + | expiry := time.Now().Add(48 * time.Hour).UTC() | |
| 100 | + | rootPEM, fp := testCertPEM(t, true) | |
| 101 | + | cp := mocks.NewMockControlPlane(t) | |
| 102 | + | cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{ | |
| 103 | + | RootPEM: rootPEM, RootCN: "ignored", Fingerprint: fp, IntermediateExpiry: expiry, | |
| 104 | + | }, nil).Once() | |
| 105 | + | stubCARemote(t, cp) | |
| 106 | + | ||
| 107 | + | cmd := newCAInfoCmd() | |
| 108 | + | require.NoError(t, cmd.Flags().Set("json", "true")) | |
| 109 | + | var buf bytes.Buffer | |
| 110 | + | cmd.SetOut(&buf) | |
| 111 | + | cmd.SetContext(context.Background()) | |
| 112 | + | require.NoError(t, cmd.RunE(cmd, nil)) | |
| 113 | + | ||
| 114 | + | assert.Contains(t, buf.String(), `"root_cn": "Remote Root"`) | |
| 115 | + | assert.Contains(t, buf.String(), `"fingerprint": "SHA256:`+fp+`"`) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestCAInfoRemoteText(t *testing.T) { | |
| 119 | + | rootPEM, fp := testCertPEM(t, true) | |
| 120 | + | cp := mocks.NewMockControlPlane(t) | |
| 121 | + | cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{ | |
| 122 | + | RootPEM: rootPEM, Fingerprint: fp, IntermediateExpiry: time.Now().Add(time.Hour), | |
| 123 | + | }, nil).Once() | |
| 124 | + | stubCARemote(t, cp) | |
| 125 | + | ||
| 126 | + | cmd := newCAInfoCmd() | |
| 127 | + | var buf bytes.Buffer | |
| 128 | + | cmd.SetOut(&buf) | |
| 129 | + | cmd.SetContext(context.Background()) | |
| 130 | + | require.NoError(t, cmd.RunE(cmd, nil)) | |
| 131 | + | ||
| 132 | + | assert.Contains(t, buf.String(), "Remote Root") | |
| 133 | + | assert.Contains(t, buf.String(), "SHA256:"+fp) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestCARemoteErrorIsWrapped(t *testing.T) { | |
| 137 | + | cp := mocks.NewMockControlPlane(t) | |
| 138 | + | cp.EXPECT().GetCA(context.Background()).Return(nil, errors.New("internal TLS is disabled on this server")).Once() | |
| 139 | + | stubCARemote(t, cp) | |
| 140 | + | ||
| 141 | + | cmd := newCAInfoCmd() | |
| 142 | + | cmd.SetContext(context.Background()) | |
| 143 | + | err := cmd.RunE(cmd, nil) | |
| 144 | + | require.Error(t, err) | |
| 145 | + | assert.Contains(t, err.Error(), "failed to get CA from remote") | |
| 146 | + | assert.Contains(t, err.Error(), "internal TLS is disabled on this server") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestCAInstallRejectsRemote(t *testing.T) { | |
| 150 | + | // No GetCA expectation: the mock fails the test if the remote is contacted. | |
| 151 | + | stubCARemote(t, mocks.NewMockControlPlane(t)) | |
| 152 | + | ||
| 153 | + | for _, uninstall := range []bool{false, true} { | |
| 154 | + | cmd := newCAInstallCmd() | |
| 155 | + | if uninstall { | |
| 156 | + | require.NoError(t, cmd.Flags().Set("uninstall", "true")) | |
| 157 | + | } | |
| 158 | + | cmd.SetContext(context.Background()) | |
| 159 | + | err := cmd.RunE(cmd, nil) | |
| 160 | + | require.Error(t, err) | |
| 161 | + | assert.Contains(t, err.Error(), "ca install only works on the Gordon host") | |
| 162 | + | assert.Contains(t, err.Error(), "gordon ca export --remote <name> --out gordon-ca.pem") | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestCARemoteRejectsInvalidRoot(t *testing.T) { | |
| 167 | + | nonCAPEM, nonCAFP := testCertPEM(t, false) | |
| 168 | + | goodPEM, _ := testCertPEM(t, true) | |
| 169 | + | twoPEM := goodPEM + goodPEM | |
| 170 | + | ||
| 171 | + | tests := []struct { | |
| 172 | + | name string | |
| 173 | + | resp dto.CAResponse | |
| 174 | + | wantErr string | |
| 175 | + | }{ | |
| 176 | + | {name: "invalid pem", resp: dto.CAResponse{RootPEM: "not a pem"}, wantErr: "no CERTIFICATE PEM block"}, | |
| 177 | + | {name: "garbage der", resp: dto.CAResponse{RootPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("junk")}))}, wantErr: "parse certificate"}, | |
| 178 | + | {name: "wrong block type", resp: dto.CAResponse{RootPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: []byte("x")}))}, wantErr: "no CERTIFICATE PEM block"}, | |
| 179 | + | {name: "multiple certs", resp: dto.CAResponse{RootPEM: twoPEM}, wantErr: "exactly one certificate"}, | |
| 180 | + | {name: "not a CA", resp: dto.CAResponse{RootPEM: nonCAPEM, Fingerprint: nonCAFP}, wantErr: "not a CA"}, | |
| 181 | + | {name: "fingerprint mismatch", resp: dto.CAResponse{RootPEM: goodPEM, Fingerprint: "AA:BB"}, wantErr: "does not match"}, | |
| 182 | + | } | |
| 183 | + | for _, tt := range tests { | |
| 184 | + | t.Run(tt.name, func(t *testing.T) { | |
| 185 | + | resp := tt.resp | |
| 186 | + | cp := mocks.NewMockControlPlane(t) | |
| 187 | + | cp.EXPECT().GetCA(context.Background()).Return(&resp, nil).Once() | |
| 188 | + | ||
| 189 | + | _, err := fetchRemoteCADetails(context.Background(), cp) | |
| 190 | + | require.Error(t, err) | |
| 191 | + | assert.Contains(t, err.Error(), "invalid root CA") | |
| 192 | + | assert.Contains(t, err.Error(), tt.wantErr) | |
| 193 | + | }) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestCARemoteRejectsNotSelfSigned(t *testing.T) { | |
| 198 | + | caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 199 | + | require.NoError(t, err) | |
| 200 | + | caTmpl := &x509.Certificate{ | |
| 201 | + | SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Issuer"}, | |
| 202 | + | NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), | |
| 203 | + | IsCA: true, BasicConstraintsValid: true, | |
| 204 | + | } | |
| 205 | + | otherKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 206 | + | require.NoError(t, err) | |
| 207 | + | // Subject claims CA, but it is signed by a different key than its own. | |
| 208 | + | subTmpl := &x509.Certificate{ | |
| 209 | + | SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "Sub"}, | |
| 210 | + | NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), | |
| 211 | + | IsCA: true, BasicConstraintsValid: true, | |
| 212 | + | } | |
| 213 | + | der, err := x509.CreateCertificate(rand.Reader, subTmpl, caTmpl, &otherKey.PublicKey, caKey) | |
| 214 | + | require.NoError(t, err) | |
| 215 | + | ||
| 216 | + | cp := mocks.NewMockControlPlane(t) | |
| 217 | + | cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{ | |
| 218 | + | RootPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), | |
| 219 | + | }, nil).Once() | |
| 220 | + | ||
| 221 | + | _, err = fetchRemoteCADetails(context.Background(), cp) | |
| 222 | + | require.Error(t, err) | |
| 223 | + | assert.Contains(t, err.Error(), "not validly self-signed") | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func TestCARemoteComputesFingerprintAndCNLocally(t *testing.T) { | |
| 227 | + | rootPEM, fp := testCertPEM(t, true) | |
| 228 | + | cp := mocks.NewMockControlPlane(t) | |
| 229 | + | // Server omits fingerprint and CN; both are derived from the certificate. | |
| 230 | + | cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{RootPEM: rootPEM}, nil).Once() | |
| 231 | + | ||
| 232 | + | details, err := fetchRemoteCADetails(context.Background(), cp) | |
| 233 | + | require.NoError(t, err) | |
| 234 | + | assert.Equal(t, "Remote Root", details.rootCN) | |
| 235 | + | assert.Equal(t, fp, details.fingerprint) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func TestCALocalErrorMentionsConfigAndRemote(t *testing.T) { | |
| 239 | + | stubCARemote(t, nil) | |
| 240 | + | ||
| 241 | + | cfg := filepath.Join(t.TempDir(), "gordon.toml") | |
| 242 | + | require.NoError(t, os.WriteFile(cfg, []byte("[server]\nport = 8088\n"), 0600)) | |
| 243 | + | // cliConfigPath is a package var: this test must not run in parallel. | |
| 244 | + | prev := cliConfigPath | |
| 245 | + | cliConfigPath = cfg | |
| 246 | + | t.Cleanup(func() { cliConfigPath = prev }) | |
| 247 | + | ||
| 248 | + | cmd := newCAExportCmd() | |
| 249 | + | cmd.SetContext(context.Background()) | |
| 250 | + | err := cmd.RunE(cmd, nil) | |
| 251 | + | require.Error(t, err) | |
| 252 | + | assert.Contains(t, err.Error(), "internal TLS is disabled") | |
| 253 | + | assert.Contains(t, err.Error(), cfg) | |
| 254 | + | assert.Contains(t, err.Error(), "--remote") | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func TestErrLocalInternalTLSDisabledWithoutConfigFile(t *testing.T) { | |
| 258 | + | err := errLocalInternalTLSDisabled("", true) | |
| 259 | + | assert.Contains(t, err.Error(), "no local server config file was found") | |
| 260 | + | assert.Contains(t, err.Error(), "--remote") | |
| 261 | + | ||
| 262 | + | // install is local-only, so it must not suggest --remote. | |
| 263 | + | assert.NotContains(t, errLocalInternalTLSDisabled("", false).Error(), "--remote") | |
| 264 | + | } | |
| @@ -0,0 +1,264 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "crypto/ecdsa" | |||
| 7 | + | "crypto/elliptic" | |||
| 8 | + | "crypto/rand" | |||
| 9 | + | "crypto/x509" | |||
| 10 | + | "crypto/x509/pkix" | |||
| 11 | + | "encoding/pem" | |||
| 12 | + | "errors" | |||
| 13 | + | "math/big" | |||
| 14 | + | "os" | |||
| 15 | + | "path/filepath" | |||
| 16 | + | "testing" | |||
| 17 | + | "time" | |||
| 18 | + | ||||
| 19 | + | "github.com/stretchr/testify/assert" | |||
| 20 | + | "github.com/stretchr/testify/require" | |||
| 21 | + | ||||
| 22 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 23 | + | "github.com/bnema/gordon/internal/adapters/in/cli/mocks" | |||
| 24 | + | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | func testCertPEM(t *testing.T, isCA bool) (pemStr, fingerprint string) { | |||
| 28 | + | t.Helper() | |||
| 29 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |||
| 30 | + | require.NoError(t, err) | |||
| 31 | + | tmpl := &x509.Certificate{ | |||
| 32 | + | SerialNumber: big.NewInt(1), | |||
| 33 | + | Subject: pkix.Name{CommonName: "Remote Root"}, | |||
| 34 | + | NotBefore: time.Now().Add(-time.Hour), | |||
| 35 | + | NotAfter: time.Now().Add(time.Hour), | |||
| 36 | + | IsCA: isCA, | |||
| 37 | + | BasicConstraintsValid: true, | |||
| 38 | + | } | |||
| 39 | + | der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | cert, err := x509.ParseCertificate(der) | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), pkiadapter.CertFingerprint(cert) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func testRootPEM(t *testing.T) string { | |||
| 47 | + | t.Helper() | |||
| 48 | + | p, _ := testCertPEM(t, true) | |||
| 49 | + | return p | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // stubCARemote swaps the package-level caResolveRemote. Tests using it must | |||
| 53 | + | // not run in parallel. | |||
| 54 | + | func stubCARemote(t *testing.T, cp ControlPlane) { | |||
| 55 | + | t.Helper() | |||
| 56 | + | prev := caResolveRemote | |||
| 57 | + | caResolveRemote = func() (ControlPlane, error) { return cp, nil } | |||
| 58 | + | t.Cleanup(func() { caResolveRemote = prev }) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestCAExportRemoteUsesControlPlane(t *testing.T) { | |||
| 62 | + | rootPEM := testRootPEM(t) | |||
| 63 | + | cp := mocks.NewMockControlPlane(t) | |||
| 64 | + | cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{RootPEM: rootPEM, RootCN: "Remote Root"}, nil).Once() | |||
| 65 | + | stubCARemote(t, cp) | |||
| 66 | + | ||||
| 67 | + | cmd := newCAExportCmd() | |||
| 68 | + | var buf, errBuf bytes.Buffer | |||
| 69 | + | cmd.SetOut(&buf) | |||
| 70 | + | cmd.SetErr(&errBuf) | |||
| 71 | + | cmd.SetContext(context.Background()) | |||
| 72 | + | require.NoError(t, cmd.RunE(cmd, nil)) | |||
| 73 | + | ||||
| 74 | + | assert.Equal(t, rootPEM, buf.String(), "stdout carries only the PEM") | |||
| 75 | + | assert.Contains(t, errBuf.String(), "fingerprint", "stderr asks to verify the fingerprint out of band") | |||
| 76 | + | assert.Contains(t, errBuf.String(), "gordon ca info") | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestCAExportRemoteWritesFile(t *testing.T) { | |||
| 80 | + | rootPEM := testRootPEM(t) | |||
| 81 | + | cp := mocks.NewMockControlPlane(t) | |||
| 82 | + | cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{RootPEM: rootPEM}, nil).Once() | |||
| 83 | + | stubCARemote(t, cp) | |||
| 84 | + | ||||
| 85 | + | outPath := filepath.Join(t.TempDir(), "sub", "ca.pem") | |||
| 86 | + | cmd := newCAExportCmd() | |||
| 87 | + | require.NoError(t, cmd.Flags().Set("out", outPath)) | |||
| 88 | + | var buf bytes.Buffer | |||
| 89 | + | cmd.SetOut(&buf) | |||
| 90 | + | cmd.SetContext(context.Background()) | |||
| 91 | + | require.NoError(t, cmd.RunE(cmd, nil)) | |||
| 92 | + | ||||
| 93 | + | data, err := os.ReadFile(outPath) | |||
| 94 | + | require.NoError(t, err) | |||
| 95 | + | assert.Equal(t, rootPEM, string(data)) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestCAInfoRemoteUsesControlPlane(t *testing.T) { | |||
| 99 | + | expiry := time.Now().Add(48 * time.Hour).UTC() | |||
| 100 | + | rootPEM, fp := testCertPEM(t, true) | |||
| 101 | + | cp := mocks.NewMockControlPlane(t) | |||
| 102 | + | cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{ | |||
| 103 | + | RootPEM: rootPEM, RootCN: "ignored", Fingerprint: fp, IntermediateExpiry: expiry, | |||
| 104 | + | }, nil).Once() | |||
| 105 | + | stubCARemote(t, cp) | |||
| 106 | + | ||||
| 107 | + | cmd := newCAInfoCmd() | |||
| 108 | + | require.NoError(t, cmd.Flags().Set("json", "true")) | |||
| 109 | + | var buf bytes.Buffer | |||
| 110 | + | cmd.SetOut(&buf) | |||
| 111 | + | cmd.SetContext(context.Background()) | |||
| 112 | + | require.NoError(t, cmd.RunE(cmd, nil)) | |||
| 113 | + | ||||
| 114 | + | assert.Contains(t, buf.String(), `"root_cn": "Remote Root"`) | |||
| 115 | + | assert.Contains(t, buf.String(), `"fingerprint": "SHA256:`+fp+`"`) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestCAInfoRemoteText(t *testing.T) { | |||
| 119 | + | rootPEM, fp := testCertPEM(t, true) | |||
| 120 | + | cp := mocks.NewMockControlPlane(t) | |||
| 121 | + | cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{ | |||
| 122 | + | RootPEM: rootPEM, Fingerprint: fp, IntermediateExpiry: time.Now().Add(time.Hour), | |||
| 123 | + | }, nil).Once() | |||
| 124 | + | stubCARemote(t, cp) | |||
| 125 | + | ||||
| 126 | + | cmd := newCAInfoCmd() | |||
| 127 | + | var buf bytes.Buffer | |||
| 128 | + | cmd.SetOut(&buf) | |||
| 129 | + | cmd.SetContext(context.Background()) | |||
| 130 | + | require.NoError(t, cmd.RunE(cmd, nil)) | |||
| 131 | + | ||||
| 132 | + | assert.Contains(t, buf.String(), "Remote Root") | |||
| 133 | + | assert.Contains(t, buf.String(), "SHA256:"+fp) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestCARemoteErrorIsWrapped(t *testing.T) { | |||
| 137 | + | cp := mocks.NewMockControlPlane(t) | |||
| 138 | + | cp.EXPECT().GetCA(context.Background()).Return(nil, errors.New("internal TLS is disabled on this server")).Once() | |||
| 139 | + | stubCARemote(t, cp) | |||
| 140 | + | ||||
| 141 | + | cmd := newCAInfoCmd() | |||
| 142 | + | cmd.SetContext(context.Background()) | |||
| 143 | + | err := cmd.RunE(cmd, nil) | |||
| 144 | + | require.Error(t, err) | |||
| 145 | + | assert.Contains(t, err.Error(), "failed to get CA from remote") | |||
| 146 | + | assert.Contains(t, err.Error(), "internal TLS is disabled on this server") | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestCAInstallRejectsRemote(t *testing.T) { | |||
| 150 | + | // No GetCA expectation: the mock fails the test if the remote is contacted. | |||
| 151 | + | stubCARemote(t, mocks.NewMockControlPlane(t)) | |||
| 152 | + | ||||
| 153 | + | for _, uninstall := range []bool{false, true} { | |||
| 154 | + | cmd := newCAInstallCmd() | |||
| 155 | + | if uninstall { | |||
| 156 | + | require.NoError(t, cmd.Flags().Set("uninstall", "true")) | |||
| 157 | + | } | |||
| 158 | + | cmd.SetContext(context.Background()) | |||
| 159 | + | err := cmd.RunE(cmd, nil) | |||
| 160 | + | require.Error(t, err) | |||
| 161 | + | assert.Contains(t, err.Error(), "ca install only works on the Gordon host") | |||
| 162 | + | assert.Contains(t, err.Error(), "gordon ca export --remote <name> --out gordon-ca.pem") | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func TestCARemoteRejectsInvalidRoot(t *testing.T) { | |||
| 167 | + | nonCAPEM, nonCAFP := testCertPEM(t, false) | |||
| 168 | + | goodPEM, _ := testCertPEM(t, true) | |||
| 169 | + | twoPEM := goodPEM + goodPEM | |||
| 170 | + | ||||
| 171 | + | tests := []struct { | |||
| 172 | + | name string | |||
| 173 | + | resp dto.CAResponse | |||
| 174 | + | wantErr string | |||
| 175 | + | }{ | |||
| 176 | + | {name: "invalid pem", resp: dto.CAResponse{RootPEM: "not a pem"}, wantErr: "no CERTIFICATE PEM block"}, | |||
| 177 | + | {name: "garbage der", resp: dto.CAResponse{RootPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("junk")}))}, wantErr: "parse certificate"}, | |||
| 178 | + | {name: "wrong block type", resp: dto.CAResponse{RootPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: []byte("x")}))}, wantErr: "no CERTIFICATE PEM block"}, | |||
| 179 | + | {name: "multiple certs", resp: dto.CAResponse{RootPEM: twoPEM}, wantErr: "exactly one certificate"}, | |||
| 180 | + | {name: "not a CA", resp: dto.CAResponse{RootPEM: nonCAPEM, Fingerprint: nonCAFP}, wantErr: "not a CA"}, | |||
| 181 | + | {name: "fingerprint mismatch", resp: dto.CAResponse{RootPEM: goodPEM, Fingerprint: "AA:BB"}, wantErr: "does not match"}, | |||
| 182 | + | } | |||
| 183 | + | for _, tt := range tests { | |||
| 184 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 185 | + | resp := tt.resp | |||
| 186 | + | cp := mocks.NewMockControlPlane(t) | |||
| 187 | + | cp.EXPECT().GetCA(context.Background()).Return(&resp, nil).Once() | |||
| 188 | + | ||||
| 189 | + | _, err := fetchRemoteCADetails(context.Background(), cp) | |||
| 190 | + | require.Error(t, err) | |||
| 191 | + | assert.Contains(t, err.Error(), "invalid root CA") | |||
| 192 | + | assert.Contains(t, err.Error(), tt.wantErr) | |||
| 193 | + | }) | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func TestCARemoteRejectsNotSelfSigned(t *testing.T) { | |||
| 198 | + | caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |||
| 199 | + | require.NoError(t, err) | |||
| 200 | + | caTmpl := &x509.Certificate{ | |||
| 201 | + | SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Issuer"}, | |||
| 202 | + | NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), | |||
| 203 | + | IsCA: true, BasicConstraintsValid: true, | |||
| 204 | + | } | |||
| 205 | + | otherKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |||
| 206 | + | require.NoError(t, err) | |||
| 207 | + | // Subject claims CA, but it is signed by a different key than its own. | |||
| 208 | + | subTmpl := &x509.Certificate{ | |||
| 209 | + | SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "Sub"}, | |||
| 210 | + | NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), | |||
| 211 | + | IsCA: true, BasicConstraintsValid: true, | |||
| 212 | + | } | |||
| 213 | + | der, err := x509.CreateCertificate(rand.Reader, subTmpl, caTmpl, &otherKey.PublicKey, caKey) | |||
| 214 | + | require.NoError(t, err) | |||
| 215 | + | ||||
| 216 | + | cp := mocks.NewMockControlPlane(t) | |||
| 217 | + | cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{ | |||
| 218 | + | RootPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), | |||
| 219 | + | }, nil).Once() | |||
| 220 | + | ||||
| 221 | + | _, err = fetchRemoteCADetails(context.Background(), cp) | |||
| 222 | + | require.Error(t, err) | |||
| 223 | + | assert.Contains(t, err.Error(), "not validly self-signed") | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func TestCARemoteComputesFingerprintAndCNLocally(t *testing.T) { | |||
| 227 | + | rootPEM, fp := testCertPEM(t, true) | |||
| 228 | + | cp := mocks.NewMockControlPlane(t) | |||
| 229 | + | // Server omits fingerprint and CN; both are derived from the certificate. | |||
| 230 | + | cp.EXPECT().GetCA(context.Background()).Return(&dto.CAResponse{RootPEM: rootPEM}, nil).Once() | |||
| 231 | + | ||||
| 232 | + | details, err := fetchRemoteCADetails(context.Background(), cp) | |||
| 233 | + | require.NoError(t, err) | |||
| 234 | + | assert.Equal(t, "Remote Root", details.rootCN) | |||
| 235 | + | assert.Equal(t, fp, details.fingerprint) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func TestCALocalErrorMentionsConfigAndRemote(t *testing.T) { | |||
| 239 | + | stubCARemote(t, nil) | |||
| 240 | + | ||||
| 241 | + | cfg := filepath.Join(t.TempDir(), "gordon.toml") | |||
| 242 | + | require.NoError(t, os.WriteFile(cfg, []byte("[server]\nport = 8088\n"), 0600)) | |||
| 243 | + | // cliConfigPath is a package var: this test must not run in parallel. | |||
| 244 | + | prev := cliConfigPath | |||
| 245 | + | cliConfigPath = cfg | |||
| 246 | + | t.Cleanup(func() { cliConfigPath = prev }) | |||
| 247 | + | ||||
| 248 | + | cmd := newCAExportCmd() | |||
| 249 | + | cmd.SetContext(context.Background()) | |||
| 250 | + | err := cmd.RunE(cmd, nil) | |||
| 251 | + | require.Error(t, err) | |||
| 252 | + | assert.Contains(t, err.Error(), "internal TLS is disabled") | |||
| 253 | + | assert.Contains(t, err.Error(), cfg) | |||
| 254 | + | assert.Contains(t, err.Error(), "--remote") | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func TestErrLocalInternalTLSDisabledWithoutConfigFile(t *testing.T) { | |||
| 258 | + | err := errLocalInternalTLSDisabled("", true) | |||
| 259 | + | assert.Contains(t, err.Error(), "no local server config file was found") | |||
| 260 | + | assert.Contains(t, err.Error(), "--remote") | |||
| 261 | + | ||||
| 262 | + | // install is local-only, so it must not suggest --remote. | |||
| 263 | + | assert.NotContains(t, errLocalInternalTLSDisabled("", false).Error(), "--remote") | |||
| 264 | + | } | |||
M internal/adapters/in/cli/controlplane.go +1 -0
| @@ -31,6 +31,7 @@ type ControlPlane interface { | |||
| 31 | 31 | GetStatus(ctx context.Context) (*remote.Status, error) | |
| 32 | 32 | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | |
| 33 | 33 | GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) | |
| 34 | + | GetCA(ctx context.Context) (*dto.CAResponse, error) | |
| 34 | 35 | Reload(ctx context.Context) error | |
| 35 | 36 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | |
| 36 | 37 | GetConfig(ctx context.Context) (*remote.Config, error) | |
| @@ -31,6 +31,7 @@ type ControlPlane interface { | |||||
| 31 | GetStatus(ctx context.Context) (*remote.Status, error) | 31 | GetStatus(ctx context.Context) (*remote.Status, error) | ||
| 32 | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | 32 | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | ||
| 33 | GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) | 33 | GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) | ||
| 34 | + | GetCA(ctx context.Context) (*dto.CAResponse, error) | |||
| 34 | Reload(ctx context.Context) error | 35 | Reload(ctx context.Context) error | ||
| 35 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | 36 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | ||
| 36 | GetConfig(ctx context.Context) (*remote.Config, error) | 37 | GetConfig(ctx context.Context) (*remote.Config, error) | ||
M internal/adapters/in/cli/local.go +7 -0
| @@ -18,6 +18,7 @@ type LocalServices struct { | |||
| 18 | 18 | configSvc in.ConfigService | |
| 19 | 19 | dataDir string | |
| 20 | 20 | tlsEnabled bool | |
| 21 | + | configFile string | |
| 21 | 22 | } | |
| 22 | 23 | ||
| 23 | 24 | // GetConfigService returns the config service. | |
| @@ -30,6 +31,11 @@ func (l *LocalServices) GetDataDir() string { | |||
| 30 | 31 | return l.dataDir | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 34 | + | // GetConfigFile returns the config file that was read, or "" when none was found. | |
| 35 | + | func (l *LocalServices) GetConfigFile() string { | |
| 36 | + | return l.configFile | |
| 37 | + | } | |
| 38 | + | ||
| 33 | 39 | // HasInternalTLS reports whether a TLS-capable entrypoint enables the internal CA. | |
| 34 | 40 | func (l *LocalServices) HasInternalTLS() bool { | |
| 35 | 41 | return l.tlsEnabled | |
| @@ -71,6 +77,7 @@ func GetLocalServices(cliConfigPath string) (*LocalServices, error) { | |||
| 71 | 77 | configSvc: configSvc, | |
| 72 | 78 | dataDir: dataDir, | |
| 73 | 79 | tlsEnabled: hasLocalTLSCapableEntrypoint(v), | |
| 80 | + | configFile: v.ConfigFileUsed(), | |
| 74 | 81 | }, nil | |
| 75 | 82 | } | |
| 76 | 83 | ||
| @@ -18,6 +18,7 @@ type LocalServices struct { | |||||
| 18 | configSvc in.ConfigService | 18 | configSvc in.ConfigService | ||
| 19 | dataDir string | 19 | dataDir string | ||
| 20 | tlsEnabled bool | 20 | tlsEnabled bool | ||
| 21 | + | configFile string | |||
| 21 | } | 22 | } | ||
| 22 | 23 | ||||
| 23 | // GetConfigService returns the config service. | 24 | // GetConfigService returns the config service. | ||
| @@ -30,6 +31,11 @@ func (l *LocalServices) GetDataDir() string { | |||||
| 30 | return l.dataDir | 31 | return l.dataDir | ||
| 31 | } | 32 | } | ||
| 32 | 33 | ||||
| 34 | + | // GetConfigFile returns the config file that was read, or "" when none was found. | |||
| 35 | + | func (l *LocalServices) GetConfigFile() string { | |||
| 36 | + | return l.configFile | |||
| 37 | + | } | |||
| 38 | + | ||||
| 33 | // HasInternalTLS reports whether a TLS-capable entrypoint enables the internal CA. | 39 | // HasInternalTLS reports whether a TLS-capable entrypoint enables the internal CA. | ||
| 34 | func (l *LocalServices) HasInternalTLS() bool { | 40 | func (l *LocalServices) HasInternalTLS() bool { | ||
| 35 | return l.tlsEnabled | 41 | return l.tlsEnabled | ||
| @@ -71,6 +77,7 @@ func GetLocalServices(cliConfigPath string) (*LocalServices, error) { | |||||
| 71 | configSvc: configSvc, | 77 | configSvc: configSvc, | ||
| 72 | dataDir: dataDir, | 78 | dataDir: dataDir, | ||
| 73 | tlsEnabled: hasLocalTLSCapableEntrypoint(v), | 79 | tlsEnabled: hasLocalTLSCapableEntrypoint(v), | ||
| 80 | + | configFile: v.ConfigFileUsed(), | |||
| 74 | }, nil | 81 | }, nil | ||
| 75 | } | 82 | } | ||
| 76 | 83 | ||||
M internal/adapters/in/cli/mocks/mock_control_plane.go +62 -0
| @@ -390,6 +390,68 @@ func (_c *MockControlPlane_DiffApp_Call) RunAndReturn(run func(ctx context.Conte | |||
| 390 | 390 | return _c | |
| 391 | 391 | } | |
| 392 | 392 | ||
| 393 | + | // GetCA provides a mock function for the type MockControlPlane | |
| 394 | + | func (_mock *MockControlPlane) GetCA(ctx context.Context) (*dto.CAResponse, error) { | |
| 395 | + | ret := _mock.Called(ctx) | |
| 396 | + | ||
| 397 | + | if len(ret) == 0 { | |
| 398 | + | panic("no return value specified for GetCA") | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | var r0 *dto.CAResponse | |
| 402 | + | var r1 error | |
| 403 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (*dto.CAResponse, error)); ok { | |
| 404 | + | return returnFunc(ctx) | |
| 405 | + | } | |
| 406 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) *dto.CAResponse); ok { | |
| 407 | + | r0 = returnFunc(ctx) | |
| 408 | + | } else { | |
| 409 | + | if ret.Get(0) != nil { | |
| 410 | + | r0 = ret.Get(0).(*dto.CAResponse) | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 414 | + | r1 = returnFunc(ctx) | |
| 415 | + | } else { | |
| 416 | + | r1 = ret.Error(1) | |
| 417 | + | } | |
| 418 | + | return r0, r1 | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | // MockControlPlane_GetCA_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCA' | |
| 422 | + | type MockControlPlane_GetCA_Call struct { | |
| 423 | + | *mock.Call | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | // GetCA is a helper method to define mock.On call | |
| 427 | + | // - ctx context.Context | |
| 428 | + | func (_e *MockControlPlane_Expecter) GetCA(ctx any) *MockControlPlane_GetCA_Call { | |
| 429 | + | return &MockControlPlane_GetCA_Call{Call: _e.mock.On("GetCA", ctx)} | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | func (_c *MockControlPlane_GetCA_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetCA_Call { | |
| 433 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 434 | + | var arg0 context.Context | |
| 435 | + | if args[0] != nil { | |
| 436 | + | arg0 = args[0].(context.Context) | |
| 437 | + | } | |
| 438 | + | run( | |
| 439 | + | arg0, | |
| 440 | + | ) | |
| 441 | + | }) | |
| 442 | + | return _c | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | func (_c *MockControlPlane_GetCA_Call) Return(cAResponse *dto.CAResponse, err error) *MockControlPlane_GetCA_Call { | |
| 446 | + | _c.Call.Return(cAResponse, err) | |
| 447 | + | return _c | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | func (_c *MockControlPlane_GetCA_Call) RunAndReturn(run func(ctx context.Context) (*dto.CAResponse, error)) *MockControlPlane_GetCA_Call { | |
| 451 | + | _c.Call.Return(run) | |
| 452 | + | return _c | |
| 453 | + | } | |
| 454 | + | ||
| 393 | 455 | // GetConfig provides a mock function for the type MockControlPlane | |
| 394 | 456 | func (_mock *MockControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) { | |
| 395 | 457 | ret := _mock.Called(ctx) | |
| @@ -390,6 +390,68 @@ func (_c *MockControlPlane_DiffApp_Call) RunAndReturn(run func(ctx context.Conte | |||||
| 390 | return _c | 390 | return _c | ||
| 391 | } | 391 | } | ||
| 392 | 392 | ||||
| 393 | + | // GetCA provides a mock function for the type MockControlPlane | |||
| 394 | + | func (_mock *MockControlPlane) GetCA(ctx context.Context) (*dto.CAResponse, error) { | |||
| 395 | + | ret := _mock.Called(ctx) | |||
| 396 | + | ||||
| 397 | + | if len(ret) == 0 { | |||
| 398 | + | panic("no return value specified for GetCA") | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | var r0 *dto.CAResponse | |||
| 402 | + | var r1 error | |||
| 403 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (*dto.CAResponse, error)); ok { | |||
| 404 | + | return returnFunc(ctx) | |||
| 405 | + | } | |||
| 406 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) *dto.CAResponse); ok { | |||
| 407 | + | r0 = returnFunc(ctx) | |||
| 408 | + | } else { | |||
| 409 | + | if ret.Get(0) != nil { | |||
| 410 | + | r0 = ret.Get(0).(*dto.CAResponse) | |||
| 411 | + | } | |||
| 412 | + | } | |||
| 413 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 414 | + | r1 = returnFunc(ctx) | |||
| 415 | + | } else { | |||
| 416 | + | r1 = ret.Error(1) | |||
| 417 | + | } | |||
| 418 | + | return r0, r1 | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | // MockControlPlane_GetCA_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCA' | |||
| 422 | + | type MockControlPlane_GetCA_Call struct { | |||
| 423 | + | *mock.Call | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | // GetCA is a helper method to define mock.On call | |||
| 427 | + | // - ctx context.Context | |||
| 428 | + | func (_e *MockControlPlane_Expecter) GetCA(ctx any) *MockControlPlane_GetCA_Call { | |||
| 429 | + | return &MockControlPlane_GetCA_Call{Call: _e.mock.On("GetCA", ctx)} | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | func (_c *MockControlPlane_GetCA_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetCA_Call { | |||
| 433 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 434 | + | var arg0 context.Context | |||
| 435 | + | if args[0] != nil { | |||
| 436 | + | arg0 = args[0].(context.Context) | |||
| 437 | + | } | |||
| 438 | + | run( | |||
| 439 | + | arg0, | |||
| 440 | + | ) | |||
| 441 | + | }) | |||
| 442 | + | return _c | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | func (_c *MockControlPlane_GetCA_Call) Return(cAResponse *dto.CAResponse, err error) *MockControlPlane_GetCA_Call { | |||
| 446 | + | _c.Call.Return(cAResponse, err) | |||
| 447 | + | return _c | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | func (_c *MockControlPlane_GetCA_Call) RunAndReturn(run func(ctx context.Context) (*dto.CAResponse, error)) *MockControlPlane_GetCA_Call { | |||
| 451 | + | _c.Call.Return(run) | |||
| 452 | + | return _c | |||
| 453 | + | } | |||
| 454 | + | ||||
| 393 | // GetConfig provides a mock function for the type MockControlPlane | 455 | // GetConfig provides a mock function for the type MockControlPlane | ||
| 394 | func (_mock *MockControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) { | 456 | func (_mock *MockControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) { | ||
| 395 | ret := _mock.Called(ctx) | 457 | ret := _mock.Called(ctx) | ||
M internal/adapters/in/cli/remote/client.go +15 -0
| @@ -522,6 +522,21 @@ func (c *Client) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusRespon | |||
| 522 | 522 | return &status, nil | |
| 523 | 523 | } | |
| 524 | 524 | ||
| 525 | + | // GetCA returns the server's internal root CA certificate and status. | |
| 526 | + | func (c *Client) GetCA(ctx context.Context) (*dto.CAResponse, error) { | |
| 527 | + | resp, err := c.requestWithRetry(ctx, http.MethodGet, "/ca", nil) | |
| 528 | + | if err != nil { | |
| 529 | + | return nil, fmt.Errorf("request CA: %w", err) | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | var ca dto.CAResponse | |
| 533 | + | if err := parseResponse(resp, &ca); err != nil { | |
| 534 | + | return nil, fmt.Errorf("parse CA response: %w", err) | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | return &ca, nil | |
| 538 | + | } | |
| 539 | + | ||
| 525 | 540 | // GetStatus returns the Gordon server status. | |
| 526 | 541 | func (c *Client) GetStatus(ctx context.Context) (*Status, error) { | |
| 527 | 542 | resp, err := c.requestWithRetry(ctx, http.MethodGet, "/status", nil) | |
| @@ -522,6 +522,21 @@ func (c *Client) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusRespon | |||||
| 522 | return &status, nil | 522 | return &status, nil | ||
| 523 | } | 523 | } | ||
| 524 | 524 | ||||
| 525 | + | // GetCA returns the server's internal root CA certificate and status. | |||
| 526 | + | func (c *Client) GetCA(ctx context.Context) (*dto.CAResponse, error) { | |||
| 527 | + | resp, err := c.requestWithRetry(ctx, http.MethodGet, "/ca", nil) | |||
| 528 | + | if err != nil { | |||
| 529 | + | return nil, fmt.Errorf("request CA: %w", err) | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | var ca dto.CAResponse | |||
| 533 | + | if err := parseResponse(resp, &ca); err != nil { | |||
| 534 | + | return nil, fmt.Errorf("parse CA response: %w", err) | |||
| 535 | + | } | |||
| 536 | + | ||||
| 537 | + | return &ca, nil | |||
| 538 | + | } | |||
| 539 | + | ||||
| 525 | // GetStatus returns the Gordon server status. | 540 | // GetStatus returns the Gordon server status. | ||
| 526 | func (c *Client) GetStatus(ctx context.Context) (*Status, error) { | 541 | func (c *Client) GetStatus(ctx context.Context) (*Status, error) { | ||
| 527 | resp, err := c.requestWithRetry(ctx, http.MethodGet, "/status", nil) | 542 | resp, err := c.requestWithRetry(ctx, http.MethodGet, "/status", nil) | ||
M internal/adapters/in/cli/remote/client_test.go +30 -0
| @@ -175,6 +175,36 @@ func TestClientGetTrafficStatus(t *testing.T) { | |||
| 175 | 175 | assert.Equal(t, int64(1), status.Counters.ActiveTCPConnections) | |
| 176 | 176 | } | |
| 177 | 177 | ||
| 178 | + | func TestClientGetCA(t *testing.T) { | |
| 179 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 180 | + | require.Equal(t, "/admin/ca", r.URL.Path) | |
| 181 | + | require.Equal(t, http.MethodGet, r.Method) | |
| 182 | + | w.Header().Set("Content-Type", "application/json") | |
| 183 | + | _, _ = w.Write([]byte(`{"root_cn":"Gordon Root","fingerprint":"AB:CD","intermediate_expiry":"2027-01-02T03:04:05Z","root_pem":"PEM"}`)) | |
| 184 | + | })) | |
| 185 | + | defer srv.Close() | |
| 186 | + | ||
| 187 | + | ca, err := NewClient(srv.URL).GetCA(context.Background()) | |
| 188 | + | require.NoError(t, err) | |
| 189 | + | assert.Equal(t, "Gordon Root", ca.RootCN) | |
| 190 | + | assert.Equal(t, "AB:CD", ca.Fingerprint) | |
| 191 | + | assert.Equal(t, "PEM", ca.RootPEM) | |
| 192 | + | assert.Equal(t, 2027, ca.IntermediateExpiry.Year()) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func TestClientGetCADisabled(t *testing.T) { | |
| 196 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 197 | + | w.Header().Set("Content-Type", "application/json") | |
| 198 | + | w.WriteHeader(http.StatusNotFound) | |
| 199 | + | _, _ = w.Write([]byte(`{"error":"internal TLS is disabled on this server"}`)) | |
| 200 | + | })) | |
| 201 | + | defer srv.Close() | |
| 202 | + | ||
| 203 | + | _, err := NewClient(srv.URL).GetCA(context.Background()) | |
| 204 | + | require.Error(t, err) | |
| 205 | + | assert.Contains(t, err.Error(), "internal TLS is disabled") | |
| 206 | + | } | |
| 207 | + | ||
| 178 | 208 | func TestClientGetTLSStatus(t *testing.T) { | |
| 179 | 209 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 180 | 210 | require.Equal(t, "/admin/tls/status", r.URL.Path) | |
| @@ -175,6 +175,36 @@ func TestClientGetTrafficStatus(t *testing.T) { | |||||
| 175 | assert.Equal(t, int64(1), status.Counters.ActiveTCPConnections) | 175 | assert.Equal(t, int64(1), status.Counters.ActiveTCPConnections) | ||
| 176 | } | 176 | } | ||
| 177 | 177 | ||||
| 178 | + | func TestClientGetCA(t *testing.T) { | |||
| 179 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 180 | + | require.Equal(t, "/admin/ca", r.URL.Path) | |||
| 181 | + | require.Equal(t, http.MethodGet, r.Method) | |||
| 182 | + | w.Header().Set("Content-Type", "application/json") | |||
| 183 | + | _, _ = w.Write([]byte(`{"root_cn":"Gordon Root","fingerprint":"AB:CD","intermediate_expiry":"2027-01-02T03:04:05Z","root_pem":"PEM"}`)) | |||
| 184 | + | })) | |||
| 185 | + | defer srv.Close() | |||
| 186 | + | ||||
| 187 | + | ca, err := NewClient(srv.URL).GetCA(context.Background()) | |||
| 188 | + | require.NoError(t, err) | |||
| 189 | + | assert.Equal(t, "Gordon Root", ca.RootCN) | |||
| 190 | + | assert.Equal(t, "AB:CD", ca.Fingerprint) | |||
| 191 | + | assert.Equal(t, "PEM", ca.RootPEM) | |||
| 192 | + | assert.Equal(t, 2027, ca.IntermediateExpiry.Year()) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func TestClientGetCADisabled(t *testing.T) { | |||
| 196 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 197 | + | w.Header().Set("Content-Type", "application/json") | |||
| 198 | + | w.WriteHeader(http.StatusNotFound) | |||
| 199 | + | _, _ = w.Write([]byte(`{"error":"internal TLS is disabled on this server"}`)) | |||
| 200 | + | })) | |||
| 201 | + | defer srv.Close() | |||
| 202 | + | ||||
| 203 | + | _, err := NewClient(srv.URL).GetCA(context.Background()) | |||
| 204 | + | require.Error(t, err) | |||
| 205 | + | assert.Contains(t, err.Error(), "internal TLS is disabled") | |||
| 206 | + | } | |||
| 207 | + | ||||
| 178 | func TestClientGetTLSStatus(t *testing.T) { | 208 | func TestClientGetTLSStatus(t *testing.T) { | ||
| 179 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 209 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 180 | require.Equal(t, "/admin/tls/status", r.URL.Path) | 210 | require.Equal(t, "/admin/tls/status", r.URL.Path) | ||
M internal/adapters/in/cli/root.go +1 -1
| @@ -58,7 +58,7 @@ Applications are declared in standalone TOML files and managed with | |||
| 58 | 58 | gordon apps; gordon images push transfers OCI content only and never deploys. | |
| 59 | 59 | ||
| 60 | 60 | Commands are organized by where they run: | |
| 61 | - | Server-only: Run on the machine hosting Gordon (serve, auth, ca) | |
| 61 | + | Server-only: Run on the machine hosting Gordon (serve, auth, ca install; ca export/info also accept --remote) | |
| 62 | 62 | Management: Work locally or remotely via --remote flag (apps, daemon, images, etc.) | |
| 63 | 63 | Client-only: CLI utilities that don't require a running Gordon server`, | |
| 64 | 64 | } | |
| @@ -58,7 +58,7 @@ Applications are declared in standalone TOML files and managed with | |||||
| 58 | gordon apps; gordon images push transfers OCI content only and never deploys. | 58 | gordon apps; gordon images push transfers OCI content only and never deploys. | ||
| 59 | 59 | ||||
| 60 | Commands are organized by where they run: | 60 | Commands are organized by where they run: | ||
| 61 | - | Server-only: Run on the machine hosting Gordon (serve, auth, ca) | 61 | + | Server-only: Run on the machine hosting Gordon (serve, auth, ca install; ca export/info also accept --remote) |
| 62 | Management: Work locally or remotely via --remote flag (apps, daemon, images, etc.) | 62 | Management: Work locally or remotely via --remote flag (apps, daemon, images, etc.) | ||
| 63 | Client-only: CLI utilities that don't require a running Gordon server`, | 63 | Client-only: CLI utilities that don't require a running Gordon server`, | ||
| 64 | } | 64 | } | ||
M internal/adapters/in/http/admin/handler.go +4 -0
| @@ -47,6 +47,7 @@ type Handler struct { | |||
| 47 | 47 | reloadTrigger reloadTrigger | |
| 48 | 48 | publicTLSSvc in.PublicTLSService | |
| 49 | 49 | trafficSvc in.TrafficStatusService | |
| 50 | + | caSvc in.CAInfoService | |
| 50 | 51 | appSvc in.AppService | |
| 51 | 52 | log zerowrap.Logger | |
| 52 | 53 | } | |
| @@ -124,6 +125,7 @@ type HandlerDeps struct { | |||
| 124 | 125 | ReloadTrigger reloadTrigger | |
| 125 | 126 | PublicTLSSvc in.PublicTLSService | |
| 126 | 127 | TrafficSvc in.TrafficStatusService | |
| 128 | + | CASvc in.CAInfoService | |
| 127 | 129 | AppSvc in.AppService | |
| 128 | 130 | } | |
| 129 | 131 | ||
| @@ -143,6 +145,7 @@ func NewHandler(deps HandlerDeps) *Handler { | |||
| 143 | 145 | reloadTrigger: deps.ReloadTrigger, | |
| 144 | 146 | publicTLSSvc: deps.PublicTLSSvc, | |
| 145 | 147 | trafficSvc: deps.TrafficSvc, | |
| 148 | + | caSvc: deps.CASvc, | |
| 146 | 149 | appSvc: deps.AppSvc, | |
| 147 | 150 | log: deps.Log, | |
| 148 | 151 | } | |
| @@ -199,6 +202,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 199 | 202 | "/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) }, | |
| 200 | 203 | "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) }, | |
| 201 | 204 | "/traffic/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTrafficStatus(w, r) }, | |
| 205 | + | "/ca": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleCA(w, r) }, | |
| 202 | 206 | } | |
| 203 | 207 | if handler, ok := exactRoutes[path]; ok { | |
| 204 | 208 | return handler, true | |
| @@ -47,6 +47,7 @@ type Handler struct { | |||||
| 47 | reloadTrigger reloadTrigger | 47 | reloadTrigger reloadTrigger | ||
| 48 | publicTLSSvc in.PublicTLSService | 48 | publicTLSSvc in.PublicTLSService | ||
| 49 | trafficSvc in.TrafficStatusService | 49 | trafficSvc in.TrafficStatusService | ||
| 50 | + | caSvc in.CAInfoService | |||
| 50 | appSvc in.AppService | 51 | appSvc in.AppService | ||
| 51 | log zerowrap.Logger | 52 | log zerowrap.Logger | ||
| 52 | } | 53 | } | ||
| @@ -124,6 +125,7 @@ type HandlerDeps struct { | |||||
| 124 | ReloadTrigger reloadTrigger | 125 | ReloadTrigger reloadTrigger | ||
| 125 | PublicTLSSvc in.PublicTLSService | 126 | PublicTLSSvc in.PublicTLSService | ||
| 126 | TrafficSvc in.TrafficStatusService | 127 | TrafficSvc in.TrafficStatusService | ||
| 128 | + | CASvc in.CAInfoService | |||
| 127 | AppSvc in.AppService | 129 | AppSvc in.AppService | ||
| 128 | } | 130 | } | ||
| 129 | 131 | ||||
| @@ -143,6 +145,7 @@ func NewHandler(deps HandlerDeps) *Handler { | |||||
| 143 | reloadTrigger: deps.ReloadTrigger, | 145 | reloadTrigger: deps.ReloadTrigger, | ||
| 144 | publicTLSSvc: deps.PublicTLSSvc, | 146 | publicTLSSvc: deps.PublicTLSSvc, | ||
| 145 | trafficSvc: deps.TrafficSvc, | 147 | trafficSvc: deps.TrafficSvc, | ||
| 148 | + | caSvc: deps.CASvc, | |||
| 146 | appSvc: deps.AppSvc, | 149 | appSvc: deps.AppSvc, | ||
| 147 | log: deps.Log, | 150 | log: deps.Log, | ||
| 148 | } | 151 | } | ||
| @@ -199,6 +202,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||||
| 199 | "/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) }, | 202 | "/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) }, | ||
| 200 | "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) }, | 203 | "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) }, | ||
| 201 | "/traffic/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTrafficStatus(w, r) }, | 204 | "/traffic/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTrafficStatus(w, r) }, | ||
| 205 | + | "/ca": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleCA(w, r) }, | |||
| 202 | } | 206 | } | ||
| 203 | if handler, ok := exactRoutes[path]; ok { | 207 | if handler, ok := exactRoutes[path]; ok { | ||
| 204 | return handler, true | 208 | return handler, true | ||
A internal/adapters/in/http/admin/handler_ca.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package admin | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/http" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 7 | + | "github.com/bnema/gordon/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | const caDisabledMessage = "internal TLS is disabled on this server (no TLS-capable entrypoint configured)" | |
| 11 | + | ||
| 12 | + | func (h *Handler) handleCA(w http.ResponseWriter, r *http.Request) { | |
| 13 | + | if !HasAccess(r.Context(), domain.AdminResourceStatus, domain.AdminActionRead) { | |
| 14 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |
| 15 | + | return | |
| 16 | + | } | |
| 17 | + | if r.Method != http.MethodGet { | |
| 18 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 19 | + | return | |
| 20 | + | } | |
| 21 | + | if h.caSvc == nil { | |
| 22 | + | h.sendError(w, http.StatusNotFound, caDisabledMessage) | |
| 23 | + | return | |
| 24 | + | } | |
| 25 | + | h.sendJSON(w, http.StatusOK, dto.CAResponse{ | |
| 26 | + | RootCN: h.caSvc.RootCommonName(), | |
| 27 | + | Fingerprint: h.caSvc.RootFingerprint(), | |
| 28 | + | IntermediateExpiry: h.caSvc.IntermediateExpiresAt().UTC(), | |
| 29 | + | RootPEM: string(h.caSvc.RootCertificate()), | |
| 30 | + | }) | |
| 31 | + | } | |
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package admin | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/http" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | const caDisabledMessage = "internal TLS is disabled on this server (no TLS-capable entrypoint configured)" | |||
| 11 | + | ||||
| 12 | + | func (h *Handler) handleCA(w http.ResponseWriter, r *http.Request) { | |||
| 13 | + | if !HasAccess(r.Context(), domain.AdminResourceStatus, domain.AdminActionRead) { | |||
| 14 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |||
| 15 | + | return | |||
| 16 | + | } | |||
| 17 | + | if r.Method != http.MethodGet { | |||
| 18 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 19 | + | return | |||
| 20 | + | } | |||
| 21 | + | if h.caSvc == nil { | |||
| 22 | + | h.sendError(w, http.StatusNotFound, caDisabledMessage) | |||
| 23 | + | return | |||
| 24 | + | } | |||
| 25 | + | h.sendJSON(w, http.StatusOK, dto.CAResponse{ | |||
| 26 | + | RootCN: h.caSvc.RootCommonName(), | |||
| 27 | + | Fingerprint: h.caSvc.RootFingerprint(), | |||
| 28 | + | IntermediateExpiry: h.caSvc.IntermediateExpiresAt().UTC(), | |||
| 29 | + | RootPEM: string(h.caSvc.RootCertificate()), | |||
| 30 | + | }) | |||
| 31 | + | } | |||
A internal/adapters/in/http/admin/handler_ca_test.go +72 -0
| @@ -0,0 +1,72 @@ | |||
| 1 | + | package admin | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "net/http" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 13 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestHandler_CA(t *testing.T) { | |
| 17 | + | expiry := time.Date(2027, 1, 2, 3, 4, 5, 0, time.UTC) | |
| 18 | + | caSvc := inmocks.NewMockCAInfoService(t) | |
| 19 | + | caSvc.EXPECT().RootCommonName().Return("Gordon Root") | |
| 20 | + | caSvc.EXPECT().RootFingerprint().Return("AB:CD") | |
| 21 | + | caSvc.EXPECT().IntermediateExpiresAt().Return(expiry) | |
| 22 | + | caSvc.EXPECT().RootCertificate().Return([]byte("-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----\n")) | |
| 23 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.CASvc = caSvc }) | |
| 24 | + | server := newScopedTestServer(t, handler, "admin:status:read") | |
| 25 | + | ||
| 26 | + | resp, err := http.Get(server.URL + "/admin/ca") | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | defer resp.Body.Close() | |
| 29 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 30 | + | ||
| 31 | + | var body dto.CAResponse | |
| 32 | + | require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) | |
| 33 | + | assert.Equal(t, "Gordon Root", body.RootCN) | |
| 34 | + | assert.Equal(t, "AB:CD", body.Fingerprint) | |
| 35 | + | assert.True(t, expiry.Equal(body.IntermediateExpiry)) | |
| 36 | + | assert.Contains(t, body.RootPEM, "BEGIN CERTIFICATE") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestHandler_CARequiresStatusRead(t *testing.T) { | |
| 40 | + | caSvc := inmocks.NewMockCAInfoService(t) | |
| 41 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.CASvc = caSvc }) | |
| 42 | + | server := newScopedTestServer(t, handler, "admin:config:read") | |
| 43 | + | ||
| 44 | + | resp, err := http.Get(server.URL + "/admin/ca") | |
| 45 | + | require.NoError(t, err) | |
| 46 | + | defer resp.Body.Close() | |
| 47 | + | assert.Equal(t, http.StatusForbidden, resp.StatusCode) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestHandler_CARejectsNonGet(t *testing.T) { | |
| 51 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.CASvc = inmocks.NewMockCAInfoService(t) }) | |
| 52 | + | server := newScopedTestServer(t, handler, "admin:status:read") | |
| 53 | + | ||
| 54 | + | resp, err := http.Post(server.URL+"/admin/ca", "application/json", nil) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | defer resp.Body.Close() | |
| 57 | + | assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestHandler_CADisabledReturnsNotFound(t *testing.T) { | |
| 61 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.CASvc = nil }) | |
| 62 | + | server := newScopedTestServer(t, handler, "admin:status:read") | |
| 63 | + | ||
| 64 | + | resp, err := http.Get(server.URL + "/admin/ca") | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | defer resp.Body.Close() | |
| 67 | + | require.Equal(t, http.StatusNotFound, resp.StatusCode) | |
| 68 | + | ||
| 69 | + | var body dto.ErrorResponse | |
| 70 | + | require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) | |
| 71 | + | assert.Contains(t, body.Error, "internal TLS is disabled") | |
| 72 | + | } | |
| @@ -0,0 +1,72 @@ | |||||
| 1 | + | package admin | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "net/http" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 13 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestHandler_CA(t *testing.T) { | |||
| 17 | + | expiry := time.Date(2027, 1, 2, 3, 4, 5, 0, time.UTC) | |||
| 18 | + | caSvc := inmocks.NewMockCAInfoService(t) | |||
| 19 | + | caSvc.EXPECT().RootCommonName().Return("Gordon Root") | |||
| 20 | + | caSvc.EXPECT().RootFingerprint().Return("AB:CD") | |||
| 21 | + | caSvc.EXPECT().IntermediateExpiresAt().Return(expiry) | |||
| 22 | + | caSvc.EXPECT().RootCertificate().Return([]byte("-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----\n")) | |||
| 23 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.CASvc = caSvc }) | |||
| 24 | + | server := newScopedTestServer(t, handler, "admin:status:read") | |||
| 25 | + | ||||
| 26 | + | resp, err := http.Get(server.URL + "/admin/ca") | |||
| 27 | + | require.NoError(t, err) | |||
| 28 | + | defer resp.Body.Close() | |||
| 29 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 30 | + | ||||
| 31 | + | var body dto.CAResponse | |||
| 32 | + | require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) | |||
| 33 | + | assert.Equal(t, "Gordon Root", body.RootCN) | |||
| 34 | + | assert.Equal(t, "AB:CD", body.Fingerprint) | |||
| 35 | + | assert.True(t, expiry.Equal(body.IntermediateExpiry)) | |||
| 36 | + | assert.Contains(t, body.RootPEM, "BEGIN CERTIFICATE") | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestHandler_CARequiresStatusRead(t *testing.T) { | |||
| 40 | + | caSvc := inmocks.NewMockCAInfoService(t) | |||
| 41 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.CASvc = caSvc }) | |||
| 42 | + | server := newScopedTestServer(t, handler, "admin:config:read") | |||
| 43 | + | ||||
| 44 | + | resp, err := http.Get(server.URL + "/admin/ca") | |||
| 45 | + | require.NoError(t, err) | |||
| 46 | + | defer resp.Body.Close() | |||
| 47 | + | assert.Equal(t, http.StatusForbidden, resp.StatusCode) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestHandler_CARejectsNonGet(t *testing.T) { | |||
| 51 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.CASvc = inmocks.NewMockCAInfoService(t) }) | |||
| 52 | + | server := newScopedTestServer(t, handler, "admin:status:read") | |||
| 53 | + | ||||
| 54 | + | resp, err := http.Post(server.URL+"/admin/ca", "application/json", nil) | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | defer resp.Body.Close() | |||
| 57 | + | assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestHandler_CADisabledReturnsNotFound(t *testing.T) { | |||
| 61 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.CASvc = nil }) | |||
| 62 | + | server := newScopedTestServer(t, handler, "admin:status:read") | |||
| 63 | + | ||||
| 64 | + | resp, err := http.Get(server.URL + "/admin/ca") | |||
| 65 | + | require.NoError(t, err) | |||
| 66 | + | defer resp.Body.Close() | |||
| 67 | + | require.Equal(t, http.StatusNotFound, resp.StatusCode) | |||
| 68 | + | ||||
| 69 | + | var body dto.ErrorResponse | |||
| 70 | + | require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) | |||
| 71 | + | assert.Contains(t, body.Error, "internal TLS is disabled") | |||
| 72 | + | } | |||
M internal/adapters/out/pki/ca.go +7 -1
| @@ -93,7 +93,13 @@ func (ca *CA) RootCertificateDER() []byte { return ca.rootCert.Raw } | |||
| 93 | 93 | // RootFingerprint returns the SHA-256 fingerprint of the root CA cert | |
| 94 | 94 | // formatted as colon-separated hex. | |
| 95 | 95 | func (ca *CA) RootFingerprint() string { | |
| 96 | - | sum := sha256.Sum256(ca.rootCert.Raw) | |
| 96 | + | return CertFingerprint(ca.rootCert) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // CertFingerprint returns the SHA-256 fingerprint of cert formatted as | |
| 100 | + | // colon-separated uppercase hex (the format used by RootFingerprint). | |
| 101 | + | func CertFingerprint(cert *x509.Certificate) string { | |
| 102 | + | sum := sha256.Sum256(cert.Raw) | |
| 97 | 103 | parts := make([]string, 0, sha256.Size) | |
| 98 | 104 | for _, b := range sum { | |
| 99 | 105 | parts = append(parts, fmt.Sprintf("%02X", b)) | |
| @@ -93,7 +93,13 @@ func (ca *CA) RootCertificateDER() []byte { return ca.rootCert.Raw } | |||||
| 93 | // RootFingerprint returns the SHA-256 fingerprint of the root CA cert | 93 | // RootFingerprint returns the SHA-256 fingerprint of the root CA cert | ||
| 94 | // formatted as colon-separated hex. | 94 | // formatted as colon-separated hex. | ||
| 95 | func (ca *CA) RootFingerprint() string { | 95 | func (ca *CA) RootFingerprint() string { | ||
| 96 | - | sum := sha256.Sum256(ca.rootCert.Raw) | 96 | + | return CertFingerprint(ca.rootCert) |
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // CertFingerprint returns the SHA-256 fingerprint of cert formatted as | |||
| 100 | + | // colon-separated uppercase hex (the format used by RootFingerprint). | |||
| 101 | + | func CertFingerprint(cert *x509.Certificate) string { | |||
| 102 | + | sum := sha256.Sum256(cert.Raw) | |||
| 97 | parts := make([]string, 0, sha256.Size) | 103 | parts := make([]string, 0, sha256.Size) | ||
| 98 | for _, b := range sum { | 104 | for _, b := range sum { | ||
| 99 | parts = append(parts, fmt.Sprintf("%02X", b)) | 105 | parts = append(parts, fmt.Sprintf("%02X", b)) | ||
M internal/app/services.go +10 -0
| @@ -438,9 +438,19 @@ func (si *serviceInit) initHandlers() { | |||
| 438 | 438 | PublicTLSSvc: si.svc.publicTLSSvc, | |
| 439 | 439 | TrafficSvc: si.svc.trafficManager, | |
| 440 | 440 | AppSvc: si.svc.appSvc, | |
| 441 | + | CASvc: si.caInfoProvider(), | |
| 441 | 442 | }) | |
| 442 | 443 | } | |
| 443 | 444 | ||
| 445 | + | // caInfoProvider returns the internal CA as a CAInfoService, or an untyped nil | |
| 446 | + | // when internal TLS is disabled so the admin handler sees a nil interface. | |
| 447 | + | func (si *serviceInit) caInfoProvider() in.CAInfoService { | |
| 448 | + | if si.svc.caAdapter == nil { | |
| 449 | + | return nil | |
| 450 | + | } | |
| 451 | + | return si.svc.caAdapter | |
| 452 | + | } | |
| 453 | + | ||
| 444 | 454 | // injectTelemetryMetrics creates and injects OTel metrics into services when | |
| 445 | 455 | // telemetry is enabled. Skipped otherwise to avoid unnecessary allocations. | |
| 446 | 456 | func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) { | |
| @@ -438,9 +438,19 @@ func (si *serviceInit) initHandlers() { | |||||
| 438 | PublicTLSSvc: si.svc.publicTLSSvc, | 438 | PublicTLSSvc: si.svc.publicTLSSvc, | ||
| 439 | TrafficSvc: si.svc.trafficManager, | 439 | TrafficSvc: si.svc.trafficManager, | ||
| 440 | AppSvc: si.svc.appSvc, | 440 | AppSvc: si.svc.appSvc, | ||
| 441 | + | CASvc: si.caInfoProvider(), | |||
| 441 | }) | 442 | }) | ||
| 442 | } | 443 | } | ||
| 443 | 444 | ||||
| 445 | + | // caInfoProvider returns the internal CA as a CAInfoService, or an untyped nil | |||
| 446 | + | // when internal TLS is disabled so the admin handler sees a nil interface. | |||
| 447 | + | func (si *serviceInit) caInfoProvider() in.CAInfoService { | |||
| 448 | + | if si.svc.caAdapter == nil { | |||
| 449 | + | return nil | |||
| 450 | + | } | |||
| 451 | + | return si.svc.caAdapter | |||
| 452 | + | } | |||
| 453 | + | ||||
| 444 | // injectTelemetryMetrics creates and injects OTel metrics into services when | 454 | // injectTelemetryMetrics creates and injects OTel metrics into services when | ||
| 445 | // telemetry is enabled. Skipped otherwise to avoid unnecessary allocations. | 455 | // telemetry is enabled. Skipped otherwise to avoid unnecessary allocations. | ||
| 446 | func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) { | 456 | func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) { | ||
A internal/app/services_ca_test.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ||
| 8 | + | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestCAInfoProvider_DisabledReturnsUntypedNil(t *testing.T) { | |
| 12 | + | si := &serviceInit{svc: &services{}} | |
| 13 | + | ||
| 14 | + | // A typed-nil *pki.CA wrapped in the interface would be non-nil and make | |
| 15 | + | // the admin handler dereference a nil CA instead of answering 404. | |
| 16 | + | assert.True(t, si.caInfoProvider() == nil) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func TestCAInfoProvider_EnabledReturnsCA(t *testing.T) { | |
| 20 | + | si := &serviceInit{svc: &services{caAdapter: &pkiadapter.CA{}}} | |
| 21 | + | ||
| 22 | + | assert.True(t, si.caInfoProvider() != nil) | |
| 23 | + | } | |
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ||||
| 8 | + | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestCAInfoProvider_DisabledReturnsUntypedNil(t *testing.T) { | |||
| 12 | + | si := &serviceInit{svc: &services{}} | |||
| 13 | + | ||||
| 14 | + | // A typed-nil *pki.CA wrapped in the interface would be non-nil and make | |||
| 15 | + | // the admin handler dereference a nil CA instead of answering 404. | |||
| 16 | + | assert.True(t, si.caInfoProvider() == nil) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func TestCAInfoProvider_EnabledReturnsCA(t *testing.T) { | |||
| 20 | + | si := &serviceInit{svc: &services{caAdapter: &pkiadapter.CA{}}} | |||
| 21 | + | ||||
| 22 | + | assert.True(t, si.caInfoProvider() != nil) | |||
| 23 | + | } | |||
A internal/boundaries/in/ca.go +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | package in | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // CAInfoService exposes read-only information about the internal root CA. | |
| 6 | + | type CAInfoService interface { | |
| 7 | + | // RootCertificate returns the root CA certificate in PEM format. | |
| 8 | + | RootCertificate() []byte | |
| 9 | + | // RootCommonName returns the CN of the root CA certificate. | |
| 10 | + | RootCommonName() string | |
| 11 | + | // RootFingerprint returns the SHA-256 fingerprint of the root CA certificate. | |
| 12 | + | RootFingerprint() string | |
| 13 | + | // IntermediateExpiresAt returns the intermediate CA certificate expiry. | |
| 14 | + | IntermediateExpiresAt() time.Time | |
| 15 | + | } | |
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | package in | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // CAInfoService exposes read-only information about the internal root CA. | |||
| 6 | + | type CAInfoService interface { | |||
| 7 | + | // RootCertificate returns the root CA certificate in PEM format. | |||
| 8 | + | RootCertificate() []byte | |||
| 9 | + | // RootCommonName returns the CN of the root CA certificate. | |||
| 10 | + | RootCommonName() string | |||
| 11 | + | // RootFingerprint returns the SHA-256 fingerprint of the root CA certificate. | |||
| 12 | + | RootFingerprint() string | |||
| 13 | + | // IntermediateExpiresAt returns the intermediate CA certificate expiry. | |||
| 14 | + | IntermediateExpiresAt() time.Time | |||
| 15 | + | } | |||
A internal/boundaries/in/mocks/mock_ca_info_service.go +225 -0
| @@ -0,0 +1,225 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | mock "github.com/stretchr/testify/mock" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // NewMockCAInfoService creates a new instance of MockCAInfoService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | + | // The first argument is typically a *testing.T value. | |
| 15 | + | func NewMockCAInfoService(t interface { | |
| 16 | + | mock.TestingT | |
| 17 | + | Cleanup(func()) | |
| 18 | + | }) *MockCAInfoService { | |
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | + | helper.Helper() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | mock := &MockCAInfoService{} | |
| 24 | + | mock.Mock.Test(t) | |
| 25 | + | ||
| 26 | + | t.Cleanup(func() { | |
| 27 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 28 | + | helper.Helper() | |
| 29 | + | } | |
| 30 | + | mock.AssertExpectations(t) | |
| 31 | + | }) | |
| 32 | + | ||
| 33 | + | return mock | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // MockCAInfoService is an autogenerated mock type for the CAInfoService type | |
| 37 | + | type MockCAInfoService struct { | |
| 38 | + | mock.Mock | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type MockCAInfoService_Expecter struct { | |
| 42 | + | mock *mock.Mock | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (_m *MockCAInfoService) EXPECT() *MockCAInfoService_Expecter { | |
| 46 | + | return &MockCAInfoService_Expecter{mock: &_m.Mock} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // IntermediateExpiresAt provides a mock function for the type MockCAInfoService | |
| 50 | + | func (_mock *MockCAInfoService) IntermediateExpiresAt() time.Time { | |
| 51 | + | ret := _mock.Called() | |
| 52 | + | ||
| 53 | + | if len(ret) == 0 { | |
| 54 | + | panic("no return value specified for IntermediateExpiresAt") | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | var r0 time.Time | |
| 58 | + | if returnFunc, ok := ret.Get(0).(func() time.Time); ok { | |
| 59 | + | r0 = returnFunc() | |
| 60 | + | } else { | |
| 61 | + | r0 = ret.Get(0).(time.Time) | |
| 62 | + | } | |
| 63 | + | return r0 | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // MockCAInfoService_IntermediateExpiresAt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IntermediateExpiresAt' | |
| 67 | + | type MockCAInfoService_IntermediateExpiresAt_Call struct { | |
| 68 | + | *mock.Call | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // IntermediateExpiresAt is a helper method to define mock.On call | |
| 72 | + | func (_e *MockCAInfoService_Expecter) IntermediateExpiresAt() *MockCAInfoService_IntermediateExpiresAt_Call { | |
| 73 | + | return &MockCAInfoService_IntermediateExpiresAt_Call{Call: _e.mock.On("IntermediateExpiresAt")} | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (_c *MockCAInfoService_IntermediateExpiresAt_Call) Run(run func()) *MockCAInfoService_IntermediateExpiresAt_Call { | |
| 77 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 78 | + | run() | |
| 79 | + | }) | |
| 80 | + | return _c | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (_c *MockCAInfoService_IntermediateExpiresAt_Call) Return(time1 time.Time) *MockCAInfoService_IntermediateExpiresAt_Call { | |
| 84 | + | _c.Call.Return(time1) | |
| 85 | + | return _c | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (_c *MockCAInfoService_IntermediateExpiresAt_Call) RunAndReturn(run func() time.Time) *MockCAInfoService_IntermediateExpiresAt_Call { | |
| 89 | + | _c.Call.Return(run) | |
| 90 | + | return _c | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // RootCertificate provides a mock function for the type MockCAInfoService | |
| 94 | + | func (_mock *MockCAInfoService) RootCertificate() []byte { | |
| 95 | + | ret := _mock.Called() | |
| 96 | + | ||
| 97 | + | if len(ret) == 0 { | |
| 98 | + | panic("no return value specified for RootCertificate") | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | var r0 []byte | |
| 102 | + | if returnFunc, ok := ret.Get(0).(func() []byte); ok { | |
| 103 | + | r0 = returnFunc() | |
| 104 | + | } else { | |
| 105 | + | if ret.Get(0) != nil { | |
| 106 | + | r0 = ret.Get(0).([]byte) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | return r0 | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // MockCAInfoService_RootCertificate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RootCertificate' | |
| 113 | + | type MockCAInfoService_RootCertificate_Call struct { | |
| 114 | + | *mock.Call | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // RootCertificate is a helper method to define mock.On call | |
| 118 | + | func (_e *MockCAInfoService_Expecter) RootCertificate() *MockCAInfoService_RootCertificate_Call { | |
| 119 | + | return &MockCAInfoService_RootCertificate_Call{Call: _e.mock.On("RootCertificate")} | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func (_c *MockCAInfoService_RootCertificate_Call) Run(run func()) *MockCAInfoService_RootCertificate_Call { | |
| 123 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 124 | + | run() | |
| 125 | + | }) | |
| 126 | + | return _c | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func (_c *MockCAInfoService_RootCertificate_Call) Return(bytes []byte) *MockCAInfoService_RootCertificate_Call { | |
| 130 | + | _c.Call.Return(bytes) | |
| 131 | + | return _c | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (_c *MockCAInfoService_RootCertificate_Call) RunAndReturn(run func() []byte) *MockCAInfoService_RootCertificate_Call { | |
| 135 | + | _c.Call.Return(run) | |
| 136 | + | return _c | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // RootCommonName provides a mock function for the type MockCAInfoService | |
| 140 | + | func (_mock *MockCAInfoService) RootCommonName() string { | |
| 141 | + | ret := _mock.Called() | |
| 142 | + | ||
| 143 | + | if len(ret) == 0 { | |
| 144 | + | panic("no return value specified for RootCommonName") | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | var r0 string | |
| 148 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |
| 149 | + | r0 = returnFunc() | |
| 150 | + | } else { | |
| 151 | + | r0 = ret.Get(0).(string) | |
| 152 | + | } | |
| 153 | + | return r0 | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // MockCAInfoService_RootCommonName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RootCommonName' | |
| 157 | + | type MockCAInfoService_RootCommonName_Call struct { | |
| 158 | + | *mock.Call | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // RootCommonName is a helper method to define mock.On call | |
| 162 | + | func (_e *MockCAInfoService_Expecter) RootCommonName() *MockCAInfoService_RootCommonName_Call { | |
| 163 | + | return &MockCAInfoService_RootCommonName_Call{Call: _e.mock.On("RootCommonName")} | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func (_c *MockCAInfoService_RootCommonName_Call) Run(run func()) *MockCAInfoService_RootCommonName_Call { | |
| 167 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 168 | + | run() | |
| 169 | + | }) | |
| 170 | + | return _c | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func (_c *MockCAInfoService_RootCommonName_Call) Return(s string) *MockCAInfoService_RootCommonName_Call { | |
| 174 | + | _c.Call.Return(s) | |
| 175 | + | return _c | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func (_c *MockCAInfoService_RootCommonName_Call) RunAndReturn(run func() string) *MockCAInfoService_RootCommonName_Call { | |
| 179 | + | _c.Call.Return(run) | |
| 180 | + | return _c | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // RootFingerprint provides a mock function for the type MockCAInfoService | |
| 184 | + | func (_mock *MockCAInfoService) RootFingerprint() string { | |
| 185 | + | ret := _mock.Called() | |
| 186 | + | ||
| 187 | + | if len(ret) == 0 { | |
| 188 | + | panic("no return value specified for RootFingerprint") | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | var r0 string | |
| 192 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |
| 193 | + | r0 = returnFunc() | |
| 194 | + | } else { | |
| 195 | + | r0 = ret.Get(0).(string) | |
| 196 | + | } | |
| 197 | + | return r0 | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // MockCAInfoService_RootFingerprint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RootFingerprint' | |
| 201 | + | type MockCAInfoService_RootFingerprint_Call struct { | |
| 202 | + | *mock.Call | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // RootFingerprint is a helper method to define mock.On call | |
| 206 | + | func (_e *MockCAInfoService_Expecter) RootFingerprint() *MockCAInfoService_RootFingerprint_Call { | |
| 207 | + | return &MockCAInfoService_RootFingerprint_Call{Call: _e.mock.On("RootFingerprint")} | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func (_c *MockCAInfoService_RootFingerprint_Call) Run(run func()) *MockCAInfoService_RootFingerprint_Call { | |
| 211 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 212 | + | run() | |
| 213 | + | }) | |
| 214 | + | return _c | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func (_c *MockCAInfoService_RootFingerprint_Call) Return(s string) *MockCAInfoService_RootFingerprint_Call { | |
| 218 | + | _c.Call.Return(s) | |
| 219 | + | return _c | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func (_c *MockCAInfoService_RootFingerprint_Call) RunAndReturn(run func() string) *MockCAInfoService_RootFingerprint_Call { | |
| 223 | + | _c.Call.Return(run) | |
| 224 | + | return _c | |
| 225 | + | } | |
| @@ -0,0 +1,225 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | mock "github.com/stretchr/testify/mock" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // NewMockCAInfoService creates a new instance of MockCAInfoService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 14 | + | // The first argument is typically a *testing.T value. | |||
| 15 | + | func NewMockCAInfoService(t interface { | |||
| 16 | + | mock.TestingT | |||
| 17 | + | Cleanup(func()) | |||
| 18 | + | }) *MockCAInfoService { | |||
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 20 | + | helper.Helper() | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | mock := &MockCAInfoService{} | |||
| 24 | + | mock.Mock.Test(t) | |||
| 25 | + | ||||
| 26 | + | t.Cleanup(func() { | |||
| 27 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 28 | + | helper.Helper() | |||
| 29 | + | } | |||
| 30 | + | mock.AssertExpectations(t) | |||
| 31 | + | }) | |||
| 32 | + | ||||
| 33 | + | return mock | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // MockCAInfoService is an autogenerated mock type for the CAInfoService type | |||
| 37 | + | type MockCAInfoService struct { | |||
| 38 | + | mock.Mock | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type MockCAInfoService_Expecter struct { | |||
| 42 | + | mock *mock.Mock | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (_m *MockCAInfoService) EXPECT() *MockCAInfoService_Expecter { | |||
| 46 | + | return &MockCAInfoService_Expecter{mock: &_m.Mock} | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // IntermediateExpiresAt provides a mock function for the type MockCAInfoService | |||
| 50 | + | func (_mock *MockCAInfoService) IntermediateExpiresAt() time.Time { | |||
| 51 | + | ret := _mock.Called() | |||
| 52 | + | ||||
| 53 | + | if len(ret) == 0 { | |||
| 54 | + | panic("no return value specified for IntermediateExpiresAt") | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | var r0 time.Time | |||
| 58 | + | if returnFunc, ok := ret.Get(0).(func() time.Time); ok { | |||
| 59 | + | r0 = returnFunc() | |||
| 60 | + | } else { | |||
| 61 | + | r0 = ret.Get(0).(time.Time) | |||
| 62 | + | } | |||
| 63 | + | return r0 | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // MockCAInfoService_IntermediateExpiresAt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IntermediateExpiresAt' | |||
| 67 | + | type MockCAInfoService_IntermediateExpiresAt_Call struct { | |||
| 68 | + | *mock.Call | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // IntermediateExpiresAt is a helper method to define mock.On call | |||
| 72 | + | func (_e *MockCAInfoService_Expecter) IntermediateExpiresAt() *MockCAInfoService_IntermediateExpiresAt_Call { | |||
| 73 | + | return &MockCAInfoService_IntermediateExpiresAt_Call{Call: _e.mock.On("IntermediateExpiresAt")} | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (_c *MockCAInfoService_IntermediateExpiresAt_Call) Run(run func()) *MockCAInfoService_IntermediateExpiresAt_Call { | |||
| 77 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 78 | + | run() | |||
| 79 | + | }) | |||
| 80 | + | return _c | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (_c *MockCAInfoService_IntermediateExpiresAt_Call) Return(time1 time.Time) *MockCAInfoService_IntermediateExpiresAt_Call { | |||
| 84 | + | _c.Call.Return(time1) | |||
| 85 | + | return _c | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (_c *MockCAInfoService_IntermediateExpiresAt_Call) RunAndReturn(run func() time.Time) *MockCAInfoService_IntermediateExpiresAt_Call { | |||
| 89 | + | _c.Call.Return(run) | |||
| 90 | + | return _c | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // RootCertificate provides a mock function for the type MockCAInfoService | |||
| 94 | + | func (_mock *MockCAInfoService) RootCertificate() []byte { | |||
| 95 | + | ret := _mock.Called() | |||
| 96 | + | ||||
| 97 | + | if len(ret) == 0 { | |||
| 98 | + | panic("no return value specified for RootCertificate") | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | var r0 []byte | |||
| 102 | + | if returnFunc, ok := ret.Get(0).(func() []byte); ok { | |||
| 103 | + | r0 = returnFunc() | |||
| 104 | + | } else { | |||
| 105 | + | if ret.Get(0) != nil { | |||
| 106 | + | r0 = ret.Get(0).([]byte) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | return r0 | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // MockCAInfoService_RootCertificate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RootCertificate' | |||
| 113 | + | type MockCAInfoService_RootCertificate_Call struct { | |||
| 114 | + | *mock.Call | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // RootCertificate is a helper method to define mock.On call | |||
| 118 | + | func (_e *MockCAInfoService_Expecter) RootCertificate() *MockCAInfoService_RootCertificate_Call { | |||
| 119 | + | return &MockCAInfoService_RootCertificate_Call{Call: _e.mock.On("RootCertificate")} | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func (_c *MockCAInfoService_RootCertificate_Call) Run(run func()) *MockCAInfoService_RootCertificate_Call { | |||
| 123 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 124 | + | run() | |||
| 125 | + | }) | |||
| 126 | + | return _c | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func (_c *MockCAInfoService_RootCertificate_Call) Return(bytes []byte) *MockCAInfoService_RootCertificate_Call { | |||
| 130 | + | _c.Call.Return(bytes) | |||
| 131 | + | return _c | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func (_c *MockCAInfoService_RootCertificate_Call) RunAndReturn(run func() []byte) *MockCAInfoService_RootCertificate_Call { | |||
| 135 | + | _c.Call.Return(run) | |||
| 136 | + | return _c | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // RootCommonName provides a mock function for the type MockCAInfoService | |||
| 140 | + | func (_mock *MockCAInfoService) RootCommonName() string { | |||
| 141 | + | ret := _mock.Called() | |||
| 142 | + | ||||
| 143 | + | if len(ret) == 0 { | |||
| 144 | + | panic("no return value specified for RootCommonName") | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | var r0 string | |||
| 148 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |||
| 149 | + | r0 = returnFunc() | |||
| 150 | + | } else { | |||
| 151 | + | r0 = ret.Get(0).(string) | |||
| 152 | + | } | |||
| 153 | + | return r0 | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // MockCAInfoService_RootCommonName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RootCommonName' | |||
| 157 | + | type MockCAInfoService_RootCommonName_Call struct { | |||
| 158 | + | *mock.Call | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // RootCommonName is a helper method to define mock.On call | |||
| 162 | + | func (_e *MockCAInfoService_Expecter) RootCommonName() *MockCAInfoService_RootCommonName_Call { | |||
| 163 | + | return &MockCAInfoService_RootCommonName_Call{Call: _e.mock.On("RootCommonName")} | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func (_c *MockCAInfoService_RootCommonName_Call) Run(run func()) *MockCAInfoService_RootCommonName_Call { | |||
| 167 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 168 | + | run() | |||
| 169 | + | }) | |||
| 170 | + | return _c | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func (_c *MockCAInfoService_RootCommonName_Call) Return(s string) *MockCAInfoService_RootCommonName_Call { | |||
| 174 | + | _c.Call.Return(s) | |||
| 175 | + | return _c | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func (_c *MockCAInfoService_RootCommonName_Call) RunAndReturn(run func() string) *MockCAInfoService_RootCommonName_Call { | |||
| 179 | + | _c.Call.Return(run) | |||
| 180 | + | return _c | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // RootFingerprint provides a mock function for the type MockCAInfoService | |||
| 184 | + | func (_mock *MockCAInfoService) RootFingerprint() string { | |||
| 185 | + | ret := _mock.Called() | |||
| 186 | + | ||||
| 187 | + | if len(ret) == 0 { | |||
| 188 | + | panic("no return value specified for RootFingerprint") | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | var r0 string | |||
| 192 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |||
| 193 | + | r0 = returnFunc() | |||
| 194 | + | } else { | |||
| 195 | + | r0 = ret.Get(0).(string) | |||
| 196 | + | } | |||
| 197 | + | return r0 | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // MockCAInfoService_RootFingerprint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RootFingerprint' | |||
| 201 | + | type MockCAInfoService_RootFingerprint_Call struct { | |||
| 202 | + | *mock.Call | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // RootFingerprint is a helper method to define mock.On call | |||
| 206 | + | func (_e *MockCAInfoService_Expecter) RootFingerprint() *MockCAInfoService_RootFingerprint_Call { | |||
| 207 | + | return &MockCAInfoService_RootFingerprint_Call{Call: _e.mock.On("RootFingerprint")} | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func (_c *MockCAInfoService_RootFingerprint_Call) Run(run func()) *MockCAInfoService_RootFingerprint_Call { | |||
| 211 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 212 | + | run() | |||
| 213 | + | }) | |||
| 214 | + | return _c | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func (_c *MockCAInfoService_RootFingerprint_Call) Return(s string) *MockCAInfoService_RootFingerprint_Call { | |||
| 218 | + | _c.Call.Return(s) | |||
| 219 | + | return _c | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func (_c *MockCAInfoService_RootFingerprint_Call) RunAndReturn(run func() string) *MockCAInfoService_RootFingerprint_Call { | |||
| 223 | + | _c.Call.Return(run) | |||
| 224 | + | return _c | |||
| 225 | + | } | |||