bastion 0.58.0
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
// Linux drivers for observe:folders, observe:journal, observe:logins and
|
||||
// observe:cert (observe_reads.go holds their grammar and parsers). The
|
||||
// programs are the ones observe_ops_linux.go keeps to — journalctl, systemctl
|
||||
// and ss, read-only forms only — and every exec shares one op budget.
|
||||
//
|
||||
// No elevation of its own. The networked install runs the bastion as root
|
||||
// (deploy/install.sh writes no User=), which is how observe:host already reads
|
||||
// nft and iptables, and so how these read the journal and every folder. A
|
||||
// bastion run as a user reads what that user can: a folder it cannot open is
|
||||
// named in skipped, and a journal it cannot read wholly is said so outright
|
||||
// (journalAccess), because journalctl does not fail for such a user — it shows
|
||||
// only the user's own lines, an empty answer that would read as a quiet machine.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// journalAccess is the default observeJournalAccess. root reads everything;
|
||||
// any other account can read the system journal only when it may open the
|
||||
// system journal file itself (the systemd-journal or adm group), which is
|
||||
// exactly what journalctl will be able to do.
|
||||
func journalAccess() string {
|
||||
if os.Geteuid() == 0 {
|
||||
return ""
|
||||
}
|
||||
raw, err := os.ReadFile("/etc/machine-id")
|
||||
mid := strings.TrimSpace(string(raw))
|
||||
if err == nil && machineIDRe.MatchString(mid) {
|
||||
for _, dir := range []string{"/var/log/journal/", "/run/log/journal/"} {
|
||||
if f, err := os.Open(dir + mid + "/system.journal"); err == nil {
|
||||
_ = f.Close()
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return "the bastion runs as an account that cannot read the system journal; " +
|
||||
"run it as root (the networked install does) or add that account to the systemd-journal group"
|
||||
}
|
||||
|
||||
var machineIDRe = regexp.MustCompile(`^[0-9a-f]{32}$`)
|
||||
|
||||
// ── observe:folders ──────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
// folderReadBatch is how many names one directory read takes: a folder of
|
||||
// a million files is read in pieces, not into memory at once.
|
||||
folderReadBatch = 512
|
||||
// folderLinkCap bounds the set of hard-linked files already counted.
|
||||
// Past it each further link is counted (sizes may read high) and the
|
||||
// answer says so.
|
||||
folderLinkCap = 1 << 20
|
||||
// folderStuckMax bounds the walks left inside a read that has not
|
||||
// returned. Each holds an OS thread for as long as that read hangs (a hard
|
||||
// network mount: until it comes back), and a Host asking again and again
|
||||
// about a path on one would otherwise pile them up until the bastion
|
||||
// could start no more. Past it the op answers at once, sizing nothing.
|
||||
folderStuckMax = 4
|
||||
)
|
||||
|
||||
// folderStuckGrace is how long past the op budget the answer waits for a walk
|
||||
// that is inside one read, before answering without it. folderSizer is the
|
||||
// walk. Both are variables so a test can stand in a walk that never returns.
|
||||
var (
|
||||
folderStuckGrace = 2 * time.Second
|
||||
folderSizer = sizeFolders
|
||||
// folderWalksStuck counts the walks readFolders answered without that have not
|
||||
// returned since.
|
||||
folderWalksStuck atomic.Int32
|
||||
)
|
||||
|
||||
// folderBeforeOpen is called with each folder's path between its stat and its
|
||||
// open: a seam so a test can swap the folder in that gap. nil outside tests.
|
||||
var folderBeforeOpen func(string)
|
||||
|
||||
// reasonChanged is the skip reason for a folder that is no longer the one the
|
||||
// walk stat'd when it comes to open it: swapped for a link, or reached
|
||||
// through a folder above it that was.
|
||||
const reasonChanged = "changed while it was read, not counted"
|
||||
|
||||
type folderWalk struct {
|
||||
ctx context.Context
|
||||
root string
|
||||
depth int
|
||||
dev uint64
|
||||
mounts map[string]bool
|
||||
linked map[uint64]struct{}
|
||||
linkFull bool
|
||||
top folderTop
|
||||
sized int
|
||||
skips folderSkips
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (w *folderWalk) expired() bool {
|
||||
if !w.cut && w.ctx.Err() != nil {
|
||||
w.cut = true
|
||||
}
|
||||
return w.cut
|
||||
}
|
||||
|
||||
// skip records what could not be sized against the folder that holds it,
|
||||
// folded to the asked depth. A file is never named: its folder is.
|
||||
func (w *folderWalk) skip(dir, reason string) {
|
||||
w.skips.add(folderAt(w.root, dir, w.depth), reason)
|
||||
}
|
||||
|
||||
func folderSkipReason(err error) string {
|
||||
var errno syscall.Errno
|
||||
if errors.As(err, &errno) {
|
||||
if errno == syscall.EACCES || errno == syscall.EPERM {
|
||||
return "permission denied"
|
||||
}
|
||||
return errno.Error()
|
||||
}
|
||||
return "could not be read"
|
||||
}
|
||||
|
||||
// firstLink says whether a file with more than one link is being seen for
|
||||
// the first time, so its blocks are counted once, as du counts them.
|
||||
func (w *folderWalk) firstLink(ino uint64) bool {
|
||||
if _, seen := w.linked[ino]; seen {
|
||||
return false
|
||||
}
|
||||
if len(w.linked) >= folderLinkCap {
|
||||
w.linkFull = true
|
||||
return true
|
||||
}
|
||||
w.linked[ino] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
// openFolder opens p only when it is still the folder st describes. Between
|
||||
// the stat and the open, p, or a folder above it, can be swapped for a link
|
||||
// by whoever owns the folder above; O_NOFOLLOW refuses a link at p itself,
|
||||
// and the device and inode of what opened refuse one above it. Without this
|
||||
// a link put there in that gap would lead the walk elsewhere, onto another
|
||||
// filesystem or into /proc, under p's name.
|
||||
func openFolder(p string, st *syscall.Stat_t) (*os.File, error) {
|
||||
f, err := os.OpenFile(p, os.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.ELOOP) || errors.Is(err, syscall.ENOTDIR) {
|
||||
return nil, errFolderChanged
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
got, ok := fi.Sys().(*syscall.Stat_t)
|
||||
if !ok || got.Dev != st.Dev || got.Ino != st.Ino {
|
||||
_ = f.Close()
|
||||
return nil, errFolderChanged
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
var errFolderChanged = errors.New(reasonChanged)
|
||||
|
||||
// dir sizes one folder and everything under it on the same filesystem, and
|
||||
// returns the bytes. st is the folder's own stat, taken when its parent was
|
||||
// read: its blocks are counted, and the folder opened must still be the one
|
||||
// it describes (openFolder). Names are read in batches and stat'd as read;
|
||||
// subfolders are walked after the folder is closed, so the walk holds one
|
||||
// open folder at a time however deep it goes.
|
||||
func (w *folderWalk) dir(p string, level int, st *syscall.Stat_t) int64 {
|
||||
total := int64(st.Blocks) * 512
|
||||
if w.expired() {
|
||||
return total
|
||||
}
|
||||
if folderBeforeOpen != nil {
|
||||
folderBeforeOpen(p)
|
||||
}
|
||||
f, err := openFolder(p, st)
|
||||
if err != nil {
|
||||
if errors.Is(err, errFolderChanged) {
|
||||
w.skip(p, reasonChanged)
|
||||
} else {
|
||||
w.skip(p, folderSkipReason(err))
|
||||
}
|
||||
return total
|
||||
}
|
||||
var subdirs []string
|
||||
var subSt []syscall.Stat_t
|
||||
for !w.expired() {
|
||||
ents, rerr := f.ReadDir(folderReadBatch)
|
||||
for _, e := range ents {
|
||||
child := childPath(p, e.Name())
|
||||
if w.mounts[child] {
|
||||
w.skip(child, reasonOtherFS)
|
||||
continue
|
||||
}
|
||||
var st syscall.Stat_t
|
||||
if err := syscall.Lstat(child, &st); err != nil {
|
||||
// Gone between the read and the stat is churn, not a gap.
|
||||
if !errors.Is(err, syscall.ENOENT) {
|
||||
w.skip(p, folderSkipReason(err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
size := int64(st.Blocks) * 512
|
||||
if st.Mode&syscall.S_IFMT == syscall.S_IFDIR {
|
||||
// The mount table is read once at the start; a mount made
|
||||
// since is caught here by its device instead.
|
||||
if uint64(st.Dev) != w.dev {
|
||||
w.skip(child, reasonOtherFS)
|
||||
continue
|
||||
}
|
||||
subdirs = append(subdirs, child)
|
||||
subSt = append(subSt, st)
|
||||
continue
|
||||
}
|
||||
if uint64(st.Nlink) > 1 && !w.firstLink(uint64(st.Ino)) {
|
||||
continue
|
||||
}
|
||||
total += size
|
||||
}
|
||||
if rerr != nil {
|
||||
if rerr != io.EOF {
|
||||
w.skip(p, folderSkipReason(rerr))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = f.Close()
|
||||
for i, c := range subdirs {
|
||||
if w.expired() {
|
||||
break
|
||||
}
|
||||
total += w.dir(c, level+1, &subSt[i])
|
||||
}
|
||||
if level >= 1 && level <= w.depth {
|
||||
w.sized++
|
||||
w.top.offer(folderSize{Path: p, Bytes: total, Depth: level})
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// readFolders sizes the folders under asked. The error is a refusal: a path
|
||||
// that is not a folder here, or whose links lead into a kernel tree.
|
||||
//
|
||||
// All of it, the path's resolution included, runs on its own goroutine, so
|
||||
// the answer goes out at the op budget even when one call never returns: a
|
||||
// stat of a dead network mount named as the path, or a disk erroring slowly
|
||||
// under it. That goroutine owns everything it touches; on the stuck path this
|
||||
// function reads nothing of it and answers without it. Such a walk is counted
|
||||
// until it returns, and past folderStuckMax of them no walk starts.
|
||||
func readFolders(asked string, depth int) (foldersReport, error) {
|
||||
stuck := func(partial, note string) foldersReport {
|
||||
return foldersReport{Supported: true, Path: asked, Depth: depth, Folders: []folderSize{},
|
||||
Skipped: []folderSkip{}, Partial: partial, Note: note}
|
||||
}
|
||||
if folderWalksStuck.Load() >= folderStuckMax {
|
||||
return stuck("earlier reads on this machine have not returned",
|
||||
"nothing was sized: "+strconv.Itoa(folderStuckMax)+" earlier walks are still inside a read that "+
|
||||
"has not returned (a network mount that stopped answering, most often); try again once it answers"), nil
|
||||
}
|
||||
ctx, cancel := opBudget()
|
||||
defer cancel()
|
||||
type result struct {
|
||||
rep foldersReport
|
||||
err error
|
||||
}
|
||||
// 0 running, 1 returned, 2 answered without: whichever side moves it
|
||||
// from 0 first decides whether the walk holds a place in folderWalksStuck.
|
||||
var state atomic.Int32
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
rep, err := folderSizer(ctx, asked, depth)
|
||||
done <- result{rep, err}
|
||||
if !state.CompareAndSwap(0, 1) {
|
||||
folderWalksStuck.Add(-1)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case r := <-done:
|
||||
return r.rep, r.err
|
||||
case <-ctx.Done():
|
||||
select {
|
||||
case r := <-done:
|
||||
return r.rep, r.err
|
||||
case <-time.After(folderStuckGrace):
|
||||
if !state.CompareAndSwap(0, 2) {
|
||||
r := <-done
|
||||
return r.rep, r.err
|
||||
}
|
||||
folderWalksStuck.Add(1)
|
||||
return stuck("a read under this path did not return inside the op budget",
|
||||
"nothing was sized; ask about a folder further down, or try again"), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sizeFolders(ctx context.Context, asked string, depth int) (foldersReport, error) {
|
||||
resolved, err := filepath.EvalSymlinks(asked)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return foldersReport{}, errors.New("there is no folder at that path on this machine")
|
||||
}
|
||||
return foldersReport{}, errors.New("that path cannot be read: " + folderSkipReason(err))
|
||||
}
|
||||
if err := folderPathAllowed(resolved); err != nil {
|
||||
return foldersReport{}, err
|
||||
}
|
||||
var st syscall.Stat_t
|
||||
if err := syscall.Lstat(resolved, &st); err != nil {
|
||||
return foldersReport{}, errors.New("that path cannot be read: " + folderSkipReason(err))
|
||||
}
|
||||
if st.Mode&syscall.S_IFMT != syscall.S_IFDIR {
|
||||
return foldersReport{}, errors.New("that path is not a folder")
|
||||
}
|
||||
rep := foldersReport{Supported: true, Path: asked, Depth: depth}
|
||||
if resolved != asked {
|
||||
rep.Resolved = resolved
|
||||
}
|
||||
mounts := map[string]bool{}
|
||||
if b, err := os.ReadFile("/proc/self/mountinfo"); err == nil {
|
||||
mounts = parseMountPoints(string(b))
|
||||
}
|
||||
w := &folderWalk{ctx: ctx, root: resolved, depth: depth, dev: uint64(st.Dev), mounts: mounts,
|
||||
linked: map[uint64]struct{}{}}
|
||||
rep.TotalBytes = w.dir(resolved, 0, &st)
|
||||
rep.Folders = w.top.sorted()
|
||||
rep.Sized = w.sized
|
||||
rep.Skipped = w.skips.list()
|
||||
rep.SkippedTotal = w.skips.total
|
||||
rep.Complete = !w.cut
|
||||
if w.cut {
|
||||
rep.Partial = "timeout"
|
||||
rep.Note = "the walk ran out of time: every size is at least what is shown, and a folder it never reached is missing"
|
||||
}
|
||||
if w.linkFull {
|
||||
rep.Note = strings.TrimSpace(rep.Note + " More than " + strconv.Itoa(folderLinkCap) +
|
||||
" hard-linked files: past that each link was counted, so sizes may read high.")
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// ── observe:journal ──────────────────────────────────────────────────────────
|
||||
|
||||
// readJournal reads one unit's recent journal. The unit has passed
|
||||
// normalizeUnitName; the error is a refusal when this machine has no such
|
||||
// unit, and every other gap is a reported reason.
|
||||
func readJournal(unit string, minutes int) (journalReport, error) {
|
||||
rep := journalReport{Source: "journal", Unit: unit, Minutes: minutes}
|
||||
if !observeHave("journalctl") {
|
||||
rep.Reason = "journalctl not installed"
|
||||
return rep, nil
|
||||
}
|
||||
if !observeHave("systemctl") {
|
||||
rep.Reason = "systemctl not installed, so this machine cannot say which units it has"
|
||||
return rep, nil
|
||||
}
|
||||
if why := observeJournalAccess(); why != "" {
|
||||
rep.Reason = why
|
||||
return rep, nil
|
||||
}
|
||||
ctx, cancel := opBudget()
|
||||
defer cancel()
|
||||
// list-units is observe:units' read with --all for --failed: every unit
|
||||
// systemd has loaded. A failed run is not an empty list here either —
|
||||
// it would refuse every unit as unknown — so its error is checked.
|
||||
listed, lerr := observeExec(ctx, "systemctl", "list-units", "--all", "--no-legend", "--plain")
|
||||
if ctx.Err() != nil {
|
||||
rep.Reason = "systemctl list-units: timeout"
|
||||
return rep, nil
|
||||
}
|
||||
if lerr != nil && len(listed) == 0 {
|
||||
rep.Reason = "systemctl list-units: " + lerr.Error()
|
||||
return rep, nil
|
||||
}
|
||||
// The same for list-unit-files: without it an installed unit that is not
|
||||
// loaded right now would be refused as one this machine does not have.
|
||||
files, ferr := observeExec(ctx, "systemctl", "list-unit-files", "--no-legend", "--plain")
|
||||
if ctx.Err() != nil {
|
||||
rep.Reason = "systemctl list-unit-files: timeout"
|
||||
return rep, nil
|
||||
}
|
||||
if ferr != nil && len(files) == 0 {
|
||||
rep.Reason = "systemctl list-unit-files: " + ferr.Error()
|
||||
return rep, nil
|
||||
}
|
||||
loaded, installed := parseUnitList(string(listed)), parseUnitFiles(string(files))
|
||||
if !unitKnown(unit, loaded, installed) {
|
||||
return journalReport{}, errUnknownUnit(unit, loaded, installed)
|
||||
}
|
||||
// Bound to their flags with '=', so neither can be read as a flag of its
|
||||
// own whatever it held. --lines is observeLogLineLimit, the line limit
|
||||
// observe:logs asks Loki for: the newest 5000 lines in the window.
|
||||
unitArg := "--unit=" + unit
|
||||
sinceArg := "--since=-" + strconv.Itoa(minutes) + "min"
|
||||
out, err := observeExec(ctx, "journalctl", unitArg, sinceArg, "-o", "short-iso", "--no-pager", "-q", "--lines=5000")
|
||||
if ctx.Err() != nil {
|
||||
rep.Partial = "journalctl: timeout"
|
||||
} else if err != nil && len(out) == 0 {
|
||||
rep.Reason = "journalctl: " + err.Error()
|
||||
return rep, nil
|
||||
}
|
||||
rep.Supported = true
|
||||
rep.Log, rep.Truncated = capLines(string(out), observeLogLineLimit, observeTextCap)
|
||||
if strings.TrimSpace(rep.Log) != "" {
|
||||
rep.Lines = strings.Count(strings.TrimRight(rep.Log, "\n"), "\n") + 1
|
||||
} else {
|
||||
rep.Log = ""
|
||||
rep.Note = "no lines from " + unit + " in the last " + strconv.Itoa(minutes) + " minutes"
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// ── observe:logins ───────────────────────────────────────────────────────────
|
||||
|
||||
// readLogins counts sshd's logins over the window. sshd is matched two ways,
|
||||
// either one enough ("+" is journalctl's OR): by program — sshd, and the
|
||||
// sshd-session and sshd-auth processes newer OpenSSH splits it into — and by
|
||||
// unit, ssh.service on Debian and Ubuntu, sshd.service elsewhere. --lines is
|
||||
// loginsLineCap.
|
||||
//
|
||||
// Each match also needs _UID=0. Any account can run a program named sshd and
|
||||
// log under _COMM=sshd, and one whose shell stays in ssh.service's cgroup (no
|
||||
// pam_systemd) logs under that unit; either could add failures or accepted
|
||||
// logins, or bury the real ones under 50,000 lines. _UID is journald's own,
|
||||
// from the sender's credentials, and every message parsed here is written by
|
||||
// sshd's root monitor (OpenSSH sends its unprivileged child's lines through
|
||||
// it). A sshd run by an ordinary account is not read.
|
||||
func readLogins(hours int) loginsReport {
|
||||
rep := loginsReport{Source: "journal", Hours: hours, FailedTotal: -1, AcceptedTotal: -1,
|
||||
FailedBySource: []loginSource{}, FailedByUser: []loginUser{}, Accepted: []acceptedLogin{}}
|
||||
if !observeHave("journalctl") {
|
||||
rep.Reason = "journalctl not installed"
|
||||
return rep
|
||||
}
|
||||
if why := observeJournalAccess(); why != "" {
|
||||
rep.Reason = why
|
||||
return rep
|
||||
}
|
||||
ctx, cancel := opBudget()
|
||||
defer cancel()
|
||||
sinceArg := "--since=-" + strconv.Itoa(hours) + "h"
|
||||
out, err := observeExec(ctx, "journalctl", sinceArg, "-o", "short-iso", "--no-pager", "-q", "--lines=50000",
|
||||
"_COMM=sshd", "_COMM=sshd-session", "_COMM=sshd-auth", "_UID=0",
|
||||
"+", "_SYSTEMD_UNIT=ssh.service", "_SYSTEMD_UNIT=sshd.service", "_UID=0")
|
||||
late := ctx.Err() != nil
|
||||
if !late && err != nil && len(out) == 0 {
|
||||
rep.Reason = "journalctl: " + err.Error()
|
||||
return rep
|
||||
}
|
||||
t := parseSSHLogins(string(out))
|
||||
rep.Supported = true
|
||||
t.fill(&rep)
|
||||
rep.Truncated = t.entries >= loginsLineCap
|
||||
if late {
|
||||
rep.Partial = "journalctl: timeout"
|
||||
}
|
||||
if t.entries == 0 {
|
||||
rep.Note = "no sshd entries in the journal in the last " + strconv.Itoa(hours) + " hours"
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
// ── observe:cert ─────────────────────────────────────────────────────────────
|
||||
|
||||
// readCert reads the certificate this machine's listener on port serves. The
|
||||
// listener table is observe:sockets' own read (ss -tlnpH); the error is a
|
||||
// refusal when nothing listens on the port.
|
||||
func readCert(port int, name string) (certReport, error) {
|
||||
rep := certReport{Port: port, ServerName: name, Listening: []string{}, Tried: []string{},
|
||||
CheckedAt: time.Now().UTC().Format(time.RFC3339)}
|
||||
if !observeHave("ss") {
|
||||
rep.Reason = "ss not installed, so this machine cannot say which ports it listens on"
|
||||
return rep, nil
|
||||
}
|
||||
ctx, cancel := opBudget()
|
||||
defer cancel()
|
||||
out, err := observeExec(ctx, "ss", "-tlnpH")
|
||||
// A failed ss is not an empty table (readSockets says why): here an empty
|
||||
// table would refuse every port as not listening.
|
||||
if ctx.Err() != nil {
|
||||
rep.Reason = "ss: timeout"
|
||||
return rep, nil
|
||||
}
|
||||
if err != nil {
|
||||
rep.Reason = "ss: " + err.Error()
|
||||
return rep, nil
|
||||
}
|
||||
targets, listening := certTargets(parseSS("tcp", string(out)), port, observeOwnAddrs())
|
||||
if len(listening) == 0 {
|
||||
return certReport{}, errors.New("nothing on this machine listens on tcp port " + strconv.Itoa(port))
|
||||
}
|
||||
rep.Supported = true
|
||||
rep.Listening = listening
|
||||
dialCert(ctx, &rep, targets, name)
|
||||
return rep, nil
|
||||
}
|
||||
Reference in New Issue
Block a user