Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Add one Admin page with status, accounts, and the audit log

43e681d8a5d94567aa9530d32b2c89223c1a17f9 · Keenan Nemetz · 5d ago · parent af018ce8

Add one Admin page with status, accounts, and the audit log

Instance administration was split between the Status and Accounts pages,
and the audit log had no browser view at all. The new /admin page gathers
the status tabs, the account roster, and a read-only audit viewer into one
place with a tab for each, and /status, /runners, and /users redirect to
the tab they named so old links keep working.

The audit reader walks the file backwards a chunk at a time, returns the
newest records first, and shows one extra record to know whether an older
page exists. It skips a line it cannot parse rather than failing the page
and refuses a symlink or a non-regular file, as startup already does.

Refs #198 (01M6MK0NNZ2XR1D5TQKNWYT4HN).

internal/audit/read.go +172

@@ -0,0 +1,172 @@
1 package audit
2
3 // Reading the log back. The file is append-only and has no index, so the browser
4 // pages through it from the newest record backwards: a reader that only ever
5 // needs the most recent pages should not load an instance's whole history into
6 // memory. Format and Parse are inverses; a line this package did not write is
7 // reported rather than guessed at.
8
9 import (
10 "bytes"
11 "errors"
12 "fmt"
13 "io"
14 "os"
15 "strings"
16 "time"
17 )
18
19 // Entry is one parsed line: when it was recorded and what it recorded.
20 type Entry struct {
21 Time time.Time
22 Record
23 }
24
25 // readChunk is how much of the file is read at a time while walking backwards.
26 // An audit line is at most five escaped fields plus a timestamp, so one chunk
27 // normally holds many lines.
28 const readChunk = 64 << 10
29
30 // maxPendingLine bounds the bytes held for a line that is split across chunks.
31 // The writer caps each field at FieldLimit, so a longer line is a corrupt file
32 // rather than a record worth displaying.
33 const maxPendingLine = 1 << 20
34
35 // Parse reads one line written by Format. A line with the wrong number of
36 // columns or an unreadable timestamp is an error: the caller decides whether to
37 // skip it, and the log itself is never rewritten to hide it.
38 func Parse(line string) (Entry, error) {
39 line = strings.TrimSuffix(line, "\r")
40 parts := strings.SplitN(line, "\t", 5)
41 if len(parts) != 5 {
42 return Entry{}, fmt.Errorf("audit: line has %d columns, want 5", len(parts))
43 }
44 at, err := time.Parse(time.RFC3339, parts[0])
45 if err != nil {
46 return Entry{}, fmt.Errorf("audit: bad timestamp %q: %w", parts[0], err)
47 }
48 return Entry{
49 Time: at.UTC(),
50 Record: Record{
51 Action: parts[1],
52 Actor: parts[2],
53 Subject: parts[3],
54 Detail: parts[4],
55 },
56 }, nil
57 }
58
59 // Tail returns up to limit entries, newest first, after skipping the newest
60 // offset entries. more reports whether an older entry exists beyond the page.
61 //
62 // A missing file is an empty log, not an error: an instance that has recorded
63 // nothing yet has nothing to show. A line that Parse rejects is skipped rather
64 // than failing the page, so one damaged record does not hide the rest of the
65 // history around it.
66 func Tail(path string, offset, limit int) ([]Entry, bool, error) {
67 if limit < 1 {
68 return nil, false, errors.New("audit: page size must be positive")
69 }
70 if offset < 0 {
71 offset = 0
72 }
73 if path == "" {
74 return nil, false, ErrNoSink
75 }
76
77 f, size, err := openLog(path)
78 if err != nil || f == nil {
79 return nil, false, err
80 }
81 defer f.Close()
82
83 // One extra line is enough to know whether an older page exists, without
84 // counting the file or reading to its beginning.
85 want := offset + limit + 1
86 entries := make([]Entry, 0, want)
87 var pending []byte
88 buf := make([]byte, readChunk)
89 for pos := size; pos > 0 && len(entries) < want; {
90 n := int64(len(buf))
91 if pos < n {
92 n = pos
93 }
94 pos -= n
95 if _, err := f.ReadAt(buf[:n], pos); err != nil && err != io.EOF {
96 return nil, false, fmt.Errorf("audit: read %s: %w", path, err)
97 }
98
99 // The chunk is read from its end forwards. `pending` holds a line whose
100 // beginning is in an earlier chunk, so it is appended after this chunk's
101 // bytes; parts[0] is then the tail of a line that starts before `pos`.
102 combined := make([]byte, 0, int(n)+len(pending))
103 combined = append(combined, buf[:n]...)
104 combined = append(combined, pending...)
105 parts := bytes.Split(combined, []byte{'\n'})
106 if pos == 0 {
107 pending = nil
108 } else {
109 pending = parts[0]
110 if len(pending) > maxPendingLine {
111 return nil, false, fmt.Errorf("audit: %s has a line longer than %d bytes", path, maxPendingLine)
112 }
113 parts = parts[1:]
114 }
115 for i := len(parts) - 1; i >= 0 && len(entries) < want; i-- {
116 if len(parts[i]) == 0 {
117 continue
118 }
119 e, err := Parse(string(parts[i]))
120 if err != nil {
121 continue
122 }
123 entries = append(entries, e)
124 }
125 }
126
127 if len(entries) > offset {
128 entries = entries[offset:]
129 } else {
130 entries = nil
131 }
132 more := false
133 if len(entries) > limit {
134 entries = entries[:limit]
135 more = true
136 }
137 return entries, more, nil
138 }
139
140 // openLog opens the audit file for reading without following a symlink, and
141 // verifies that what was opened is the regular file that was inspected. The log
142 // is a secret: startup already refuses a symlink or another owner's file, and a
143 // read surface must not quietly accept what startup would refuse.
144 func openLog(path string) (*os.File, int64, error) {
145 fi, err := os.Lstat(path)
146 if os.IsNotExist(err) {
147 return nil, 0, nil
148 }
149 if err != nil {
150 return nil, 0, fmt.Errorf("audit: %w", err)
151 }
152 if fi.Mode()&os.ModeSymlink != 0 {
153 return nil, 0, fmt.Errorf("audit: %s is a symlink, expected a file", path)
154 }
155 if !fi.Mode().IsRegular() {
156 return nil, 0, fmt.Errorf("audit: %s is not a regular file", path)
157 }
158 f, err := os.Open(path)
159 if err != nil {
160 return nil, 0, fmt.Errorf("audit: open %s: %w", path, err)
161 }
162 st, err := f.Stat()
163 if err != nil {
164 f.Close()
165 return nil, 0, fmt.Errorf("audit: %w", err)
166 }
167 if !st.Mode().IsRegular() || !os.SameFile(fi, st) {
168 f.Close()
169 return nil, 0, fmt.Errorf("audit: %s changed while it was opened", path)
170 }
171 return f, st.Size(), nil
172 }