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 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-08 15:10:57 +00:00
1 parent f528c2aa9c
commit cf49df0346
22 files changed
+562 -18

No files matched your search

+8
View File
@@ -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 the web UI. Networks smaller than a /24 explain that they belong in the
enclosing /24's zone. enclosing /24's zone.
- docs/operations.md: setting up a secondary server with its own jarld. - 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 - Jarl is released under the MIT License (`LICENSE`), which is installed
with the docs and is the Debian package's `copyright` file (with the with the docs and is the Debian package's `copyright` file (with the
bundled Cinzel font's OFL). bundled Cinzel font's OFL).
+2 -4
View File
@@ -65,14 +65,12 @@ details):
```sh ```sh
make deb && sudo apt install ./dist/jarl_*.deb # or: make && sudo make install, see install.md 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 -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 jarld adopt # switch named over (keeps a backup, rolls back on failure)
sudo systemctl enable --now jarl sudo systemctl enable --now jarl
jarl config set server http://127.0.0.1:8053 sudo jarl status # root on the server needs no token (local socket)
jarl config set token # paste the token from 'jarld init'
jarl status
``` ```
Then open the web UI at `http://127.0.0.1:8053/`, or set `listen` and `tls` Then open the web UI at `http://127.0.0.1:8053/`, or set `listen` and `tls`
+4
View File
@@ -8,6 +8,7 @@ package main
// server can't be reached. // server can't be reached.
import ( import (
"os"
"strings" "strings"
"time" "time"
@@ -125,6 +126,9 @@ func completeServers(groups bool) cobra.CompletionFunc {
if len(cfg.Servers) > 0 { if len(cfg.Servers) > 0 {
out = append(out, cobra.CompletionWithDesc(prefix+"all", "every server")) 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 return out, cobra.ShellCompDirectiveNoFileComp
} }
+2 -2
View File
@@ -395,8 +395,8 @@ func storeEntry(cfg *cliConfig, name string, e *serverEntry) {
} }
func checkConfigName(name string) error { func checkConfigName(name string) error {
if name == "" || name == "all" || name == "none" || strings.ContainsAny(name, ",:/ \t") { if name == "" || name == "all" || name == "none" || name == localName || strings.ContainsAny(name, ",:/ \t") {
return fmt.Errorf("invalid name %q: use letters, digits, - and _ (not \"all\" or \"none\")", name) return fmt.Errorf("invalid name %q: use letters, digits, - and _ (not \"all\", \"none\" or \"local\")", name)
} }
return nil return nil
} }
+7 -1
View File
@@ -116,7 +116,13 @@ func newClient() (*client.Client, error) {
if t.Name != "" && t.Token != "" { if t.Name != "" && t.Token != "" {
token = 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 != "" { if t.Name != "" {
return nil, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name) return nil, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name)
} }
+66 -1
View File
@@ -21,12 +21,16 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"net"
"net/url" "net/url"
"os" "os"
"os/exec" "os/exec"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"syscall"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -74,10 +78,31 @@ func targetSpec(cfg *cliConfig) string {
return firstNonEmpty(flagServer, os.Getenv("JARL_SERVER"), cfg.Default) 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 // resolveTargets turns a --server value into servers, in order and without
// duplicates. // duplicates.
func resolveTargets(cfg *cliConfig, spec string) ([]target, error) { func resolveTargets(cfg *cliConfig, spec string) ([]target, error) {
if strings.TrimSpace(spec) == "" { 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 { if cfg.Server != "" || len(cfg.Servers) != 1 {
return []target{{URL: cfg.Server, Token: cfg.Token, Insecure: cfg.Insecure, CACert: cfg.CACert}}, nil 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 return nil
} }
if item == localName {
if !seen[item] {
seen[item] = true
out = append(out, localTarget())
}
return nil
}
if item == "all" && len(cfg.Servers) > 0 { if item == "all" && len(cfg.Servers) > 0 {
for _, name := range sortedKeys(cfg.Servers) { for _, name := range sortedKeys(cfg.Servers) {
if err := add(name, depth); err != nil { 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. // validServerURL checks a server URL from the command line.
func validServerURL(value string) (string, error) { func validServerURL(value string) (string, error) {
if _, ok := client.SocketPath(value); ok {
return value, nil
}
u, err := url.Parse(value) u, err := url.Parse(value)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { 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 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)
}
+10
View File
@@ -32,6 +32,8 @@ func TestResolveTargets(t *testing.T) {
{"https://ns2.example:8053/", "ns2=jarl_2"}, // a configured URL uses its entry {"https://ns2.example:8053/", "ns2=jarl_2"}, // a configured URL uses its entry
{"https://old.example:8053", "https://old.example:8053=jarl_old"}, {"https://old.example:8053", "https://old.example:8053=jarl_old"},
{"https://new.example", "https://new.example="}, // an unknown URL gets no token {"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 { for _, c := range cases {
ts, err := resolveTargets(cfg, c.spec) ts, err := resolveTargets(cfg, c.spec)
@@ -48,6 +50,14 @@ func TestResolveTargets(t *testing.T) {
t.Errorf("%q accepted", bad) 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. // With only named servers and no default, a single one is used.
one := &cliConfig{Servers: map[string]*serverEntry{"ns1": {URL: "https://ns1.example"}}} one := &cliConfig{Servers: map[string]*serverEntry{"ns1": {URL: "https://ns1.example"}}}
if ts, err := resolveTargets(one, ""); err != nil || nameOf(ts[0]) != "ns1" { if ts, err := resolveTargets(one, ""); err != nil || nameOf(ts[0]) != "ns1" {
+1 -1
View File
@@ -286,7 +286,7 @@ func serveCmd() *cobra.Command {
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, 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), TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime),
SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure, 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{ HTTPReq: server.HTTPReq{
Enabled: a.cfg.ACME.HTTPReq.Enabled, Enabled: a.cfg.ACME.HTTPReq.Enabled,
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS, AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
+3
View File
@@ -28,6 +28,9 @@ StateDirectory=jarl
StateDirectoryMode=0750 StateDirectoryMode=0750
ConfigurationDirectory=jarl ConfigurationDirectory=jarl
ConfigurationDirectoryMode=0750 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 # Sandboxing. jarld may only write its own state and the directories it
# manages for named (created by tmpfiles.d/jarl.conf). # manages for named (created by tmpfiles.d/jarl.conf).
+8
View File
@@ -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 `GET /auth/whoami` returns the user, the token (if any), the effective
permissions and whether the caller is an administrator. 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 **HTTP Basic**, only on the lego `httpreq` routes (below): the username is
the token's user and the password the API token. the token's user and the password the API token.
+24
View File
@@ -46,6 +46,30 @@ file is `~/.config/jarl/config.yaml` (mode 0600), or `--config` /
Get tokens from the web UI (Account → API tokens), from Get tokens from the web UI (Account → API tokens), from
`jarl token create`, or from `jarld init --token` on the server. `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 ### Several servers and groups
With more than one Jarl (say a primary and a secondary, each with its own With more than one Jarl (say a primary and a secondary, each with its own
+1
View File
@@ -19,6 +19,7 @@ The CLI has its own small configuration file, described in [cli.md](cli.md).
| Key | Default | Meaning | | Key | Default | Meaning |
|-----|---------|---------| |-----|---------|---------|
| `listen` | `["127.0.0.1:8053"]` | Addresses to listen on (`host:port`, several allowed). Keep these on localhost and management networks. | | `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. | | `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. | | `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... | | `session.lifetime` | `12h` | A web session ends this long after login... |
+7 -2
View File
@@ -227,10 +227,15 @@ Open `https://<management-address>:8053/` and log in as `admin`.
## 9. Set up the CLI ## 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 ```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 config set token # paste the token from 'jarld init' at the prompt
jarl status jarl status
jarl zone list jarl zone list
+49 -1
View File
@@ -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. | | 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. | | 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. | | 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". | | 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: 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 old secret should keep working and how long the token should last, then
shows the new secret once. 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 ## 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 ```sh
sudo -u jarl jarld user list sudo -u jarl jarld user list
+24 -5
View File
@@ -11,6 +11,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -57,11 +58,19 @@ func New(o Options) (*Client, error) {
if o.Server == "" { if o.Server == "" {
return nil, fmt.Errorf("no server configured: use --server, JARL_SERVER or 'jarl config set server https://...'") 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() 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 tc := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: o.Insecure} //nolint:gosec // explicit user choice
if o.CACert != "" { if o.CACert != "" {
pem, err := os.ReadFile(o.CACert) pem, err := os.ReadFile(o.CACert)
@@ -82,7 +91,17 @@ func New(o Options) (*Client, error) {
if timeout == 0 { if timeout == 0 {
timeout = 2 * time.Minute 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. // Error is an API error.
+16
View File
@@ -22,6 +22,9 @@ type Config struct {
Listen []string `yaml:"listen"` Listen []string `yaml:"listen"`
TLS TLS `yaml:"tls"` TLS TLS `yaml:"tls"`
TrustedProxies []string `yaml:"trusted_proxies"` TrustedProxies []string `yaml:"trusted_proxies"`
// 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"` Database string `yaml:"database"`
StagingDir string `yaml:"staging_dir"` StagingDir string `yaml:"staging_dir"`
Log Log `yaml:"log"` Log Log `yaml:"log"`
@@ -168,6 +171,7 @@ func ParseDuration(s string) (time.Duration, error) {
func Default() *Config { func Default() *Config {
return &Config{ return &Config{
Listen: []string{"127.0.0.1:8053"}, Listen: []string{"127.0.0.1:8053"},
Socket: "/run/jarl/jarl.sock",
Database: "/var/lib/jarl/jarl.db", Database: "/var/lib/jarl/jarl.db",
StagingDir: "/var/lib/jarl/staging", StagingDir: "/var/lib/jarl/staging",
Log: Log{Level: "info", Format: "text"}, Log: Log{Level: "info", Format: "text"},
@@ -220,6 +224,12 @@ func (c *Config) check() error {
if len(c.Listen) == 0 { if len(c.Listen) == 0 {
return fmt.Errorf("listen: at least one address is needed") 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 == "") { if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") {
return fmt.Errorf("tls: set both cert_file and key_file") return fmt.Errorf("tls: set both cert_file and key_file")
} }
@@ -334,6 +344,12 @@ tls:
cert_file: "" cert_file: ""
key_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 # Reverse proxies whose X-Forwarded-For / X-Forwarded-Proto headers are
# trusted (addresses or prefixes). # trusted (addresses or prefixes).
trusted_proxies: [] trusted_proxies: []
+8
View File
@@ -110,6 +110,14 @@ var errUnauthenticated = &httpError{http.StatusUnauthorized, "unauthenticated",
// cookie. For sessions, state-changing requests must carry the CSRF token. // cookie. For sessions, state-changing requests must carry the CSRF token.
func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session, error) { func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session, error) {
ip := s.clientIP(r) 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 != "" { if h := r.Header.Get("Authorization"); h != "" {
scheme, tok, _ := strings.Cut(h, " ") scheme, tok, _ := strings.Cut(h, " ")
if !strings.EqualFold(scheme, "Bearer") { if !strings.EqualFold(scheme, "Bearer") {
+3
View File
@@ -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 { 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) return s.createTokenFor(w, r, p, p.User)
} }
+44 -1
View File
@@ -40,6 +40,8 @@ type Config struct {
Stats *stats.Collector Stats *stats.Collector
// HTTPReq configures the endpoint for lego's httpreq DNS provider. // HTTPReq configures the endpoint for lego's httpreq DNS provider.
HTTPReq HTTPReq HTTPReq HTTPReq
// Socket is the local unix socket's path ("" for none); see socket.go.
Socket string
} }
// Server is the HTTP server. // Server is the HTTP server.
@@ -104,7 +106,7 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
s.certs = cr s.certs = cr
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: cr.get} 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 var listeners []net.Listener
for _, addr := range s.cfg.Listen { for _, addr := range s.cfg.Listen {
ln, err := net.Listen("tcp", addr) 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) s.logHTTPReq(tlsCfg != nil)
if ready != nil { if ready != nil {
ready() ready()
@@ -170,6 +173,37 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
return nil 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() { func (s *Server) shutdown() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
@@ -178,6 +212,9 @@ func (s *Server) shutdown() {
for _, srv := range s.srvs { for _, srv := range s.srvs {
srv.Shutdown(ctx) srv.Shutdown(ctx)
} }
if s.cfg.Socket != "" {
os.Remove(s.cfg.Socket)
}
} }
// ReloadTLS rereads the TLS certificate (on SIGHUP). // 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 // clientIP returns the client's address, honoring X-Forwarded-For from
// trusted proxies. // trusted proxies.
func (s *Server) clientIP(r *http.Request) string { func (s *Server) clientIP(r *http.Request) string {
if peerOf(r.Context()) != nil {
return "local"
}
host, _, err := net.SplitHostPort(r.RemoteAddr) host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil { if err != nil {
host = r.RemoteAddr host = r.RemoteAddr
@@ -413,6 +453,9 @@ func (s *Server) transport(r *http.Request) (https bool, why string) {
if r.TLS != nil { if r.TLS != nil {
return true, "HTTPS to jarld (tls in jarl.yaml)" 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) host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil { if err != nil {
host = r.RemoteAddr host = r.RemoteAddr
+124
View File
@@ -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"}
}
+140
View File
@@ -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")
}
}
+11
View File
@@ -86,8 +86,11 @@ $NAMED -c $E/legacy/named.conf -f -n 1 > $E/named.log 2>&1 &
NAMED_PID=$! 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 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 <<EOF cat > $E/jarl.yaml <<EOF
listen: ["127.0.0.1:$HTTP"] listen: ["127.0.0.1:$HTTP"]
socket: $SOCK
database: $E/data/jarl.db database: $E/data/jarl.db
staging_dir: $E/data/staging staging_dir: $E/data/staging
log: {level: info} log: {level: info}
@@ -133,6 +136,12 @@ expect_dig() { # DESC WANT DIG-ARGS...
} }
contains() { if echo "$OUT" | grep -q -- "$1"; then ok "output has '$1'"; else bad "output lacks '$1'"; echo "$OUT" | sed 's/^/ /'; fi; } contains() { if echo "$OUT" | grep -q -- "$1"; then ok "output has '$1'"; else bad "output lacks '$1'"; echo "$OUT" | sed 's/^/ /'; fi; }
# With nothing configured, the CLI uses jarld's local socket, where
# jarld's own user (this script's) is an administrator without a token.
run "socket: nothing configured uses local jarld" 0 env JARL_SOCKET=$SOCK $J whoami
contains "admin (everything)"
run "socket: missing socket is explained" 1 env JARL_SOCKET=${XDG_RUNTIME_DIR:-/tmp}/jarl-e2e-none.sock $J status
contains "no local jarld"
run "config set server" 0 $J config set server http://127.0.0.1:$HTTP run "config set server" 0 $J config set server http://127.0.0.1:$HTTP
run "config set token (from stdin)" 0 sh -c "$J config set token < $E/admin.token" run "config set token (from stdin)" 0 sh -c "$J config set token < $E/admin.token"
run "config show masks token" 0 $J config show run "config show masks token" 0 $J config show
@@ -143,6 +152,8 @@ run "whoami" 0 $J whoami
contains "admin (everything)" contains "admin (everything)"
run "zone list" 0 $J zone list run "zone list" 0 $J zone list
contains "adbraun.test" contains "adbraun.test"
run "socket: -s local with a server configured" 0 env JARL_SOCKET=$SOCK $J -s local zone list
contains "adbraun.test"
# Records # Records
run "record add with PTR" 0 $J record add adbraun.test web A 192.168.1.30 --ptr --ttl 300 run "record add with PTR" 0 $J record add adbraun.test web A 192.168.1.30 --ptr --ttl 300