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:
1 parent
f528c2aa9c
commit
cf49df0346
22 files changed
+570
-26
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user