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
+570 -26

No files matched your search

+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,