0.34.2_agent

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-19 22:38:17 +02:00
1 parent b2b5b45d8a
commit 8c0de520d7
13 files changed
+820 -123

No files matched your search

+6
View File
@@ -1,3 +1,9 @@
## Docker Agent Linux packaging extension (source-level changes)
- Added independent `rustproxy-docker-agent` DEB/RPM build and signed APT/DNF publication to the external Package Builder; no FreeBSD `pkg` for the agent.
- Agent CLI now supports `init`, `info` (text/JSON, token redacted by default), `show-token`, `rotate-token`, `cert-init`, `print-fingerprint` and `version`; existing flags and default server invocation remain supported.
- Manual and package installation share token/certificate provisioning; upgrades preserve TLS files and existing tokens, while startup journals print only nonsecret connection and certificate metadata.
# Changelog
Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in 0.34.2.
+1
View File
@@ -37,6 +37,7 @@ Use the maintained packages where available:
- Linux DEB/RPM: https://rustproxy.linuxiarz.pl/
- OPNsense: https://rustproxy-opn.linuxiarz.pl/
- Docker hosts (optional agent, Linux DEB/RPM): install `rustproxy-docker-agent` independently; see [Docker discovery](docs/DOCKER_DISCOVERY.md).
For Debian 13+ source installation, manual compilation, managed updates or Docker, see [Installation and updates](docs/INSTALL.md).
+17 -15
View File
@@ -2,26 +2,28 @@
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
65441f53fe87a21b1b960ddd7b76a6346a7ab97d6c75ba71c04a1d28504a7ee0 AGENTS.md
cf8531560d308e9acb81c5155b644b5ee0821b8f8ad57d96955e7668030f883c CHANGELOG.md
03a75f7f891aa9ed5e09aa2461510e4b021be397ca0dad7533fb95bd50d42aea CHANGELOG.md
b8d1eceeb2e8aa506da7d5675307a3ca7f101bc7a78a8cc00bb3b441a4dd85a9 Cargo.lock
55788c48310d81442f3a937de1a42e67cae7482f88ec4f1699b50e37f6f56acb Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
c5cd6441c38a73f4abfe10e353e84bd93ad54f6fa4b86a1c83a8524ca312fec7 README.md
ef845b5a97d7b62b3fe765a3fac5789f708109c50a5358a605b63b185b325322 README.md
ae6d14ada6fde7d0b8514840b821678418cf5b1816c18434e3b7fb2ae27a0b21 ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
99ac985e49fc0bf7ddfb0b157c241261ecb69f24f92e5dcc99e4f72d58ce6cea deploy/README.md
4fdba6349bbec3b8bc18411287b80b065aff59b52fe87ded1d9bdf7dcd79e593 deploy/rustproxy.service
c111f5b20484707b80efb7dd9176acbaa27b63ac78db497261ac234821e1fade dev.sh
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
8a1ad36b0c7bda1fc6fe235c5a17becde8644600c1242b0d958d9eb57c2d6f62 docker-agent/README.md
e9f18b358a740f1fe16e369ed16ee37581b8ce4c94ddeb34a1a37b89a49b5a06 docker-agent/certificate.go
600d5aebc8557d6987158969d494bade1ba46ccf9d1cd573d357d0106db26b2b docker-agent/cli_test.go
067793eee01fd284da66f79c6f8b6f19f8773e64f84af03168c8f751ff044685 docker-agent/credentials.go
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
bf687a851374d8f1fa59d050f3f76532f7c12f16a6daa7d3c36102c2d9a8ec9c docker-agent/docker.go
9e9f3f17f38f9b0730f598f65988ce8ef562d2182ef6778fc7292b990e9cf22f docker-agent/go.mod
9d77c90767fa929ee8338734d72ce83284d3d965673270f62cb2b1613d587a33 docker-agent/install.sh
b68234472bec5cedd67c7cfe1de7d8806a965dfd4101f944aa6320132f18c14e docker-agent/main.go
3657c195ee6ff9c3c9f1e0d99a09b1ec2091edc61173098ef03db9b8dd7483fa docker-agent/install.sh
b7d0466f70b541a705d234fc8a88b99b6197fdd5e682d3a288a78c76764358cf docker-agent/main.go
3d4051a1f805d145df3877476e4b87773e74be8e123045065955a5133afdc68c docker-agent/main_test.go
68ad007eac216f115809695d3c2c6651e2850ff3d7334685ae6e49bc21b01087 docker-agent/model.go
3cc158839e9b539b7762840f8666ea14f2ee8525438e58f6ea74e938b145393b docker-agent/model.go
e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go
634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
@@ -35,14 +37,14 @@ ede69043675c78151bb199a393ae20041047571f7f4bc5e1a2c2c3b8d8c01727 docs/CONFIGURA
80bcc666736b10f6202386d0342e8d6feeba858d77aea310bf3ec6de01c2bfb3 docs/CONTAINER.md
dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LOCATIONS.md
990153083a982ba820f5b9f475a5fc657b3613d8f1af11c2d34f6483dc7449cf docs/DASHBOARD.md
71fcb8e67135e2197207bd8b979805a9b0ae5df9c6a8594de9b7fadb72fa15e2 docs/DOCKER_DISCOVERY.md
d73d0ff31f20f8ca96377bde5cb05753c5c29e417d25b908baac4e51a628ea48 docs/INSTALL.md
5a8d311c7ab36af9fb84964585bd974b92d8ea4979829b5735fb834ef10156ce docs/DOCKER_DISCOVERY.md
dbae1a739874ac7b61e276c1ad6fd7c3465acc95afa4db3751224c8426ad6569 docs/INSTALL.md
37001cbf8ed248ca05ec40f12dd9876d33f6e0c97dcb21eb2e6f53c3d97a60bb docs/INTEGRATIONS.md
8c5007550ebae8408cbb72a75b2af9894578a1b9acda6bd34bc5f533916ddf72 docs/LOGS.md
888cdc8c56e10b8a7615012e192e085bf2899d0ec9b3ddfb5731e7de6ab5bcde docs/MIGRATION.md
e4ffd659be1e6c445b582a76643a46408699ef1e929d3a4c6e741d8146ded031 docs/MONITORING.md
d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md
3061263d5ac19ea579dd3f00ca9d9583296a9e761b84180595a33833daf7b9e0 docs/README.md
1e8eada9d90fab1671cc137e2884656a13589c78cc11620b98b821eb5ebd1331 docs/README.md
7c47aed9c178e8c707b98d1b20ec985c1170f193de0c7b10b682e76da576e764 docs/RESPONSE_DELIVERY.md
701023a4cdcac145dcb2bc5bb709afe79599a5d70cbda6fdfe19ab39ad703bfc docs/SECURITY.md
519f9c45fe31ede5afdfb8c4e4ef05f17540843ac93fe8b05ff6c4fbea7bcf68 docs/STREAMS.md
@@ -138,7 +140,7 @@ ea7aafb4466caa66c6147f0e08d189c42222c43212bd7acd8e8842fd935a48ef src/admin/docu
ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file_logs.rs
c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file_logs_io.rs
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
ab453d459cd8de4e71421353edefa574085eceb5d39c66ca796cd6aaf4035e9f src/admin/logging_api.rs
4e15827a896c30e8aeba4677bc138695cce61c9feb847de0f3de9d66f542ff7a src/admin/logging_api.rs
8d1cf7855e2b95798819af33a47a3209cf2438a6108c814cd291fe2ca89fa006 src/admin/mod.rs
4ef9b39fdde727e44c1ab1b7907cf5dc4d477c26d4d98da54cd482a2dfa6d436 src/admin/openapi.json
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
@@ -150,7 +152,7 @@ d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/oper
4a7a2017ba57c9e7a8b29e73764d439f6d03653403909ef198ded8c48d152784 src/admin/public.rs
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs
d3e35d7c5b066990fd6cea07464970e6ddced8989e6c92ca327709b5d1c87017 src/admin/router.rs
48842cddf9a0e5b1da57988af824da96c81258f4c870b31641e0a45d1ed61ece src/admin/router.rs
0ca8c45132b11c926967a0f78fc7e5e36c379a99597e13096ad453140c372cc2 src/admin/session.rs
66111616ccb59a561003c723554c25ba35925afbef7f9b73e5479ca8f0b35b54 src/admin/status.rs
4b8a0483ede651d08489a4d6e5bb616f06423094bbbefcee70b8755d65c04398 src/admin/status_live.rs
@@ -349,14 +351,14 @@ e5c2e9189777725f4afa1c3c052e4f75047ec3f2f486630d990f5d3c194bcc96 src/main/tasks
e9cd8d9aff1de7c92b3381f7646e5c577f857d960bbb276e0736b29973b97319 src/metrics/vhost/tests.rs
a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.rs
c915d30bbd7032d867e7916b4cd45f3bb7e7604d3f4558f4383c59394b4300c6 src/not_found/tests.rs
0393d094c02b5ca76c887b4e4e24ebd7cbc59277eb2eceee65683be34acd9416 src/pages.rs
3567c2b65b09553e8aa46fb223dcab885b358fee9fc69cd0faf148059433b9e4 src/pages.rs
da40e8ece87245efc49a0c4e750526b1d19fd97565856bd5caf641fcc1dfe18d src/pages/tests.rs
63a84af77b6c3dd390aa9dfde71c527eec6e1a3c0a38c1f02c08055e525f250c src/prometheus.rs
cc6fb14351b009a16d07ed0f412c2bcc68cd7e551421aea03d8d97ceec9bee40 src/prometheus/core.rs
74822d8293fe1ce64b18d8363a67e9f0ed2ee9d181e2668e5ca6bd258f98285c src/prometheus/docker_misc.rs
175e4b8566eaddddc51370f971bef5548b9f87636c703433c7cd542fbf4bd949 src/prometheus/json.rs
97926653a7c00e884784c2739a998d666abb3a891f1642bcf1ecc6c49d0efa62 src/prometheus/json.rs
c34c3204bfb07a0de95250dfc1208979a5e701ccf5b4093cfbfd9b996ff3ba15 src/prometheus/model.rs
59fd9e547516999dd54ad07d25066abbd39e58c60bc1c313280b5fb0bdcc982d src/prometheus/tests.rs
67ea91c74c9e7860ee16da8f3543f642d5b6549a148a50a35e4d8f660510517f src/prometheus/tests.rs
028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs
e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus/vhosts_backends.rs
fb2d8d45cd2e947f783eb981b37fa418c39fcb327a3af17a648fadea7f37f888 src/prometheus/web_php.rs
+83 -23
View File
@@ -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.
+12 -4
View File
@@ -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
}
+181
View File
@@ -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")
}
}
+278
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
func envBoolValue(raw string, fallback bool) bool {
if strings.TrimSpace(raw) == "" {
return fallback
}
func envBool(name string, fallback bool) bool {
raw := strings.TrimSpace(os.Getenv(name))
if 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 -1
View File
@@ -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
+50 -6
View File
@@ -4,13 +4,57 @@ RustProxy discovers published Docker services through a **separate read-only Doc
## Set up a Docker host
1. On the Docker host, review `docker-agent/README.md`, `docker-agent/docker-agent.env.example` and `docker-agent/install.sh`.
2. Build/install the Go agent with `cd docker-agent && sudo ./install.sh`; the installer runs tests, installs the service, initializes a Bearer token and an HTTPS certificate, and prints the SHA-256 certificate fingerprint.
3. Restrict the agent port (default shown in the example environment: `9876`) to the RustProxy host/network. Access to the Docker socket is privileged, even though the agent's HTTP API is read-only.
4. In **Settings → Docker agents**, create an agent with an endpoint such as `https://10.10.0.21:9876`, its token and printed fingerprint, base domain, selected listener IDs and optional certificate/ACL/default backend IP.
5. Use **Docker vhosts → Refresh agents** and check that the agent observation is successful before publishing any service.
Install the **optional Docker agent on each Linux Docker host**, not on the RustProxy server. It is distributed as a separate `rustproxy-docker-agent` DEB/RPM package and is **not built as FreeBSD `pkg`**. Docker Engine, systemd and access to `/var/run/docker.sock` are required at runtime.
The agent normally presents its own persistent self-signed certificate; pin the exact fingerprint in RustProxy. When using a CA-issued agent certificate, normal TLS CA/hostname verification can be used instead. Plain HTTP exposes the bearer token and should be avoided.
### Option A: prebuilt DEB/RPM (recommended)
After configuring the signed APT/DNF RustProxy repository (see [Installation](INSTALL.md)):
```bash
# Debian / Ubuntu
sudo apt update && sudo apt install rustproxy-docker-agent
# Or on RHEL / Rocky / Alma / Fedora
sudo dnf install rustproxy-docker-agent
```
For offline/local installation use `sudo apt install ./rustproxy-docker-agent_<version>-<release>_amd64.deb` or `sudo dnf install ./rustproxy-docker-agent-<version>-<release>.x86_64.rpm`. The post-install script generates a random token and self-signed TLS certificate on first install, creates a systemd service, and preserves credentials across upgrades. It does not print the token to unattended installation logs.
```bash
sudo rustproxy-docker-agent info # endpoint, TLS fingerprint, validity, token status
sudo rustproxy-docker-agent info --show-token # full secret token: run locally as root
sudo systemctl status rustproxy-docker-agent
```
### Option B: build and install manually (optional)
```bash
cd docker-agent
go test ./...
sudo ./install.sh
```
Go 1.22+ is required only for the manual build, not for a DEB/RPM installation. The manual installer uses the same Go CLI to create credentials and TLS identity and displays the token interactively. Detailed custom systemd setup and migration between manual/package-managed services are in [docker-agent/README.md](../docker-agent/README.md).
### Connect to RustProxy
1. Allow port `9876` **only from the RustProxy server** in the Docker-host firewall. The default example listens on `0.0.0.0:9876`.
2. In **Settings → Docker agents**, create an agent with an endpoint such as `https://10.10.0.21:9876`, its Bearer token and certificate SHA-256 fingerprint, base domain, selected listener IDs, and optional certificate/ACL/default backend IP.
3. Use **Docker vhosts → Refresh agents** and check that discovery succeeds before publishing.
The self-signed certificate is persistent. RustProxy pins its exact fingerprint; if replaced, update the pin. A trusted CA-issued certificate can use normal CA and hostname verification instead. Plain HTTP is available only by explicit opt-out and exposes the token in transit.
To inspect or rotate credentials after installation:
```bash
sudo rustproxy-docker-agent info --json
sudo rustproxy-docker-agent print-fingerprint
sudo rustproxy-docker-agent rotate-token
sudo systemctl restart rustproxy-docker-agent
sudo rustproxy-docker-agent show-token # secret: update it in RustProxy settings
```
A token change invalidates the previous token after service restart. The agent logs certificate details and **redacted** token status at startup. The Docker socket is root-equivalent access; isolate the service account and host even though the HTTP inventory endpoint is read-only.
## What discovery reports
+3 -2
View File
@@ -6,13 +6,14 @@ Use maintained packages whenever possible. For custom installations, use the scr
| Installation | Recommended starting point |
|---|---|
| Linux DEB/RPM | https://rustproxy.linuxiarz.pl/ |
| Linux DEB/RPM (RustProxy server) | https://rustproxy.linuxiarz.pl/ |
| Docker host discovery agent (independent DEB/RPM) | `sudo apt install rustproxy-docker-agent` or `sudo dnf install rustproxy-docker-agent`; see [Docker discovery](DOCKER_DISCOVERY.md) |
| OPNsense plugin | https://rustproxy-opn.linuxiarz.pl/ |
| Debian 13+ source bootstrap | `install-debian13.sh` from the project repository |
| Manual Linux/systemd install | Compile the application; use `install.sh` and `update.sh` |
| OCI container | Included `Dockerfile`; see [Container](CONTAINER.md) |
Package/plugin instructions and maintenance policies are maintained separately from the source archive.
Package/plugin instructions and maintenance policies are maintained separately from the source archive. The optional Docker agent is packaged independently of RustProxy's main service for **Linux amd64/x86_64 only**, not FreeBSD pkg. On a Docker host, the agent can also be installed manually from `docker-agent/install.sh`; the installer and packages preserve existing tokens and TLS keys. See [Docker discovery](DOCKER_DISCOVERY.md) and [agent operations](../docker-agent/README.md).
## Debian 13+ source bootstrap
+1 -1
View File
@@ -24,7 +24,7 @@ Practical guides for **RustProxy 0.34.2**. This index and the listed pages are a
## Integrations and operations
- [Certificates and automatic renewal](ACME_PROVIDERS.md) — PEM, HTTP-01, DNS-01, Certbot and external certificates.
- [Docker discovery](DOCKER_DISCOVERY.md) — agent installation, networking and generated Vhosts.
- [Docker discovery](DOCKER_DISCOVERY.md) — optional Linux agent DEB/RPM or manual installation, TLS/token CLI, networking and generated Vhosts.
- [Other integrations](INTEGRATIONS.md) — Tinyauth, NPM migration, OPNsense and monitoring tools.
- [Dashboard](DASHBOARD.md) — metrics panels, presets and configuration history.
- [Monitoring](MONITORING.md) — Prometheus, full JSON and compact `summary` API, traffic, backend and cache observations.