package store import ( "encoding/binary" "encoding/json" "errors" "fmt" "os" "path/filepath" "time" "helix-proxy/internal/config" "helix-proxy/internal/models" "go.etcd.io/bbolt" ) const ( bucketProxyHosts = "proxy_hosts" bucketAccessLists = "access_lists" bucketStreams = "streams" bucketCertificates = "certificates" bucketRedirectionHosts = "redirection_hosts" bucketDeadHosts = "dead_hosts" bucketUsers = "users" bucketAuditLogs = "audit_logs" bucketSettings = "settings" bucketMeta = "meta" ) type bboltStore struct { db *bbolt.DB path string } // NewBboltStore opens (or creates) the bbolt embedded database at the standard // location (data/db.bolt by default, overridable via DATA_DIR etc). func NewBboltStore() (Store, error) { p := config.Resolve("db.bolt") if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { return nil, err } db, err := bbolt.Open(p, 0o600, &bbolt.Options{Timeout: 0}) if err != nil { return nil, fmt.Errorf("open bbolt db: %w", err) } s := &bboltStore{db: db, path: p} // Ensure all buckets exist if err := s.createBuckets(); err != nil { db.Close() return nil, err } // First-run seed (admin user + default settings) if nothing exists if !s.hasAnyUsers() { if err := s.seedDefaults(); err != nil { db.Close() return nil, err } } return s, nil } func (s *bboltStore) Close() error { if s.db != nil { return s.db.Close() } return nil } func (s *bboltStore) createBuckets() error { return s.db.Update(func(tx *bbolt.Tx) error { buckets := []string{ bucketProxyHosts, bucketAccessLists, bucketStreams, bucketCertificates, bucketRedirectionHosts, bucketDeadHosts, bucketUsers, bucketAuditLogs, bucketSettings, bucketMeta, } for _, name := range buckets { if _, err := tx.CreateBucketIfNotExists([]byte(name)); err != nil { return err } } return nil }) } func (s *bboltStore) seedDefaults() error { return s.db.Update(func(tx *bbolt.Tx) error { // Seed admin user + default settings on first run (empty data dir) admin := models.User{ ID: 1, Email: "admin@example.com", Name: "Admin", Roles: []string{"admin"}, } if err := s.putJSON(tx, bucketUsers, admin.ID, admin); err != nil { return err } // Default settings b := tx.Bucket([]byte(bucketSettings)) defaults := map[string]any{"default_site": "congratulations"} jb, _ := json.Marshal(defaults) b.Put([]byte("data"), jb) // Advance user sequence bu := tx.Bucket([]byte(bucketUsers)) bu.NextSequence() // advance past 1 return nil }) } func (s *bboltStore) hasAnyUsers() bool { var count int s.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucketUsers)) if b != nil { count = b.Stats().KeyN } return nil }) return count > 0 } // --- helpers --- func itob(i int) []byte { b := make([]byte, 8) binary.BigEndian.PutUint64(b, uint64(i)) return b } func btoi(b []byte) int { if len(b) < 8 { return 0 } return int(binary.BigEndian.Uint64(b)) } func (s *bboltStore) putJSON(tx *bbolt.Tx, bucket string, id int, v any) error { b := tx.Bucket([]byte(bucket)) jb, err := json.Marshal(v) if err != nil { return err } return b.Put(itob(id), jb) } func (s *bboltStore) getJSON(tx *bbolt.Tx, bucket string, id int, out any) bool { b := tx.Bucket([]byte(bucket)) if b == nil { return false } data := b.Get(itob(id)) if data == nil { return false } return json.Unmarshal(data, out) == nil } // --- Store interface implementation --- func (s *bboltStore) IsSetup() bool { var has bool s.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucketUsers)) if b != nil { has = b.Stats().KeyN > 0 } return nil }) return has } func (s *bboltStore) GetUsers() []models.User { var out []models.User s.db.View(func(tx *bbolt.Tx) error { out = s.loadUsers(tx) return nil }) return out } // --- ProxyHost --- func (s *bboltStore) GetProxyHosts() []models.ProxyHost { var out []models.ProxyHost s.db.View(func(tx *bbolt.Tx) error { out = s.loadProxyHosts(tx) return nil }) return out } func (s *bboltStore) GetProxyHost(id int) (models.ProxyHost, bool) { var h models.ProxyHost s.db.View(func(tx *bbolt.Tx) error { s.getJSON(tx, bucketProxyHosts, id, &h) return nil }) return h, h.ID != 0 } func (s *bboltStore) CreateProxyHost(h models.ProxyHost) (models.ProxyHost, error) { err := s.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucketProxyHosts)) if h.ID == 0 { seq, _ := b.NextSequence() h.ID = int(seq) } if h.DomainNames == nil { h.DomainNames = []string{} } if h.Meta == nil { h.Meta = map[string]any{} } if h.CreatedOn == "" { h.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers) } if err := validateProxyHostSources(s.loadProxyHosts(tx), h); err != nil { return err } return s.putJSON(tx, bucketProxyHosts, h.ID, h) }) return h, err } func (s *bboltStore) UpdateProxyHost(h models.ProxyHost) (models.ProxyHost, error) { err := s.db.Update(func(tx *bbolt.Tx) error { if !s.getJSON(tx, bucketProxyHosts, h.ID, &models.ProxyHost{}) { return errors.New("not found") } if err := validateProxyHostSources(s.loadProxyHosts(tx), h); err != nil { return err } return s.putJSON(tx, bucketProxyHosts, h.ID, h) }) return h, err } func (s *bboltStore) DeleteProxyHost(id int) error { return s.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucketProxyHosts)) return b.Delete(itob(id)) }) } func (s *bboltStore) SetProxyHostEnabled(id int, enabled bool) error { return s.db.Update(func(tx *bbolt.Tx) error { var h models.ProxyHost if !s.getJSON(tx, bucketProxyHosts, id, &h) { return errors.New("not found") } h.Enabled = enabled return s.putJSON(tx, bucketProxyHosts, id, h) }) } // --- AccessList --- func (s *bboltStore) GetAccessLists() []models.AccessList { var out []models.AccessList s.db.View(func(tx *bbolt.Tx) error { out = s.loadAccessLists(tx) return nil }) return out } func (s *bboltStore) GetAccessList(id int) (models.AccessList, bool) { var al models.AccessList s.db.View(func(tx *bbolt.Tx) error { s.getJSON(tx, bucketAccessLists, id, &al) return nil }) return al, al.ID != 0 } func (s *bboltStore) CreateAccessList(al models.AccessList) (models.AccessList, error) { err := s.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucketAccessLists)) if al.ID == 0 { seq, _ := b.NextSequence() al.ID = int(seq) } if al.Clients == nil { al.Clients = []models.AccessClient{} } if al.Items == nil { al.Items = []models.AccessItem{} } if al.CreatedOn == "" { al.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers) } return s.putJSON(tx, bucketAccessLists, al.ID, al) }) return al, err } func (s *bboltStore) UpdateAccessList(al models.AccessList) (models.AccessList, error) { err := s.db.Update(func(tx *bbolt.Tx) error { return s.putJSON(tx, bucketAccessLists, al.ID, al) }) return al, err } func (s *bboltStore) DeleteAccessList(id int) error { return s.db.Update(func(tx *bbolt.Tx) error { return tx.Bucket([]byte(bucketAccessLists)).Delete(itob(id)) }) } // --- Stream --- func (s *bboltStore) GetStreams() []models.Stream { var out []models.Stream s.db.View(func(tx *bbolt.Tx) error { out = s.loadStreams(tx) return nil }) return out } func (s *bboltStore) GetStream(id int) (models.Stream, bool) { var st models.Stream s.db.View(func(tx *bbolt.Tx) error { s.getJSON(tx, bucketStreams, id, &st) return nil }) return st, st.ID != 0 } func (s *bboltStore) CreateStream(st models.Stream) (models.Stream, error) { err := s.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucketStreams)) if st.ID == 0 { seq, _ := b.NextSequence() st.ID = int(seq) } if st.Meta == nil { st.Meta = map[string]any{} } if st.CreatedOn == "" { st.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers) } if err := validateStreamIncomingPort(st, s.loadStreams(tx)); err != nil { return err } return s.putJSON(tx, bucketStreams, st.ID, st) }) return st, err } func (s *bboltStore) UpdateStream(st models.Stream) (models.Stream, error) { err := s.db.Update(func(tx *bbolt.Tx) error { if !s.getJSON(tx, bucketStreams, st.ID, &models.Stream{}) { return errors.New("not found") } if err := validateStreamIncomingPort(st, s.loadStreams(tx)); err != nil { return err } return s.putJSON(tx, bucketStreams, st.ID, st) }) return st, err } func (s *bboltStore) DeleteStream(id int) error { return s.db.Update(func(tx *bbolt.Tx) error { return tx.Bucket([]byte(bucketStreams)).Delete(itob(id)) }) } func (s *bboltStore) SetStreamEnabled(id int, enabled bool) error { return s.db.Update(func(tx *bbolt.Tx) error { var st models.Stream if !s.getJSON(tx, bucketStreams, id, &st) { return errors.New("not found") } st.Enabled = enabled if enabled { if err := validateStreamIncomingPort(st, s.loadStreams(tx)); err != nil { return err } } return s.putJSON(tx, bucketStreams, id, st) }) } // --- Certificate --- func (s *bboltStore) GetCertificates() []models.Certificate { var out []models.Certificate s.db.View(func(tx *bbolt.Tx) error { out = s.loadCertificates(tx) return nil }) return out } func (s *bboltStore) GetCertificate(id int) (models.Certificate, bool) { var c models.Certificate s.db.View(func(tx *bbolt.Tx) error { s.getJSON(tx, bucketCertificates, id, &c) return nil }) return c, c.ID != 0 } func (s *bboltStore) CreateCertificate(c models.Certificate) (models.Certificate, error) { err := s.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucketCertificates)) if c.ID == 0 { seq, _ := b.NextSequence() c.ID = int(seq) } if c.DomainNames == nil { c.DomainNames = []string{} } if c.Meta == nil { c.Meta = map[string]any{} } if c.CreatedOn == "" { c.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers) } return s.putJSON(tx, bucketCertificates, c.ID, c) }) return c, err } func (s *bboltStore) SetCertificate(c models.Certificate) error { return s.db.Update(func(tx *bbolt.Tx) error { var existing models.Certificate if s.getJSON(tx, bucketCertificates, c.ID, &existing) && c.CreatedOn == "" { c.CreatedOn = existing.CreatedOn } return s.putJSON(tx, bucketCertificates, c.ID, c) }) } func (s *bboltStore) DeleteCertificate(id int) error { return s.db.Update(func(tx *bbolt.Tx) error { return tx.Bucket([]byte(bucketCertificates)).Delete(itob(id)) }) } // --- RedirectionHost --- func (s *bboltStore) GetRedirectionHosts() []models.RedirectionHost { var out []models.RedirectionHost s.db.View(func(tx *bbolt.Tx) error { out = s.loadRedirectionHosts(tx) return nil }) return out } func (s *bboltStore) GetRedirectionHost(id int) (models.RedirectionHost, bool) { var rh models.RedirectionHost s.db.View(func(tx *bbolt.Tx) error { s.getJSON(tx, bucketRedirectionHosts, id, &rh) return nil }) return rh, rh.ID != 0 } func (s *bboltStore) CreateRedirectionHost(h models.RedirectionHost) (models.RedirectionHost, error) { err := s.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucketRedirectionHosts)) if h.ID == 0 { seq, _ := b.NextSequence() h.ID = int(seq) } if h.DomainNames == nil { h.DomainNames = []string{} } if h.Meta == nil { h.Meta = map[string]any{} } if h.CreatedOn == "" { h.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers) } if err := validateRedirectionHostSources(s.loadRedirectionHosts(tx), h); err != nil { return err } return s.putJSON(tx, bucketRedirectionHosts, h.ID, h) }) return h, err } func (s *bboltStore) UpdateRedirectionHost(h models.RedirectionHost) (models.RedirectionHost, error) { err := s.db.Update(func(tx *bbolt.Tx) error { if !s.getJSON(tx, bucketRedirectionHosts, h.ID, &models.RedirectionHost{}) { return errors.New("not found") } if err := validateRedirectionHostSources(s.loadRedirectionHosts(tx), h); err != nil { return err } return s.putJSON(tx, bucketRedirectionHosts, h.ID, h) }) return h, err } func (s *bboltStore) DeleteRedirectionHost(id int) error { return s.db.Update(func(tx *bbolt.Tx) error { return tx.Bucket([]byte(bucketRedirectionHosts)).Delete(itob(id)) }) } func (s *bboltStore) SetRedirectionHostEnabled(id int, enabled bool) error { return s.db.Update(func(tx *bbolt.Tx) error { var rh models.RedirectionHost if !s.getJSON(tx, bucketRedirectionHosts, id, &rh) { return errors.New("not found") } rh.Enabled = enabled return s.putJSON(tx, bucketRedirectionHosts, id, rh) }) } // --- DeadHost --- func (s *bboltStore) GetDeadHosts() []models.DeadHost { var out []models.DeadHost s.db.View(func(tx *bbolt.Tx) error { out = s.loadDeadHosts(tx) return nil }) return out } func (s *bboltStore) GetDeadHost(id int) (models.DeadHost, bool) { var dh models.DeadHost s.db.View(func(tx *bbolt.Tx) error { s.getJSON(tx, bucketDeadHosts, id, &dh) return nil }) return dh, dh.ID != 0 } func (s *bboltStore) CreateDeadHost(h models.DeadHost) (models.DeadHost, error) { err := s.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucketDeadHosts)) if h.ID == 0 { seq, _ := b.NextSequence() h.ID = int(seq) } if h.DomainNames == nil { h.DomainNames = []string{} } if h.Meta == nil { h.Meta = map[string]any{} } if h.CreatedOn == "" { h.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers) } if err := validateDeadHostSources(s.loadDeadHosts(tx), h); err != nil { return err } return s.putJSON(tx, bucketDeadHosts, h.ID, h) }) return h, err } func (s *bboltStore) UpdateDeadHost(h models.DeadHost) (models.DeadHost, error) { err := s.db.Update(func(tx *bbolt.Tx) error { if !s.getJSON(tx, bucketDeadHosts, h.ID, &models.DeadHost{}) { return errors.New("not found") } if err := validateDeadHostSources(s.loadDeadHosts(tx), h); err != nil { return err } return s.putJSON(tx, bucketDeadHosts, h.ID, h) }) return h, err } func (s *bboltStore) DeleteDeadHost(id int) error { return s.db.Update(func(tx *bbolt.Tx) error { return tx.Bucket([]byte(bucketDeadHosts)).Delete(itob(id)) }) } func (s *bboltStore) SetDeadHostEnabled(id int, enabled bool) error { return s.db.Update(func(tx *bbolt.Tx) error { var dh models.DeadHost if !s.getJSON(tx, bucketDeadHosts, id, &dh) { return errors.New("not found") } dh.Enabled = enabled return s.putJSON(tx, bucketDeadHosts, id, dh) }) } // --- Settings --- func (s *bboltStore) GetSettings() map[string]any { var m map[string]any s.db.View(func(tx *bbolt.Tx) error { m = s.loadSettings(tx) return nil }) return m } func (s *bboltStore) SetSetting(name string, value any) error { return s.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucketSettings)) var current map[string]any if data := b.Get([]byte("data")); data != nil { json.Unmarshal(data, ¤t) } if current == nil { current = map[string]any{} } current[name] = value jb, _ := json.Marshal(current) return b.Put([]byte("data"), jb) }) } // --- AuditLog --- func (s *bboltStore) GetAuditLogs() []models.AuditLog { var out []models.AuditLog s.db.View(func(tx *bbolt.Tx) error { out = s.loadAuditLogs(tx) return nil }) return out } func (s *bboltStore) AddAuditLog(log models.AuditLog) (models.AuditLog, error) { err := s.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucketAuditLogs)) if log.ID == 0 { seq, _ := b.NextSequence() log.ID = int(seq) } if log.Meta == nil { log.Meta = map[string]any{} } if log.CreatedOn == "" { // set now if caller did not provide (callers in main rarely do) log.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") } return s.putJSON(tx, bucketAuditLogs, log.ID, log) }) return log, err } // --- Users --- func (s *bboltStore) GetUserByEmail(email string) (models.User, bool) { var found models.User s.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucketUsers)) c := b.Cursor() for k, v := c.First(); k != nil; k, v = c.Next() { var u models.User if json.Unmarshal(v, &u) == nil && u.Email == email { found = u return nil } } return nil }) return found, found.ID != 0 } func (s *bboltStore) CreateUser(u models.User) (models.User, error) { err := s.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucketUsers)) if u.ID == 0 { seq, _ := b.NextSequence() u.ID = int(seq) } if u.Roles == nil { u.Roles = []string{} } return s.putJSON(tx, bucketUsers, u.ID, u) }) return u, err } func (s *bboltStore) UpdateUser(u models.User) error { return s.db.Update(func(tx *bbolt.Tx) error { return s.putJSON(tx, bucketUsers, u.ID, u) }) } func (s *bboltStore) UpdateUserFull(u models.User) error { return s.UpdateUser(u) } func (s *bboltStore) DeleteUser(id int) error { return s.db.Update(func(tx *bbolt.Tx) error { return tx.Bucket([]byte(bucketUsers)).Delete(itob(id)) }) } // --- load helpers (used by Root and lists) --- func (s *bboltStore) loadUsers(tx *bbolt.Tx) []models.User { var out []models.User b := tx.Bucket([]byte(bucketUsers)) if b == nil { return out } b.ForEach(func(_, v []byte) error { var u models.User if json.Unmarshal(v, &u) == nil { out = append(out, u) } return nil }) return out } func (s *bboltStore) loadProxyHosts(tx *bbolt.Tx) []models.ProxyHost { var out []models.ProxyHost b := tx.Bucket([]byte(bucketProxyHosts)) if b == nil { return out } b.ForEach(func(_, v []byte) error { var h models.ProxyHost if json.Unmarshal(v, &h) == nil { out = append(out, h) } return nil }) return out } func (s *bboltStore) loadAccessLists(tx *bbolt.Tx) []models.AccessList { var out []models.AccessList b := tx.Bucket([]byte(bucketAccessLists)) if b == nil { return out } b.ForEach(func(_, v []byte) error { var al models.AccessList if json.Unmarshal(v, &al) == nil { out = append(out, al) } return nil }) return out } func (s *bboltStore) loadStreams(tx *bbolt.Tx) []models.Stream { var out []models.Stream b := tx.Bucket([]byte(bucketStreams)) if b == nil { return out } b.ForEach(func(_, v []byte) error { var st models.Stream if json.Unmarshal(v, &st) == nil { out = append(out, st) } return nil }) return out } func (s *bboltStore) loadCertificates(tx *bbolt.Tx) []models.Certificate { var out []models.Certificate b := tx.Bucket([]byte(bucketCertificates)) if b == nil { return out } b.ForEach(func(_, v []byte) error { var c models.Certificate if json.Unmarshal(v, &c) == nil { out = append(out, c) } return nil }) return out } func (s *bboltStore) loadRedirectionHosts(tx *bbolt.Tx) []models.RedirectionHost { var out []models.RedirectionHost b := tx.Bucket([]byte(bucketRedirectionHosts)) if b == nil { return out } b.ForEach(func(_, v []byte) error { var rh models.RedirectionHost if json.Unmarshal(v, &rh) == nil { out = append(out, rh) } return nil }) return out } func (s *bboltStore) loadDeadHosts(tx *bbolt.Tx) []models.DeadHost { var out []models.DeadHost b := tx.Bucket([]byte(bucketDeadHosts)) if b == nil { return out } b.ForEach(func(_, v []byte) error { var dh models.DeadHost if json.Unmarshal(v, &dh) == nil { out = append(out, dh) } return nil }) return out } func (s *bboltStore) loadAuditLogs(tx *bbolt.Tx) []models.AuditLog { var out []models.AuditLog b := tx.Bucket([]byte(bucketAuditLogs)) if b == nil { return out } b.ForEach(func(_, v []byte) error { var a models.AuditLog if json.Unmarshal(v, &a) == nil { out = append(out, a) } return nil }) return out } func (s *bboltStore) loadSettings(tx *bbolt.Tx) map[string]any { b := tx.Bucket([]byte(bucketSettings)) if b == nil { return map[string]any{} } data := b.Get([]byte("data")) if data == nil { return map[string]any{} } var m map[string]any json.Unmarshal(data, &m) if m == nil { return map[string]any{} } return m } // Compile-time check var _ Store = (*bboltStore)(nil)