v0.24.5
This commit is contained in:
commit
5e23e9af02
240 files changed
+99957
No files matched your search
@@ -0,0 +1,46 @@
|
||||
# RustProxy Docker Agent
|
||||
|
||||
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 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.
|
||||
|
||||
## TLS by default
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
Show the current fingerprint again with:
|
||||
|
||||
```bash
|
||||
sudo rustproxy-docker-agent --print-fingerprint
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Build manually
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go build -trimpath -ldflags='-s -w' -o rustproxy-docker-agent .
|
||||
```
|
||||
|
||||
## systemd
|
||||
|
||||
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`.
|
||||
|
||||
The inventory endpoint is `GET /v1/inventory` with `Authorization: Bearer <token>`. `/healthz` does not expose Docker inventory. Both are served over HTTPS by default.
|
||||
|
||||
`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.
|
||||
@@ -0,0 +1,10 @@
|
||||
RUSTPROXY_DOCKER_AGENT_LISTEN=0.0.0.0:9876
|
||||
RUSTPROXY_DOCKER_AGENT_TOKEN=replace-with-at-least-24-random-characters
|
||||
# HTTPS is enabled by default. The agent creates and reuses a self-signed certificate.
|
||||
RUSTPROXY_DOCKER_AGENT_TLS=true
|
||||
RUSTPROXY_DOCKER_AGENT_CERT_FILE=/var/lib/rustproxy-docker-agent/agent.crt
|
||||
RUSTPROXY_DOCKER_AGENT_KEY_FILE=/var/lib/rustproxy-docker-agent/agent.key
|
||||
# Optional preferred address. Other usable host addresses are still auto-detected and reported.
|
||||
RUSTPROXY_DOCKER_ADVERTISE_IP=
|
||||
# Optional external/public address lookup. Set empty to disable.
|
||||
RUSTPROXY_DOCKER_PUBLIC_IP_URL=https://api.ipify.org
|
||||
@@ -0,0 +1,3 @@
|
||||
module rustproxy/docker-agent
|
||||
|
||||
go 1.22
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
|
||||
echo "Run as root: sudo ./install.sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
command -v go >/dev/null 2>&1 || { echo "Go is required to build the agent." >&2; exit 2; }
|
||||
command -v systemctl >/dev/null 2>&1 || { echo "systemd is required." >&2; exit 2; }
|
||||
getent group docker >/dev/null 2>&1 || { echo "Docker group not found; install Docker first." >&2; exit 2; }
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
cd "$ROOT"
|
||||
go test ./...
|
||||
go build -trimpath -ldflags='-s -w' -o "$TMP/rustproxy-docker-agent" .
|
||||
|
||||
if ! id rustproxy-docker-agent >/dev/null 2>&1; then
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin --gid docker rustproxy-docker-agent
|
||||
else
|
||||
usermod -a -G docker rustproxy-docker-agent
|
||||
fi
|
||||
install -m 0755 "$TMP/rustproxy-docker-agent" /usr/local/bin/rustproxy-docker-agent
|
||||
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."
|
||||
fi
|
||||
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."
|
||||
@@ -0,0 +1,701 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const version = "0.24.5"
|
||||
|
||||
type config struct {
|
||||
Listen string
|
||||
Token string
|
||||
DockerSock string
|
||||
AdvertiseIP string
|
||||
PublicIPURL string
|
||||
Hostname string
|
||||
TLS bool
|
||||
CertFile string
|
||||
KeyFile string
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
type dockerPort struct {
|
||||
IP string `json:"IP"`
|
||||
PrivatePort uint16 `json:"PrivatePort"`
|
||||
PublicPort uint16 `json:"PublicPort"`
|
||||
Type string `json:"Type"`
|
||||
}
|
||||
|
||||
type dockerContainer struct {
|
||||
ID string `json:"Id"`
|
||||
Names []string `json:"Names"`
|
||||
Image string `json:"Image"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
Ports []dockerPort `json:"Ports"`
|
||||
}
|
||||
|
||||
type binding struct {
|
||||
BindIP string `json:"bind_ip"`
|
||||
PublicPort uint16 `json:"public_port"`
|
||||
}
|
||||
|
||||
type service struct {
|
||||
Key string `json:"key"`
|
||||
TechnicalName string `json:"technical_name"`
|
||||
ContainerID string `json:"container_id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
Image string `json:"image"`
|
||||
PrivatePort uint16 `json:"private_port"`
|
||||
Protocol string `json:"protocol"`
|
||||
Bindings []binding `json:"bindings"`
|
||||
}
|
||||
|
||||
type hostAddress struct {
|
||||
IP string `json:"ip"`
|
||||
Source string `json:"source"`
|
||||
Interface string `json:"interface,omitempty"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type inventory struct {
|
||||
AgentVersion string `json:"agent_version"`
|
||||
Hostname string `json:"hostname"`
|
||||
PublicIP string `json:"public_ip"`
|
||||
PublicIPSource string `json:"public_ip_source"`
|
||||
CertificateSHA256 string `json:"certificate_sha256"`
|
||||
Addresses []hostAddress `json:"addresses"`
|
||||
ObservedAt string `json:"observed_at"`
|
||||
Services []service `json:"services"`
|
||||
}
|
||||
|
||||
type unixRoundTripper struct{ sock string }
|
||||
|
||||
func (u unixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "unix", u.sock)
|
||||
},
|
||||
DisableKeepAlives: false,
|
||||
}
|
||||
clone := req.Clone(req.Context())
|
||||
clone.URL.Scheme = "http"
|
||||
clone.URL.Host = "docker"
|
||||
return transport.RoundTrip(clone)
|
||||
}
|
||||
|
||||
func dockerContainers(ctx context.Context, sock string) ([]dockerContainer, error) {
|
||||
client := &http.Client{Transport: unixRoundTripper{sock: sock}, Timeout: 4 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker/containers/json?all=0", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("docker API returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var items []dockerContainer
|
||||
dec := json.NewDecoder(io.LimitReader(resp.Body, 4<<20))
|
||||
if err := dec.Decode(&items); err != nil {
|
||||
return nil, fmt.Errorf("decode Docker response: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func cleanName(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return "container"
|
||||
}
|
||||
name := strings.TrimPrefix(names[0], "/")
|
||||
if name == "" {
|
||||
return "container"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func dnsLabel(value string) string {
|
||||
value = strings.ToLower(value)
|
||||
var b strings.Builder
|
||||
dash := false
|
||||
for _, r := range value {
|
||||
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
||||
if ok {
|
||||
b.WriteRune(r)
|
||||
dash = false
|
||||
} else if b.Len() > 0 && !dash {
|
||||
b.WriteByte('-')
|
||||
dash = true
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if out == "" {
|
||||
out = "app"
|
||||
}
|
||||
if len(out) > 48 {
|
||||
out = strings.Trim(out[:48], "-")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func serviceIdentity(c dockerContainer, p dockerPort) (key, technical string) {
|
||||
name := cleanName(c.Names)
|
||||
project := c.Labels["com.docker.compose.project"]
|
||||
composeService := c.Labels["com.docker.compose.service"]
|
||||
replica := c.Labels["com.docker.compose.container-number"]
|
||||
suffix := strconv.Itoa(int(p.PrivatePort))
|
||||
if project != "" && composeService != "" {
|
||||
if replica == "" {
|
||||
replica = name
|
||||
}
|
||||
key = fmt.Sprintf("compose:%s/%s/%s:%d/%s", project, composeService, replica, p.PrivatePort, p.Type)
|
||||
technicalBase := project + "-" + composeService
|
||||
if replica != "1" {
|
||||
technicalBase += "-" + replica
|
||||
}
|
||||
technical = dnsLabel(technicalBase + "-" + suffix)
|
||||
} else {
|
||||
key = fmt.Sprintf("container:%s:%d/%s", name, p.PrivatePort, p.Type)
|
||||
technical = dnsLabel(name + "-" + suffix)
|
||||
}
|
||||
return key, technical
|
||||
}
|
||||
|
||||
func validIP(raw string) string {
|
||||
ip := net.ParseIP(strings.TrimSpace(raw))
|
||||
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsMulticast() || ip.IsLinkLocalUnicast() {
|
||||
return ""
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
|
||||
func servicesFrom(containers []dockerContainer) []service {
|
||||
byKey := make(map[string]*service)
|
||||
bindingSeen := make(map[string]map[string]struct{})
|
||||
for _, c := range containers {
|
||||
name := cleanName(c.Names)
|
||||
for _, p := range c.Ports {
|
||||
if p.PublicPort == 0 || p.Type != "tcp" {
|
||||
continue
|
||||
}
|
||||
if bind := net.ParseIP(strings.TrimSpace(p.IP)); bind != nil && bind.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
key, technical := serviceIdentity(c, p)
|
||||
item := byKey[key]
|
||||
if item == nil {
|
||||
id := c.ID
|
||||
if len(id) > 12 {
|
||||
id = id[:12]
|
||||
}
|
||||
item = &service{
|
||||
Key: key, TechnicalName: technical, ContainerID: id, ContainerName: name,
|
||||
Image: c.Image, PrivatePort: p.PrivatePort, Protocol: p.Type,
|
||||
}
|
||||
byKey[key] = item
|
||||
bindingSeen[key] = make(map[string]struct{})
|
||||
}
|
||||
bindIP := strings.TrimSpace(p.IP)
|
||||
dedupe := bindIP + "|" + strconv.Itoa(int(p.PublicPort))
|
||||
if _, ok := bindingSeen[key][dedupe]; ok {
|
||||
continue
|
||||
}
|
||||
bindingSeen[key][dedupe] = struct{}{}
|
||||
item.Bindings = append(item.Bindings, binding{BindIP: bindIP, PublicPort: p.PublicPort})
|
||||
}
|
||||
}
|
||||
out := make([]service, 0, len(byKey))
|
||||
for _, item := range byKey {
|
||||
sort.Slice(item.Bindings, func(i, j int) bool {
|
||||
left := net.ParseIP(strings.TrimSpace(item.Bindings[i].BindIP))
|
||||
right := net.ParseIP(strings.TrimSpace(item.Bindings[j].BindIP))
|
||||
leftWildcard := left == nil || left.IsUnspecified()
|
||||
rightWildcard := right == nil || right.IsUnspecified()
|
||||
if leftWildcard != rightWildcard {
|
||||
return !leftWildcard
|
||||
}
|
||||
if item.Bindings[i].BindIP != item.Bindings[j].BindIP {
|
||||
return item.Bindings[i].BindIP < item.Bindings[j].BindIP
|
||||
}
|
||||
return item.Bindings[i].PublicPort < item.Bindings[j].PublicPort
|
||||
})
|
||||
out = append(out, *item)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
||||
return out
|
||||
}
|
||||
|
||||
func publicIPFromURL(ctx context.Context, rawURL string) string {
|
||||
if rawURL == "" {
|
||||
return ""
|
||||
}
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return ""
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 128))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return validIP(string(body))
|
||||
}
|
||||
|
||||
func routeIP() string {
|
||||
conn, err := net.DialTimeout("udp", "1.1.1.1:53", time.Second)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer conn.Close()
|
||||
if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok {
|
||||
return validIP(addr.IP.String())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ipScope(ip net.IP) string {
|
||||
switch {
|
||||
case ip.IsPrivate():
|
||||
return "private"
|
||||
case ip.IsLinkLocalUnicast():
|
||||
return "link_local"
|
||||
default:
|
||||
return "public"
|
||||
}
|
||||
}
|
||||
|
||||
func skipInterface(name string) bool {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
return name == "docker0" || strings.HasPrefix(name, "docker") || strings.HasPrefix(name, "veth") || strings.HasPrefix(name, "br-") || strings.HasPrefix(name, "virbr")
|
||||
}
|
||||
|
||||
func localHostAddresses() []hostAddress {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]hostAddress, 0)
|
||||
for _, iface := range interfaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 || skipInterface(iface.Name) {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
raw := addr.String()
|
||||
if host, _, err := net.ParseCIDR(raw); err == nil {
|
||||
raw = host.String()
|
||||
} else if host, _, err := net.SplitHostPort(raw); err == nil {
|
||||
raw = host
|
||||
}
|
||||
clean := validIP(raw)
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
ip := net.ParseIP(clean)
|
||||
out = append(out, hostAddress{IP: clean, Source: "interface", Interface: iface.Name, Scope: ipScope(ip)})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Interface != out[j].Interface {
|
||||
return out[i].Interface < out[j].Interface
|
||||
}
|
||||
return out[i].IP < out[j].IP
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
type ipDetector struct {
|
||||
mu sync.Mutex
|
||||
publicIP string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func (d *ipDetector) public(ctx context.Context, rawURL string) string {
|
||||
if rawURL == "" {
|
||||
return ""
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if time.Now().Before(d.expires) {
|
||||
return d.publicIP
|
||||
}
|
||||
d.publicIP = publicIPFromURL(ctx, rawURL)
|
||||
d.expires = time.Now().Add(5 * time.Minute)
|
||||
if d.publicIP == "" {
|
||||
d.expires = time.Now().Add(30 * time.Second)
|
||||
}
|
||||
return d.publicIP
|
||||
}
|
||||
|
||||
func mergeHostAddress(out *[]hostAddress, index map[string]int, candidate hostAddress) {
|
||||
candidate.IP = validIP(candidate.IP)
|
||||
if candidate.IP == "" {
|
||||
return
|
||||
}
|
||||
if candidate.Scope == "" {
|
||||
candidate.Scope = ipScope(net.ParseIP(candidate.IP))
|
||||
}
|
||||
if pos, ok := index[candidate.IP]; ok {
|
||||
existing := &(*out)[pos]
|
||||
if candidate.Interface != "" && existing.Interface == "" {
|
||||
existing.Interface = candidate.Interface
|
||||
}
|
||||
if candidate.Source != "" && !strings.Contains(existing.Source, candidate.Source) {
|
||||
if existing.Source == "" {
|
||||
existing.Source = candidate.Source
|
||||
} else {
|
||||
existing.Source += "," + candidate.Source
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
index[candidate.IP] = len(*out)
|
||||
*out = append(*out, candidate)
|
||||
}
|
||||
|
||||
func detectedAddresses(ctx context.Context, cfg config, detector *ipDetector) ([]hostAddress, string, string) {
|
||||
out := make([]hostAddress, 0, 8)
|
||||
index := make(map[string]int)
|
||||
for _, item := range localHostAddresses() {
|
||||
mergeHostAddress(&out, index, item)
|
||||
}
|
||||
routed := routeIP()
|
||||
if routed != "" {
|
||||
mergeHostAddress(&out, index, hostAddress{IP: routed, Source: "route"})
|
||||
}
|
||||
public := detector.public(ctx, cfg.PublicIPURL)
|
||||
if public != "" {
|
||||
mergeHostAddress(&out, index, hostAddress{IP: public, Source: "public_api"})
|
||||
}
|
||||
advertised := validIP(cfg.AdvertiseIP)
|
||||
if advertised != "" {
|
||||
mergeHostAddress(&out, index, hostAddress{IP: advertised, Source: "override"})
|
||||
}
|
||||
|
||||
switch {
|
||||
case advertised != "":
|
||||
return out, advertised, "override"
|
||||
case routed != "":
|
||||
return out, routed, "route"
|
||||
case public != "":
|
||||
return out, public, "public_api"
|
||||
case len(out) > 0:
|
||||
return out, out[0].IP, out[0].Source
|
||||
default:
|
||||
return out, "", "unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
func certificateFingerprintDER(der []byte) string {
|
||||
sum := sha256.Sum256(der)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func certificateFingerprint(certFile string) (string, error) {
|
||||
raw, err := os.ReadFile(certFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, _ := pem.Decode(raw)
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return "", 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 certificateFingerprintDER(cert.Raw), nil
|
||||
}
|
||||
|
||||
func atomicWrite(path string, data []byte, mode os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".rustproxy-agent-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if err := tmp.Chmod(mode); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
func ensureSelfSignedCertificate(certFile, keyFile, hostname string) (string, bool, error) {
|
||||
certInfo, certErr := os.Stat(certFile)
|
||||
keyInfo, keyErr := os.Stat(keyFile)
|
||||
if certErr == nil && keyErr == nil {
|
||||
if certInfo.IsDir() || keyInfo.IsDir() {
|
||||
return "", false, errors.New("TLS certificate/key path points to a directory")
|
||||
}
|
||||
if _, err := tls.LoadX509KeyPair(certFile, keyFile); err != nil {
|
||||
return "", false, fmt.Errorf("load TLS certificate/key: %w", err)
|
||||
}
|
||||
fp, err := certificateFingerprint(certFile)
|
||||
return fp, false, err
|
||||
}
|
||||
if (certErr == nil) != (keyErr == nil) {
|
||||
return "", false, errors.New("only one TLS certificate/key file exists; refusing to replace the other")
|
||||
}
|
||||
if certErr != nil && !os.IsNotExist(certErr) {
|
||||
return "", false, certErr
|
||||
}
|
||||
if keyErr != nil && !os.IsNotExist(keyErr) {
|
||||
return "", false, keyErr
|
||||
}
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("generate TLS key: %w", err)
|
||||
}
|
||||
serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
serial, err := rand.Int(rand.Reader, serialLimit)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("generate TLS serial: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: hostname, Organization: []string{"RustProxy Docker Agent"}},
|
||||
NotBefore: now.Add(-5 * time.Minute),
|
||||
NotAfter: now.AddDate(10, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
if hostname != "" {
|
||||
if ip := net.ParseIP(hostname); ip != nil {
|
||||
template.IPAddresses = append(template.IPAddresses, ip)
|
||||
} else {
|
||||
template.DNSNames = append(template.DNSNames, hostname)
|
||||
}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("create TLS certificate: %w", err)
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("marshal TLS key: %w", err)
|
||||
}
|
||||
if err := atomicWrite(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0600); err != nil {
|
||||
return "", false, fmt.Errorf("write TLS key: %w", err)
|
||||
}
|
||||
if err := atomicWrite(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0644); err != nil {
|
||||
_ = os.Remove(keyFile)
|
||||
return "", false, fmt.Errorf("write TLS certificate: %w", err)
|
||||
}
|
||||
return certificateFingerprintDER(der), true, nil
|
||||
}
|
||||
|
||||
func tokenOK(got, expected string) bool {
|
||||
if expected == "" || len(got) != len(expected) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1
|
||||
}
|
||||
|
||||
func handler(cfg config) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
detector := &ipDetector{}
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if cfg.Fingerprint != "" {
|
||||
w.Header().Set("X-RustProxy-Agent-Certificate-SHA256", cfg.Fingerprint)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"status":"ok"}`)
|
||||
})
|
||||
mux.HandleFunc("/v1/inventory", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
auth := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(auth, prefix) || !tokenOK(strings.TrimSpace(strings.TrimPrefix(auth, prefix)), cfg.Token) {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
containers, err := dockerContainers(r.Context(), cfg.DockerSock)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
addresses, primaryIP, primarySource := detectedAddresses(r.Context(), cfg, detector)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if cfg.Fingerprint != "" {
|
||||
w.Header().Set("X-RustProxy-Agent-Certificate-SHA256", cfg.Fingerprint)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(inventory{
|
||||
AgentVersion: version, Hostname: cfg.Hostname, PublicIP: primaryIP, PublicIPSource: primarySource, CertificateSHA256: cfg.Fingerprint,
|
||||
Addresses: addresses, ObservedAt: time.Now().UTC().Format(time.RFC3339), Services: servicesFrom(containers),
|
||||
})
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
return fallback
|
||||
}
|
||||
value, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func main() {
|
||||
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()
|
||||
|
||||
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
|
||||
}
|
||||
if *printFingerprint {
|
||||
fingerprint, err := certificateFingerprint(cfg.CertFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("%s\n", fingerprint)
|
||||
return
|
||||
}
|
||||
if len(cfg.Token) < 24 {
|
||||
log.Fatal("RUSTPROXY_DOCKER_AGENT_TOKEN must contain at least 24 characters")
|
||||
}
|
||||
if _, err := os.Stat(cfg.DockerSock); err != nil {
|
||||
log.Fatalf("Docker socket unavailable: %v", err)
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(cfg.Listen); err != nil {
|
||||
log.Fatalf("invalid listen address: %v", err)
|
||||
}
|
||||
if cfg.TLS {
|
||||
fingerprint, created, err := ensureSelfSignedCertificate(cfg.CertFile, cfg.KeyFile, cfg.Hostname)
|
||||
if err != nil {
|
||||
log.Fatalf("TLS certificate: %v", err)
|
||||
}
|
||||
cfg.Fingerprint = fingerprint
|
||||
if created {
|
||||
log.Printf("generated self-signed TLS certificate; SHA-256 %s", fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdown)
|
||||
}()
|
||||
scheme := "http"
|
||||
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)
|
||||
}
|
||||
var err error
|
||||
if cfg.TLS {
|
||||
err = server.ListenAndServeTLS(cfg.CertFile, cfg.KeyFile)
|
||||
} else {
|
||||
err = server.ListenAndServe()
|
||||
}
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func fakeDocker(t *testing.T, payload string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
sock := filepath.Join(dir, "docker.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/containers/json" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(payload))
|
||||
})}
|
||||
go srv.Serve(ln)
|
||||
t.Cleanup(func() { _ = srv.Close(); _ = os.Remove(sock) })
|
||||
return sock
|
||||
}
|
||||
|
||||
func TestServicesFromUsesStableComposeIdentity(t *testing.T) {
|
||||
items := []dockerContainer{{ID: strings.Repeat("a", 64), Names: []string{"/web-1"}, Image: "demo", Labels: map[string]string{"com.docker.compose.project": "lab", "com.docker.compose.service": "web", "com.docker.compose.container-number": "1"}, Ports: []dockerPort{{IP: "0.0.0.0", PrivatePort: 8080, PublicPort: 18080, Type: "tcp"}, {PrivatePort: 53, PublicPort: 1053, Type: "udp"}}}}
|
||||
got := servicesFrom(items)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d services", len(got))
|
||||
}
|
||||
if got[0].Key != "compose:lab/web/1:8080/tcp" || got[0].TechnicalName != "lab-web-8080" {
|
||||
t.Fatalf("unexpected service %#v", got[0])
|
||||
}
|
||||
if len(got[0].Bindings) != 1 || got[0].Bindings[0].PublicPort != 18080 {
|
||||
t.Fatalf("unexpected bindings %#v", got[0].Bindings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicesFromKeepsComposeReplicasAndAllBindings(t *testing.T) {
|
||||
labels := func(number string) map[string]string {
|
||||
return map[string]string{"com.docker.compose.project": "lab", "com.docker.compose.service": "web", "com.docker.compose.container-number": number}
|
||||
}
|
||||
items := []dockerContainer{
|
||||
{ID: "a", Names: []string{"/lab-web-1"}, Labels: labels("1"), Ports: []dockerPort{{IP: "0.0.0.0", PrivatePort: 8080, PublicPort: 18080, Type: "tcp"}, {IP: "::", PrivatePort: 8080, PublicPort: 18080, Type: "tcp"}, {IP: "10.10.0.21", PrivatePort: 8080, PublicPort: 18081, Type: "tcp"}}},
|
||||
{ID: "b", Names: []string{"/lab-web-2"}, Labels: labels("2"), Ports: []dockerPort{{IP: "0.0.0.0", PrivatePort: 8080, PublicPort: 18082, Type: "tcp"}}},
|
||||
}
|
||||
got := servicesFrom(items)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected two replicas, got %#v", got)
|
||||
}
|
||||
if got[0].Key != "compose:lab/web/1:8080/tcp" || got[1].Key != "compose:lab/web/2:8080/tcp" {
|
||||
t.Fatalf("unexpected replica keys %#v", got)
|
||||
}
|
||||
if len(got[0].Bindings) != 3 {
|
||||
t.Fatalf("expected all published bindings, got %#v", got[0].Bindings)
|
||||
}
|
||||
if got[0].Bindings[0].BindIP != "10.10.0.21" || got[0].Bindings[0].PublicPort != 18081 {
|
||||
t.Fatalf("concrete binding should be retained, got %#v", got[0].Bindings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicesFromSkipsLoopbackOnlyBindings(t *testing.T) {
|
||||
items := []dockerContainer{{ID: "abc", Names: []string{"/app"}, Labels: map[string]string{}, Ports: []dockerPort{
|
||||
{IP: "127.0.0.1", PrivatePort: 8080, PublicPort: 18080, Type: "tcp"},
|
||||
{IP: "::1", PrivatePort: 8080, PublicPort: 18081, Type: "tcp"},
|
||||
{IP: "0.0.0.0", PrivatePort: 8082, PublicPort: 18082, Type: "tcp"},
|
||||
}}}
|
||||
got := servicesFrom(items)
|
||||
if len(got) != 1 || len(got[0].Bindings) != 1 || got[0].Bindings[0].PublicPort != 18082 {
|
||||
t.Fatalf("unexpected public services %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPDetectorCachesPublicLookup(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
_, _ = w.Write([]byte("203.0.113.44"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
detector := &ipDetector{}
|
||||
for range 2 {
|
||||
ip := detector.public(context.Background(), srv.URL)
|
||||
if ip != "203.0.113.44" {
|
||||
t.Fatalf("unexpected detection %s", ip)
|
||||
}
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("expected one public lookup, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHostAddressKeepsInterfaceAndSources(t *testing.T) {
|
||||
var got []hostAddress
|
||||
index := map[string]int{}
|
||||
mergeHostAddress(&got, index, hostAddress{IP: "10.20.30.40", Source: "interface", Interface: "eno1", Scope: "private"})
|
||||
mergeHostAddress(&got, index, hostAddress{IP: "10.20.30.40", Source: "route"})
|
||||
if len(got) != 1 || got[0].Interface != "eno1" || got[0].Source != "interface,route" {
|
||||
t.Fatalf("unexpected merged address %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventoryRequiresTokenAndReportsMultipleAddressModel(t *testing.T) {
|
||||
sock := fakeDocker(t, `[{"Id":"1234567890abcdef","Names":["/app1"],"Image":"example/app","Labels":{},"Ports":[{"IP":"0.0.0.0","PrivatePort":8080,"PublicPort":18080,"Type":"tcp"}]}]`)
|
||||
cfg := config{Token: strings.Repeat("x", 24), DockerSock: sock, AdvertiseIP: "203.0.113.10", PublicIPURL: "", Hostname: "docker-a"}
|
||||
srv := httptest.NewServer(handler(cfg))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := http.Get(srv.URL + "/v1/inventory")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", res.StatusCode)
|
||||
}
|
||||
_ = res.Body.Close()
|
||||
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL+"/v1/inventory", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||
client := &http.Client{Timeout: time.Second}
|
||||
res, err = client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", res.StatusCode)
|
||||
}
|
||||
var got inventory
|
||||
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PublicIP != "203.0.113.10" || got.PublicIPSource != "override" || len(got.Services) != 1 {
|
||||
t.Fatalf("unexpected inventory %#v", got)
|
||||
}
|
||||
found := false
|
||||
for _, address := range got.Addresses {
|
||||
if address.IP == "203.0.113.10" && strings.Contains(address.Source, "override") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("advertised address missing from %#v", got.Addresses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipInterfaceFiltersDockerGeneratedLinksButKeepsHostBridges(t *testing.T) {
|
||||
for _, name := range []string{"docker0", "docker_gwbridge", "veth1234", "br-123456", "virbr0"} {
|
||||
if !skipInterface(name) {
|
||||
t.Fatalf("expected %s to be filtered", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"eno1", "eth0", "br0", "bond0", "wg0"} {
|
||||
if skipInterface(name) {
|
||||
t.Fatalf("expected %s to remain", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfSignedCertificateIsCreatedOnceAndFingerprintStable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certFile := filepath.Join(dir, "agent.crt")
|
||||
keyFile := filepath.Join(dir, "agent.key")
|
||||
first, created, err := ensureSelfSignedCertificate(certFile, keyFile, "docker-a.lab.local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !created || len(first) != 64 {
|
||||
t.Fatalf("unexpected initial certificate result created=%v fingerprint=%q", created, first)
|
||||
}
|
||||
second, createdAgain, err := ensureSelfSignedCertificate(certFile, keyFile, "docker-a.lab.local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if createdAgain || second != first {
|
||||
t.Fatalf("certificate should be stable: first=%q second=%q created=%v", first, second, createdAgain)
|
||||
}
|
||||
if mode := fileMode(t, keyFile); mode.Perm() != 0600 {
|
||||
t.Fatalf("private key mode = %o, want 600", mode.Perm())
|
||||
}
|
||||
if mode := fileMode(t, certFile); mode.Perm() != 0644 {
|
||||
t.Fatalf("certificate mode = %o, want 644", mode.Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func fileMode(t *testing.T, path string) os.FileMode {
|
||||
t.Helper()
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return info.Mode()
|
||||
}
|
||||
|
||||
func TestTLSInventoryReportsServingCertificateFingerprint(t *testing.T) {
|
||||
sock := fakeDocker(t, `[{"Id":"abc","Names":["/app"],"Image":"demo","Labels":{},"Ports":[{"IP":"0.0.0.0","PrivatePort":8080,"PublicPort":18080,"Type":"tcp"}]}]`)
|
||||
dir := t.TempDir()
|
||||
certFile := filepath.Join(dir, "agent.crt")
|
||||
keyFile := filepath.Join(dir, "agent.key")
|
||||
fingerprint, _, err := ensureSelfSignedCertificate(certFile, keyFile, "localhost")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := config{Token: strings.Repeat("z", 24), DockerSock: sock, Hostname: "docker-tls", TLS: true, CertFile: certFile, KeyFile: keyFile, Fingerprint: fingerprint}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &http.Server{Handler: handler(cfg)}
|
||||
go func() { _ = server.ServeTLS(listener, certFile, keyFile) }()
|
||||
t.Cleanup(func() { _ = server.Close() })
|
||||
|
||||
transport := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} // test-only: verify the endpoint is actually TLS; RustProxy pins it in production.
|
||||
client := &http.Client{Transport: transport, Timeout: time.Second}
|
||||
req, _ := http.NewRequest(http.MethodGet, "https://"+listener.Addr().String()+"/v1/inventory", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if got := res.Header.Get("X-RustProxy-Agent-Certificate-SHA256"); got != fingerprint {
|
||||
t.Fatalf("certificate header = %q, want %q", got, fingerprint)
|
||||
}
|
||||
var got inventory
|
||||
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.CertificateSHA256 != fingerprint {
|
||||
t.Fatalf("inventory fingerprint = %q, want %q", got.CertificateSHA256, fingerprint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
[Unit]
|
||||
Description=RustProxy Docker discovery agent
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=rustproxy-docker-agent
|
||||
Group=docker
|
||||
EnvironmentFile=/etc/rustproxy/docker-agent.env
|
||||
StateDirectory=rustproxy-docker-agent
|
||||
StateDirectoryMode=0750
|
||||
UMask=0027
|
||||
ExecStart=/usr/local/bin/rustproxy-docker-agent
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
ProtectClock=true
|
||||
ProtectKernelLogs=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictNamespaces=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
SystemCallArchitectures=native
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in new issue
Block a user