mesh/merge-gate fail: builds new: modules/systemd-resolved, sent nowhere; no bus step; a manifest the change touches fails the module check: modules/system…
mesh/repo-check fail: its merge-check.sh failed: long-running resources without health: 70
mesh/delivery superseded: a newer head of the same pull request
Holds node-resolver and provides split-dns at the machine's reach, for a machine whose VPN client pushes resolvers of its own. It writes the resolver file naming the machine's private address, gives resolved the mesh's resolvers as the default route, and serves routes, route and unroute on the mesh and, over a root-only socket, on the machine. Its guard keeps an outside write of the file for the module that handles it and puts the module's file back: at once when taken, after 90 s otherwise, so a write nothing declared to handle is still raised by the node-engine.
433 lines
14 KiB
Go
433 lines
14 KiB
Go
// The guard: the module's one long-running process, as root (novox/hq ADR 0247). It keeps the machine's
|
|
// resolver file the module's own, and serves the seat's verbs on the machine itself.
|
|
//
|
|
// **An outside write is kept, then put back.** Another program writing /etc/resolv.conf — a VPN client
|
|
// does it on every connect — is the module's file displaced. The guard keeps what that program wrote, for
|
|
// whoever handles it on the machine to read, and puts the module's own file back:
|
|
//
|
|
// - at once, when a module on the machine took the write: it read what it needed and routed it (`route`
|
|
// with `takes`);
|
|
// - otherwise after Hold, which is longer than the node-engine needs to see the rewrite twice — so a
|
|
// write nobody declared to handle is still said, as ADR 0241's rewrite, naming its writer, and then
|
|
// ends within a bound instead of at the next reconcile.
|
|
//
|
|
// What was written stays on this machine, in a directory only root reads, and is gone at the next boot.
|
|
// What the guard says of it anywhere else — the `routes` verb's history — is when, the writer the file's
|
|
// own header names, and what became of it: never a server or a domain.
|
|
//
|
|
// **It knows nothing of any VPN**: a writer is whatever the file's header says, and a taker whichever
|
|
// module says it took it.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Where the guard keeps its things. The socket's path is the seat's protocol on the machine: any holder of
|
|
// node-resolver serves its verbs there, so a module calling them does not know which holder answers.
|
|
const (
|
|
ResolvConf = "/etc/resolv.conf"
|
|
// KeptPath is the module's own resolver file, rendered by the mesh beside the live one (the fact
|
|
// `kept`), so the guard compares and puts back exactly what the mesh declared.
|
|
KeptPath = "/etc/node-resolver/resolv.conf"
|
|
RunDir = "/run/node-resolver"
|
|
Socket = RunDir + "/verbs.sock"
|
|
History = RunDir + "/history.json"
|
|
Displaced = RunDir + "/displaced"
|
|
)
|
|
|
|
// Timing.
|
|
const (
|
|
// Look is how often the guard reads the file.
|
|
Look = 500 * time.Millisecond
|
|
// Hold is how long a write nobody took stands: two of the node-engine's looks (30 s each, ADR 0241)
|
|
// with room, so it is said before it is put back.
|
|
Hold = 90 * time.Second
|
|
// Kept is how many displaced writes are kept on the machine, and in the history.
|
|
Kept = 10
|
|
// DefaultRouteEvery is how often a link's servers are kept from being a default route.
|
|
DefaultRouteEvery = 5 * time.Second
|
|
)
|
|
|
|
// Displacement is one outside write of the resolver file, as the guard says it.
|
|
type Displacement struct {
|
|
ID string `json:"id"`
|
|
At time.Time `json:"at"`
|
|
// Writer is who the file's own header names, or empty.
|
|
Writer string `json:"writer,omitempty"`
|
|
// TakenBy is the module that took it, and when.
|
|
TakenBy string `json:"taken_by,omitempty"`
|
|
TakenAt *time.Time `json:"taken_at,omitempty"`
|
|
// Ended is when the module's own file stood again, and How.
|
|
Ended *time.Time `json:"ended,omitempty"`
|
|
How string `json:"how,omitempty"`
|
|
|
|
content string
|
|
}
|
|
|
|
// Guard is the module's file kept, and its verbs served on the machine.
|
|
type Guard struct {
|
|
Path, KeptPath, Dir string
|
|
Hold time.Duration
|
|
Now func() time.Time
|
|
Resolver *Resolver
|
|
// Log says what the guard did, on its own journal: never a server or a domain.
|
|
Log func(format string, args ...any)
|
|
|
|
mu sync.Mutex
|
|
pending *Displacement
|
|
history []Displacement
|
|
wake chan struct{}
|
|
}
|
|
|
|
// NewGuard is the machine's.
|
|
func NewGuard() *Guard {
|
|
return &Guard{Path: ResolvConf, KeptPath: KeptPath, Dir: RunDir, Hold: Hold, Now: time.Now,
|
|
Resolver: ThisResolver(), Log: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) },
|
|
wake: make(chan struct{}, 1)}
|
|
}
|
|
|
|
// signs are the words a writer leaves in its file's comments, and its name. The same list the node-engine
|
|
// names a writer from (ADR 0241 rule 3): what a file says of itself.
|
|
var signs = []struct{ word, name string }{
|
|
{"forti", "FortiClient"}, {"openfortivpn", "openfortivpn"}, {"networkmanager", "NetworkManager"},
|
|
{"systemd-resolved", "systemd-resolved"}, {"resolvconf", "resolvconf"}, {"dhcpcd", "dhcpcd"},
|
|
{"dhclient", "dhclient"}, {"netconfig", "netconfig"}, {"openvpn", "OpenVPN"},
|
|
{"openconnect", "OpenConnect"}, {"vpnc", "vpnc"}, {"tailscale", "Tailscale"}, {"connman", "ConnMan"},
|
|
}
|
|
|
|
// WriterOf is the writer a file's comments name, or empty.
|
|
func WriterOf(content string) string {
|
|
for _, line := range strings.Split(content, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, ";") {
|
|
continue
|
|
}
|
|
lower := strings.ToLower(line)
|
|
for _, s := range signs {
|
|
if strings.Contains(lower, s.word) {
|
|
return s.name
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// same is whether two resolver files say the same, apart from surrounding whitespace — the node-engine's
|
|
// own comparison (ADR 0241 rule 1).
|
|
func same(a, b string) bool { return strings.TrimSpace(a) == strings.TrimSpace(b) }
|
|
|
|
// read is the file as a reader of it sees it: its content, or what a link in its place points at.
|
|
func read(path string) (content string, isLink bool, err error) {
|
|
fi, err := os.Lstat(path)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if fi.Mode()&os.ModeSymlink != 0 {
|
|
target, _ := os.Readlink(path)
|
|
raw, _ := os.ReadFile(path)
|
|
return "# a link to " + target + "\n" + string(raw), true, nil
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
return string(raw), false, err
|
|
}
|
|
|
|
// Tick is one look: notice a write, put the module's file back when it was taken or held long enough, and
|
|
// close a displacement once the file is the module's again. It answers what it did, for the log and tests.
|
|
func (g *Guard) Tick() string {
|
|
kept, err := os.ReadFile(g.KeptPath)
|
|
if err != nil {
|
|
return "" // not yet rendered: nothing declared to keep
|
|
}
|
|
current, isLink, err := read(g.Path)
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return ""
|
|
}
|
|
now := g.Now()
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
if err == nil && !isLink && same(current, string(kept)) {
|
|
if g.pending != nil {
|
|
how := "the module's file was written back by another"
|
|
if g.pending.How != "" {
|
|
how = g.pending.How
|
|
}
|
|
g.end(now, how)
|
|
return "ended"
|
|
}
|
|
return ""
|
|
}
|
|
if g.pending == nil || g.pending.content != current {
|
|
if g.pending != nil {
|
|
g.end(now, "written over again before it was put back")
|
|
}
|
|
d := &Displacement{ID: now.UTC().Format("20060102T150405.000Z"), At: now, Writer: WriterOf(current), content: current}
|
|
g.pending = d
|
|
g.keep(d)
|
|
g.Log("the resolver file was written by %s; kept as %s", orAnother(d.Writer), d.ID)
|
|
}
|
|
d := g.pending
|
|
switch {
|
|
case d.TakenBy != "":
|
|
if err := g.putBack(kept); err != nil {
|
|
g.Log("putting the resolver file back failed: %v", err)
|
|
return "failed"
|
|
}
|
|
d.How = "taken by " + d.TakenBy + ", and the module's file put back"
|
|
g.end(g.Now(), d.How)
|
|
return "put back, taken"
|
|
case now.Sub(d.At) >= g.Hold:
|
|
if err := g.putBack(kept); err != nil {
|
|
g.Log("putting the resolver file back failed: %v", err)
|
|
return "failed"
|
|
}
|
|
d.How = fmt.Sprintf("nobody took it; the module's file put back after %s", g.Hold)
|
|
g.end(g.Now(), d.How)
|
|
return "put back, held"
|
|
}
|
|
return "holding"
|
|
}
|
|
|
|
func orAnother(w string) string {
|
|
if w == "" {
|
|
return "another program"
|
|
}
|
|
return w
|
|
}
|
|
|
|
// end closes the pending displacement into the history.
|
|
func (g *Guard) end(at time.Time, how string) {
|
|
d := *g.pending
|
|
d.Ended, d.How = &at, how
|
|
g.pending = nil
|
|
g.history = append([]Displacement{d}, g.history...)
|
|
if len(g.history) > Kept {
|
|
g.history = g.history[:Kept]
|
|
}
|
|
g.writeHistory()
|
|
g.Log("displacement %s ended: %s", d.ID, how)
|
|
}
|
|
|
|
// keep writes what was written where only root reads it, and the oldest beyond Kept goes.
|
|
func (g *Guard) keep(d *Displacement) {
|
|
dir := filepath.Join(g.Dir, "displaced")
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return
|
|
}
|
|
_ = os.WriteFile(filepath.Join(dir, d.ID+".conf"), []byte(d.content), 0o600)
|
|
entries, _ := os.ReadDir(dir)
|
|
var names []string
|
|
for _, e := range entries {
|
|
names = append(names, e.Name())
|
|
}
|
|
sort.Strings(names)
|
|
for len(names) > Kept {
|
|
_ = os.Remove(filepath.Join(dir, names[0]))
|
|
names = names[1:]
|
|
}
|
|
g.writeHistory()
|
|
}
|
|
|
|
// writeHistory says, readable by the operator's account, what became of each write: never what it held.
|
|
func (g *Guard) writeHistory() {
|
|
list := []Displacement{}
|
|
if g.pending != nil {
|
|
list = append(list, *g.pending)
|
|
}
|
|
list = append(list, g.history...)
|
|
raw, _ := json.MarshalIndent(list, "", " ")
|
|
tmp := filepath.Join(g.Dir, ".history.json")
|
|
if os.WriteFile(tmp, raw, 0o644) == nil {
|
|
_ = os.Rename(tmp, filepath.Join(g.Dir, "history.json"))
|
|
}
|
|
}
|
|
|
|
// putBack writes the module's file in place, whole, by a rename in the same directory: a reader sees the
|
|
// old file or the new one, never half, and a link in its place is replaced by the file.
|
|
func (g *Guard) putBack(kept []byte) error {
|
|
tmp := filepath.Join(filepath.Dir(g.Path), ".resolv.conf.node-resolver")
|
|
if err := os.WriteFile(tmp, kept, 0o644); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chmod(tmp, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, g.Path)
|
|
}
|
|
|
|
// Pending is the write standing now, with what it held — for a module on this machine, over the socket.
|
|
type Pending struct {
|
|
ID string `json:"id"`
|
|
At time.Time `json:"at"`
|
|
Writer string `json:"writer,omitempty"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// Displaced is the write standing now, or nil.
|
|
func (g *Guard) Displaced() *Pending {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
if g.pending == nil {
|
|
return nil
|
|
}
|
|
return &Pending{ID: g.pending.ID, At: g.pending.At, Writer: g.pending.Writer, Content: g.pending.content}
|
|
}
|
|
|
|
// Take marks the write standing now as taken by a module, and has it put back at the next look.
|
|
func (g *Guard) Take(id, by string) error {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
if g.pending == nil || g.pending.ID != id {
|
|
return fmt.Errorf("no write %q stands now", id)
|
|
}
|
|
now := g.Now()
|
|
g.pending.TakenBy, g.pending.TakenAt = by, &now
|
|
g.writeHistory()
|
|
select {
|
|
case g.wake <- struct{}{}:
|
|
default:
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReadHistory is what became of the last writes, as the guard said it; empty where no guard runs.
|
|
func ReadHistory(path string) []Displacement {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return []Displacement{}
|
|
}
|
|
var list []Displacement
|
|
if json.Unmarshal(raw, &list) != nil {
|
|
return []Displacement{}
|
|
}
|
|
return list
|
|
}
|
|
|
|
// Run looks until the context ends, and keeps every link's own servers from being a default route.
|
|
func (g *Guard) Run(ctx context.Context) {
|
|
look := time.NewTicker(Look)
|
|
defer look.Stop()
|
|
routes := time.NewTicker(DefaultRouteEvery)
|
|
defer routes.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-look.C:
|
|
g.Tick()
|
|
case <-g.wake:
|
|
g.Tick()
|
|
case <-routes.C:
|
|
if changed, err := g.Resolver.OnlyTheMeshIsADefaultRoute(ctx); err == nil && len(changed) > 0 {
|
|
g.Log("%s had servers answering every name; now only their own domains", strings.Join(changed, ", "))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Request is one call over the socket: a verb and its arguments, one JSON line.
|
|
type Request struct {
|
|
Verb string `json:"verb"`
|
|
Args map[string]any `json:"args"`
|
|
}
|
|
|
|
// Reply is its answer, one JSON line.
|
|
type Reply struct {
|
|
Result any `json:"result,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
var takerName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
|
|
|
// Answer is one verb, as the machine's modules call it. `route` with `takes` and `by` also takes the write
|
|
// standing now, once its route is in place.
|
|
func (g *Guard) Answer(ctx context.Context, req Request) Reply {
|
|
str := func(k string) string { s, _ := req.Args[k].(string); return strings.TrimSpace(s) }
|
|
var result any
|
|
var err error
|
|
switch req.Verb {
|
|
case "routes":
|
|
var routes *Routes
|
|
if routes, err = g.Resolver.Routes(ctx); err == nil {
|
|
result = map[string]any{"mesh": routes.Mesh, "links": routes.Links}
|
|
}
|
|
case "route":
|
|
var routed *Routed
|
|
routed, err = g.Resolver.Route(ctx, str("link"), Split(req.Args["domains"]), Split(req.Args["servers"]))
|
|
if err == nil && str("takes") != "" {
|
|
if !takerName.MatchString(str("by")) {
|
|
err = errors.New("a write is taken by a module, named in `by`")
|
|
} else {
|
|
err = g.Take(str("takes"), str("by"))
|
|
}
|
|
}
|
|
result = routed
|
|
case "unroute":
|
|
result, err = g.Resolver.Unroute(ctx, str("link"))
|
|
case "displaced":
|
|
result = g.Displaced()
|
|
default:
|
|
err = fmt.Errorf("%q is not a verb of node-resolver", req.Verb)
|
|
}
|
|
if err != nil {
|
|
return Reply{Error: err.Error()}
|
|
}
|
|
return Reply{Result: result}
|
|
}
|
|
|
|
// Serve answers the socket until the context ends. Only root can reach it: what a module hands over
|
|
// here — a VPN's servers and domains — never leaves the machine.
|
|
func (g *Guard) Serve(ctx context.Context, path string) error {
|
|
_ = os.Remove(path)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
l, err := net.Listen("unix", path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chmod(path, 0o600); err != nil {
|
|
l.Close()
|
|
return err
|
|
}
|
|
go func() { <-ctx.Done(); l.Close() }()
|
|
for {
|
|
conn, err := l.Accept()
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
go func(c net.Conn) {
|
|
defer c.Close()
|
|
_ = c.SetDeadline(time.Now().Add(30 * time.Second))
|
|
line, err := bufio.NewReader(c).ReadBytes('\n')
|
|
var reply Reply
|
|
var req Request
|
|
if err != nil && len(line) == 0 {
|
|
return
|
|
}
|
|
if jerr := json.Unmarshal(line, &req); jerr != nil {
|
|
reply = Reply{Error: "one JSON object per line: {\"verb\": …, \"args\": {…}}"}
|
|
} else {
|
|
reply = g.Answer(ctx, req)
|
|
}
|
|
raw, _ := json.Marshal(reply)
|
|
_, _ = c.Write(append(raw, '\n'))
|
|
}(conn)
|
|
}
|
|
}
|