init
This commit is contained in:
commit
23c7133fa9
33 files changed
+2896
No files matched your search
@@ -0,0 +1,75 @@
|
||||
// Package audit maintains an append-only record of connect/disconnect
|
||||
// events (independent of journald, which may rotate) and answers "who is
|
||||
// online right now" — which for jump/forward-only sessions is not visible
|
||||
// via `who`/`loginctl`, since those rely on pty allocation that this tool
|
||||
// deliberately disables.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
)
|
||||
|
||||
// Event is one line of the audit trail.
|
||||
type Event struct {
|
||||
Time time.Time `json:"time"`
|
||||
User string `json:"user"`
|
||||
Action string `json:"action"` // "connect", "disconnect", "admin"
|
||||
Detail string `json:"detail,omitempty"`
|
||||
SrcIP string `json:"src_ip,omitempty"`
|
||||
}
|
||||
|
||||
// Append adds one event to the JSONL audit log at path, under a file lock
|
||||
// so the interactive TUI and the background journal-tailer (see tail.go)
|
||||
// can write concurrently without interleaving corrupt lines.
|
||||
func Append(path string, ev Event) error {
|
||||
fl := flock.New(path + ".lock")
|
||||
if err := fl.Lock(); err != nil {
|
||||
return fmt.Errorf("lock audit log: %w", err)
|
||||
}
|
||||
defer fl.Unlock()
|
||||
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open audit log: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.Write(append(data, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
// ReadRecent returns the last n events from the audit log, oldest first.
|
||||
func ReadRecent(path string, n int) ([]Event, error) {
|
||||
f, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var all []Event
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
var ev Event
|
||||
if err := json.Unmarshal(sc.Bytes(), &ev); err == nil {
|
||||
all = append(all, ev)
|
||||
}
|
||||
}
|
||||
if len(all) > n {
|
||||
all = all[len(all)-n:]
|
||||
}
|
||||
return all, sc.Err()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OnlineSession is one currently-connected sshd process for a managed user.
|
||||
type OnlineSession struct {
|
||||
User string
|
||||
PID int
|
||||
Elapsed string // e.g. "01:23:45" as reported by ps etime
|
||||
}
|
||||
|
||||
// ListOnline reports users currently connected. Port-forwarding-only
|
||||
// sessions never allocate a pty, so they are invisible to `who`/`loginctl`;
|
||||
// what *is* always true is that an authenticated connection keeps a
|
||||
// "sshd: <user> [priv]"-style privilege-separated process alive for as long
|
||||
// as the session lasts, so we read that from `ps` instead.
|
||||
func ListOnline(managedUsers map[string]bool) ([]OnlineSession, error) {
|
||||
out, err := exec.Command("ps", "-eo", "user:32,pid,etime,args", "--no-headers").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ps: %w", err)
|
||||
}
|
||||
var sessions []OnlineSession
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
user, pidStr, etime := fields[0], fields[1], fields[2]
|
||||
args := strings.Join(fields[3:], " ")
|
||||
if !strings.Contains(args, "sshd:") {
|
||||
continue
|
||||
}
|
||||
if !managedUsers[user] {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(pidStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sessions = append(sessions, OnlineSession{User: user, PID: pid, Elapsed: etime})
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
func now() time.Time { return time.Now() }
|
||||
|
||||
var (
|
||||
reAccepted = regexp.MustCompile(`Accepted publickey for (\S+) from (\S+) port \d+`)
|
||||
reDisconnect = regexp.MustCompile(`Disconnected from user (\S+) (\S+) port \d+`)
|
||||
reSessClosed = regexp.MustCompile(`session closed for user (\S+)`)
|
||||
)
|
||||
|
||||
// TailJournal runs indefinitely (intended to be run under systemd — see
|
||||
// scripts/ssh-access-tui-audit.service), following sshd's journal output and
|
||||
// appending normalized connect/disconnect events to the audit log at
|
||||
// logPath. This is what makes "who logged in/out" survive independently of
|
||||
// journald's own retention/rotation policy.
|
||||
func TailJournal(logPath string) error {
|
||||
cmd := exec.Command("journalctl", "-u", "ssh", "-u", "sshd", "-f", "-n", "0", "-o", "cat")
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("journalctl pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start journalctl: %w", err)
|
||||
}
|
||||
|
||||
sc := bufio.NewScanner(stdout)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
switch {
|
||||
case reAccepted.MatchString(line):
|
||||
m := reAccepted.FindStringSubmatch(line)
|
||||
_ = Append(logPath, Event{Time: now(), User: m[1], Action: "connect", SrcIP: m[2]})
|
||||
case reDisconnect.MatchString(line):
|
||||
m := reDisconnect.FindStringSubmatch(line)
|
||||
_ = Append(logPath, Event{Time: now(), User: m[1], Action: "disconnect", SrcIP: m[2]})
|
||||
case reSessClosed.MatchString(line):
|
||||
m := reSessClosed.FindStringSubmatch(line)
|
||||
_ = Append(logPath, Event{Time: now(), User: m[1], Action: "disconnect"})
|
||||
}
|
||||
}
|
||||
return cmd.Wait()
|
||||
}
|
||||
Reference in new issue
Block a user