0.34.2_agent
This commit is contained in:
1 parent
b2b5b45d8a
commit
8c0de520d7
13 files changed
+820
-123
No files matched your search
+83
-23
@@ -1,46 +1,106 @@
|
||||
# RustProxy Docker Agent
|
||||
# RustProxy Docker Agent: Linux DEB/RPM or manual installation
|
||||
|
||||
Small read-only discovery service for Docker hosts. It talks to the local Docker Engine Unix socket, reports running containers with non-loopback published TCP ports, preserves every host binding for each service, and exposes the inventory to RustProxy through a Bearer-token protected HTTPS endpoint.
|
||||
The optional `rustproxy-docker-agent` runs **on a Docker host**, not on the RustProxy server. It exposes a read-only, Bearer-protected Docker discovery API over HTTPS. It reports containers with published TCP ports, host bindings, and usable host addresses. RustProxy itself never needs access to the Docker socket.
|
||||
|
||||
The inventory also reports multiple usable host addresses: active host interface addresses, the primary routed address, an optional public-IP lookup result, and the optional advertised/preferred address. Loopback/link-local and obvious Docker-generated bridge/veth interfaces are omitted from the host-address list.
|
||||
## Requirements and security
|
||||
|
||||
## TLS by default
|
||||
- Linux amd64/x86_64 with **systemd and Docker Engine** (including a usable `/var/run/docker.sock`). No FreeBSD `pkg` is built for this agent.
|
||||
- Restrict inbound port `9876` to the RustProxy server through a firewall. The example binds `0.0.0.0:9876`; this is reachable from other machines when the firewall permits it.
|
||||
- Access to `docker.sock` grants substantial **host-level privilege**, regardless of the agent's read-only HTTP API. The service uses a dedicated unprivileged identity granted access to the `docker` group.
|
||||
- Self-signed ECDSA P-256 HTTPS is enabled by default; the certificate and private key persist under `/var/lib/rustproxy-docker-agent/` and are **not** regenerated on upgrades/restarts. The key is `0600`.
|
||||
- The Bearer token is generated using a cryptographically secure random source, saved to `/etc/rustproxy/docker-agent.env` with mode `0600`, and preserved on upgrade. Never include it in tickets, unattended logs, or screenshots.
|
||||
|
||||
HTTPS is enabled by default. On first start the agent creates a persistent ECDSA P-256 self-signed certificate under `/var/lib/rustproxy-docker-agent/` and reuses it across restarts. The private key is written with mode `0600`.
|
||||
## Recommended: install from DEB/RPM
|
||||
|
||||
The installer prints the certificate SHA-256 fingerprint. Configure that fingerprint in **RustProxy -> Settings -> Docker agents**. RustProxy then pins the exact agent certificate while still verifying the TLS handshake signatures. A changed/replaced certificate is rejected until the configured fingerprint is updated.
|
||||
The Linux package repository publishes **two independent packages** built from the same source commit: `rustproxy` (main server) and `rustproxy-docker-agent` (Docker host). Installing the latter does **not** install the main RustProxy server and does **not** require Go on the destination host.
|
||||
|
||||
Show the current fingerprint again with:
|
||||
On a Debian/Ubuntu Docker host, after configuring the RustProxy signed APT repository:
|
||||
|
||||
```bash
|
||||
sudo rustproxy-docker-agent --print-fingerprint
|
||||
sudo apt update
|
||||
sudo apt install rustproxy-docker-agent
|
||||
# Or install a downloaded artifact directly:
|
||||
sudo apt install ./rustproxy-docker-agent_<version>-<release>_amd64.deb
|
||||
```
|
||||
|
||||
If you replace the generated certificate with one signed by a normal public CA, the fingerprint field in RustProxy may be left empty so standard CA/hostname validation is used. Plain HTTP is available only when `RUSTPROXY_DOCKER_AGENT_TLS=false`; it is not the default.
|
||||
|
||||
## Quick install on systemd
|
||||
On a RHEL/Rocky/Alma/Fedora Docker host, after configuring the signed RPM repository:
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
sudo dnf install rustproxy-docker-agent
|
||||
# Or install a downloaded artifact directly:
|
||||
sudo dnf install ./rustproxy-docker-agent-<version>-<release>.x86_64.rpm
|
||||
```
|
||||
|
||||
The installer runs tests, builds the binary, creates the service account, writes a random token on first install, installs the hardened unit, starts it and prints the TLS fingerprint.
|
||||
Find APT/DNF repository configuration in the Package Builder README or on the repository's index page. The package post-install script creates the system account/state directory, generates missing token and TLS identity, enables the service and starts it when Docker is available. It **does not print the secret token** into package manager logs. If Docker isn't installed/running, the package can be installed first, but the service cannot run until Docker/socket/group are available.
|
||||
|
||||
## Build manually
|
||||
Show all connection details **locally, as root**:
|
||||
|
||||
```bash
|
||||
sudo rustproxy-docker-agent info
|
||||
sudo rustproxy-docker-agent info --show-token
|
||||
sudo rustproxy-docker-agent info --json # machine-readable, redacted by default
|
||||
sudo rustproxy-docker-agent print-fingerprint
|
||||
sudo systemctl status rustproxy-docker-agent
|
||||
sudo journalctl -u rustproxy-docker-agent -n 100 --no-pager
|
||||
```
|
||||
|
||||
Configure **RustProxy → Settings → Docker agents** with the host's `https://host:9876` URL, the full Bearer token, and the printed certificate SHA-256 fingerprint. The RustProxy server pins the certificate when a fingerprint is configured; a replaced certificate requires updating the fingerprint in the panel.
|
||||
|
||||
## CLI commands
|
||||
|
||||
| Command | Behavior |
|
||||
|---|---|
|
||||
| `rustproxy-docker-agent serve` (or no arguments) | Run the discovery API, auto-create a missing TLS certificate with the service account, log endpoint/cert details and **redacted** token status |
|
||||
| `sudo rustproxy-docker-agent init` | Create missing token and self-signed TLS identity; keep existing valid values and certificate |
|
||||
| `sudo rustproxy-docker-agent info` | Show listening endpoint, hostname, Docker socket, token status/length, TLS certificate paths, SHA-256, issuer, subject, SAN, serial, algorithms and validity |
|
||||
| `sudo rustproxy-docker-agent info --show-token` | Explicitly reveal the full Bearer token in addition to metadata |
|
||||
| `sudo rustproxy-docker-agent show-token` | Print **only** the full secret token (protect stdout) |
|
||||
| `sudo rustproxy-docker-agent rotate-token` | Create a new token without replacing the certificate; **restart the service and update RustProxy** before continuing |
|
||||
| `sudo rustproxy-docker-agent cert-init` | Create a missing certificate/key and print SHA-256; never overwrites existing valid certificate/key |
|
||||
| `sudo rustproxy-docker-agent print-fingerprint` | Print current SHA-256 certificate fingerprint |
|
||||
| `rustproxy-docker-agent version` | Print agent version |
|
||||
|
||||
All subcommands accept `--env-file /path/to/file` and relevant connection/TLS overrides such as `--cert-file`, `--key-file`, `--hostname` and `--listen`. Older `--init-cert` and `--print-fingerprint` flags still work. Use `--help` for options; it never includes the configured token in default values.
|
||||
|
||||
To rotate the token:
|
||||
|
||||
```bash
|
||||
sudo rustproxy-docker-agent rotate-token
|
||||
sudo systemctl restart rustproxy-docker-agent
|
||||
sudo rustproxy-docker-agent info --show-token
|
||||
```
|
||||
|
||||
**Important:** The previous token becomes invalid after restart. Update the token in RustProxy Settings. Do not paste the full token into public logs. At normal startup only nonsecret certificate details and token presence/length are written to the journal.
|
||||
|
||||
## Optional: compile and install manually
|
||||
|
||||
From `docker-agent/` in this source tree (Go 1.22+ required):
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go build -trimpath -ldflags='-s -w' -o rustproxy-docker-agent .
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
## systemd
|
||||
The installer builds Go, installs `/usr/local/bin/rustproxy-docker-agent`, creates the same service account/config/state layout, calls the Go CLI `init`, enables/starts systemd and prints the certificate and token for interactive setup. Subsequent manual installations preserve existing credentials.
|
||||
|
||||
1. Create a dedicated `rustproxy-docker-agent` system user and grant it read access to the Docker socket (commonly by membership in the `docker` group). Docker socket access is privileged; isolate this account and the host firewall.
|
||||
2. Copy the binary to `/usr/local/bin/rustproxy-docker-agent`.
|
||||
3. Copy `docker-agent.env.example` to `/etc/rustproxy/docker-agent.env`, generate a long random token and bind only to an address reachable by RustProxy.
|
||||
4. Ensure `/var/lib/rustproxy-docker-agent` is writable only by the service identity. The shipped systemd unit creates it through `StateDirectory=`.
|
||||
5. Copy `rustproxy-docker-agent.service` to `/etc/systemd/system/`, then run `systemctl daemon-reload && systemctl enable --now rustproxy-docker-agent`.
|
||||
For completely manual setup, create a system account with access to the `docker` group; install the binary; copy `docker-agent.env.example` into `/etc/rustproxy/docker-agent.env`; make the env file root-only; and place `rustproxy-docker-agent.service` under `/etc/systemd/system`. Create a writable `/var/lib/rustproxy-docker-agent` owned by the service account, then run `sudo rustproxy-docker-agent init`, `sudo systemctl daemon-reload` and `sudo systemctl enable --now rustproxy-docker-agent`.
|
||||
|
||||
The inventory endpoint is `GET /v1/inventory` with `Authorization: Bearer <token>`. `/healthz` does not expose Docker inventory. Both are served over HTTPS by default.
|
||||
### Switching from manual to package-managed
|
||||
|
||||
`RUSTPROXY_DOCKER_ADVERTISE_IP` marks a preferred address but does not suppress automatic discovery of the host's other addresses. `RUSTPROXY_DOCKER_PUBLIC_IP_URL` adds the externally observed address when enabled. RustProxy can choose a concrete reported address independently for every Docker-backed vhost.
|
||||
Both installation types use the **same** config and certificate paths, but different binary paths (`/usr/local/bin` versus `/usr/bin`). A manual unit in `/etc/systemd/system/` takes precedence over the package unit in `/usr/lib/systemd/system/`. To migrate, first back up `/etc/rustproxy/docker-agent.env` and `/var/lib/rustproxy-docker-agent/`, stop the service, and move the manual service unit outside systemd's unit directories. Run `systemctl daemon-reload`, install the DEB/RPM, and inspect `systemctl cat rustproxy-docker-agent`. Remove the obsolete `/usr/local/bin` binary only after verifying the packaged service. Do **not** delete or regenerate the existing token/key.
|
||||
|
||||
### Removing or upgrading the package
|
||||
|
||||
Upgrades do not reset credentials or private state. The environment file is installed with `config|noreplace`; local edits remain in place according to package-manager conffile behavior. Removal does not delete `/etc/rustproxy/docker-agent.env` or `/var/lib/rustproxy-docker-agent` (back these up separately).
|
||||
|
||||
## API and troubleshooting
|
||||
|
||||
The API is `GET /v1/inventory` with `Authorization: Bearer <token>`; `GET /healthz` has no inventory and requires no token. Both use HTTPS by default. Plain HTTP is possible with `RUSTPROXY_DOCKER_AGENT_TLS=false` but exposes credentials in transit; use it only in a secured network and never across the public Internet.
|
||||
|
||||
- `no valid token`: execute `sudo rustproxy-docker-agent init`; check env file permissions and `EnvironmentFile`.
|
||||
- `Docker socket unavailable`: install/start Docker Engine, check `/var/run/docker.sock`, the `docker` group and membership of `rustproxy-docker-agent`.
|
||||
- `TLS certificate/key`: inspect ownership of `/var/lib/rustproxy-docker-agent/`, mode `0600` on the private key, and both certificate/key paths. Refuse partially missing pairs rather than overwriting them.
|
||||
- `x509` or fingerprint mismatch in RustProxy: compare `sudo rustproxy-docker-agent print-fingerprint` with the pin configured in Settings.
|
||||
- Cannot reach port `9876`: check `RUSTPROXY_DOCKER_AGENT_LISTEN`, listening socket, firewall, and that HTTPS is used.
|
||||
|
||||
`RUSTPROXY_DOCKER_ADVERTISE_IP` adds a preferred host address without suppressing automatic address discovery. `RUSTPROXY_DOCKER_PUBLIC_IP_URL` controls the optional externally observed public IP. The agent's Docker socket access remains privileged; isolate the Docker host and network accordingly.
|
||||
@@ -24,18 +24,26 @@ func certificateFingerprintDER(der []byte) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func certificateFingerprint(certFile string) (string, error) {
|
||||
func readCertificate(certFile string) (*x509.Certificate, error) {
|
||||
raw, err := os.ReadFile(certFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
block, _ := pem.Decode(raw)
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return "", fmt.Errorf("%s does not contain a PEM certificate", certFile)
|
||||
return nil, fmt.Errorf("%s does not contain a PEM certificate", certFile)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse certificate: %w", err)
|
||||
return nil, fmt.Errorf("parse certificate: %w", err)
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
func certificateFingerprint(certFile string) (string, error) {
|
||||
cert, err := readCertificate(certFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return certificateFingerprintDER(cert.Raw), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCLIProvisionShowRedactAndRotate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := filepath.Join(dir, "docker-agent.env")
|
||||
cert := filepath.Join(dir, "state", "agent.crt")
|
||||
key := filepath.Join(dir, "state", "agent.key")
|
||||
initial := "# keep comment\nRUSTPROXY_DOCKER_AGENT_LISTEN=0.0.0.0:19876\n" +
|
||||
"RUSTPROXY_DOCKER_AGENT_TOKEN=replace-with-at-least-24-random-characters\n" +
|
||||
"RUSTPROXY_DOCKER_AGENT_HOSTNAME=docker.lab\n"
|
||||
if err := os.WriteFile(env, []byte(initial), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := func(command string, flags ...string) string {
|
||||
t.Helper()
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := append([]string{command, "--env-file", env, "--cert-file", cert, "--key-file", key}, flags...)
|
||||
if err := run(args, &stdout, &stderr); err != nil {
|
||||
t.Fatalf("run(%s): %v (stderr: %s)", command, err, stderr.String())
|
||||
}
|
||||
return stdout.String()
|
||||
}
|
||||
output := call("init")
|
||||
if !strings.Contains(output, "Certificate SHA-256:") {
|
||||
t.Fatalf("init output %q", output)
|
||||
}
|
||||
token := strings.TrimSpace(call("show-token"))
|
||||
if len(token) != 64 {
|
||||
t.Fatalf("unexpected token length %d", len(token))
|
||||
}
|
||||
if strings.TrimSpace(call("show-token")) != token {
|
||||
t.Fatal("token changed on read")
|
||||
}
|
||||
if !strings.Contains(call("info"), "Token redacted") || strings.Contains(call("info"), token) {
|
||||
t.Fatal("info leaked the bearer token")
|
||||
}
|
||||
if !strings.Contains(call("info", "--show-token"), token) {
|
||||
t.Fatal("explicit info did not show token")
|
||||
}
|
||||
if !strings.Contains(call("init"), "Provisioning complete") {
|
||||
t.Fatal("second init failed")
|
||||
}
|
||||
if strings.TrimSpace(call("show-token")) != token {
|
||||
t.Fatal("token changed on repeat init")
|
||||
}
|
||||
oldFingerprint, err := certificateFingerprint(cert)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if oldFingerprint != strings.TrimSpace(call("print-fingerprint")) {
|
||||
t.Fatal("fingerprint changed")
|
||||
}
|
||||
if mode, err := os.Stat(key); err != nil || mode.Mode().Perm() != 0600 {
|
||||
t.Fatalf("key mode: %v, %v", mode, err)
|
||||
}
|
||||
if mode, err := os.Stat(env); err != nil || mode.Mode().Perm() != 0600 {
|
||||
t.Fatalf("env mode: %v, %v", mode, err)
|
||||
}
|
||||
if !strings.Contains(call("rotate-token"), "Restart") {
|
||||
t.Fatal("missing rotation warning")
|
||||
}
|
||||
rotated := strings.TrimSpace(call("show-token"))
|
||||
if len(rotated) != 64 || rotated == token {
|
||||
t.Fatal("rotation did not change token")
|
||||
}
|
||||
raw, err := os.ReadFile(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "# keep comment") || strings.Count(string(raw), "RUSTPROXY_DOCKER_AGENT_TOKEN=") != 1 {
|
||||
t.Fatalf("rotation changed unrelated config or duplicated tokens: %s", raw)
|
||||
}
|
||||
if fp, err := certificateFingerprint(cert); err != nil || fp != oldFingerprint {
|
||||
t.Fatal("rotation changed certificate")
|
||||
}
|
||||
var info agentDetails
|
||||
if err := json.Unmarshal([]byte(call("info", "--json")), &info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Token != "" || !info.TokenConfigured || info.Certificate == nil || info.Certificate.SHA256 != oldFingerprint || info.Listen != "0.0.0.0:19876" {
|
||||
t.Fatalf("incorrect json info: %+v", info)
|
||||
}
|
||||
if info.Certificate.Subject == "" || info.Certificate.Issuer == "" || info.Certificate.NotAfter == "" || !info.Certificate.ValidNow {
|
||||
t.Fatalf("missing certificate details: %+v", info.Certificate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIHelpNeverLeaksToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := filepath.Join(dir, "agent.env")
|
||||
secret := strings.Repeat("very-secret-", 4)
|
||||
if err := os.WriteFile(env, []byte("RUSTPROXY_DOCKER_AGENT_TOKEN="+secret+"\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out, errout bytes.Buffer
|
||||
if err := run([]string{"--env-file", env, "--help"}, &out, &errout); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out.String()+errout.String(), secret) {
|
||||
t.Fatal("--help leaked token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIRejectsShortTokenWithoutSilentReplacement(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := filepath.Join(dir, "agent.env")
|
||||
if err := os.WriteFile(env, []byte("RUSTPROXY_DOCKER_AGENT_TOKEN=weak\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out, errout bytes.Buffer
|
||||
if err := run([]string{"init", "--env-file", env}, &out, &errout); err == nil {
|
||||
t.Fatal("short custom token must not be silently replaced")
|
||||
}
|
||||
got, err := os.ReadFile(env)
|
||||
if err != nil || !strings.Contains(string(got), "TOKEN=weak") {
|
||||
t.Fatalf("unexpected file modification: %s %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLICertWithoutToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cert := filepath.Join(dir, "cert.crt")
|
||||
key := filepath.Join(dir, "cert.key")
|
||||
var out, errout bytes.Buffer
|
||||
args := []string{"cert-init", "--env-file", filepath.Join(dir, "missing.env"), "--cert-file", cert, "--key-file", key}
|
||||
if err := run(args, &out, &errout); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(strings.TrimSpace(out.String())) != 64 {
|
||||
t.Fatal("invalid SHA fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIInfoCanReportMissingCertificate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var out, errout bytes.Buffer
|
||||
err := run([]string{"info", "--env-file", filepath.Join(dir, "missing.env"), "--cert-file", filepath.Join(dir, "no.crt")}, &out, &errout)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "TLS certificate: missing") {
|
||||
t.Fatalf("info: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIDoesNotFollowSymlinkToProvisionToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
original := filepath.Join(dir, "original")
|
||||
if err := os.WriteFile(original, []byte("untouched\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(dir, "agent.env")
|
||||
if err := os.Symlink(original, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := provisionToken(link, true); err == nil {
|
||||
t.Fatal("symlink must be rejected")
|
||||
}
|
||||
got, _ := os.ReadFile(original)
|
||||
if string(got) != "untouched\n" {
|
||||
t.Fatal("target file was modified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIRejectsExamplePlaceholderAsServerToken(t *testing.T) {
|
||||
if validToken("replace-with-at-least-24-random-characters") {
|
||||
t.Fatal("example placeholder accepted as a token")
|
||||
}
|
||||
if !validToken(strings.Repeat("t", 24)) {
|
||||
t.Fatal("valid random-like token incorrectly rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultEnvFile = "/etc/rustproxy/docker-agent.env"
|
||||
|
||||
// requestedEnvFile is handled before other flags so CLI calls can load the same
|
||||
// configuration as systemd. Both --env-file=X and --env-file X are supported.
|
||||
func requestedEnvFile(args []string) (string, error) {
|
||||
path := defaultEnvFile
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == "--env-file" || args[i] == "-env-file" {
|
||||
if i+1 >= len(args) {
|
||||
return "", errors.New("--env-file requires a path")
|
||||
}
|
||||
path = args[i+1]
|
||||
i++
|
||||
} else if strings.HasPrefix(args[i], "--env-file=") || strings.HasPrefix(args[i], "-env-file=") {
|
||||
_, path, _ = strings.Cut(args[i], "=")
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
return "", errors.New("--env-file cannot be empty")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Read a restricted, plain KEY=VALUE EnvironmentFile. Never execute it as shell.
|
||||
func readEnvironmentFile(path string) (map[string]string, error) {
|
||||
result := make(map[string]string)
|
||||
f, err := os.Open(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return result, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "export ")
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if strings.HasPrefix(key, "RUSTPROXY_DOCKER_") {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
|
||||
decoded, err := strconv.Unquote(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid quoted environment value for %s: %w", key, err)
|
||||
}
|
||||
value = decoded
|
||||
} else if len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'' {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func makeToken() (string, error) {
|
||||
data := make([]byte, 32)
|
||||
if _, err := rand.Read(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(data), nil
|
||||
}
|
||||
|
||||
func provisionToken(path string, rotate bool) (string, bool, error) {
|
||||
// Reject symlinks, devices and directories before opening anything.
|
||||
stat, err := os.Lstat(path)
|
||||
var raw []byte
|
||||
if err == nil {
|
||||
if !stat.Mode().IsRegular() {
|
||||
return "", false, fmt.Errorf("refusing non-regular environment file %s", path)
|
||||
}
|
||||
if stat.Size() > 1<<20 {
|
||||
return "", false, fmt.Errorf("environment file too large: %s", path)
|
||||
}
|
||||
raw, err = os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
values, err := readEnvironmentFile(path)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
token := values["RUSTPROXY_DOCKER_AGENT_TOKEN"]
|
||||
needsToken := token == "" || strings.HasPrefix(token, "replace-with-")
|
||||
if !rotate && !needsToken {
|
||||
if len(token) < 24 {
|
||||
return "", false, errors.New("configured Bearer token is shorter than 24 characters; use rotate-token")
|
||||
}
|
||||
return token, false, nil
|
||||
}
|
||||
token, err = makeToken()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
raw = []byte("# RustProxy Docker Agent: edit the listen address and firewall as needed.\n" +
|
||||
"RUSTPROXY_DOCKER_AGENT_LISTEN=0.0.0.0:9876\n" +
|
||||
"RUSTPROXY_DOCKER_AGENT_TLS=true\n")
|
||||
}
|
||||
// Preserve non-secret settings/comments, remove duplicate token assignments.
|
||||
lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
|
||||
filtered := make([]string, 0, len(lines)+1)
|
||||
for _, line := range lines {
|
||||
normalized := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "export "))
|
||||
key, _, ok := strings.Cut(normalized, "=")
|
||||
if ok && strings.TrimSpace(key) == "RUSTPROXY_DOCKER_AGENT_TOKEN" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, line)
|
||||
}
|
||||
filtered = append(filtered, "RUSTPROXY_DOCKER_AGENT_TOKEN="+token)
|
||||
if err := atomicWrite(path, []byte(strings.Join(filtered, "\n")+"\n"), 0600); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return token, true, nil
|
||||
}
|
||||
|
||||
// Generated keys must be readable by the unprivileged systemd service even
|
||||
// when 'init' is invoked via sudo during DEB/RPM or manual installation.
|
||||
func setServiceCertificateOwnership(certFile, keyFile string) error {
|
||||
if os.Geteuid() != 0 {
|
||||
return nil
|
||||
}
|
||||
serviceUser, err := user.Lookup("rustproxy-docker-agent")
|
||||
if err != nil {
|
||||
return nil
|
||||
} // Standalone CLI; warn operators to run init as service user.
|
||||
uid, err := strconv.Atoi(serviceUser.Uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gid, err := strconv.Atoi(serviceUser.Gid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chown(keyFile, uid, gid); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chown(certFile, uid, gid); err != nil {
|
||||
return err
|
||||
}
|
||||
// Parent generated via atomicWrite must also be traversable by the service.
|
||||
dir := filepath.Dir(keyFile)
|
||||
if dir == filepath.Dir(certFile) {
|
||||
if err := os.Chown(dir, uid, gid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type certDetails struct {
|
||||
Path string `json:"path"`
|
||||
KeyPath string `json:"key_path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Subject string `json:"subject"`
|
||||
Issuer string `json:"issuer"`
|
||||
Serial string `json:"serial"`
|
||||
NotBefore string `json:"not_before"`
|
||||
NotAfter string `json:"not_after"`
|
||||
DNSNames []string `json:"dns_names"`
|
||||
IPAddresses []string `json:"ip_addresses"`
|
||||
SignatureAlgorithm string `json:"signature_algorithm"`
|
||||
PublicKeyAlgorithm string `json:"public_key_algorithm"`
|
||||
ValidNow bool `json:"valid_now"`
|
||||
}
|
||||
|
||||
func describeCertificate(path string) (certDetails, error) {
|
||||
cert, err := readCertificate(path)
|
||||
if err != nil {
|
||||
return certDetails{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
result := certDetails{
|
||||
Path: path, SHA256: certificateFingerprintDER(cert.Raw), Subject: cert.Subject.String(),
|
||||
Issuer: cert.Issuer.String(), Serial: cert.SerialNumber.Text(16),
|
||||
NotBefore: cert.NotBefore.UTC().Format(time.RFC3339), NotAfter: cert.NotAfter.UTC().Format(time.RFC3339),
|
||||
DNSNames: cert.DNSNames, SignatureAlgorithm: cert.SignatureAlgorithm.String(),
|
||||
PublicKeyAlgorithm: cert.PublicKeyAlgorithm.String(),
|
||||
ValidNow: !now.Before(cert.NotBefore) && !now.After(cert.NotAfter),
|
||||
}
|
||||
for _, ip := range cert.IPAddresses {
|
||||
result.IPAddresses = append(result.IPAddresses, ip.String())
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type agentDetails struct {
|
||||
Version string `json:"version"`
|
||||
Listen string `json:"listen"`
|
||||
TLS bool `json:"tls"`
|
||||
Hostname string `json:"hostname"`
|
||||
DockerSocket string `json:"docker_socket"`
|
||||
EnvironmentFile string `json:"environment_file"`
|
||||
TokenConfigured bool `json:"token_configured"`
|
||||
TokenLength int `json:"token_length"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Certificate *certDetails `json:"certificate,omitempty"`
|
||||
CertificateStatus string `json:"certificate_status"`
|
||||
}
|
||||
|
||||
func printAgentInfo(w io.Writer, cfg config, envFile string, showToken, asJSON bool) error {
|
||||
info := agentDetails{Version: version, Listen: cfg.Listen, TLS: cfg.TLS, Hostname: cfg.Hostname,
|
||||
DockerSocket: cfg.DockerSock, EnvironmentFile: envFile,
|
||||
TokenConfigured: validToken(cfg.Token), TokenLength: len(cfg.Token), CertificateStatus: "disabled"}
|
||||
if showToken {
|
||||
info.Token = cfg.Token
|
||||
}
|
||||
if cfg.TLS {
|
||||
info.CertificateStatus = "missing"
|
||||
if _, err := os.Stat(cfg.CertFile); err == nil {
|
||||
certificate, err := describeCertificate(cfg.CertFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
certificate.KeyPath = cfg.KeyFile
|
||||
info.Certificate = &certificate
|
||||
info.CertificateStatus = "present"
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if asJSON {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(info)
|
||||
}
|
||||
fmt.Fprintf(w, "RustProxy Docker Agent %s\nListen: %s://%s\nHostname: %s\nDocker socket: %s\nConfiguration: %s\n", info.Version, map[bool]string{true: "https", false: "http"}[cfg.TLS], cfg.Listen, cfg.Hostname, cfg.DockerSock, envFile)
|
||||
fmt.Fprintf(w, "Bearer token: configured=%t length=%d", info.TokenConfigured, info.TokenLength)
|
||||
if showToken {
|
||||
fmt.Fprintf(w, " value=%s", cfg.Token)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "TLS certificate: %s\n", info.CertificateStatus)
|
||||
if c := info.Certificate; c != nil {
|
||||
fmt.Fprintf(w, " Certificate: %s\n Private key: %s (contents never displayed)\n SHA-256: %s\n Subject: %s\n Issuer: %s\n Serial: %s\n Valid from: %s\n Valid until: %s\n Valid now: %t\n DNS SAN: %s\n IP SAN: %s\n Signature algorithm: %s\n Public key algorithm: %s\n",
|
||||
c.Path, c.KeyPath, c.SHA256, c.Subject, c.Issuer, c.Serial, c.NotBefore, c.NotAfter, c.ValidNow, strings.Join(c.DNSNames, ", "), strings.Join(c.IPAddresses, ", "), c.SignatureAlgorithm, c.PublicKeyAlgorithm)
|
||||
}
|
||||
if !showToken {
|
||||
fmt.Fprintln(w, "Token redacted. Use 'sudo rustproxy-docker-agent info --show-token' or 'show-token' to reveal it.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time reference: readCertificate returns an x509 parsed certificate.
|
||||
+7
-24
@@ -26,33 +26,16 @@ install -m 0755 "$TMP/rustproxy-docker-agent" /usr/local/bin/rustproxy-docker-ag
|
||||
install -d -m 0750 /etc/rustproxy
|
||||
install -d -o rustproxy-docker-agent -g docker -m 0750 /var/lib/rustproxy-docker-agent
|
||||
if [[ ! -f /etc/rustproxy/docker-agent.env ]]; then
|
||||
install -m 0640 docker-agent.env.example /etc/rustproxy/docker-agent.env
|
||||
TOKEN=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')
|
||||
sed -i "s/replace-with-at-least-24-random-characters/$TOKEN/" /etc/rustproxy/docker-agent.env
|
||||
echo "Created /etc/rustproxy/docker-agent.env with a random token."
|
||||
install -m 0600 docker-agent.env.example /etc/rustproxy/docker-agent.env
|
||||
fi
|
||||
# The Go CLI is the single implementation for both manual and packaged
|
||||
# provisioning; it preserves existing credentials, certificate and key.
|
||||
/usr/local/bin/rustproxy-docker-agent init
|
||||
install -m 0644 rustproxy-docker-agent.service /etc/systemd/system/rustproxy-docker-agent.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now rustproxy-docker-agent.service
|
||||
systemctl --no-pager --full status rustproxy-docker-agent.service || true
|
||||
|
||||
CERT_FILE=/var/lib/rustproxy-docker-agent/agent.crt
|
||||
for _ in $(seq 1 30); do
|
||||
[[ -s "$CERT_FILE" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
if [[ -s "$CERT_FILE" ]]; then
|
||||
FINGERPRINT=$(/usr/local/bin/rustproxy-docker-agent --cert-file "$CERT_FILE" --print-fingerprint)
|
||||
printf '\nAgent certificate SHA-256:\n%s\n' "$FINGERPRINT"
|
||||
else
|
||||
echo "Agent certificate was not created; inspect journalctl -u rustproxy-docker-agent." >&2
|
||||
fi
|
||||
|
||||
TOKEN=$(sed -n 's/^RUSTPROXY_DOCKER_AGENT_TOKEN=//p' /etc/rustproxy/docker-agent.env | head -n1)
|
||||
if [[ -n "$TOKEN" ]]; then
|
||||
printf '\nBearer token:\n%s\n' "$TOKEN"
|
||||
else
|
||||
echo "Bearer token is missing from /etc/rustproxy/docker-agent.env." >&2
|
||||
fi
|
||||
|
||||
echo "Installed. Configure RustProxy -> Settings -> Docker agents with the https:// endpoint, bearer token, and certificate fingerprint printed above."
|
||||
printf '\nAgent endpoint, TLS certificate details and Bearer token (sensitive):\n'
|
||||
/usr/local/bin/rustproxy-docker-agent info --show-token
|
||||
printf '\nInstalled. Configure RustProxy -> Settings -> Docker agents with the HTTPS endpoint, token and SHA-256 fingerprint above.\n'
|
||||
+180
-47
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -16,80 +17,214 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func envOr(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envBool(name string, fallback bool) bool {
|
||||
raw := strings.TrimSpace(os.Getenv(name))
|
||||
if raw == "" {
|
||||
func envBoolValue(raw string, fallback bool) bool {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return fallback
|
||||
}
|
||||
value, err := strconv.ParseBool(raw)
|
||||
value, err := strconv.ParseBool(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func validToken(token string) bool {
|
||||
return len(token) >= 24 && !strings.HasPrefix(token, "replace-with-")
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string, stdout, stderr io.Writer) error {
|
||||
command := "serve" // Preserve the legacy invocation used by existing systemd units.
|
||||
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||
command, args = args[0], args[1:]
|
||||
}
|
||||
if command == "help" {
|
||||
printUsage(stdout)
|
||||
return nil
|
||||
}
|
||||
if command == "version" {
|
||||
fmt.Fprintln(stdout, version)
|
||||
return nil
|
||||
}
|
||||
|
||||
envFile, err := requestedEnvFile(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileValues, err := readEnvironmentFile(envFile)
|
||||
if err != nil {
|
||||
// PID 1 already loaded EnvironmentFile for the unprivileged daemon;
|
||||
// access to the on-disk secret is deliberately restricted to root.
|
||||
if !errors.Is(err, os.ErrPermission) || os.Getenv("RUSTPROXY_DOCKER_AGENT_TOKEN") == "" {
|
||||
return err
|
||||
}
|
||||
fileValues = map[string]string{}
|
||||
}
|
||||
env := func(name, fallback string) string {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := fileValues[name]; ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
hostname, _ := os.Hostname()
|
||||
cfg := config{}
|
||||
flag.StringVar(&cfg.Listen, "listen", envOr("RUSTPROXY_DOCKER_AGENT_LISTEN", "127.0.0.1:9876"), "listen address")
|
||||
flag.StringVar(&cfg.Token, "token", envOr("RUSTPROXY_DOCKER_AGENT_TOKEN", ""), "Bearer token (prefer environment variable)")
|
||||
flag.StringVar(&cfg.DockerSock, "docker-socket", envOr("RUSTPROXY_DOCKER_SOCKET", "/var/run/docker.sock"), "Docker Engine unix socket")
|
||||
flag.StringVar(&cfg.AdvertiseIP, "advertise-ip", envOr("RUSTPROXY_DOCKER_ADVERTISE_IP", ""), "optional preferred host IP; all usable host addresses are still reported")
|
||||
flag.StringVar(&cfg.PublicIPURL, "public-ip-url", envOr("RUSTPROXY_DOCKER_PUBLIC_IP_URL", "https://api.ipify.org"), "public IP lookup URL; empty disables")
|
||||
flag.StringVar(&cfg.Hostname, "hostname", envOr("RUSTPROXY_DOCKER_HOSTNAME", hostname), "reported host name")
|
||||
flag.BoolVar(&cfg.TLS, "tls", envBool("RUSTPROXY_DOCKER_AGENT_TLS", true), "serve HTTPS; enabled by default")
|
||||
flag.StringVar(&cfg.CertFile, "cert-file", envOr("RUSTPROXY_DOCKER_AGENT_CERT_FILE", "/var/lib/rustproxy-docker-agent/agent.crt"), "TLS certificate file")
|
||||
flag.StringVar(&cfg.KeyFile, "key-file", envOr("RUSTPROXY_DOCKER_AGENT_KEY_FILE", "/var/lib/rustproxy-docker-agent/agent.key"), "TLS private key file")
|
||||
initCert := flag.Bool("init-cert", false, "create the self-signed TLS certificate if missing and exit")
|
||||
printFingerprint := flag.Bool("print-fingerprint", false, "print SHA-256 certificate fingerprint and exit")
|
||||
flag.Parse()
|
||||
|
||||
fs := flag.NewFlagSet("rustproxy-docker-agent "+command, flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
fs.StringVar(&envFile, "env-file", envFile, "systemd EnvironmentFile path")
|
||||
fs.StringVar(&cfg.Listen, "listen", env("RUSTPROXY_DOCKER_AGENT_LISTEN", "127.0.0.1:9876"), "listen address")
|
||||
// NEVER supply a token as a flag default: `--help` would leak it into output.
|
||||
tokenOverride := fs.String("token", "", "Bearer token override (prefer the protected environment file)")
|
||||
fs.StringVar(&cfg.DockerSock, "docker-socket", env("RUSTPROXY_DOCKER_SOCKET", "/var/run/docker.sock"), "Docker Engine unix socket")
|
||||
fs.StringVar(&cfg.AdvertiseIP, "advertise-ip", env("RUSTPROXY_DOCKER_ADVERTISE_IP", ""), "preferred host IP")
|
||||
fs.StringVar(&cfg.PublicIPURL, "public-ip-url", env("RUSTPROXY_DOCKER_PUBLIC_IP_URL", "https://api.ipify.org"), "public IP lookup URL")
|
||||
fs.StringVar(&cfg.Hostname, "hostname", env("RUSTPROXY_DOCKER_HOSTNAME", hostname), "reported host name")
|
||||
fs.BoolVar(&cfg.TLS, "tls", envBoolValue(env("RUSTPROXY_DOCKER_AGENT_TLS", "true"), true), "serve HTTPS")
|
||||
fs.StringVar(&cfg.CertFile, "cert-file", env("RUSTPROXY_DOCKER_AGENT_CERT_FILE", "/var/lib/rustproxy-docker-agent/agent.crt"), "TLS certificate file")
|
||||
fs.StringVar(&cfg.KeyFile, "key-file", env("RUSTPROXY_DOCKER_AGENT_KEY_FILE", "/var/lib/rustproxy-docker-agent/agent.key"), "TLS private key file")
|
||||
showToken := fs.Bool("show-token", false, "include the SECRET Bearer token in 'info' output")
|
||||
asJSON := fs.Bool("json", false, "print 'info' as JSON")
|
||||
initCert := fs.Bool("init-cert", false, "legacy alias for 'cert-init'")
|
||||
printFingerprint := fs.Bool("print-fingerprint", false, "legacy alias for 'print-fingerprint'")
|
||||
showVersion := fs.Bool("version", false, "print version")
|
||||
fs.Usage = func() { printUsage(stderr); fs.PrintDefaults() }
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " "))
|
||||
}
|
||||
if *showVersion {
|
||||
fmt.Fprintln(stdout, version)
|
||||
return nil
|
||||
}
|
||||
if *initCert {
|
||||
fingerprint, created, err := ensureSelfSignedCertificate(cfg.CertFile, cfg.KeyFile, cfg.Hostname)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("%s\n", fingerprint)
|
||||
if created {
|
||||
log.Printf("created self-signed certificate %s", cfg.CertFile)
|
||||
}
|
||||
return
|
||||
command = "cert-init"
|
||||
}
|
||||
if *printFingerprint {
|
||||
command = "print-fingerprint"
|
||||
}
|
||||
cfg.Token = env("RUSTPROXY_DOCKER_AGENT_TOKEN", "")
|
||||
if *tokenOverride != "" {
|
||||
cfg.Token = *tokenOverride
|
||||
}
|
||||
|
||||
switch command {
|
||||
case "init":
|
||||
_, created, err := provisionToken(envFile, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created {
|
||||
fmt.Fprintf(stdout, "Created a random Bearer token in %s\n", envFile)
|
||||
}
|
||||
// Reload because a new token was written after the initial environment read.
|
||||
fresh, err := readEnvironmentFile(envFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Token = fresh["RUSTPROXY_DOCKER_AGENT_TOKEN"]
|
||||
if cfg.TLS {
|
||||
fingerprint, created, err := ensureSelfSignedCertificate(cfg.CertFile, cfg.KeyFile, cfg.Hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created {
|
||||
if err := setServiceCertificateOwnership(cfg.CertFile, cfg.KeyFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(stdout, "Certificate SHA-256: %s\n", fingerprint)
|
||||
}
|
||||
fmt.Fprintf(stdout, "Provisioning complete. Run 'sudo rustproxy-docker-agent info --show-token' to configure RustProxy.\n")
|
||||
return nil
|
||||
case "rotate-token":
|
||||
if _, _, err := provisionToken(envFile, true); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(stdout, "Rotated token in %s. Restart rustproxy-docker-agent.service to activate it and update RustProxy's agent configuration.\n", envFile)
|
||||
return nil
|
||||
case "show-token":
|
||||
if !validToken(cfg.Token) {
|
||||
return errors.New("no valid token configured; run 'sudo rustproxy-docker-agent init'")
|
||||
}
|
||||
fmt.Fprintln(stdout, cfg.Token) // Deliberate: an explicit administrative command.
|
||||
return nil
|
||||
case "info":
|
||||
return printAgentInfo(stdout, cfg, envFile, *showToken, *asJSON)
|
||||
case "cert-init":
|
||||
fingerprint, created, err := ensureSelfSignedCertificate(cfg.CertFile, cfg.KeyFile, cfg.Hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created {
|
||||
if err := setServiceCertificateOwnership(cfg.CertFile, cfg.KeyFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(stdout, fingerprint)
|
||||
return nil
|
||||
case "print-fingerprint":
|
||||
fingerprint, err := certificateFingerprint(cfg.CertFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s\n", fingerprint)
|
||||
return
|
||||
fmt.Fprintln(stdout, fingerprint)
|
||||
return nil
|
||||
case "serve":
|
||||
return serve(cfg)
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q (use 'help')", command)
|
||||
}
|
||||
if len(cfg.Token) < 24 {
|
||||
log.Fatal("RUSTPROXY_DOCKER_AGENT_TOKEN must contain at least 24 characters")
|
||||
}
|
||||
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "RustProxy Docker Agent commands:")
|
||||
fmt.Fprintln(w, " serve (default) Run the HTTPS Docker inventory server")
|
||||
fmt.Fprintln(w, " init Generate missing token and certificate, preserve existing secrets")
|
||||
fmt.Fprintln(w, " info [--show-token] Show endpoint, TLS certificate details and token status")
|
||||
fmt.Fprintln(w, " show-token Print the full Bearer token (sensitive)")
|
||||
fmt.Fprintln(w, " rotate-token Replace the Bearer token (service restart required)")
|
||||
fmt.Fprintln(w, " cert-init Create a missing TLS certificate and key")
|
||||
fmt.Fprintln(w, " print-fingerprint Print current TLS certificate SHA-256 fingerprint")
|
||||
fmt.Fprintln(w, " version Print version")
|
||||
fmt.Fprintln(w, " --env-file PATH Override /etc/rustproxy/docker-agent.env")
|
||||
fmt.Fprintln(w, " --help Show command flags")
|
||||
}
|
||||
|
||||
func serve(cfg config) error {
|
||||
if !validToken(cfg.Token) {
|
||||
return errors.New("RUSTPROXY_DOCKER_AGENT_TOKEN must be a non-placeholder value with at least 24 characters; run 'sudo rustproxy-docker-agent init'")
|
||||
}
|
||||
if _, err := os.Stat(cfg.DockerSock); err != nil {
|
||||
log.Fatalf("Docker socket unavailable: %v", err)
|
||||
return fmt.Errorf("Docker socket unavailable: %w", err)
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(cfg.Listen); err != nil {
|
||||
log.Fatalf("invalid listen address: %v", err)
|
||||
return fmt.Errorf("invalid listen address: %w", err)
|
||||
}
|
||||
if cfg.TLS {
|
||||
fingerprint, created, err := ensureSelfSignedCertificate(cfg.CertFile, cfg.KeyFile, cfg.Hostname)
|
||||
if err != nil {
|
||||
log.Fatalf("TLS certificate: %v", err)
|
||||
return fmt.Errorf("TLS certificate: %w", err)
|
||||
}
|
||||
cfg.Fingerprint = fingerprint
|
||||
if created {
|
||||
log.Printf("generated self-signed TLS certificate; SHA-256 %s", fingerprint)
|
||||
}
|
||||
if details, err := describeCertificate(cfg.CertFile); err == nil {
|
||||
log.Printf("TLS certificate: subject=%s valid_until=%s SHA-256=%s", details.Subject, details.NotAfter, details.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
server := &http.Server{Addr: cfg.Listen, Handler: handler(cfg), ReadHeaderTimeout: 3 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 16 << 10}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -103,10 +238,7 @@ func main() {
|
||||
if cfg.TLS {
|
||||
scheme = "https"
|
||||
}
|
||||
log.Printf("RustProxy Docker Agent %s listening on %s://%s", version, scheme, cfg.Listen)
|
||||
if cfg.Fingerprint != "" {
|
||||
log.Printf("TLS certificate SHA-256: %s", cfg.Fingerprint)
|
||||
}
|
||||
log.Printf("RustProxy Docker Agent %s listening on %s://%s; Bearer token configured (%d chars, redacted)", version, scheme, cfg.Listen, len(cfg.Token))
|
||||
var err error
|
||||
if cfg.TLS {
|
||||
err = server.ListenAndServeTLS(cfg.CertFile, cfg.KeyFile)
|
||||
@@ -114,6 +246,7 @@ func main() {
|
||||
err = server.ListenAndServe()
|
||||
}
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package main
|
||||
|
||||
const version = "0.29.2"
|
||||
var version = "0.34.2" // overridden by the package builder using -ldflags -X main.version
|
||||
|
||||
type config struct {
|
||||
Listen string
|
||||
|
||||
Reference in new issue
Block a user