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
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).
+2 -4
View File
@@ -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`
+4
View File
@@ -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
}
+2 -2
View File
@@ -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
}
+7 -1
View File
@@ -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)
}
+66 -1
View File
@@ -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)
}
+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://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" {
+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,
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,
+3
View File
@@ -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).
+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
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.
+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
`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
+1
View File
@@ -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... |
+7 -2
View File
@@ -227,10 +227,15 @@ Open `https://<management-address>: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
+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. |
| 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
+24 -5
View File
@@ -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.
+16
View File
@@ -22,6 +22,9 @@ type Config struct {
Listen []string `yaml:"listen"`
TLS TLS `yaml:"tls"`
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"`
StagingDir string `yaml:"staging_dir"`
Log Log `yaml:"log"`
@@ -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: []
+8
View File
@@ -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") {
+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 {
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)
}
+44 -1
View File
@@ -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
+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=$!
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
listen: ["127.0.0.1:$HTTP"]
socket: $SOCK
database: $E/data/jarl.db
staging_dir: $E/data/staging
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; }
# 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 token (from stdin)" 0 sh -c "$J config set token < $E/admin.token"
run "config show masks token" 0 $J config show
@@ -143,6 +152,8 @@ run "whoami" 0 $J whoami
contains "admin (everything)"
run "zone list" 0 $J zone list
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
run "record add with PTR" 0 $J record add adbraun.test web A 192.168.1.30 --ptr --ttl 300