From cf49df0346498efde70d9bf8c35e1332fb2c6aca Mon Sep 17 00:00:00 2001 From: abrown38 Date: Thu, 8 Oct 2026 15:09:39 +0000 Subject: [PATCH] Add a local unix socket for root on the server jarld also listens on /run/jarl/jarl.sock (socket in jarl.yaml). Requests on it from root or jarld's own user need no token and act as an administrator; the kernel's SO_PEERCRED says who is connecting, and anyone else must send a token. The jarl CLI uses the socket when no server is configured, or with -s local, so 'sudo jarl status' works on the server with nothing set up and a lost token or password never locks root out. Changes are audited under the system user's name via "socket". The systemd unit creates /run/jarl (0750). Socket paths over 107 bytes are refused with a clear message. The docs explain the socket and what happens when a token is revoked. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++ README.md | 6 +- cmd/jarl/complete.go | 4 + cmd/jarl/config_file.go | 4 +- cmd/jarl/main.go | 8 +- cmd/jarl/targets.go | 67 +++++++++++++++- cmd/jarl/targets_test.go | 10 +++ cmd/jarld/main.go | 2 +- deploy/systemd/jarl.service | 3 + docs/api.md | 8 ++ docs/cli.md | 24 ++++++ docs/configuration.md | 1 + docs/install.md | 9 ++- docs/permissions.md | 50 +++++++++++- internal/client/client.go | 29 +++++-- internal/config/config.go | 32 ++++++-- internal/server/authn.go | 8 ++ internal/server/h_auth.go | 3 + internal/server/server.go | 45 ++++++++++- internal/server/socket.go | 124 +++++++++++++++++++++++++++++ internal/server/socket_test.go | 140 +++++++++++++++++++++++++++++++++ scripts/e2e.sh | 11 +++ 22 files changed, 570 insertions(+), 26 deletions(-) create mode 100644 internal/server/socket.go create mode 100644 internal/server/socket_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c7543b5..92840d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,14 @@ The first version, to be released as 0.1.0. the web UI. Networks smaller than a /24 explain that they belong in the enclosing /24's zone. - docs/operations.md: setting up a secondary server with its own jarld. +- A local unix socket, `/run/jarl/jarl.sock` (`socket` in jarl.yaml). + Requests on it from root or jarld's own user need no token and act as + an administrator (the kernel's `SO_PEERCRED` says who is connecting); + anyone else must send a token. The `jarl` CLI uses it when no server is + configured, or with `-s local`, so `sudo jarl status` works on the + server with nothing set up, and losing every token or password never + locks root out. Changes are audited under the system user's name via + `socket`. The systemd unit creates `/run/jarl` (0750). - Jarl is released under the MIT License (`LICENSE`), which is installed with the docs and is the Debian package's `copyright` file (with the bundled Cinzel font's OFL). diff --git a/README.md b/README.md index ff6d879..14ce273 100644 --- a/README.md +++ b/README.md @@ -65,14 +65,12 @@ details): ```sh make deb && sudo apt install ./dist/jarl_*.deb # or: make && sudo make install, see install.md -sudo -u jarl jarld init --token # first admin + a CLI token +sudo -u jarl jarld init # database + first administrator sudo -u jarl jarld import # read the current named.conf and zones sudo jarld adopt # switch named over (keeps a backup, rolls back on failure) sudo systemctl enable --now jarl -jarl config set server http://127.0.0.1:8053 -jarl config set token # paste the token from 'jarld init' -jarl status +sudo jarl status # root on the server needs no token (local socket) ``` Then open the web UI at `http://127.0.0.1:8053/`, or set `listen` and `tls` diff --git a/cmd/jarl/complete.go b/cmd/jarl/complete.go index 8757320..aef86e5 100644 --- a/cmd/jarl/complete.go +++ b/cmd/jarl/complete.go @@ -8,6 +8,7 @@ package main // server can't be reached. import ( + "os" "strings" "time" @@ -125,6 +126,9 @@ func completeServers(groups bool) cobra.CompletionFunc { if len(cfg.Servers) > 0 { out = append(out, cobra.CompletionWithDesc(prefix+"all", "every server")) } + if _, err := os.Stat(socketPath()); err == nil { + out = append(out, cobra.CompletionWithDesc(prefix+localName, "jarld on this machine (local socket)")) + } } return out, cobra.ShellCompDirectiveNoFileComp } diff --git a/cmd/jarl/config_file.go b/cmd/jarl/config_file.go index c208de8..b9cf891 100644 --- a/cmd/jarl/config_file.go +++ b/cmd/jarl/config_file.go @@ -395,8 +395,8 @@ func storeEntry(cfg *cliConfig, name string, e *serverEntry) { } func checkConfigName(name string) error { - if name == "" || name == "all" || name == "none" || strings.ContainsAny(name, ",:/ \t") { - return fmt.Errorf("invalid name %q: use letters, digits, - and _ (not \"all\" or \"none\")", name) + if name == "" || name == "all" || name == "none" || name == localName || strings.ContainsAny(name, ",:/ \t") { + return fmt.Errorf("invalid name %q: use letters, digits, - and _ (not \"all\", \"none\" or \"local\")", name) } return nil } diff --git a/cmd/jarl/main.go b/cmd/jarl/main.go index a1edd64..4432e66 100644 --- a/cmd/jarl/main.go +++ b/cmd/jarl/main.go @@ -116,7 +116,13 @@ func newClient() (*client.Client, error) { if t.Name != "" && t.Token != "" { token = t.Token } - if token == "" { + sock, isSocket := client.SocketPath(t.URL) + if isSocket { + if err := checkSocket(sock, t.Name == localName && flagServer == "" && os.Getenv("JARL_SERVER") == ""); err != nil { + return nil, err + } + } + if token == "" && !isSocket { if t.Name != "" { return nil, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name) } diff --git a/cmd/jarl/targets.go b/cmd/jarl/targets.go index 510284c..90c94d9 100644 --- a/cmd/jarl/targets.go +++ b/cmd/jarl/targets.go @@ -21,12 +21,16 @@ import ( "errors" "fmt" "io" + "io/fs" + "net" "net/url" "os" "os/exec" "sort" "strings" "sync" + "syscall" + "time" "github.com/spf13/cobra" @@ -74,10 +78,31 @@ func targetSpec(cfg *cliConfig) string { return firstNonEmpty(flagServer, os.Getenv("JARL_SERVER"), cfg.Default) } +// defaultSocket is where jarld listens locally (socket in jarl.yaml). +const defaultSocket = "/run/jarl/jarl.sock" + +// localName is the reserved server name for jarld's local socket. +const localName = "local" + +// socketPath is jarld's local socket: JARL_SOCKET, or the default. +func socketPath() string { + return firstNonEmpty(os.Getenv("JARL_SOCKET"), defaultSocket) +} + +// localTarget is jarld on this machine, through its unix socket. Root and +// the jarl user need no token there. +func localTarget() target { + return target{Name: localName, URL: "unix://" + socketPath()} +} + // resolveTargets turns a --server value into servers, in order and without // duplicates. func resolveTargets(cfg *cliConfig, spec string) ([]target, error) { if strings.TrimSpace(spec) == "" { + // With no server configured at all, use jarld on this machine. + if cfg.Server == "" && len(cfg.Servers) == 0 { + return []target{localTarget()}, nil + } if cfg.Server != "" || len(cfg.Servers) != 1 { return []target{{URL: cfg.Server, Token: cfg.Token, Insecure: cfg.Insecure, CACert: cfg.CACert}}, nil } @@ -129,6 +154,13 @@ func resolveTargets(cfg *cliConfig, spec string) ([]target, error) { } return nil } + if item == localName { + if !seen[item] { + seen[item] = true + out = append(out, localTarget()) + } + return nil + } if item == "all" && len(cfg.Servers) > 0 { for _, name := range sortedKeys(cfg.Servers) { if err := add(name, depth); err != nil { @@ -394,9 +426,42 @@ func header(w io.Writer, t target) { // validServerURL checks a server URL from the command line. func validServerURL(value string) (string, error) { + if _, ok := client.SocketPath(value); ok { + return value, nil + } u, err := url.Parse(value) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - return "", fmt.Errorf("invalid server URL %q (use e.g. https://ns1.example.com:8053)", value) + return "", fmt.Errorf("invalid server URL %q (use e.g. https://ns1.example.com:8053, or unix:///run/jarl/jarl.sock)", value) } return strings.TrimSuffix(value, "/"), nil } + +// checkSocket explains why jarld's local socket can't be used, before any +// request is made. implicit says the socket was chosen because no server +// is configured. +func checkSocket(path string, implicit bool) error { + if len(path) > 107 { + return fmt.Errorf("the socket path %s is too long (unix sockets allow at most 107 bytes)", path) + } + c, err := net.DialTimeout("unix", path, 2*time.Second) + if err == nil { + c.Close() + return nil + } + hint := "" + if implicit { + hint = "\nTo use a Jarl server over the network: jarl config set server URL, then jarl config set token." + } + switch { + case errors.Is(err, fs.ErrNotExist): + if implicit { + return fmt.Errorf("no server is configured, and there is no local jarld (%s doesn't exist).%s", path, hint) + } + return fmt.Errorf("there is no local jarld: %s doesn't exist (is jarld running here, with socket set in jarl.yaml?)", path) + case errors.Is(err, fs.ErrPermission): + return fmt.Errorf("only root and the jarl user can use jarld's local socket %s: run this command with sudo.%s", path, hint) + case errors.Is(err, syscall.ECONNREFUSED): + return fmt.Errorf("jarld isn't running (nothing answers on %s).%s", path, hint) + } + return fmt.Errorf("can't connect to jarld's local socket: %v%s", err, hint) +} diff --git a/cmd/jarl/targets_test.go b/cmd/jarl/targets_test.go index 57b9cfd..78b523b 100644 --- a/cmd/jarl/targets_test.go +++ b/cmd/jarl/targets_test.go @@ -32,6 +32,8 @@ func TestResolveTargets(t *testing.T) { {"https://ns2.example:8053/", "ns2=jarl_2"}, // a configured URL uses its entry {"https://old.example:8053", "https://old.example:8053=jarl_old"}, {"https://new.example", "https://new.example="}, // an unknown URL gets no token + {"local", "local="}, // jarld's local socket + {"ns1,local", "ns1=jarl_1 local="}, } for _, c := range cases { ts, err := resolveTargets(cfg, c.spec) @@ -48,6 +50,14 @@ func TestResolveTargets(t *testing.T) { t.Errorf("%q accepted", bad) } } + // With nothing configured, jarld's local socket is used. + t.Setenv("JARL_SOCKET", "/tmp/test.sock") + if ts, err := resolveTargets(&cliConfig{}, ""); err != nil || len(ts) != 1 || ts[0].URL != "unix:///tmp/test.sock" || ts[0].Name != "local" { + t.Errorf("nothing configured: %v %v", ts, err) + } + if err := checkConfigName("local"); err == nil { + t.Error(`"local" accepted as a server name`) + } // With only named servers and no default, a single one is used. one := &cliConfig{Servers: map[string]*serverEntry{"ns1": {URL: "https://ns1.example"}}} if ts, err := resolveTargets(one, ""); err != nil || nameOf(ts[0]) != "ns1" { diff --git a/cmd/jarld/main.go b/cmd/jarld/main.go index b7b55bf..9382dfa 100644 --- a/cmd/jarld/main.go +++ b/cmd/jarld/main.go @@ -286,7 +286,7 @@ func serveCmd() *cobra.Command { Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure, - Version: version.Version, Stats: collector, + Version: version.Version, Stats: collector, Socket: a.cfg.Socket, HTTPReq: server.HTTPReq{ Enabled: a.cfg.ACME.HTTPReq.Enabled, AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS, diff --git a/deploy/systemd/jarl.service b/deploy/systemd/jarl.service index 3525c4b..fdc6d54 100644 --- a/deploy/systemd/jarl.service +++ b/deploy/systemd/jarl.service @@ -28,6 +28,9 @@ StateDirectory=jarl StateDirectoryMode=0750 ConfigurationDirectory=jarl ConfigurationDirectoryMode=0750 +# /run/jarl: the local API socket (jarl.sock), for root and the jarl user. +RuntimeDirectory=jarl +RuntimeDirectoryMode=0750 # Sandboxing. jarld may only write its own state and the directories it # manages for named (created by tmpfiles.d/jarl.conf). diff --git a/docs/api.md b/docs/api.md index b71aebe..3a6f014 100644 --- a/docs/api.md +++ b/docs/api.md @@ -28,6 +28,14 @@ change something (anything but GET/HEAD) must send it as `X-CSRF-Token`. `GET /auth/whoami` returns the user, the token (if any), the effective permissions and whether the caller is an administrator. +**The local socket**: on jarld's unix socket (`/run/jarl/jarl.sock`), +requests from root or jarld's own user need no `Authorization` header and +act as an administrator; other users must send a token: + +```sh +sudo curl --unix-socket /run/jarl/jarl.sock http://localhost/api/v1/status +``` + **HTTP Basic**, only on the lego `httpreq` routes (below): the username is the token's user and the password the API token. diff --git a/docs/cli.md b/docs/cli.md index 57c2955..01187d0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -46,6 +46,30 @@ file is `~/.config/jarl/config.yaml` (mode 0600), or `--config` / Get tokens from the web UI (Account → API tokens), from `jarl token create`, or from `jarld init --token` on the server. +### On the server itself + +On the DNS server, root needs no setup at all: + +```sh +sudo jarl status +sudo jarl record add adbraun.lol www A 192.0.2.10 +``` + +With no server configured, the CLI talks to jarld through its local unix +socket, `/run/jarl/jarl.sock`, where root and the `jarl` user are +administrators without a token ([permissions.md](permissions.md#the-local-socket)). +The history and audit log show such changes as made by `root` via +`socket`. + +* `-s local` (or `JARL_SERVER=local`) uses the socket even when servers + are configured, and `local` can be part of a list (`-s local,ns2`). + `local` is reserved and can't be a configured server's name. +* `JARL_SOCKET` points at another socket path. +* A token given with `--token` or `JARL_TOKEN` is sent over the socket as + well, and then its permissions apply. +* Other users get a clear error ("only root and the jarl user can use + jarld's local socket"); they configure a server and token as above. + ### Several servers and groups With more than one Jarl (say a primary and a secondary, each with its own diff --git a/docs/configuration.md b/docs/configuration.md index b93eb12..03b55ff 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,6 +19,7 @@ The CLI has its own small configuration file, described in [cli.md](cli.md). | Key | Default | Meaning | |-----|---------|---------| | `listen` | `["127.0.0.1:8053"]` | Addresses to listen on (`host:port`, several allowed). Keep these on localhost and management networks. | +| `socket` | `/run/jarl/jarl.sock` | A local unix socket for the API. Requests on it from root or jarld's own user need no token and have administrator rights; anyone else must send a token ([permissions.md](permissions.md#the-local-socket)). The `jarl` CLI uses it when no server is configured. At most 107 bytes long; `""` turns it off. If it can't be created, jarld logs a warning and serves the network addresses only. | | `tls.cert_file`, `tls.key_file` | (empty: plain HTTP) | PEM certificate chain and key. Both or neither. The files are rechecked every 10 minutes and on SIGHUP (`systemctl reload jarl`), so renewed certificates are used without a restart. | | `trusted_proxies` | `[]` | Addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` (or `Forwarded: proto=`) headers are believed. Needed for correct client IPs in the audit log and for rate limiting behind a proxy, and for knowing that a proxied request came over HTTPS (the session cookie's Secure flag, `acme.httpreq`). From other addresses these headers are ignored. | | `session.lifetime` | `12h` | A web session ends this long after login... | diff --git a/docs/install.md b/docs/install.md index 3633470..6cf7c13 100644 --- a/docs/install.md +++ b/docs/install.md @@ -227,10 +227,15 @@ Open `https://:8053/` and log in as `admin`. ## 9. Set up the CLI -The CLI can run on the server or on any machine that can reach jarld. +On the server itself, root needs nothing: `sudo jarl status` and every +other command use jarld's local socket without a token +([cli.md](cli.md#on-the-server-itself)). + +On other machines, or as an ordinary user, give the CLI a server and a +token: ```sh -jarl config set server https://ns1.adbraun.lol:8053 # or http://127.0.0.1:8053 on the server +jarl config set server https://ns1.adbraun.lol:8053 jarl config set token # paste the token from 'jarld init' at the prompt jarl status jarl zone list diff --git a/docs/permissions.md b/docs/permissions.md index 83648ee..32146ce 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -7,6 +7,7 @@ | People in the web UI | Username + password → session cookie | argon2id password hashes. The session cookie is HttpOnly and SameSite=Strict and lasts 12h (2h idle). Every change also needs the session's CSRF token, which the UI sends in `X-CSRF-Token`. Changing a password logs out the user's other sessions. | | CLI, scripts, certbot | API token: `Authorization: Bearer jarl_...` | Shown once at creation, stored hashed. Optional expiry. Revocable. Last use time and IP are recorded. | | lego / Traefik (`httpreq`) | HTTP Basic: the token's user and the API token | Only on `/api/v1/acme/httpreq/...`, only when `acme.httpreq.enabled` is set, and by default only over HTTPS ([acme.md](acme.md)). The token's grants apply as usual. | +| Root on the server, with the CLI | jarld's local socket (`/run/jarl/jarl.sock`): no token | `sudo jarl ...` with no server configured. The kernel tells jarld which system user is connecting; root and the `jarl` user are administrators, anyone else must send a token. Audited as the system user (e.g. `root`) via `socket`. See [The local socket](#the-local-socket). | | Root on the server | `jarld` subcommands (`init`, `user`, `import`, `adopt`, `check`, `resync`, `backup`) | Work directly on the database. They are for bootstrapping and recovery, and are audited as "local". | API tokens are how you give a script, a tool or a person limited access: @@ -161,9 +162,56 @@ In the web UI, the **Rotate** button next to each token asks how long the old secret should keep working and how long the token should last, then shows the new secret once. +## The local socket + +jarld also listens on a unix socket, `/run/jarl/jarl.sock` (`socket` in +jarl.yaml; systemd creates `/run/jarl`). It serves the same API, but a +request on it without a token is trusted by **who sent it**: the kernel +reports the connecting process's user (`SO_PEERCRED`), and + +| Connecting user | Without a token | With a token | +|--|--|--| +| root | administrator | the token's permissions | +| `jarl` (jarld's own user) | administrator | the token's permissions | +| anyone else | refused (401) | the token's permissions | + +This gives root nothing it doesn't already have: root can read Jarl's +database, run `jarld user token` and edit named.conf directly. What it adds +is convenience and a way back in: on the server, `sudo jarl status` and +every other command work with nothing configured and no token stored in a +file, even after every token has been revoked and every password +forgotten. + +* The socket file is mode 0600 in a 0750 directory owned by `jarl`, so + other users can't even connect; the user check doesn't depend on that. +* Changes made this way are in the history and audit log under the system + user's name (`root`) with `socket` as the method. That name isn't a Jarl + user, so it has no tokens of its own: `sudo jarl token create laptop + --user admin` makes one for a real user. +* The CLI uses the socket only when no server is configured at all, or + with `-s local`, so a configured server is never silently swapped for + the local one ([cli.md](cli.md#on-the-server-itself)). +* Turn it off with `socket: ""` in jarl.yaml. + ## Recovering access -On the server, as root or the `jarl` user: +Revoking a token never locks you out: tokens and web sessions are +separate. Revoking the token your CLI uses stops only the programs that +use it; you stay logged in to the web UI with your password, with all your +rights, and can make a new token under Account → API tokens. Jarl also +refuses to delete, disable or demote the last administrator. + +On the server, root can always use the CLI through the local socket, +without a token: + +```sh +sudo jarl whoami +sudo jarl token create laptop --user admin # a new token for admin +sudo jarl user update admin --password # a new password +``` + +The `jarld` subcommands work even when jarld isn't running. As root or the +`jarl` user: ```sh sudo -u jarl jarld user list diff --git a/internal/client/client.go b/internal/client/client.go index 6f9b84c..590330f 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "os" @@ -57,11 +58,19 @@ func New(o Options) (*Client, error) { if o.Server == "" { return nil, fmt.Errorf("no server configured: use --server, JARL_SERVER or 'jarl config set server https://...'") } - u, err := url.Parse(o.Server) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - return nil, fmt.Errorf("invalid server URL %q (use e.g. https://dns.example.com:8053)", o.Server) - } tr := http.DefaultTransport.(*http.Transport).Clone() + base := strings.TrimSuffix(o.Server, "/") + if path, ok := SocketPath(o.Server); ok { + // jarld's local unix socket: every request goes to the socket, + // whatever the URL's host. + tr.Proxy = nil + tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", path) + } + base = "http://jarld" + } else if u, err := url.Parse(o.Server); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, fmt.Errorf("invalid server URL %q (use e.g. https://dns.example.com:8053, or unix:///run/jarl/jarl.sock)", o.Server) + } tc := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: o.Insecure} //nolint:gosec // explicit user choice if o.CACert != "" { pem, err := os.ReadFile(o.CACert) @@ -82,7 +91,17 @@ func New(o Options) (*Client, error) { if timeout == 0 { timeout = 2 * time.Minute } - return &Client{BaseURL: strings.TrimSuffix(o.Server, "/"), Token: o.Token, HTTP: &http.Client{Transport: tr, Timeout: timeout}}, nil + return &Client{BaseURL: base, Token: o.Token, HTTP: &http.Client{Transport: tr, Timeout: timeout}}, nil +} + +// SocketPath returns the path of a unix:// server URL +// ("unix:///run/jarl/jarl.sock"). +func SocketPath(server string) (string, bool) { + path, ok := strings.CutPrefix(server, "unix://") + if !ok || !strings.HasPrefix(path, "/") { + return "", false + } + return path, true } // Error is an API error. diff --git a/internal/config/config.go b/internal/config/config.go index fd40110..f2eb547 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,14 +22,17 @@ type Config struct { Listen []string `yaml:"listen"` TLS TLS `yaml:"tls"` TrustedProxies []string `yaml:"trusted_proxies"` - Database string `yaml:"database"` - StagingDir string `yaml:"staging_dir"` - Log Log `yaml:"log"` - Session Session `yaml:"session"` - BIND BIND `yaml:"bind"` - Audit Audit `yaml:"audit"` - Stats Stats `yaml:"stats"` - ACME ACME `yaml:"acme"` + // Socket is a local unix socket for the API, where root and jarld's + // own user need no token ("" for none). + Socket string `yaml:"socket"` + Database string `yaml:"database"` + StagingDir string `yaml:"staging_dir"` + Log Log `yaml:"log"` + Session Session `yaml:"session"` + BIND BIND `yaml:"bind"` + Audit Audit `yaml:"audit"` + Stats Stats `yaml:"stats"` + ACME ACME `yaml:"acme"` } // ACME configures the ACME helpers. @@ -168,6 +171,7 @@ func ParseDuration(s string) (time.Duration, error) { func Default() *Config { return &Config{ Listen: []string{"127.0.0.1:8053"}, + Socket: "/run/jarl/jarl.sock", Database: "/var/lib/jarl/jarl.db", StagingDir: "/var/lib/jarl/staging", Log: Log{Level: "info", Format: "text"}, @@ -220,6 +224,12 @@ func (c *Config) check() error { if len(c.Listen) == 0 { return fmt.Errorf("listen: at least one address is needed") } + if c.Socket != "" && !filepath.IsAbs(c.Socket) { + return fmt.Errorf("socket must be an absolute path (or \"\" for none)") + } + if len(c.Socket) > 107 { + return fmt.Errorf("socket: the path is %d bytes long; unix sockets allow at most 107", len(c.Socket)) + } if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") { return fmt.Errorf("tls: set both cert_file and key_file") } @@ -334,6 +344,12 @@ tls: cert_file: "" key_file: "" +# A local unix socket for the API and the jarl CLI. Requests on it from +# root or jarld's own user need no token and have administrator rights +# ('sudo jarl status' works with nothing configured); anyone else must send +# a token. systemd creates /run/jarl. "" turns the socket off. +socket: /run/jarl/jarl.sock + # Reverse proxies whose X-Forwarded-For / X-Forwarded-Proto headers are # trusted (addresses or prefixes). trusted_proxies: [] diff --git a/internal/server/authn.go b/internal/server/authn.go index 2298fbd..b6c6c93 100644 --- a/internal/server/authn.go +++ b/internal/server/authn.go @@ -110,6 +110,14 @@ var errUnauthenticated = &httpError{http.StatusUnauthorized, "unauthenticated", // cookie. For sessions, state-changing requests must carry the CSRF token. func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session, error) { ip := s.clientIP(r) + // On the local socket, root and jarld's own user need no token + // (socket.go). A token, if sent, is used as anywhere else. + if pr := peerOf(r.Context()); pr != nil && r.Header.Get("Authorization") == "" { + if trustedUID(pr.UID) { + return socketPrincipal(pr, r.UserAgent()), nil, nil + } + return nil, nil, socketDenied(pr) + } if h := r.Header.Get("Authorization"); h != "" { scheme, tok, _ := strings.Cut(h, " ") if !strings.EqualFold(scheme, "Bearer") { diff --git a/internal/server/h_auth.go b/internal/server/h_auth.go index 34a915e..8aacafb 100644 --- a/internal/server/h_auth.go +++ b/internal/server/h_auth.go @@ -196,6 +196,9 @@ func narrower(a, b []model.Grant) bool { } func (s *Server) createOwnToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if p.User.ID == 0 { + return badRequest("%s on the local socket isn't a Jarl user and has no tokens; create the token for a user (jarl token create NAME --user admin)", p.Name()) + } return s.createTokenFor(w, r, p, p.User) } diff --git a/internal/server/server.go b/internal/server/server.go index a1cdf08..282f3d8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -40,6 +40,8 @@ type Config struct { Stats *stats.Collector // HTTPReq configures the endpoint for lego's httpreq DNS provider. HTTPReq HTTPReq + // Socket is the local unix socket's path ("" for none); see socket.go. + Socket string } // Server is the HTTP server. @@ -104,7 +106,7 @@ func (s *Server) Serve(ctx context.Context, ready func()) error { s.certs = cr tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: cr.get} } - errc := make(chan error, len(s.cfg.Listen)) + errc := make(chan error, len(s.cfg.Listen)+1) var listeners []net.Listener for _, addr := range s.cfg.Listen { ln, err := net.Listen("tcp", addr) @@ -155,6 +157,7 @@ func (s *Server) Serve(ctx context.Context, ready func()) error { } } } + s.serveSocket(errc) s.logHTTPReq(tlsCfg != nil) if ready != nil { ready() @@ -170,6 +173,37 @@ func (s *Server) Serve(ctx context.Context, ready func()) error { return nil } +// serveSocket serves the API on the local unix socket. A socket that +// can't be created (e.g. /run/jarl missing outside systemd) is logged, not +// fatal: the network listeners still work. +func (s *Server) serveSocket(errc chan error) { + if s.cfg.Socket == "" { + return + } + ln, err := listenSocket(s.cfg.Socket) + if err != nil { + s.log.Warn("not listening on the local socket", "path", s.cfg.Socket, "err", err) + return + } + srv := &http.Server{ + Handler: s.Handler(), + ConnContext: socketConnContext, + ReadHeaderTimeout: 10 * time.Second, + WriteTimeout: 5 * time.Minute, + IdleTimeout: 2 * time.Minute, + ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn), + } + s.mu.Lock() + s.srvs = append(s.srvs, srv) + s.mu.Unlock() + s.log.Info("listening", "socket", s.cfg.Socket) + go func() { + if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) { + errc <- err + } + }() +} + func (s *Server) shutdown() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -178,6 +212,9 @@ func (s *Server) shutdown() { for _, srv := range s.srvs { srv.Shutdown(ctx) } + if s.cfg.Socket != "" { + os.Remove(s.cfg.Socket) + } } // ReloadTLS rereads the TLS certificate (on SIGHUP). @@ -344,6 +381,9 @@ func (s *Server) logRequests(next http.Handler) http.Handler { // clientIP returns the client's address, honoring X-Forwarded-For from // trusted proxies. func (s *Server) clientIP(r *http.Request) string { + if peerOf(r.Context()) != nil { + return "local" + } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr @@ -413,6 +453,9 @@ func (s *Server) transport(r *http.Request) (https bool, why string) { if r.TLS != nil { return true, "HTTPS to jarld (tls in jarl.yaml)" } + if peerOf(r.Context()) != nil { + return true, "jarld's local unix socket (never crosses a network)" + } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr diff --git a/internal/server/socket.go b/internal/server/socket.go new file mode 100644 index 0000000..06bc383 --- /dev/null +++ b/internal/server/socket.go @@ -0,0 +1,124 @@ +package server + +// The local unix socket (socket in jarl.yaml, /run/jarl/jarl.sock by +// default). It serves the same API as the network listeners, but a request +// on it without an Authorization header is trusted by who sent it: the +// kernel reports the connecting process's user (SO_PEERCRED), and root and +// jarld's own user get administrator rights without a token. Both can +// already do everything (read the database, run 'jarld user token', edit +// named.conf), so this adds convenience, not power: 'sudo jarl status' +// works on the server with nothing configured, and a lost token or +// password never locks out root. +// +// Anyone else who can reach the socket must send an API token, which then +// gives that token's permissions, as on the network. The socket file is +// mode 0600 in a 0750 directory, so normally only root and the jarl user +// can connect at all; the user check doesn't rely on that. + +import ( + "context" + "fmt" + "io/fs" + "net" + "net/http" + "os" + "os/user" + "strconv" + "syscall" + + "jarl/internal/auth" + "jarl/internal/model" +) + +// peer is the process at the other end of a unix socket connection. +type peer struct { + UID int + PID int +} + +type peerKey struct{} + +// peerOf returns the socket peer of a request, or nil for network +// requests. +func peerOf(ctx context.Context) *peer { + p, _ := ctx.Value(peerKey{}).(*peer) + return p +} + +// socketConnContext records the peer's credentials on each connection to +// the unix socket. +func socketConnContext(ctx context.Context, c net.Conn) context.Context { + uc, ok := c.(*net.UnixConn) + if !ok { + return ctx + } + p := &peer{UID: -1} + if raw, err := uc.SyscallConn(); err == nil { + raw.Control(func(fd uintptr) { + if cred, err := syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED); err == nil { + p.UID, p.PID = int(cred.Uid), int(cred.Pid) + } + }) + } + // Even when the credentials can't be read, the request is marked as a + // socket request (with no trusted user). + return context.WithValue(ctx, peerKey{}, p) +} + +// trustedUID reports whether a socket peer gets administrator rights +// without a token: root and the user jarld runs as. +func trustedUID(uid int) bool { + return uid == 0 || uid == os.Getuid() +} + +// socketPrincipal is the administrator for a trusted socket peer. It isn't +// a Jarl user (ID 0), so it has no tokens or password; the audit log and +// history show it by its system user name, e.g. "root", with method +// "socket". +func socketPrincipal(p *peer, agent string) *auth.Principal { + name := "uid " + strconv.Itoa(p.UID) + if u, err := user.LookupId(strconv.Itoa(p.UID)); err == nil { + name = u.Username + } + return &auth.Principal{User: &model.User{ID: 0, Username: name, Admin: true}, Method: "socket", IP: "local", Agent: agent} +} + +// listenSocket creates the unix socket, replacing a stale one left by a +// previous run. +func listenSocket(path string) (net.Listener, error) { + if len(path) > 107 { + return nil, fmt.Errorf("the path is %d bytes long; unix sockets allow at most 107", len(path)) + } + if fi, err := os.Lstat(path); err == nil { + if fi.Mode()&fs.ModeSocket == 0 { + return nil, fmt.Errorf("%s exists and isn't a socket", path) + } + // A socket nobody answers on is left over from a previous run. + if c, err := net.Dial("unix", path); err == nil { + c.Close() + return nil, fmt.Errorf("%s is in use by another jarld", path) + } + os.Remove(path) + } + ln, err := net.Listen("unix", path) + if err != nil { + return nil, err + } + if err := os.Chmod(path, 0o600); err != nil { + ln.Close() + return nil, err + } + return ln, nil +} + +// socketDenied explains a refused tokenless request on the socket. +func socketDenied(p *peer) error { + who := "an unknown user" + if p.UID >= 0 { + who = "uid " + strconv.Itoa(p.UID) + if u, err := user.LookupId(strconv.Itoa(p.UID)); err == nil { + who = u.Username + } + } + return &httpError{http.StatusUnauthorized, "unauthenticated", "the local socket gives administrator rights without a token only to root and jarld's own user, not to " + who + "; send an API token (--token), or run the command as root"} +} diff --git a/internal/server/socket_test.go b/internal/server/socket_test.go new file mode 100644 index 0000000..9239366 --- /dev/null +++ b/internal/server/socket_test.go @@ -0,0 +1,140 @@ +package server + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "os" + "os/user" + "path/filepath" + "strings" + "testing" + + "jarl/internal/api" + "jarl/internal/client" +) + +// serveTestSocket serves e's API on a unix socket, as jarld does. +func serveTestSocket(t *testing.T, e *apiEnv) string { + t.Helper() + path := filepath.Join(t.TempDir(), "jarl.sock") + ln, err := listenSocket(path) + if err != nil { + t.Fatal(err) + } + srv := &http.Server{Handler: e.srv.Handler(), ConnContext: socketConnContext} + go srv.Serve(ln) + t.Cleanup(func() { srv.Close() }) + return path +} + +func TestSocketTrustedUser(t *testing.T) { + e := newAPIEnv(t) + path := serveTestSocket(t, e) + if fi, err := os.Stat(path); err != nil || fi.Mode().Perm() != 0o600 { + t.Fatalf("socket mode: %v %v", fi.Mode(), err) + } + // The test runs as the same user as the server: an administrator + // without a token. + c, err := client.New(client.Options{Server: "unix://" + path}) + if err != nil { + t.Fatal(err) + } + var who api.Whoami + if err := c.Get(context.Background(), "/auth/whoami", nil, &who); err != nil { + t.Fatal(err) + } + me, _ := user.Current() + if !who.Admin || who.Method != "socket" || who.User.Username != me.Username || who.Token != nil { + t.Errorf("whoami over the socket = %+v", who) + } + // It can change things, and the history and audit log name the + // system user. + if err := c.Do(context.Background(), "POST", "/zones", nil, map[string]any{"name": "sock.example", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, nil); err != nil { + t.Fatal(err) + } + var events []map[string]any + c.Get(context.Background(), "/audit", nil, &events) + found := false + for _, ev := range events { + if ev["action"] == "changeset.apply" && ev["username"] == me.Username && ev["auth"] == "socket" && ev["ip"] == "local" { + found = true + } + } + if !found { + t.Errorf("no audit event for the socket user: %v", events) + } + // A token sent over the socket is used as such. + c.Token = e.token + if err := c.Get(context.Background(), "/auth/whoami", nil, &who); err != nil || who.Method != "token" { + t.Errorf("token over the socket: %+v %v", who, err) + } + // The socket's user isn't a Jarl user, so it has no tokens of its own. + c.Token = "" + err = c.Do(context.Background(), "POST", "/me/tokens", nil, map[string]any{"name": "x"}, nil) + var ae *client.Error + if !errors.As(err, &ae) || ae.Status != 400 { + t.Errorf("own token over the socket: %v", err) + } +} + +func TestSocketOtherUserNeedsToken(t *testing.T) { + e := newAPIEnv(t) + req := func(uid int, token string) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", api.Prefix+"/auth/whoami", nil) + r = r.WithContext(context.WithValue(r.Context(), peerKey{}, &peer{UID: uid})) + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + e.srv.Handler().ServeHTTP(w, r) + return w + } + other := os.Getuid() + 4242 + if w := req(other, ""); w.Code != 401 || !strings.Contains(w.Body.String(), "only to root and jarld's own user") { + t.Errorf("untrusted user without a token: %d %s", w.Code, w.Body) + } + if w := req(-1, ""); w.Code != 401 { + t.Errorf("unknown peer without a token: %d", w.Code) + } + if w := req(other, e.token); w.Code != 200 { + t.Errorf("untrusted user with a token: %d %s", w.Code, w.Body) + } + if w := req(0, ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"method":"socket"`) { + t.Errorf("root: %d %s", w.Code, w.Body) + } + // Network requests never get this treatment. + r := httptest.NewRequest("GET", api.Prefix+"/auth/whoami", nil) + w := httptest.NewRecorder() + e.srv.Handler().ServeHTTP(w, r) + if w.Code != 401 { + t.Errorf("network request without a token: %d", w.Code) + } +} + +func TestListenSocketStale(t *testing.T) { + path := filepath.Join(t.TempDir(), "jarl.sock") + // A socket left by a process that is gone is replaced. + old, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + old.(*net.UnixListener).SetUnlinkOnClose(false) + old.Close() + ln, err := listenSocket(path) + if err != nil { + t.Fatalf("stale socket not replaced: %v", err) + } + // One that is in use isn't. + if _, err := listenSocket(path); err == nil || !strings.Contains(err.Error(), "in use") { + t.Errorf("second jarld on the same socket: %v", err) + } + ln.Close() + // A file that isn't a socket is never removed. + os.WriteFile(path, []byte("x"), 0o600) + if _, err := listenSocket(path); err == nil { + t.Error("replaced a regular file") + } +} diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 4dd0379..4028e65 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -86,8 +86,11 @@ $NAMED -c $E/legacy/named.conf -f -n 1 > $E/named.log 2>&1 & NAMED_PID=$! for i in $(seq 50); do $RNDC -k $E/legacy/rndc.key -s 127.0.0.1 -p $CTL status >/dev/null 2>&1 && break; sleep 0.2; done +# The local socket (a short path: unix socket paths are limited to 107 bytes). +SOCK=${XDG_RUNTIME_DIR:-/tmp}/jarl-e2e-$HTTP.sock cat > $E/jarl.yaml <