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>
468 lines
13 KiB
Go
468 lines
13 KiB
Go
package main
|
|
|
|
// Several Jarl servers in one CLI configuration, like an inventory:
|
|
//
|
|
// servers:
|
|
// ns1: {url: https://ns1.example.com:8053, token: jarl_...}
|
|
// ns2: {url: https://ns2.example.com:8053, token: "@/home/me/.config/jarl/ns2.token"}
|
|
// groups:
|
|
// dns: [ns1, ns2]
|
|
// default: ns1
|
|
//
|
|
// --server (or JARL_SERVER) takes a server name, a group, "all", a comma
|
|
// separated list of those, or a URL. A command aimed at several servers is
|
|
// first run on every one of them as a dry run (the "preflight"); only if it
|
|
// succeeds everywhere is it run for real, one server after another. So a
|
|
// change that one server rejects, such as a record in a zone that is a
|
|
// secondary there, changes nothing anywhere.
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"jarl/internal/client"
|
|
)
|
|
|
|
// serverEntry is one named server in the CLI configuration.
|
|
type serverEntry struct {
|
|
URL string `yaml:"url"`
|
|
Token string `yaml:"token,omitempty"`
|
|
Insecure bool `yaml:"insecure,omitempty"`
|
|
CACert string `yaml:"ca_cert,omitempty"`
|
|
}
|
|
|
|
// target is a resolved server to talk to. Name is empty for the plain
|
|
// server/token at the top of the configuration file and for URLs.
|
|
type target struct {
|
|
Name string
|
|
URL string
|
|
Token string
|
|
Insecure bool
|
|
CACert string
|
|
}
|
|
|
|
func (t target) label() string {
|
|
if t.Name == "" {
|
|
return t.URL
|
|
}
|
|
return t.Name + " (" + t.URL + ")"
|
|
}
|
|
|
|
// Environment used between the CLI and the copies of itself that it runs
|
|
// for each server of a group.
|
|
const (
|
|
envTarget = "JARL_TARGET" // the one server a copy talks to
|
|
envPreflight = "JARL_PREFLIGHT" // "1": allow only reads and dry runs
|
|
envPreflightReport = "JARL_PREFLIGHT_REPORT" // file the copy marks when it would change something
|
|
)
|
|
|
|
// targetSpec is the --server value in effect.
|
|
func targetSpec(cfg *cliConfig) string {
|
|
if t := os.Getenv(envTarget); t != "" {
|
|
return t
|
|
}
|
|
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
|
|
}
|
|
for name := range cfg.Servers { // exactly one
|
|
spec = name
|
|
}
|
|
}
|
|
var out []target
|
|
seen := map[string]bool{}
|
|
var add func(item string, depth int) error
|
|
add = func(item string, depth int) error {
|
|
item = strings.TrimSpace(item)
|
|
switch {
|
|
case item == "":
|
|
return nil
|
|
case strings.Contains(item, "://"):
|
|
if seen[item] {
|
|
return nil
|
|
}
|
|
seen[item] = true
|
|
// A URL that matches a configured server uses its settings.
|
|
for name, s := range cfg.Servers {
|
|
if strings.TrimSuffix(s.URL, "/") == strings.TrimSuffix(item, "/") {
|
|
return add(name, depth)
|
|
}
|
|
}
|
|
t := target{URL: strings.TrimSuffix(item, "/")}
|
|
if strings.TrimSuffix(cfg.Server, "/") == t.URL {
|
|
t.Token, t.Insecure, t.CACert = cfg.Token, cfg.Insecure, cfg.CACert
|
|
}
|
|
out = append(out, t)
|
|
return nil
|
|
}
|
|
if s, ok := cfg.Servers[item]; ok {
|
|
if !seen[item] {
|
|
seen[item] = true
|
|
out = append(out, target{Name: item, URL: strings.TrimSuffix(s.URL, "/"), Token: s.Token, Insecure: s.Insecure, CACert: s.CACert})
|
|
}
|
|
return nil
|
|
}
|
|
if members, ok := cfg.Groups[item]; ok {
|
|
if depth > 8 {
|
|
return fmt.Errorf("group %s: groups nest too deeply (is there a loop?)", item)
|
|
}
|
|
for _, m := range members {
|
|
if err := add(m, depth+1); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
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 {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
known := append(sortedKeys(cfg.Servers), sortedKeys(cfg.Groups)...)
|
|
if len(known) == 0 {
|
|
return fmt.Errorf("no server or group %q: %s defines none (see 'jarl config server add --help'); give a URL instead", item, flagConfig)
|
|
}
|
|
return fmt.Errorf("no server or group %q in %s (known: %s)", item, flagConfig, strings.Join(known, ", "))
|
|
}
|
|
for _, item := range strings.Split(spec, ",") {
|
|
if err := add(item, 0); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("--server %q names no servers", spec)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func sortedKeys[V any](m map[string]V) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
// ---- running a command on several servers
|
|
|
|
var errFanOut = errors.New("run on several servers")
|
|
|
|
// fanOutTargets is set by checkFanOut when the command must run on several
|
|
// servers.
|
|
var fanOutTargets []target
|
|
|
|
// preflightClients are the clients made in a preflight copy; their
|
|
// Mutated flags tell the parent whether the command changes anything.
|
|
var preflightClients []*client.Client
|
|
|
|
// checkFanOut runs before every command. It stops the command (with
|
|
// errFanOut) when --server names several servers; main then runs it on
|
|
// each of them.
|
|
func checkFanOut(cmd *cobra.Command) error {
|
|
if os.Getenv(envPreflight) == "1" {
|
|
flagDryRun = true // also answers confirmations: nothing is changed
|
|
}
|
|
if os.Getenv(envTarget) != "" || localCommand(cmd) {
|
|
return nil
|
|
}
|
|
cfg, err := loadCLIConfig()
|
|
if err != nil {
|
|
return nil // newClient reports it
|
|
}
|
|
ts, err := resolveTargets(cfg, targetSpec(cfg))
|
|
if err != nil || len(ts) < 2 {
|
|
return nil
|
|
}
|
|
if flagChangeset > 0 || os.Getenv("JARL_CHANGESET") != "" {
|
|
return fmt.Errorf("changeset IDs belong to one server, so --changeset can't be used with several servers")
|
|
}
|
|
fanOutTargets = ts
|
|
return errFanOut
|
|
}
|
|
|
|
// localCommand reports whether cmd works without a server (and so never
|
|
// runs on several).
|
|
func localCommand(cmd *cobra.Command) bool {
|
|
for c := cmd; c != nil; c = c.Parent() {
|
|
switch c.Name() {
|
|
case "config", "help", "completion", "__complete", "__completeNoDesc":
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// finishPreflight tells the parent whether this copy would change
|
|
// anything.
|
|
func finishPreflight() {
|
|
path := os.Getenv(envPreflightReport)
|
|
if path == "" {
|
|
return
|
|
}
|
|
for _, c := range preflightClients {
|
|
if c.Mutated {
|
|
os.WriteFile(path, []byte("changes\n"), 0o600)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
type runResult struct {
|
|
t target
|
|
code int
|
|
stdout, stderr bytes.Buffer
|
|
changes bool
|
|
err error
|
|
}
|
|
|
|
// fanOut runs the command line on every target and returns the exit code.
|
|
func fanOut(ts []target) int {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "error:", err)
|
|
return 1
|
|
}
|
|
args := os.Args[1:]
|
|
tmp, err := os.MkdirTemp("", "jarl-preflight-")
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "error:", err)
|
|
return 1
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
|
|
// Pass 1: a dry run on every server, in parallel.
|
|
results := make([]*runResult, len(ts))
|
|
var wg sync.WaitGroup
|
|
for i, t := range ts {
|
|
r := &runResult{t: t}
|
|
results[i] = r
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
report := fmt.Sprintf("%s/%d", tmp, i)
|
|
cmd := exec.Command(exe, args...)
|
|
cmd.Env = childEnv(r.t, envPreflight+"=1", envPreflightReport+"="+report)
|
|
cmd.Stdout, cmd.Stderr = &r.stdout, &r.stderr
|
|
r.code, r.err = exitStatus(cmd.Run())
|
|
if _, err := os.Stat(report); err == nil {
|
|
r.changes = true
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
var failed []*runResult
|
|
changes := false
|
|
for _, r := range results {
|
|
if r.code != 0 {
|
|
failed = append(failed, r)
|
|
}
|
|
changes = changes || r.changes
|
|
}
|
|
// A command that only reads (or a --dry-run) is finished: show what
|
|
// each server answered.
|
|
if !changes || flagDryRun {
|
|
code := 0
|
|
for _, r := range results {
|
|
header(headerOut(), r.t)
|
|
if r.err != nil {
|
|
fmt.Fprintln(os.Stderr, "error:", r.err)
|
|
}
|
|
os.Stdout.Write(r.stdout.Bytes())
|
|
os.Stderr.Write(r.stderr.Bytes())
|
|
if code == 0 {
|
|
code = r.code
|
|
}
|
|
}
|
|
return code
|
|
}
|
|
if len(failed) > 0 {
|
|
for _, r := range failed {
|
|
header(os.Stderr, r.t)
|
|
if r.err != nil {
|
|
fmt.Fprintln(os.Stderr, "error:", r.err)
|
|
}
|
|
os.Stderr.Write(r.stderr.Bytes())
|
|
}
|
|
names := make([]string, len(failed))
|
|
for i, r := range failed {
|
|
names[i] = nameOf(r.t)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "\nNothing was changed on any server: the command was checked on all %d first and failed on %s.\n", len(ts), strings.Join(names, ", "))
|
|
return failed[0].code
|
|
}
|
|
|
|
// Pass 2: run it for real, one server after another.
|
|
for i, t := range ts {
|
|
header(headerOut(), t)
|
|
cmd := exec.Command(exe, args...)
|
|
cmd.Env = childEnv(t)
|
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
|
code, err := exitStatus(cmd.Run())
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "error:", err)
|
|
}
|
|
if code != 0 {
|
|
var done, rest []string
|
|
for _, d := range ts[:i] {
|
|
done = append(done, nameOf(d))
|
|
}
|
|
for _, d := range ts[i+1:] {
|
|
rest = append(rest, nameOf(d))
|
|
}
|
|
fmt.Fprintf(os.Stderr, "\nFailed on %s although its dry run passed.", nameOf(t))
|
|
if len(done) > 0 {
|
|
fmt.Fprintf(os.Stderr, " Already applied on: %s.", strings.Join(done, ", "))
|
|
}
|
|
if len(rest) > 0 {
|
|
fmt.Fprintf(os.Stderr, " Not run on: %s.", strings.Join(rest, ", "))
|
|
}
|
|
fmt.Fprintln(os.Stderr)
|
|
return code
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// childEnv is the environment for running the command on t.
|
|
func childEnv(t target, extra ...string) []string {
|
|
var env []string
|
|
for _, kv := range os.Environ() {
|
|
k, _, _ := strings.Cut(kv, "=")
|
|
switch k {
|
|
case envTarget, envPreflight, envPreflightReport:
|
|
continue
|
|
}
|
|
env = append(env, kv)
|
|
}
|
|
spec := t.Name
|
|
if spec == "" {
|
|
spec = t.URL
|
|
}
|
|
return append(append(env, envTarget+"="+spec), extra...)
|
|
}
|
|
|
|
func exitStatus(err error) (int, error) {
|
|
if err == nil {
|
|
return 0, nil
|
|
}
|
|
var ee *exec.ExitError
|
|
if errors.As(err, &ee) {
|
|
return ee.ExitCode(), nil
|
|
}
|
|
return 1, err
|
|
}
|
|
|
|
func nameOf(t target) string {
|
|
if t.Name != "" {
|
|
return t.Name
|
|
}
|
|
return t.URL
|
|
}
|
|
|
|
// headerOut keeps machine-readable output on stdout free of headers.
|
|
func headerOut() io.Writer {
|
|
if flagOutput == "json" || flagOutput == "yaml" {
|
|
return os.Stderr
|
|
}
|
|
return os.Stdout
|
|
}
|
|
|
|
func header(w io.Writer, t target) {
|
|
fmt.Fprintf(w, "== %s ==\n", t.label())
|
|
}
|
|
|
|
// 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, 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)
|
|
}
|