Skip to content

Commit 99a5fae

Browse files
Add support for connecting through Unix socket (#11)
1 parent 7940c36 commit 99a5fae

File tree

12 files changed

+355
-17
lines changed

12 files changed

+355
-17
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Caddy exposes rich metrics through its admin API and Prometheus endpoint, but re
4343
- Readiness gate: `ember wait` blocks until Caddy is up (`-q` for silent scripting)
4444
- Deployment validation: `ember diff before.json after.json` compares snapshots
4545
- Zero-config setup: `ember init` checks Caddy, enables metrics, and warns about missing host matchers
46+
- Unix socket support for Caddy admin APIs configured with `admin unix//path`
4647
- TLS and mTLS support for secured Caddy admin APIs
4748
- Environment variable configuration (`EMBER_ADDR`, `EMBER_EXPOSE`, ...) for container deployments
4849
- `NO_COLOR` env var support ([no-color.org](https://no-color.org/))

cmd/ember/main_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func TestRun_InvalidFlag(t *testing.T) {
3030
func TestRun_InvalidAddr(t *testing.T) {
3131
err := app.Run([]string{"--addr", "localhost:2019"}, version)
3232
assert.Error(t, err)
33-
assert.Contains(t, err.Error(), "--addr must start with http:// or https://")
33+
assert.Contains(t, err.Error(), "--addr must start with http://, https://, or unix//")
3434
}
3535

3636
func TestRun_InvalidInterval(t *testing.T) {

docs/caddy-configuration.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,39 @@ Ember connects to the Caddy admin API (default: `localhost:2019`). Make sure it'
1414

1515
> **Caution:** The admin API is unauthenticated by default. Do not expose it on a public interface. See [Caddy's admin API documentation](https://caddyserver.com/docs/caddyfile/options#admin) for authentication options.
1616
17+
## Unix Socket
18+
19+
For improved security, Caddy can listen on a Unix socket instead of a TCP port. This avoids network exposure entirely and restricts access through filesystem permissions:
20+
21+
```
22+
{
23+
admin unix//run/caddy/admin.sock
24+
}
25+
```
26+
27+
You can also set file permissions on the socket:
28+
29+
```
30+
{
31+
admin unix//run/caddy/admin.sock|0660
32+
}
33+
```
34+
35+
To connect Ember to a Unix socket:
36+
37+
```bash
38+
ember --addr unix//run/caddy/admin.sock
39+
```
40+
41+
Or via environment variable:
42+
43+
```bash
44+
export EMBER_ADDR=unix//run/caddy/admin.sock
45+
ember
46+
```
47+
48+
> **Note:** TLS options (`--ca-cert`, `--client-cert`, `--client-key`, `--insecure`) cannot be used with Unix socket addresses, as the connection is local and does not traverse the network.
49+
1750
## Metrics Directive
1851

1952
The `metrics` directive enables Prometheus-format metrics on the admin API. Without it, Ember cannot display HTTP traffic data.

docs/cli-reference.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ ember [flags]
1010

1111
| Flag | Type | Default | Description |
1212
|------|------|---------|-------------|
13-
| `--addr` | string | `http://localhost:2019` | Caddy admin API address |
13+
| `--addr` | string | `http://localhost:2019` | Caddy admin API address (`http://`, `https://`, or `unix//path`) |
1414
| `--interval` | duration | `1s` | Polling interval |
1515
| `--timeout` | duration | `0` (none) | Global timeout. Applies to all modes and subcommands. 0 means no timeout. |
1616
| `--slow-threshold` | int | `500` | Slow request threshold in milliseconds. Requests above this are highlighted yellow; above 2x are red. |
@@ -35,7 +35,7 @@ Some flags can be set via environment variables. Explicit flags always take prec
3535

3636
| Variable | Flag | Example |
3737
|----------|------|---------|
38-
| `EMBER_ADDR` | `--addr` | `EMBER_ADDR=http://caddy:2019` |
38+
| `EMBER_ADDR` | `--addr` | `EMBER_ADDR=http://caddy:2019` or `EMBER_ADDR=unix//run/caddy/admin.sock` |
3939
| `EMBER_INTERVAL` | `--interval` | `EMBER_INTERVAL=5s` |
4040
| `EMBER_EXPOSE` | `--expose` | `EMBER_EXPOSE=:9191` |
4141
| `EMBER_METRICS_PREFIX` | `--metrics-prefix` | `EMBER_METRICS_PREFIX=myapp` |
@@ -52,6 +52,9 @@ ember
5252
# Connect to a remote Caddy instance
5353
ember --addr http://prod:2019
5454

55+
# Connect via Unix socket
56+
ember --addr unix//run/caddy/admin.sock
57+
5558
# Pipe-friendly JSON output
5659
ember --json
5760

docs/docker.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,40 @@ With this setup, Ember runs in the same network namespace as Caddy and can reach
7070

7171
> **Caution:** The image is built from `scratch`: there is no shell, no `exec`, and no debugging tools inside the container. Use `docker logs` to read Ember's stderr output.
7272

73+
## Unix Socket
74+
75+
If Caddy's admin API is configured to listen on a Unix socket, mount the socket into the Ember container:
76+
77+
```bash
78+
docker run --rm \
79+
-v /run/caddy/admin.sock:/run/caddy/admin.sock \
80+
ghcr.io/alexandre-daubois/ember \
81+
--daemon --expose :9191 --addr unix//run/caddy/admin.sock
82+
```
83+
84+
Or with Docker Compose:
85+
86+
```yaml
87+
services:
88+
caddy:
89+
image: caddy:latest
90+
volumes:
91+
- ./Caddyfile:/etc/caddy/Caddyfile
92+
- caddy-admin:/run/caddy
93+
94+
ember:
95+
image: ghcr.io/alexandre-daubois/ember
96+
environment:
97+
EMBER_ADDR: unix//run/caddy/admin.sock
98+
volumes:
99+
- caddy-admin:/run/caddy
100+
depends_on:
101+
- caddy
102+
103+
volumes:
104+
caddy-admin:
105+
```
106+
73107
## See Also
74108

75109
- [Getting Started](getting-started.md)

internal/app/daemon.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ func reloadTLS(f fetcher.Fetcher, cfg *config, log *slog.Logger) {
5050
log.Warn("TLS reload not supported for this fetcher")
5151
return
5252
}
53+
if hf.IsUnixSocket() {
54+
log.Info("TLS reload skipped (Unix socket connection)")
55+
return
56+
}
5357
if err := configureTLS(hf, cfg); err != nil {
5458
log.Error("TLS reload failed", "err", err)
5559
return

internal/app/run.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Keybindings:
6363
q Quit`,
6464
Example: ` ember # default: localhost:2019
6565
ember --addr http://prod:2019 # custom address
66+
ember --addr unix//run/caddy/admin.sock # Unix socket
6667
ember --json # pipe-friendly JSON output
6768
ember --json --once # single JSON snapshot and exit
6869
ember --expose :9191 # TUI + Prometheus endpoint
@@ -117,7 +118,7 @@ Keybindings:
117118
}
118119

119120
pf := cmd.PersistentFlags()
120-
pf.StringVar(&cfg.addr, "addr", "http://localhost:2019", "Caddy admin API address")
121+
pf.StringVar(&cfg.addr, "addr", "http://localhost:2019", "Caddy admin API address (http://, https://, or unix//path)")
121122
pf.DurationVar(&cfg.interval, "interval", 1*time.Second, "Polling interval")
122123
pf.DurationVar(&cfg.timeout, "timeout", 0, "Global timeout (0 = no timeout)")
123124
pf.IntVar(&cfg.frankenphpPID, "frankenphp-pid", 0, "FrankenPHP PID (auto-detected if not set)")
@@ -161,6 +162,9 @@ func contextWithTimeout(parent context.Context, timeout time.Duration) (context.
161162
}
162163

163164
func configureTLS(f *fetcher.HTTPFetcher, cfg *config) error {
165+
if f.IsUnixSocket() {
166+
return nil
167+
}
164168
tlsCfg, err := fetcher.BuildTLSConfig(fetcher.TLSOptions{
165169
CACert: cfg.caCert,
166170
ClientCert: cfg.clientCert,
@@ -220,8 +224,16 @@ func validate(cfg *config) error {
220224
if cfg.interval < minInterval {
221225
return fmt.Errorf("--interval must be at least %s", minInterval)
222226
}
223-
if !strings.HasPrefix(cfg.addr, "http://") && !strings.HasPrefix(cfg.addr, "https://") {
224-
return fmt.Errorf("--addr must start with http:// or https://")
227+
if !strings.HasPrefix(cfg.addr, "http://") && !strings.HasPrefix(cfg.addr, "https://") && !fetcher.IsUnixAddr(cfg.addr) {
228+
return fmt.Errorf("--addr must start with http://, https://, or unix//")
229+
}
230+
if fetcher.IsUnixAddr(cfg.addr) {
231+
if _, ok := fetcher.ParseUnixAddr(cfg.addr); !ok {
232+
return fmt.Errorf("--addr must include a non-empty Unix socket path")
233+
}
234+
if cfg.caCert != "" || cfg.clientCert != "" || cfg.clientKey != "" || cfg.insecure {
235+
return fmt.Errorf("TLS options cannot be used with Unix socket addresses")
236+
}
225237
}
226238
if cfg.metricsAuth != "" {
227239
if !strings.Contains(cfg.metricsAuth, ":") {

internal/app/run_test.go

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ func TestValidate_AddrMissingScheme(t *testing.T) {
146146
cfg := &config{interval: 1 * time.Second, addr: "localhost:2019"}
147147
err := validate(cfg)
148148
assert.Error(t, err)
149-
assert.Contains(t, err.Error(), "--addr must start with http:// or https://")
149+
assert.Contains(t, err.Error(), "--addr must start with http://, https://, or unix//")
150150
}
151151

152152
func TestValidate_AddrHTTPSOK(t *testing.T) {
@@ -159,6 +159,51 @@ func TestValidate_AddrHTTPOK(t *testing.T) {
159159
assert.NoError(t, validate(cfg))
160160
}
161161

162+
func TestValidate_AddrUnixSocket(t *testing.T) {
163+
cfg := &config{interval: 1 * time.Second, addr: "unix//run/caddy/admin.sock"}
164+
assert.NoError(t, validate(cfg))
165+
}
166+
167+
func TestValidate_AddrUnixSocketTripleSlash(t *testing.T) {
168+
cfg := &config{interval: 1 * time.Second, addr: "unix:///run/caddy/admin.sock"}
169+
assert.NoError(t, validate(cfg))
170+
}
171+
172+
func TestValidate_AddrUnixSocketEmptyPath(t *testing.T) {
173+
cfg := &config{interval: 1 * time.Second, addr: "unix//"}
174+
err := validate(cfg)
175+
assert.Error(t, err)
176+
assert.Contains(t, err.Error(), "non-empty Unix socket path")
177+
}
178+
179+
func TestValidate_AddrUnixSocketEmptyPathTripleSlash(t *testing.T) {
180+
cfg := &config{interval: 1 * time.Second, addr: "unix:///"}
181+
err := validate(cfg)
182+
assert.Error(t, err)
183+
assert.Contains(t, err.Error(), "non-empty Unix socket path")
184+
}
185+
186+
func TestValidate_AddrUnixSocketWithTLS(t *testing.T) {
187+
cfg := &config{interval: 1 * time.Second, addr: "unix//run/caddy/admin.sock", caCert: "ca.pem"}
188+
err := validate(cfg)
189+
assert.Error(t, err)
190+
assert.Contains(t, err.Error(), "TLS options cannot be used with Unix socket addresses")
191+
}
192+
193+
func TestValidate_AddrUnixSocketWithClientCert(t *testing.T) {
194+
cfg := &config{interval: 1 * time.Second, addr: "unix//run/caddy/admin.sock", clientCert: "cert.pem", clientKey: "key.pem"}
195+
err := validate(cfg)
196+
assert.Error(t, err)
197+
assert.Contains(t, err.Error(), "TLS options cannot be used with Unix socket addresses")
198+
}
199+
200+
func TestValidate_AddrUnixSocketWithInsecure(t *testing.T) {
201+
cfg := &config{interval: 1 * time.Second, addr: "unix//run/caddy/admin.sock", insecure: true}
202+
err := validate(cfg)
203+
assert.Error(t, err)
204+
assert.Contains(t, err.Error(), "TLS options cannot be used with Unix socket addresses")
205+
}
206+
162207
func TestValidate_MetricsAuthBadFormat(t *testing.T) {
163208
cfg := &config{interval: 1 * time.Second, addr: "http://localhost:2019", expose: ":9191", metricsAuth: "nopassword"}
164209
err := validate(cfg)
@@ -192,13 +237,13 @@ func TestRun_IntervalTooLow(t *testing.T) {
192237
func TestRun_AddrMissingScheme(t *testing.T) {
193238
err := Run([]string{"--addr", "localhost:2019"}, "0.0.0")
194239
assert.Error(t, err)
195-
assert.Contains(t, err.Error(), "--addr must start with http:// or https://")
240+
assert.Contains(t, err.Error(), "--addr must start with http://, https://, or unix//")
196241
}
197242

198243
func TestRun_SubcommandValidatesAddr(t *testing.T) {
199244
err := Run([]string{"wait", "--addr", "localhost:2019"}, "0.0.0")
200245
assert.Error(t, err)
201-
assert.Contains(t, err.Error(), "--addr must start with http:// or https://")
246+
assert.Contains(t, err.Error(), "--addr must start with http://, https://, or unix//")
202247
}
203248

204249
func TestRun_SubcommandValidatesInterval(t *testing.T) {

internal/fetcher/http.go

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"encoding/json"
88
"fmt"
99
"io"
10+
"net"
1011
"net/http"
1112
"os"
1213
"slices"
@@ -26,6 +27,7 @@ const (
2627

2728
type HTTPFetcher struct {
2829
baseURL string
30+
socketPath string
2931
httpClient *http.Client
3032
procHandle *processHandle
3133

@@ -40,21 +42,41 @@ type HTTPFetcher struct {
4042

4143
func NewHTTPFetcher(baseURL string, pid int32) *HTTPFetcher {
4244
ph := newProcessHandle(pid)
45+
46+
var socketPath string
47+
transport := &http.Transport{
48+
MaxIdleConns: 2,
49+
MaxIdleConnsPerHost: 2,
50+
IdleConnTimeout: 30 * time.Second,
51+
}
52+
53+
if sp, ok := ParseUnixAddr(baseURL); ok {
54+
socketPath = sp
55+
baseURL = "http://localhost"
56+
transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
57+
return (&net.Dialer{}).DialContext(ctx, "unix", sp)
58+
}
59+
}
60+
4361
return &HTTPFetcher{
44-
baseURL: strings.TrimRight(baseURL, "/"),
45-
httpClient: &http.Client{
46-
Transport: &http.Transport{
47-
MaxIdleConns: 2,
48-
MaxIdleConnsPerHost: 2,
49-
IdleConnTimeout: 30 * time.Second,
50-
},
51-
},
62+
baseURL: strings.TrimRight(baseURL, "/"),
63+
socketPath: socketPath,
64+
httpClient: &http.Client{Transport: transport},
5265
procHandle: ph,
5366
}
5467
}
5568

69+
// IsUnixSocket reports whether this fetcher communicates over a Unix socket.
70+
func (f *HTTPFetcher) IsUnixSocket() bool {
71+
return f.socketPath != ""
72+
}
73+
5674
// SetTLSConfig replaces the HTTP transport with one using the given TLS configuration.
75+
// It is a no-op when the fetcher uses a Unix socket.
5776
func (f *HTTPFetcher) SetTLSConfig(tlsConfig *tls.Config) {
77+
if f.socketPath != "" {
78+
return
79+
}
5880
f.httpClient.Transport = &http.Transport{
5981
TLSClientConfig: tlsConfig,
6082
MaxIdleConns: 2,

0 commit comments

Comments
 (0)