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
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user