Skip to content
51 changes: 45 additions & 6 deletions audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
package audit

import (
"bufio"
"encoding/json"
"errors"
"io"
"iter"
"math/rand"
"net/netip"
"os"
Expand Down Expand Up @@ -68,11 +70,11 @@ type Writer struct {
enc *json.Encoder
}

// New returns a Writer that outputs audit log entries to w as JSON
// objects. If w also implements io.Closer, Writer.Close closes w. If
// w also implements a Sync method with the same signature as os.File,
// Writer.Sync calls w.Sync.
func New(w io.Writer) *Writer {
// NewWriter returns a Writer that outputs audit log entries to w as JSON
// objects. If w also implements [io.Closer], [Writer.Close] closes w. If w
// also implements a Sync method with the same signature as [os.File],
// [Writer.Sync] calls w.Sync.
func NewWriter(w io.Writer) *Writer {
return &Writer{
w: w,
enc: json.NewEncoder(w),
Expand All @@ -86,7 +88,7 @@ func NewFile(path string) (*Writer, error) {
if err != nil {
return nil, err
}
return New(f), nil
return NewWriter(f), nil
}

// Sync commits the current contents of the file to stable storage if
Expand Down Expand Up @@ -128,3 +130,40 @@ func (l *Writer) WriteEntries(entries ...*Entry) error {
}
return l.Sync()
}

// A Reader is an audit log reader, that consume records in the JSON format
// generated by a [Writer].
type Reader struct {
dec *json.Decoder
}

// NewReader returns a Reader that consumes audit log entries from r as JSON
// objects, using the format written by a [Writer].
func NewReader(r io.Reader) Reader {
br := bufio.NewReader(r)
return Reader{dec: json.NewDecoder(br)}
}

// All iterates the records in r in sequence. Each pair reported by the iterator
// includes either a valid [Entry] and a nil error, or a nil entry and a non-nil
// error. After any error occurs, no further items are reported.
//
// The error values [io.EOF] and [io.ErrUnexpectedEOF] are treated as the end
// of the sequence without error.
func (r Reader) All() iter.Seq2[*Entry, error] {
return func(yield func(*Entry, error) bool) {
for {
var next Entry
if err := r.dec.Decode(&next); err != nil {
if err == io.EOF || err == io.ErrUnexpectedEOF {
return // no more elements available
}
yield(nil, err)
return
}
if !yield(&next, nil) {
return
}
}
}
}
70 changes: 69 additions & 1 deletion audit/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,18 @@ import (
"errors"
"net/netip"
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/tailscale/setec/acl"
"github.com/tailscale/setec/audit"
)

func TestWriter(t *testing.T) {
out := new(testWriter)

w := audit.New(out)
w := audit.NewWriter(out)

entries := []*audit.Entry{
{
Expand Down Expand Up @@ -91,3 +94,68 @@ func (t *testWriter) Sync() error { t.synced = true; return t.syncErr }
func (t *testWriter) Close() error { t.closed = true; return nil }

func addrEqual(x, y netip.Addr) bool { return x == y }

func TestReader(t *testing.T) {
// To test the audit.Reader, encode some fixed entries, then verify that
// reading them back in produces the same values.
base := time.Now()
entries := []*audit.Entry{{
ID: 123,
Time: base,
Principal: audit.Principal{
Hostname: "window",
IP: netip.MustParseAddr("1.2.3.4"),
User: "anathema",
},
Action: acl.ActionGet,
Authorized: true,
Secret: "grey/mousie",
SecretVersion: 1,
}, {
ID: 456,
Time: base.Add(3 * time.Second),
Principal: audit.Principal{
Hostname: "bookshelf",
IP: netip.MustParseAddr("2.3.4.5"),
User: "zuul",
},
Action: acl.ActionPut,
Authorized: true,
Secret: "brown/rabbit",
SecretVersion: 4,
}, {
ID: 789,
Time: base.Add(5 * time.Second),
Principal: audit.Principal{
Hostname: "fireplace",
IP: netip.MustParseAddr("3.4.5.6"),
Tags: []string{"tag:asha", "tag:athena"},
},
Action: acl.ActionActivate,
Authorized: false,
Secret: "white/mushroom",
SecretVersion: 101,
}}

// Write the test log entries out into a memory buffer.
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
for i, e := range entries {
if err := enc.Encode(e); err != nil {
t.Fatalf("Encode entry %d: %v", i+1, err)
}
}

// Scan back through the buffer to decode the entries.
var got []*audit.Entry
for e, err := range audit.NewReader(&buf).All() {
if err != nil {
t.Errorf("Next entry: unexpected error: %v", err)
continue
}
got = append(got, e)
}
if diff := cmp.Diff(got, entries, cmpopts.EquateComparable(netip.Addr{})); diff != "" {
t.Errorf("Read results (-got, +want):\n%s", diff)
}
}
45 changes: 42 additions & 3 deletions cmd/setec/setec.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ import (

"github.com/creachadair/command"
"github.com/creachadair/flax"
"github.com/tailscale/setec/acl"
"github.com/tailscale/setec/audit"
"github.com/tailscale/setec/client/setec"
"github.com/tailscale/setec/db"
"github.com/tailscale/setec/internal/tinktestutil"
"github.com/tailscale/setec/server"
"github.com/tailscale/setec/types/api"
Expand Down Expand Up @@ -246,15 +248,21 @@ func runServer(env *command.Env) error {
mux := http.NewServeMux()
tsweb.Debugger(mux)

audit, err := audit.NewFile(filepath.Join(serverArgs.StateDir, "audit.log"))
auditPath := filepath.Join(serverArgs.StateDir, "audit.log")
audit, err := audit.NewFile(auditPath)
if err != nil {
return fmt.Errorf("opening audit log: %w", err)
}
index, err := loadAccessIndex(auditPath)
if err != nil {
return fmt.Errorf("reading access index: %w", err)
}

srv, err := server.New(env.Context(), server.Config{
DBPath: filepath.Join(serverArgs.StateDir, "database"),
Key: kek,
AuditLog: audit,
AccessIndex: index, // may be nil, that's OK
WhoIs: lc.WhoIs,
BackupBucket: serverArgs.BackupBucket,
BackupBucketRegion: serverArgs.BackupBucketRegion,
Expand Down Expand Up @@ -319,13 +327,17 @@ func runList(env *command.Env) error {
}

tw := newTabWriter(os.Stdout)
io.WriteString(tw, "NAME\tACTIVE\tVERSIONS\n")
io.WriteString(tw, "NAME\tACTIVE\tVERSIONS\tLAST ACCESSED\n")
for _, s := range secrets {
vers := make([]string, 0, len(s.Versions))
for _, v := range s.Versions {
vers = append(vers, v.String())
}
fmt.Fprintf(tw, "%s\t%s\t%s\n", s.Name, s.ActiveVersion, strings.Join(vers, ","))
lastAccess := "(unknown)"
if !s.LastAccess.IsZero() {
lastAccess = s.LastAccess.Format(time.RFC3339)
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, s.ActiveVersion, strings.Join(vers, ","), lastAccess)
}
return tw.Flush()
}
Expand All @@ -348,6 +360,9 @@ func runInfo(env *command.Env, name string) error {
fmt.Fprintf(tw, "Name:\t%s\n", info.Name)
fmt.Fprintf(tw, "Active version:\t%s\n", info.ActiveVersion)
fmt.Fprintf(tw, "Versions:\t%s\n", strings.Join(vers, ", "))
if !info.LastAccess.IsZero() {
fmt.Fprintf(tw, "Last access:\t%s\n", info.LastAccess.Format(time.RFC3339))
}
return tw.Flush()
}

Expand Down Expand Up @@ -579,3 +594,27 @@ func checkPutText(value []byte) ([]byte, error) {
return nil, errors.New("text value has surrounding whitespace, " +
"specify --verbatim to keep the space or --trim-space to remove it")
}

// loadAccessIndex reads an audit log from the specified path and constructs a
// last-access index from it. It reports nil without error if the path does not
// exist.
func loadAccessIndex(path string) (db.AccessIndex, error) {
f, err := os.Open(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil // ok, nothing to do
} else if err != nil {
return nil, err
}
defer f.Close()
index := make(db.AccessIndex)
for e, err := range audit.NewReader(f).All() {
if err != nil {
return nil, err
}
if !e.Authorized || e.Action == acl.ActionInfo {
continue // this is not a successful access
}
index[e.Secret] = db.LastAccess{Time: e.Time}
}
return index, nil
}
Loading
Loading