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 | } | ||