diff --git a/go.mod b/go.mod index 4c418cd6..4a4dea0d 100644 --- a/go.mod +++ b/go.mod @@ -88,6 +88,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cloudevents/sdk-go/v2 v2.15.0 // indirect github.com/containerd/continuity v0.4.3 // indirect github.com/coreos/go-iptables v0.7.0 // indirect github.com/danieljoos/wincred v1.1.2 // indirect @@ -132,6 +133,7 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.6 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/labstack/gommon v0.4.2 // indirect @@ -145,6 +147,8 @@ require ( github.com/miekg/dns v1.1.57 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/term v0.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mtibben/percent v0.2.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0-rc6 // indirect diff --git a/go.sum b/go.sum index 6a55c2d9..89f4e3d7 100644 --- a/go.sum +++ b/go.sum @@ -291,6 +291,10 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/cilium/ebpf v0.12.3 h1:8ht6F9MquybnY97at+VDZb3eQQr8ev79RueWeVaEcG4= github.com/cilium/ebpf v0.12.3/go.mod h1:TctK1ivibvI3znr66ljgi4hqOT8EYQjz1KWBfb1UVgM= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudevents/sdk-go/v2 v2.12.0 h1:p1k+ysVOZtNiXfijnwB3WqZNA3y2cGOiKQygWkUHCEI= +github.com/cloudevents/sdk-go/v2 v2.12.0/go.mod h1:xDmKfzNjM8gBvjaF8ijFjM1VYOVUEeUfapHMUX1T5To= +github.com/cloudevents/sdk-go/v2 v2.15.0 h1:aKnhLQhyoJXqEECQdOIZnbZ9VupqlidE6hedugDGr+I= +github.com/cloudevents/sdk-go/v2 v2.15.0/go.mod h1:lL7kSWAE/V8VI4Wh0jbL2v/jvqsm6tjmaQBSvxcv4uE= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -559,6 +563,7 @@ github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4os github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= @@ -640,9 +645,11 @@ github.com/mitchellh/pointerstructure v1.2.1/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8oh github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= diff --git a/internal/config/config.go b/internal/config/config.go index 89e4372c..50612734 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -123,6 +123,15 @@ func defaultConfig() *Config { Logging: Logging{ Level: "info", }, + Events: Events{ + Log: EventsLogSink{ + Enabled: false, + }, + File: EventsFileSink{ + Enabled: false, + FileName: "events.log", + }, + }, } } @@ -143,6 +152,7 @@ type Config struct { Auth Auth `yaml:"auth,omitempty" envPrefix:"AUTH_"` DNS DNS `yaml:"dns,omitempty"` Logging Logging `yaml:"logging,omitempty" envPrefix:"LOGGING_"` + Events Events `yaml:"events,omitempty" envPrefix:"EVENTS_"` WebPublicUrl *url.URL `yaml:"-"` } @@ -167,6 +177,30 @@ type Logging struct { File string `yaml:"file,omitempty" env:"FILE"` } +type Events struct { + Log EventsLogSink `yaml:"log,omitempty" envPrefix:"LOG_"` + File EventsFileSink `yaml:"file,omitempty" envPrefix:"FILE_"` + Tcp EventsTcpSink `yaml:"tcp,omitempty" envPrefix:"TCP_"` +} + +type EventsLogSink struct { + Enabled bool `yaml:"enabled,omitempty" env:"ENABLED"` +} + +type EventsFileSink struct { + Enabled bool `yaml:"enabled,omitempty" env:"ENABLED"` + Path string `yaml:"path,omitempty" env:"PATH"` + FileName string `yaml:"name,omitempty" env:"NAME"` + MaxBytes int `yaml:"max_bytes,omitempty" env:"MAX_BYTES"` + MaxDuration time.Duration `yaml:"max_duration,omitempty" env:"MAX_DURATION"` + MaxFiles int `yaml:"max_files,omitempty" env:"MAX_FILES"` +} + +type EventsTcpSink struct { + Enabled bool `yaml:"enabled,omitempty" env:"ENABLED"` + Addr string `yaml:"addr,omitempty" env:"ADDR"` +} + type Database struct { Type string `yaml:"type,omitempty" env:"TYPE"` Url string `yaml:"url,omitempty" env:"URL"` diff --git a/internal/eventlog/events.go b/internal/eventlog/events.go new file mode 100644 index 00000000..6cb282a9 --- /dev/null +++ b/internal/eventlog/events.go @@ -0,0 +1,146 @@ +package eventlog + +import ( + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/jsiebens/ionscale/internal/domain" + "math/big" +) + +const ( + tailnetCreated = "ionscale.tailnet.create" + tailnetIamUpdated = "ionscale.tailnet.iam.update" + tailnetAclUpdated = "ionscale.tailnet.acl.update" + tailnetDNSConfigUpdated = "ionscale.tailnet.dns_config.update" + nodeCreated = "ionscale.node.create" +) + +func TailnetCreated(tailnet *domain.Tailnet, actor ActorOpts) cloudevents.Event { + data := &EventData[any]{ + Tailnet: &Target{ID: idToStr(tailnet.ID), Name: tailnet.Name}, + Target: &Target{ID: idToStr(tailnet.ID), Name: tailnet.Name}, + Actor: actor(), + } + + event := cloudevents.NewEvent() + event.SetType(tailnetCreated) + _ = event.SetData(cloudevents.ApplicationJSON, data) + + return event +} + +func TailnetIAMUpdated(tailnet *domain.Tailnet, old *domain.IAMPolicy, actor ActorOpts) cloudevents.Event { + data := &EventData[*domain.IAMPolicy]{ + Tailnet: &Target{ID: idToStr(tailnet.ID), Name: tailnet.Name}, + Target: &Target{ID: idToStr(tailnet.ID), Name: tailnet.Name}, + Actor: actor(), + Attr: &Attr[*domain.IAMPolicy]{ + New: &tailnet.IAMPolicy, + Old: old, + }, + } + + event := cloudevents.NewEvent() + event.SetType(tailnetIamUpdated) + _ = event.SetData(cloudevents.ApplicationJSON, data) + + return event +} + +func TailnetACLUpdated(tailnet *domain.Tailnet, old *domain.ACLPolicy, actor ActorOpts) cloudevents.Event { + data := &EventData[*domain.ACLPolicy]{ + Tailnet: &Target{ID: idToStr(tailnet.ID), Name: tailnet.Name}, + Target: &Target{ID: idToStr(tailnet.ID), Name: tailnet.Name}, + Actor: actor(), + Attr: &Attr[*domain.ACLPolicy]{ + New: &tailnet.ACLPolicy, + Old: old, + }, + } + + event := cloudevents.NewEvent() + event.SetType(tailnetAclUpdated) + _ = event.SetData(cloudevents.ApplicationJSON, data) + + return event +} + +func TailnetDNSConfigUpdated(tailnet *domain.Tailnet, old *domain.DNSConfig, actor ActorOpts) cloudevents.Event { + data := &EventData[*domain.DNSConfig]{ + Tailnet: &Target{ID: idToStr(tailnet.ID), Name: tailnet.Name}, + Target: &Target{ID: idToStr(tailnet.ID), Name: tailnet.Name}, + Actor: actor(), + Attr: &Attr[*domain.DNSConfig]{ + New: &tailnet.DNSConfig, + Old: old, + }, + } + + event := cloudevents.NewEvent() + event.SetType(tailnetDNSConfigUpdated) + _ = event.SetData(cloudevents.ApplicationJSON, data) + + return event +} + +func MachineCreated(machine *domain.Machine, actor ActorOpts) cloudevents.Event { + data := &EventData[any]{ + Tailnet: &Target{ID: idToStr(machine.Tailnet.ID), Name: machine.Tailnet.Name}, + Target: &Target{ID: idToStr(machine.ID), Name: machine.CompleteName()}, + Actor: actor(), + } + + event := cloudevents.NewEvent() + event.SetType(nodeCreated) + _ = event.SetData(cloudevents.ApplicationJSON, data) + + return event +} + +type ActorOpts func() Actor + +func User(u *domain.User) ActorOpts { + if u == nil { + return SystemAdmin() + } + + switch u.UserType { + case domain.UserTypePerson: + return func() Actor { + return Actor{ID: idToStr(u.ID), Name: u.Name} + } + default: + return SystemAdmin() + } +} + +func SystemAdmin() ActorOpts { + return func() Actor { + return Actor{ID: "", Name: "system admin"} + } +} + +type EventData[T any] struct { + Tailnet *Target `json:"tailnet,omitempty"` + Target *Target `json:"target,omitempty"` + Attr *Attr[T] `json:"attr,omitempty"` + Actor Actor `json:"actor"` +} + +type Target struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Actor struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` +} + +type Attr[T any] struct { + New T `json:"new"` + Old T `json:"old,omitempty"` +} + +func idToStr(id uint64) string { + return big.NewInt(int64(id)).Text(10) +} diff --git a/internal/eventlog/global.go b/internal/eventlog/global.go new file mode 100644 index 00000000..57ee4f4c --- /dev/null +++ b/internal/eventlog/global.go @@ -0,0 +1,133 @@ +package eventlog + +import ( + "bytes" + "context" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/hashicorp/go-multierror" + "github.com/jsiebens/ionscale/internal/config" + "github.com/jsiebens/ionscale/internal/util" + "go.uber.org/zap" + "io" + "os" + "path/filepath" + "sync" + "time" +) + +const ( + stdout = "/dev/stdout" + stderr = "/dev/stderr" + devnull = "/dev/null" +) + +type Events []cloudevents.Event + +func (e *Events) Add(event cloudevents.Event) { + x := append(*e, event) + *e = x +} + +type Eventer interface { + Send(ctx context.Context, events ...cloudevents.Event) error +} + +type eventer struct { + source string + sinks []sink +} + +func (e *eventer) Send(ctx context.Context, events ...cloudevents.Event) error { + groupID := util.NextIDString() + now := time.Now() + + for _, event := range events { + event.SetSource(e.source) + event.SetID(util.NextIDString()) + event.SetTime(now) + event.SetExtension("eventGroupID", groupID) + } + + var r *multierror.Error + for _, s := range e.sinks { + r = multierror.Append(r, s.process(ctx, events...)) + } + + return r.ErrorOrNil() +} + +type sink interface { + process(context.Context, ...cloudevents.Event) error +} + +var ( + _globalMu sync.RWMutex + _globalE Eventer = &eventer{} +) + +func Configure(c *config.Config) error { + var sinks []sink + + if c.Events.Log.Enabled { + sinks = append(sinks, &zapSink{logger: zap.L().Named("events").WithOptions(zap.AddCallerSkip(3))}) + } + + if c.Events.File.Enabled { + switch c.Events.File.Path { + case devnull: + // ignore + case stderr: + sinks = append(sinks, &writerSink{w: os.Stderr}) + case stdout: + sinks = append(sinks, &writerSink{w: os.Stdout}) + default: + abs, err := filepath.Abs(c.Events.File.Path) + if err != nil { + return err + } + + sinks = append(sinks, &fileSink{ + path: abs, + fileName: c.Events.File.FileName, + maxBytes: c.Events.File.MaxBytes, + maxDuration: c.Events.File.MaxDuration, + maxFiles: c.Events.File.MaxFiles, + }) + } + } + + _globalMu.Lock() + defer _globalMu.Unlock() + _globalE = &eventer{ + source: c.WebPublicUrl.String(), + sinks: sinks, + } + + return nil +} + +func Send(ctx context.Context, events ...cloudevents.Event) { + _globalMu.RLock() + l := _globalE + _globalMu.RUnlock() + + if err := l.Send(ctx, events...); err != nil { + zap.L().Error("error while processing event", zap.Error(err)) + } +} + +func writeJSONLine(w io.Writer, events ...cloudevents.Event) (int, error) { + var payload bytes.Buffer + + for _, event := range events { + eventJson, err := event.MarshalJSON() + if err != nil { + return 0, err + } + + payload.Write(eventJson) + payload.Write([]byte("\n")) + } + + return w.Write(payload.Bytes()) +} diff --git a/internal/eventlog/sinks.go b/internal/eventlog/sinks.go new file mode 100644 index 00000000..ffab7f36 --- /dev/null +++ b/internal/eventlog/sinks.go @@ -0,0 +1,200 @@ +package eventlog + +import ( + "context" + "fmt" + cloudevents "github.com/cloudevents/sdk-go/v2" + "go.uber.org/zap" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +const ( + fileMode = os.FileMode(0600) + dirMode = os.FileMode(0700) +) + +type zapSink struct { + logger *zap.Logger +} + +func (z *zapSink) process(_ context.Context, events ...cloudevents.Event) error { + for _, event := range events { + z.logger.Info(event.Type(), zap.String("data", string(event.Data()))) + } + return nil +} + +type writerSink struct { + w io.Writer +} + +func (w *writerSink) process(_ context.Context, events ...cloudevents.Event) error { + _, err := writeJSONLine(w.w, events...) + return err +} + +type fileSink struct { + path string + fileName string + maxBytes int + maxDuration time.Duration + maxFiles int + + lastCreated time.Time + bytesWritten int64 + + f *os.File + l sync.Mutex +} + +func (fs *fileSink) process(_ context.Context, events ...cloudevents.Event) error { + fs.l.Lock() + defer fs.l.Unlock() + + if fs.f == nil { + err := fs.open() + if err != nil { + return err + } + } + + if err := fs.rotate(); err != nil { + return err + } + + if n, err := writeJSONLine(fs.f, events...); err == nil { + fs.bytesWritten += int64(n) + return nil + } + + if err := fs.reopen(); err != nil { + return err + } + + _, err := writeJSONLine(fs.f, events...) + + return err +} + +func (fs *fileSink) reopen() error { + if fs.f != nil { + _, err := os.Stat(fs.f.Name()) + if os.IsNotExist(err) { + fs.f = nil + } + } + + if fs.f == nil { + return fs.open() + } + + err := fs.f.Close() + fs.f = nil + if err != nil { + return err + } + + return fs.open() +} + +func (fs *fileSink) rotate() error { + elapsed := time.Since(fs.lastCreated) + if (fs.bytesWritten >= int64(fs.maxBytes) && (fs.maxBytes > 0)) || + ((elapsed > fs.maxDuration) && (fs.maxDuration > 0)) { + + err := fs.f.Close() + if err != nil { + return err + } + fs.f = nil + + rotateTime := time.Now().UnixNano() + rotateFileName := fmt.Sprintf(fs.fileNamePattern(), strconv.FormatInt(rotateTime, 10)) + oldPath := filepath.Join(fs.path, fs.fileName) + newPath := filepath.Join(fs.path, rotateFileName) + if err := os.Rename(oldPath, newPath); err != nil { + return fmt.Errorf("failed to rotate log file: %v", err) + } + + if err := fs.pruneFiles(); err != nil { + return fmt.Errorf("failed to prune log files: %w", err) + } + + return fs.open() + } + + return nil +} + +func (fs *fileSink) open() error { + if fs.f != nil { + return nil + } + + if err := os.MkdirAll(fs.path, dirMode); err != nil { + return err + } + + createTime := time.Now() + newFileName := fs.newFileName() + newFilePath := filepath.Join(fs.path, newFileName) + + var err error + fs.f, err = os.OpenFile(newFilePath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, fileMode) + if err != nil { + return err + } + + // Reset file related statistics + fs.lastCreated = createTime + fs.bytesWritten = 0 + + return nil +} + +func (fs *fileSink) pruneFiles() error { + if fs.maxFiles == 0 { + return nil + } + + pattern := fs.fileNamePattern() + globExpression := filepath.Join(fs.path, fmt.Sprintf(pattern, "*")) + matches, err := filepath.Glob(globExpression) + if err != nil { + return err + } + + sort.Strings(matches) + + stale := len(matches) - fs.maxFiles + for i := 0; i < stale; i++ { + if err := os.Remove(matches[i]); err != nil { + return err + } + } + return nil +} + +func (fs *fileSink) fileNamePattern() string { + ext := filepath.Ext(fs.fileName) + if ext == "" { + ext = ".log" + } + + return strings.TrimSuffix(fs.fileName, ext) + "-%s" + ext +} + +func (fs *fileSink) newFileName() string { + return fs.fileName +} + +func (fs *fileSink) rotateEnabled() bool { + return fs.maxBytes > 0 || fs.maxDuration != 0 +} diff --git a/internal/handlers/authentication.go b/internal/handlers/authentication.go index ce1ac2da..65c17d56 100644 --- a/internal/handlers/authentication.go +++ b/internal/handlers/authentication.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/jsiebens/ionscale/internal/addr" "github.com/jsiebens/ionscale/internal/auth" + "github.com/jsiebens/ionscale/internal/eventlog" tpl "github.com/jsiebens/ionscale/internal/templates" "github.com/labstack/echo/v4/middleware" "github.com/mr-tron/base58" @@ -465,9 +466,11 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, form return logError(err) } + var events eventlog.Events now := time.Now().UTC() + createNewMachine := m == nil - if m == nil { + if createNewMachine { registeredTags := tags advertisedTags := domain.SanitizeTags(req.Hostinfo.RequestTags) tags := append(registeredTags, advertisedTags...) @@ -505,6 +508,8 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, form } m.IPv4 = domain.IP{Addr: ipv4} m.IPv6 = domain.IP{Addr: ipv6} + + events = append(events, eventlog.MachineCreated(m, eventlog.User(user))) } else { registeredTags := tags advertisedTags := domain.SanitizeTags(req.Hostinfo.RequestTags) @@ -555,6 +560,8 @@ func (h *AuthenticationHandlers) endMachineRegistrationFlow(c echo.Context, form return logError(err) } + eventlog.Send(ctx, events...) + if m.Authorized { return c.Redirect(http.StatusFound, "/a/success") } else { diff --git a/internal/handlers/registration.go b/internal/handlers/registration.go index 71694867..bb6d3957 100644 --- a/internal/handlers/registration.go +++ b/internal/handlers/registration.go @@ -6,6 +6,7 @@ import ( "github.com/jsiebens/ionscale/internal/config" "github.com/jsiebens/ionscale/internal/core" "github.com/jsiebens/ionscale/internal/domain" + "github.com/jsiebens/ionscale/internal/eventlog" "github.com/jsiebens/ionscale/internal/mapping" "github.com/jsiebens/ionscale/internal/util" "github.com/labstack/echo/v4" @@ -178,9 +179,11 @@ func (h *RegistrationHandlers) authenticateMachineWithAuthKey(c echo.Context, ma return logError(err) } + var events eventlog.Events now := time.Now().UTC() + createNewMachine := m == nil - if m == nil { + if createNewMachine { sanitizeHostname := dnsname.SanitizeHostname(req.Hostinfo.Hostname) nameIdx, err := h.repository.GetNextMachineNameIndex(ctx, tailnet.ID, sanitizeHostname) if err != nil { @@ -218,6 +221,8 @@ func (h *RegistrationHandlers) authenticateMachineWithAuthKey(c echo.Context, ma } m.IPv4 = domain.IP{Addr: ipv4} m.IPv6 = domain.IP{Addr: ipv6} + + events = append(events, eventlog.MachineCreated(m, eventlog.User(&user))) } else { sanitizeHostname := dnsname.SanitizeHostname(req.Hostinfo.Hostname) if m.Name != sanitizeHostname { @@ -244,6 +249,8 @@ func (h *RegistrationHandlers) authenticateMachineWithAuthKey(c echo.Context, ma return logError(err) } + eventlog.Send(ctx, events...) + tUser, tLogin := mapping.ToUser(m.User) response := tailcfg.RegisterResponse{ MachineAuthorized: true, diff --git a/internal/server/echo.go b/internal/server/echo.go index 91ed0ab1..a9771331 100644 --- a/internal/server/echo.go +++ b/internal/server/echo.go @@ -62,7 +62,7 @@ func EchoRecover() echo.MiddlewareFunc { if !ok { err = fmt.Errorf("%v", r) } - zap.L().Error("panic when processing request", zap.Error(err)) + zap.L().WithOptions(zap.AddCallerSkip(1)).Error("panic when processing request", zap.Error(err)) topErr = err } }() diff --git a/internal/server/server.go b/internal/server/server.go index 42f16e70..9d99a001 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -12,6 +12,7 @@ import ( "github.com/jsiebens/ionscale/internal/database" "github.com/jsiebens/ionscale/internal/dns" "github.com/jsiebens/ionscale/internal/domain" + "github.com/jsiebens/ionscale/internal/eventlog" "github.com/jsiebens/ionscale/internal/handlers" "github.com/jsiebens/ionscale/internal/service" "github.com/jsiebens/ionscale/internal/templates" @@ -51,6 +52,10 @@ func Start(ctx context.Context, c *config.Config) error { return err } + if err := eventlog.Configure(c); err != nil { + return logError(err) + } + httpLogger := logger.Named("http") dbLogger := logger.Named("db") diff --git a/internal/service/acl.go b/internal/service/acl.go index f1e49c91..5eb40d8c 100644 --- a/internal/service/acl.go +++ b/internal/service/acl.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/bufbuild/connect-go" "github.com/jsiebens/ionscale/internal/domain" + "github.com/jsiebens/ionscale/internal/eventlog" "github.com/jsiebens/ionscale/internal/mapping" api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1" ) @@ -60,6 +61,7 @@ func (s *Service) SetACLPolicy(ctx context.Context, req *connect.Request[api.Set return nil, logError(err) } + eventlog.Send(ctx, eventlog.TailnetACLUpdated(tailnet, &oldPolicy, eventlog.User(principal.User))) s.sessionManager.NotifyAll(tailnet.ID) return connect.NewResponse(&api.SetACLPolicyResponse{}), nil diff --git a/internal/service/dns.go b/internal/service/dns.go index 3350753d..cb1fb371 100644 --- a/internal/service/dns.go +++ b/internal/service/dns.go @@ -6,6 +6,7 @@ import ( "github.com/bufbuild/connect-go" "github.com/jsiebens/ionscale/internal/config" "github.com/jsiebens/ionscale/internal/domain" + "github.com/jsiebens/ionscale/internal/eventlog" api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1" ) @@ -66,6 +67,7 @@ func (s *Service) SetDNSConfig(ctx context.Context, req *connect.Request[api.Set return nil, logError(err) } + eventlog.Send(ctx, eventlog.TailnetDNSConfigUpdated(tailnet, &oldConfig, eventlog.User(principal.User))) s.sessionManager.NotifyAll(tailnet.ID) return connect.NewResponse(&api.SetDNSConfigResponse{Config: domainDNSConfigToApiDNSConfig(tailnet)}), nil diff --git a/internal/service/iam.go b/internal/service/iam.go index 8f742353..e481cdd2 100644 --- a/internal/service/iam.go +++ b/internal/service/iam.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/bufbuild/connect-go" "github.com/jsiebens/ionscale/internal/domain" + "github.com/jsiebens/ionscale/internal/eventlog" api "github.com/jsiebens/ionscale/pkg/gen/ionscale/v1" ) @@ -68,6 +69,8 @@ func (s *Service) SetIAMPolicy(ctx context.Context, req *connect.Request[api.Set return nil, logError(err) } + eventlog.Send(ctx, eventlog.TailnetIAMUpdated(tailnet, &oldPolicy, eventlog.User(principal.User))) + return connect.NewResponse(&api.SetIAMPolicyResponse{}), nil } diff --git a/internal/service/tailnet.go b/internal/service/tailnet.go index f6d81f19..c554ad37 100644 --- a/internal/service/tailnet.go +++ b/internal/service/tailnet.go @@ -5,7 +5,9 @@ import ( "encoding/json" "fmt" "github.com/bufbuild/connect-go" + cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/jsiebens/ionscale/internal/domain" + "github.com/jsiebens/ionscale/internal/eventlog" "github.com/jsiebens/ionscale/internal/mapping" "github.com/jsiebens/ionscale/internal/util" "github.com/jsiebens/ionscale/pkg/defaults" @@ -96,6 +98,13 @@ func (s *Service) CreateTailnet(ctx context.Context, req *connect.Request[api.Cr return nil, logError(err) } + eventlog.Send(ctx, + eventlog.TailnetCreated(tailnet, eventlog.User(principal.User)), + eventlog.TailnetIAMUpdated(tailnet, nil, eventlog.User(principal.User)), + eventlog.TailnetACLUpdated(tailnet, nil, eventlog.User(principal.User)), + eventlog.TailnetDNSConfigUpdated(tailnet, nil, eventlog.User(principal.User)), + ) + resp := &api.CreateTailnetResponse{Tailnet: t} return connect.NewResponse(resp), nil @@ -116,26 +125,48 @@ func (s *Service) UpdateTailnet(ctx context.Context, req *connect.Request[api.Up return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("tailnet not found")) } + events := make([]cloudevents.Event, 0) + if req.Msg.IamPolicy != nil { if err := validateIamPolicy(req.Msg.IamPolicy); err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid iam policy: %w", err)) } - tailnet.IAMPolicy = domain.IAMPolicy{} - if err := mapping.CopyViaJson(req.Msg.IamPolicy, &tailnet.IAMPolicy); err != nil { + oldPolicy := tailnet.IAMPolicy + var newPolicy domain.IAMPolicy + + if err := mapping.CopyViaJson(req.Msg.IamPolicy, &newPolicy); err != nil { return nil, logError(err) } + + if !oldPolicy.Equal(&newPolicy) { + tailnet.IAMPolicy = newPolicy + events = append(events, eventlog.TailnetIAMUpdated(tailnet, &oldPolicy, eventlog.User(principal.User))) + } } if req.Msg.AclPolicy != nil { - tailnet.ACLPolicy = domain.ACLPolicy{} - if err := mapping.CopyViaJson(req.Msg.AclPolicy, &tailnet.ACLPolicy); err != nil { + oldPolicy := tailnet.ACLPolicy + var newPolicy domain.ACLPolicy + + if err := mapping.CopyViaJson(req.Msg.AclPolicy, &newPolicy); err != nil { return nil, logError(err) } + + if !oldPolicy.Equal(&newPolicy) { + tailnet.ACLPolicy = newPolicy + events = append(events, eventlog.TailnetACLUpdated(tailnet, &oldPolicy, eventlog.User(principal.User))) + } } if req.Msg.DnsConfig != nil { - tailnet.DNSConfig = apiDNSConfigToDomainDNSConfig(req.Msg.DnsConfig) + oldConfig := tailnet.DNSConfig + newConfig := apiDNSConfigToDomainDNSConfig(req.Msg.DnsConfig) + + if !oldConfig.Equal(&newConfig) { + tailnet.DNSConfig = newConfig + events = append(events, eventlog.TailnetDNSConfigUpdated(tailnet, &oldConfig, eventlog.User(principal.User))) + } } tailnet.ServiceCollectionEnabled = req.Msg.ServiceCollectionEnabled @@ -147,6 +178,7 @@ func (s *Service) UpdateTailnet(ctx context.Context, req *connect.Request[api.Up return nil, logError(err) } + eventlog.Send(ctx, events...) s.sessionManager.NotifyAll(tailnet.ID) t, err := domainTailnetToApiTailnet(tailnet) @@ -216,6 +248,15 @@ func (s *Service) DeleteTailnet(ctx context.Context, req *connect.Request[api.De return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied")) } + tailnet, err := s.repository.GetTailnet(ctx, req.Msg.TailnetId) + if err != nil { + return nil, logError(err) + } + + if tailnet == nil { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("tailnet not found")) + } + count, err := s.repository.CountMachineByTailnet(ctx, req.Msg.TailnetId) if err != nil { return nil, logError(err) diff --git a/internal/util/id.go b/internal/util/id.go index 54e05d5a..41ec6dc2 100644 --- a/internal/util/id.go +++ b/internal/util/id.go @@ -3,6 +3,7 @@ package util import ( "fmt" "github.com/sony/sonyflake" + "math/big" "net" "os" "strconv" @@ -21,6 +22,10 @@ func NextID() uint64 { return id } +func NextIDString() string { + return big.NewInt(int64(NextID())).Text(62) +} + func ensureProvider() { sfOnce.Do(func() { sfInstance, err := sonyflake.New(sonyflake.Settings{