initial commit
Format / gofmt (push) Failing after 27s
CI / Build (push) Successful in 51s
CI / Go Tests (push) Failing after 21s

This commit is contained in:
2026-06-06 07:54:44 -05:00
commit 1cc94f2c99
68 changed files with 14615 additions and 0 deletions
+854
View File
@@ -0,0 +1,854 @@
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, &current)
}
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)