detached 598d46d

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:
5454 VolumeService:
5555 PublicTLSService:
5656 TrafficStatusService:
57+ CAInfoService:
5758 AppService:
5859 AppReconciler:
5960 # 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.
1414
1515 All subcommands require Gordon to have TLS-capable HTTPS fallback configured on an entrypoint such as `entrypoints.edge` with `protocol = "smart_tcp"`.
1616
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+
1727 ---
1828
1929 ## gordon ca export
@@ -23,6 +33,7 @@ Export the root CA certificate for manual trust installation on clients.
2333 ```bash
2434 gordon ca export # Print PEM to stdout
2535 gordon ca export --out ca.pem # Write to file
36+gordon ca export --remote prod # Fetch the CA from a remote Gordon
2637 ```
2738
2839 ### Options
@@ -40,6 +51,7 @@ Show CA status information: root common name, SHA-256 fingerprint, and intermedi
4051 ```bash
4152 gordon ca info
4253 gordon ca info --json
54+gordon ca info --remote prod
4355 ```
4456
4557 ### Options
@@ -63,13 +75,19 @@ gordon ca info --json
6375
6476 ## gordon ca install
6577
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.
6779
6880 ```bash
6981 sudo gordon ca install
7082 sudo gordon ca install --uninstall
7183 ```
7284
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+
7391 ### Options
7492
7593 | Option | Description |
@@ -77,7 +95,7 @@ sudo gordon ca install --uninstall
7795 | `--uninstall` | Remove the CA from trust stores instead of installing |
7896 | `--json` | Output as JSON |
7997
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.
8199
82100 ---
83101
@@ -122,3 +140,4 @@ Visit `http://<gordon-host>/.well-known/gordon/ca` from the device browser. The
122140
123141 - [Server Configuration](../config/server.md) — `force_https_redirect`, smart TCP edge, and internal CA settings
124142 - [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 export29 ## gordon ca export
@@ -23,6 +33,7 @@ Export the root CA certificate for manual trust installation on clients.
23 ```bash33 ```bash
24 gordon ca export # Print PEM to stdout34 gordon ca export # Print PEM to stdout
25 gordon ca export --out ca.pem # Write to file35 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 ### Options39 ### Options
@@ -40,6 +51,7 @@ Show CA status information: root common name, SHA-256 fingerprint, and intermedi
40 ```bash51 ```bash
41 gordon ca info52 gordon ca info
42 gordon ca info --json53 gordon ca info --json
54+gordon ca info --remote prod
43 ```55 ```
44 56
45 ### Options57 ### Options
@@ -63,13 +75,19 @@ gordon ca info --json
63 75
64 ## gordon ca install76 ## 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 ```bash80 ```bash
69 sudo gordon ca install81 sudo gordon ca install
70 sudo gordon ca install --uninstall82 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 ### Options91 ### 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 settings141 - [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:
1616 |---------|-------------|---------------|
1717 | `gordon serve` | Start the Gordon server | [serve](./serve.md) |
1818 | `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) |
2020
2121 ## Management Commands (local or remote)
2222
@@ -26,6 +26,7 @@ Management commands use the authenticated daemon API. By default they connect th
2626 |---------|-------------|---------------|
2727 | `gordon apps` | Manage applications, operations, and app secrets | [apps](./apps.md) |
2828 | `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) |
2930 | `gordon daemon` | Daemon status, logs, reload, config, TLS, traffic, and networks | [daemon](./daemon.md) |
3031 | `gordon images` | Push, list, and prune images | [images](./images.md) |
3132 | `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"
337337 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:
338338
339339 - `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
342342 - 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
343343
344344 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 entirely339 - `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 stores340+- `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 installation341+- `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 Android342 - 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 (
44 "context"
55 "crypto/x509"
66 "encoding/pem"
7+ "errors"
78 "fmt"
89 "io"
910 "os"
1011 "path/filepath"
12+ "strings"
1113 "time"
1214
1315 "github.com/spf13/cobra"
@@ -29,19 +31,48 @@ func newCACmd() *cobra.Command {
2931 return cmd
3032 }
3133
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+
3255 func newCAExportCmd() *cobra.Command {
3356 var outPath string
3457
3558 cmd := &cobra.Command{
3659 Use: "export",
3760 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.",
3962 RunE: func(cmd *cobra.Command, _ []string) error {
40- dataDir, err := resolveCADataDir()
63+ details, err := loadCADetails(cmd.Context())
4164 if err != nil {
4265 return err
4366 }
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)
4576 },
4677 }
4778
@@ -59,9 +90,12 @@ func newCAInstallCmd() *cobra.Command {
5990 cmd := &cobra.Command{
6091 Use: "install",
6192 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.",
6394 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)
6599 if err != nil {
66100 return err
67101 }
@@ -75,6 +109,24 @@ func newCAInstallCmd() *cobra.Command {
75109 return cmd
76110 }
77111
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+
78130 func newCAInfoCmd() *cobra.Command {
79131 var jsonOut bool
80132
@@ -82,11 +134,11 @@ func newCAInfoCmd() *cobra.Command {
82134 Use: "info",
83135 Short: "Show CA status information",
84136 RunE: func(cmd *cobra.Command, _ []string) error {
85- dataDir, err := resolveCADataDir()
137+ details, err := loadCADetails(cmd.Context())
86138 if err != nil {
87139 return err
88140 }
89- return runCAInfo(cmd.Context(), cmd.OutOrStdout(), dataDir, jsonOut)
141+ return runCAInfo(cmd.OutOrStdout(), details, jsonOut)
90142 },
91143 }
92144
@@ -95,17 +147,99 @@ func newCAInfoCmd() *cobra.Command {
95147 return cmd
96148 }
97149
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) {
99221 local, err := GetLocalServices(cliConfigPath)
100222 if err != nil {
101223 return "", err
102224 }
103225 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)
105227 }
106228 return local.GetDataDir(), nil
107229 }
108230
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+
109243 // loadCAFromDataDir creates a CA adapter directly (bypassing the usecase layer).
110244 // This is intentional: CLI commands like 'ca export' and 'ca info' are standalone
111245 // 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)
117251 return ca, nil
118252 }
119253
120-func runCAExport(_ context.Context, out io.Writer, dataDir, outPath string) error {
254+func loadLocalCADetails(dataDir string) (*caDetails, error) {
121255 ca, err := loadCAFromDataDir(dataDir)
122256 if err != nil {
123- return err
257+ return nil, err
124258 }
259+ return &caDetails{
260+ rootPEM: ca.RootCertificate(),
261+ rootCN: ca.RootCommonName(),
262+ fingerprint: ca.RootFingerprint(),
263+ intermediateExpiry: ca.IntermediateExpiresAt(),
264+ }, nil
265+}
125266
126- rootPEM := ca.RootCertificate()
127-
267+func runCAExport(out io.Writer, details *caDetails, outPath string) error {
128268 if outPath != "" {
129269 if err := os.MkdirAll(filepath.Dir(outPath), 0750); err != nil {
130270 return fmt.Errorf("create directory for %s: %w", outPath, err)
131271 }
132- if err := os.WriteFile(outPath, rootPEM, 0600); err != nil {
272+ if err := os.WriteFile(outPath, details.rootPEM, 0600); err != nil {
133273 return fmt.Errorf("write certificate to %s: %w", outPath, err)
134274 }
135275 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Root CA certificate written to %s", outPath)))
136276 }
137277
138- _, err = out.Write(rootPEM)
278+ _, err := out.Write(details.rootPEM)
139279 return err
140280 }
141281
@@ -174,28 +314,22 @@ func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, j
174314 return cliWriteLine(out, cliRenderSuccess("Root CA installed in system trust stores (system, Firefox, Java)"))
175315 }
176316
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)
185319
186320 if jsonOut {
187321 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),
191325 "intermediate_ttl": remaining.String(),
192326 })
193327 }
194328
195- if err := cliWriteLine(out, cliRenderMeta("Root CA:", ca.RootCommonName())); err != nil {
329+ if err := cliWriteLine(out, cliRenderMeta("Root CA:", details.rootCN)); err != nil {
196330 return err
197331 }
198- if err := cliWriteLine(out, cliRenderMeta("Fingerprint:", "SHA256:"+ca.RootFingerprint())); err != nil {
332+ if err := cliWriteLine(out, cliRenderMeta("Fingerprint:", "SHA256:"+details.fingerprint)); err != nil {
199333 return err
200334 }
201335 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 cmd31 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 string56 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 err65 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 err100 return err
67 }101 }
@@ -75,6 +109,24 @@ func newCAInstallCmd() *cobra.Command {
75 return cmd109 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 bool131 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 err139 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 cmd147 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 "", err223 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(), nil228 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 standalone244 // 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, nil251 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 err257+ 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 err279 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 err330 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 err333 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 {
3131 GetStatus(ctx context.Context) (*remote.Status, error)
3232 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)
3333 GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error)
34+ GetCA(ctx context.Context) (*dto.CAResponse, error)
3435 Reload(ctx context.Context) error
3536 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
3637 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) error35 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 {
1818 configSvc in.ConfigService
1919 dataDir string
2020 tlsEnabled bool
21+ configFile string
2122 }
2223
2324 // GetConfigService returns the config service.
@@ -30,6 +31,11 @@ func (l *LocalServices) GetDataDir() string {
3031 return l.dataDir
3132 }
3233
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+
3339 // HasInternalTLS reports whether a TLS-capable entrypoint enables the internal CA.
3440 func (l *LocalServices) HasInternalTLS() bool {
3541 return l.tlsEnabled
@@ -71,6 +77,7 @@ func GetLocalServices(cliConfigPath string) (*LocalServices, error) {
7177 configSvc: configSvc,
7278 dataDir: dataDir,
7379 tlsEnabled: hasLocalTLSCapableEntrypoint(v),
80+ configFile: v.ConfigFileUsed(),
7481 }, nil
7582 }
7683
@@ -18,6 +18,7 @@ type LocalServices struct {
18 configSvc in.ConfigService18 configSvc in.ConfigService
19 dataDir string19 dataDir string
20 tlsEnabled bool20 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.dataDir31 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.tlsEnabled41 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 }, nil81 }, 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
390390 return _c
391391 }
392392
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+
393455 // GetConfig provides a mock function for the type MockControlPlane
394456 func (_mock *MockControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) {
395457 ret := _mock.Called(ctx)
@@ -390,6 +390,68 @@ func (_c *MockControlPlane_DiffApp_Call) RunAndReturn(run func(ctx context.Conte
390 return _c390 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 MockControlPlane455 // 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
522522 return &status, nil
523523 }
524524
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+
525540 // GetStatus returns the Gordon server status.
526541 func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
527542 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, nil522 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) {
175175 assert.Equal(t, int64(1), status.Counters.ActiveTCPConnections)
176176 }
177177
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+
178208 func TestClientGetTLSStatus(t *testing.T) {
179209 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
180210 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
5858 gordon apps; gordon images push transfers OCI content only and never deploys.
5959
6060 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)
6262 Management: Work locally or remotely via --remote flag (apps, daemon, images, etc.)
6363 Client-only: CLI utilities that don't require a running Gordon server`,
6464 }
@@ -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 {
4747 reloadTrigger reloadTrigger
4848 publicTLSSvc in.PublicTLSService
4949 trafficSvc in.TrafficStatusService
50+ caSvc in.CAInfoService
5051 appSvc in.AppService
5152 log zerowrap.Logger
5253 }
@@ -124,6 +125,7 @@ type HandlerDeps struct {
124125 ReloadTrigger reloadTrigger
125126 PublicTLSSvc in.PublicTLSService
126127 TrafficSvc in.TrafficStatusService
128+ CASvc in.CAInfoService
127129 AppSvc in.AppService
128130 }
129131
@@ -143,6 +145,7 @@ func NewHandler(deps HandlerDeps) *Handler {
143145 reloadTrigger: deps.ReloadTrigger,
144146 publicTLSSvc: deps.PublicTLSSvc,
145147 trafficSvc: deps.TrafficSvc,
148+ caSvc: deps.CASvc,
146149 appSvc: deps.AppSvc,
147150 log: deps.Log,
148151 }
@@ -199,6 +202,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
199202 "/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) },
200203 "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) },
201204 "/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) },
202206 }
203207 if handler, ok := exactRoutes[path]; ok {
204208 return handler, true
@@ -47,6 +47,7 @@ type Handler struct {
47 reloadTrigger reloadTrigger47 reloadTrigger reloadTrigger
48 publicTLSSvc in.PublicTLSService48 publicTLSSvc in.PublicTLSService
49 trafficSvc in.TrafficStatusService49 trafficSvc in.TrafficStatusService
50+ caSvc in.CAInfoService
50 appSvc in.AppService51 appSvc in.AppService
51 log zerowrap.Logger52 log zerowrap.Logger
52 }53 }
@@ -124,6 +125,7 @@ type HandlerDeps struct {
124 ReloadTrigger reloadTrigger125 ReloadTrigger reloadTrigger
125 PublicTLSSvc in.PublicTLSService126 PublicTLSSvc in.PublicTLSService
126 TrafficSvc in.TrafficStatusService127 TrafficSvc in.TrafficStatusService
128+ CASvc in.CAInfoService
127 AppSvc in.AppService129 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, true208 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 }
9393 // RootFingerprint returns the SHA-256 fingerprint of the root CA cert
9494 // formatted as colon-separated hex.
9595 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)
97103 parts := make([]string, 0, sha256.Size)
98104 for _, b := range sum {
99105 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 cert93 // 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() {
438438 PublicTLSSvc: si.svc.publicTLSSvc,
439439 TrafficSvc: si.svc.trafficManager,
440440 AppSvc: si.svc.appSvc,
441+ CASvc: si.caInfoProvider(),
441442 })
442443 }
443444
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+
444454 // injectTelemetryMetrics creates and injects OTel metrics into services when
445455 // telemetry is enabled. Skipped otherwise to avoid unnecessary allocations.
446456 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 when454 // 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close