initial commit
This commit is contained in:
@@ -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, ¤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)
|
||||
@@ -0,0 +1,110 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"helix-proxy/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDuplicateProxySource = errors.New("duplicate proxy host source domain")
|
||||
ErrDuplicateRedirectionSource = errors.New("duplicate redirection host source domain")
|
||||
ErrDuplicateDeadSource = errors.New("duplicate dead host source domain")
|
||||
ErrDuplicateStreamPort = errors.New("duplicate stream incoming port")
|
||||
)
|
||||
|
||||
func IsConflictError(err error) bool {
|
||||
return errors.Is(err, ErrDuplicateProxySource) ||
|
||||
errors.Is(err, ErrDuplicateRedirectionSource) ||
|
||||
errors.Is(err, ErrDuplicateDeadSource) ||
|
||||
errors.Is(err, ErrDuplicateStreamPort)
|
||||
}
|
||||
|
||||
type domainRecord struct {
|
||||
ID int
|
||||
DomainNames []string
|
||||
}
|
||||
|
||||
func normalizeSourceDomain(d string) (normalized, display string, ok bool) {
|
||||
display = strings.TrimSpace(d)
|
||||
if display == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return strings.ToLower(display), display, true
|
||||
}
|
||||
|
||||
func validateUniqueDomains(domainNames []string, selfID int, existing []domainRecord, errSentinel error) error {
|
||||
own := make(map[string]string)
|
||||
for _, d := range domainNames {
|
||||
norm, display, ok := normalizeSourceDomain(d)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if prev, dup := own[norm]; dup {
|
||||
return fmt.Errorf("%w: duplicate source domain: %s", errSentinel, prev)
|
||||
}
|
||||
own[norm] = display
|
||||
}
|
||||
|
||||
used := make(map[string]struct{})
|
||||
for _, rec := range existing {
|
||||
if rec.ID == selfID {
|
||||
continue
|
||||
}
|
||||
for _, d := range rec.DomainNames {
|
||||
norm, _, ok := normalizeSourceDomain(d)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
used[norm] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for norm, display := range own {
|
||||
if _, conflict := used[norm]; conflict {
|
||||
return fmt.Errorf("%w: source domain already in use: %s", errSentinel, display)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateProxyHostSources(hosts []models.ProxyHost, h models.ProxyHost) error {
|
||||
existing := make([]domainRecord, len(hosts))
|
||||
for i, host := range hosts {
|
||||
existing[i] = domainRecord{ID: host.ID, DomainNames: host.DomainNames}
|
||||
}
|
||||
return validateUniqueDomains(h.DomainNames, h.ID, existing, ErrDuplicateProxySource)
|
||||
}
|
||||
|
||||
func validateRedirectionHostSources(hosts []models.RedirectionHost, h models.RedirectionHost) error {
|
||||
existing := make([]domainRecord, len(hosts))
|
||||
for i, host := range hosts {
|
||||
existing[i] = domainRecord{ID: host.ID, DomainNames: host.DomainNames}
|
||||
}
|
||||
return validateUniqueDomains(h.DomainNames, h.ID, existing, ErrDuplicateRedirectionSource)
|
||||
}
|
||||
|
||||
func validateDeadHostSources(hosts []models.DeadHost, h models.DeadHost) error {
|
||||
existing := make([]domainRecord, len(hosts))
|
||||
for i, host := range hosts {
|
||||
existing[i] = domainRecord{ID: host.ID, DomainNames: host.DomainNames}
|
||||
}
|
||||
return validateUniqueDomains(h.DomainNames, h.ID, existing, ErrDuplicateDeadSource)
|
||||
}
|
||||
|
||||
func validateStreamIncomingPort(st models.Stream, streams []models.Stream) error {
|
||||
if st.IncomingPort <= 0 {
|
||||
return nil
|
||||
}
|
||||
for _, other := range streams {
|
||||
if other.ID == st.ID {
|
||||
continue
|
||||
}
|
||||
if other.IncomingPort == st.IncomingPort {
|
||||
return fmt.Errorf("%w: incoming port already in use: %d", ErrDuplicateStreamPort, st.IncomingPort)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package store
|
||||
|
||||
import "helix-proxy/internal/models"
|
||||
|
||||
// Convenience type aliases so that code importing "helix-proxy/internal/store"
|
||||
// can refer to the domain models as e.g. store.ProxyHost (historical API surface).
|
||||
type (
|
||||
Root = models.Root
|
||||
User = models.User
|
||||
ProxyHost = models.ProxyHost
|
||||
HeaderRule = models.HeaderRule
|
||||
Location = models.Location
|
||||
AccessList = models.AccessList
|
||||
AccessClient = models.AccessClient
|
||||
AccessItem = models.AccessItem
|
||||
Stream = models.Stream
|
||||
Certificate = models.Certificate
|
||||
RedirectionHost = models.RedirectionHost
|
||||
DeadHost = models.DeadHost
|
||||
AuditLog = models.AuditLog
|
||||
)
|
||||
|
||||
// New returns the default Store implementation (bbolt, a pure-Go
|
||||
// embedded transactional database). This is the recommended constructor for
|
||||
// normal operation.
|
||||
//
|
||||
// It provides real ACID transactions and good concurrency while remaining a
|
||||
// single static binary with no external database process.
|
||||
func New() (Store, error) {
|
||||
return NewBboltStore()
|
||||
}
|
||||
|
||||
// Store is the interface for all persistent state in Helix Proxy.
|
||||
//
|
||||
// We use bbolt (pure-Go embedded transactional key/value store) as the one and only
|
||||
// implementation. The interface exists primarily to:
|
||||
// - Make the rest of the code (engine, cert manager, API handlers) easy to test with fakes.
|
||||
// - Keep a clean boundary (in case we ever need to evolve the storage layer).
|
||||
//
|
||||
// All methods are safe for concurrent use.
|
||||
//
|
||||
// Models live in internal/models.
|
||||
|
||||
type Store interface {
|
||||
// --- meta / bootstrap ---
|
||||
IsSetup() bool
|
||||
GetUsers() []models.User
|
||||
|
||||
// --- ProxyHost ---
|
||||
GetProxyHosts() []models.ProxyHost
|
||||
GetProxyHost(id int) (models.ProxyHost, bool)
|
||||
CreateProxyHost(h models.ProxyHost) (models.ProxyHost, error)
|
||||
UpdateProxyHost(h models.ProxyHost) (models.ProxyHost, error)
|
||||
DeleteProxyHost(id int) error
|
||||
SetProxyHostEnabled(id int, enabled bool) error
|
||||
|
||||
// --- AccessList ---
|
||||
GetAccessLists() []models.AccessList
|
||||
GetAccessList(id int) (models.AccessList, bool)
|
||||
CreateAccessList(al models.AccessList) (models.AccessList, error)
|
||||
UpdateAccessList(al models.AccessList) (models.AccessList, error)
|
||||
DeleteAccessList(id int) error
|
||||
|
||||
// --- Stream ---
|
||||
GetStreams() []models.Stream
|
||||
GetStream(id int) (models.Stream, bool)
|
||||
CreateStream(st models.Stream) (models.Stream, error)
|
||||
UpdateStream(st models.Stream) (models.Stream, error)
|
||||
DeleteStream(id int) error
|
||||
SetStreamEnabled(id int, enabled bool) error
|
||||
|
||||
// --- Certificate ---
|
||||
GetCertificates() []models.Certificate
|
||||
GetCertificate(id int) (models.Certificate, bool)
|
||||
CreateCertificate(c models.Certificate) (models.Certificate, error)
|
||||
SetCertificate(c models.Certificate) error // used after LE issuance/renewal mutates meta+expires
|
||||
DeleteCertificate(id int) error
|
||||
|
||||
// --- RedirectionHost ---
|
||||
GetRedirectionHosts() []models.RedirectionHost
|
||||
GetRedirectionHost(id int) (models.RedirectionHost, bool)
|
||||
CreateRedirectionHost(h models.RedirectionHost) (models.RedirectionHost, error)
|
||||
UpdateRedirectionHost(h models.RedirectionHost) (models.RedirectionHost, error)
|
||||
DeleteRedirectionHost(id int) error
|
||||
SetRedirectionHostEnabled(id int, enabled bool) error
|
||||
|
||||
// --- DeadHost ---
|
||||
GetDeadHosts() []models.DeadHost
|
||||
GetDeadHost(id int) (models.DeadHost, bool)
|
||||
CreateDeadHost(h models.DeadHost) (models.DeadHost, error)
|
||||
UpdateDeadHost(h models.DeadHost) (models.DeadHost, error)
|
||||
DeleteDeadHost(id int) error
|
||||
SetDeadHostEnabled(id int, enabled bool) error
|
||||
|
||||
// --- Settings (global) ---
|
||||
GetSettings() map[string]any
|
||||
SetSetting(name string, value any) error
|
||||
|
||||
// --- Audit ---
|
||||
GetAuditLogs() []models.AuditLog
|
||||
AddAuditLog(log models.AuditLog) (models.AuditLog, error)
|
||||
|
||||
// --- Users + 2FA ---
|
||||
GetUserByEmail(email string) (models.User, bool)
|
||||
CreateUser(u models.User) (models.User, error)
|
||||
UpdateUser(u models.User) error
|
||||
UpdateUserFull(u models.User) error // used when updating without changing password
|
||||
DeleteUser(id int) error
|
||||
|
||||
// GetUsers avoids needing to fetch the entire root for user lists.
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"helix-proxy/internal/config"
|
||||
)
|
||||
|
||||
// --- test helpers ---
|
||||
|
||||
func newTestBbolt(t *testing.T) Store {
|
||||
t.Helper()
|
||||
tmp := t.TempDir()
|
||||
config.ResetForTest()
|
||||
t.Setenv("DATA_DIR", tmp)
|
||||
s, err := NewBboltStore()
|
||||
if err != nil {
|
||||
t.Fatalf("NewBboltStore: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// closeIfBbolt helps cleanup for bbolt (interface doesn't have Close but impl does)
|
||||
func closeIfBbolt(s Store) {
|
||||
if c, ok := s.(interface{ Close() error }); ok {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_SeedsAdminAndSettings(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
|
||||
if !s.IsSetup() {
|
||||
t.Error("IsSetup false after new")
|
||||
}
|
||||
users := s.GetUsers()
|
||||
if len(users) != 1 || users[0].Email != "admin@example.com" || users[0].ID != 1 {
|
||||
t.Errorf("seeded admin wrong: %+v", users)
|
||||
}
|
||||
settings := s.GetSettings()
|
||||
if settings["default_site"] != "congratulations" {
|
||||
t.Errorf("default settings wrong: %v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDGeneration(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
|
||||
h1, _ := s.CreateProxyHost(ProxyHost{DomainNames: []string{"a.com"}})
|
||||
h2, _ := s.CreateProxyHost(ProxyHost{DomainNames: []string{"b.com"}})
|
||||
if h1.ID == 0 || h2.ID == 0 || h2.ID <= h1.ID {
|
||||
t.Errorf("ids not increasing: %d %d", h1.ID, h2.ID)
|
||||
}
|
||||
if h1.CreatedOn == "" {
|
||||
t.Error("CreateProxyHost did not populate CreatedOn")
|
||||
}
|
||||
al1, _ := s.CreateAccessList(AccessList{Name: "t1"})
|
||||
if al1.CreatedOn == "" {
|
||||
t.Error("CreateAccessList did not populate CreatedOn")
|
||||
}
|
||||
st1, _ := s.CreateStream(Stream{IncomingPort: 1})
|
||||
if st1.CreatedOn == "" {
|
||||
t.Error("CreateStream did not populate CreatedOn")
|
||||
}
|
||||
// explicit id should be honored? (current impls assign only if 0)
|
||||
h3, _ := s.CreateProxyHost(ProxyHost{ID: 99, DomainNames: []string{"c.com"}})
|
||||
if h3.ID != 99 {
|
||||
t.Errorf("explicit id not kept: %d", h3.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHostCRUD(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
|
||||
// create
|
||||
h := ProxyHost{
|
||||
DomainNames: []string{"example.com", "www.example.com"},
|
||||
ForwardScheme: "https",
|
||||
ForwardHost: "10.0.0.1",
|
||||
ForwardPort: 8443,
|
||||
Enabled: true,
|
||||
Meta: map[string]any{"foo": "bar"},
|
||||
}
|
||||
created, err := s.CreateProxyHost(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.ID == 0 {
|
||||
t.Fatal("no id assigned")
|
||||
}
|
||||
if !reflect.DeepEqual(created.DomainNames, h.DomainNames) || created.Meta["foo"] != "bar" {
|
||||
t.Error("roundtrip fidelity lost on create")
|
||||
}
|
||||
|
||||
// get list / get
|
||||
list := s.GetProxyHosts()
|
||||
if len(list) != 1 {
|
||||
t.Errorf("list len %d", len(list))
|
||||
}
|
||||
got, ok := s.GetProxyHost(created.ID)
|
||||
if !ok || got.ID != created.ID {
|
||||
t.Error("get by id failed")
|
||||
}
|
||||
|
||||
// update
|
||||
created.ForwardHost = "10.0.0.2"
|
||||
created.Meta["new"] = 42
|
||||
up, err := s.UpdateProxyHost(created)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if up.ForwardHost != "10.0.0.2" || up.Meta["new"] != 42 {
|
||||
t.Error("update not persisted")
|
||||
}
|
||||
if up.CreatedOn == "" {
|
||||
t.Error("UpdateProxyHost did not preserve CreatedOn")
|
||||
}
|
||||
|
||||
// set enabled
|
||||
if err := s.SetProxyHostEnabled(created.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g2, _ := s.GetProxyHost(created.ID)
|
||||
if g2.Enabled {
|
||||
t.Error("set enabled false failed")
|
||||
}
|
||||
|
||||
// delete
|
||||
if err := s.DeleteProxyHost(created.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := s.GetProxyHost(created.ID); ok {
|
||||
t.Error("delete did not remove")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHostDuplicateSources(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
|
||||
h1, err := s.CreateProxyHost(ProxyHost{DomainNames: []string{"a.example.com"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = s.CreateProxyHost(ProxyHost{DomainNames: []string{"a.example.com"}})
|
||||
if !errors.Is(err, ErrDuplicateProxySource) {
|
||||
t.Fatalf("expected duplicate error on cross-host conflict, got %v", err)
|
||||
}
|
||||
|
||||
_, err = s.CreateProxyHost(ProxyHost{DomainNames: []string{"A.EXAMPLE.COM"}})
|
||||
if !errors.Is(err, ErrDuplicateProxySource) {
|
||||
t.Fatalf("expected duplicate error on case-insensitive conflict, got %v", err)
|
||||
}
|
||||
|
||||
_, err = s.CreateProxyHost(ProxyHost{DomainNames: []string{"b.example.com", "b.example.com"}})
|
||||
if !errors.Is(err, ErrDuplicateProxySource) {
|
||||
t.Fatalf("expected duplicate error within same host, got %v", err)
|
||||
}
|
||||
|
||||
h1.DomainNames = []string{"a.example.com", "c.example.com"}
|
||||
if _, err = s.UpdateProxyHost(h1); err != nil {
|
||||
t.Fatalf("update own domains: %v", err)
|
||||
}
|
||||
|
||||
h2, err := s.CreateProxyHost(ProxyHost{DomainNames: []string{"d.example.com"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h2.DomainNames = []string{"c.example.com"}
|
||||
_, err = s.UpdateProxyHost(h2)
|
||||
if !errors.Is(err, ErrDuplicateProxySource) {
|
||||
t.Fatalf("expected duplicate error on update conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectionHostDuplicateSources(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
|
||||
_, err := s.CreateRedirectionHost(RedirectionHost{DomainNames: []string{"redir.example.com"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = s.CreateRedirectionHost(RedirectionHost{DomainNames: []string{"redir.example.com"}})
|
||||
if !errors.Is(err, ErrDuplicateRedirectionSource) {
|
||||
t.Fatalf("expected duplicate error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeadHostDuplicateSources(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
|
||||
_, err := s.CreateDeadHost(DeadHost{DomainNames: []string{"dead.example.com"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = s.CreateDeadHost(DeadHost{DomainNames: []string{"DEAD.example.com"}})
|
||||
if !errors.Is(err, ErrDuplicateDeadSource) {
|
||||
t.Fatalf("expected duplicate error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamDuplicateIncomingPort(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
|
||||
_, err := s.CreateStream(Stream{IncomingPort: 9001, ForwardingHost: "127.0.0.1", ForwardingPort: 80})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = s.CreateStream(Stream{IncomingPort: 9001, ForwardingHost: "127.0.0.1", ForwardingPort: 8080})
|
||||
if !errors.Is(err, ErrDuplicateStreamPort) {
|
||||
t.Fatalf("expected duplicate port error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamUpdateDuplicateIncomingPort(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
|
||||
a, err := s.CreateStream(Stream{IncomingPort: 9001, ForwardingHost: "127.0.0.1", ForwardingPort: 80})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := s.CreateStream(Stream{IncomingPort: 9002, ForwardingHost: "127.0.0.1", ForwardingPort: 80})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.IncomingPort = 9001
|
||||
_, err = s.UpdateStream(b)
|
||||
if !errors.Is(err, ErrDuplicateStreamPort) {
|
||||
t.Fatalf("expected duplicate port error on update, got %v", err)
|
||||
}
|
||||
_ = a
|
||||
}
|
||||
|
||||
func TestAccessListCRUD(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
|
||||
al := AccessList{
|
||||
Name: "test al",
|
||||
SatisfyAny: true,
|
||||
Clients: []AccessClient{{Address: "1.2.3.4", Directive: "allow"}},
|
||||
Items: []AccessItem{{Username: "u", Password: "p"}},
|
||||
PassAuth: true,
|
||||
}
|
||||
created, err := s.CreateAccessList(al)
|
||||
if err != nil || created.ID == 0 {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if len(created.Clients) != 1 || len(created.Items) != 1 {
|
||||
t.Error("clients/items nilled?")
|
||||
}
|
||||
|
||||
// update
|
||||
created.Name = "renamed"
|
||||
up, err := s.UpdateAccessList(created)
|
||||
if err != nil || up.Name != "renamed" {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if up.CreatedOn == "" {
|
||||
t.Error("UpdateAccessList did not preserve CreatedOn")
|
||||
}
|
||||
|
||||
if err := s.DeleteAccessList(created.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := s.GetAccessList(created.ID); ok {
|
||||
t.Error("not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCRUD(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
|
||||
st := Stream{
|
||||
IncomingPort: 1234,
|
||||
ForwardingHost: "127.0.0.1",
|
||||
ForwardingPort: 5678,
|
||||
TCPForwarding: true,
|
||||
Enabled: true,
|
||||
Meta: map[string]any{"k": 1},
|
||||
}
|
||||
c, err := s.CreateStream(st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.ID == 0 || c.Meta == nil {
|
||||
t.Error("id or meta")
|
||||
}
|
||||
c.Enabled = false
|
||||
up, _ := s.UpdateStream(c)
|
||||
if up.CreatedOn == "" {
|
||||
t.Error("UpdateStream did not preserve CreatedOn")
|
||||
}
|
||||
if err := s.SetStreamEnabled(c.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, _ := s.GetStream(c.ID)
|
||||
if !g.Enabled {
|
||||
t.Error("set enabled")
|
||||
}
|
||||
_ = s.DeleteStream(c.ID)
|
||||
}
|
||||
|
||||
func TestCertificateCRUD(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
|
||||
c := Certificate{
|
||||
Provider: "letsencrypt",
|
||||
DomainNames: []string{"ex.com"},
|
||||
Meta: map[string]any{"cert": "pem"},
|
||||
}
|
||||
created, err := s.CreateCertificate(c)
|
||||
if err != nil || len(created.DomainNames) == 0 || created.Meta == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created.ExpiresOn = "2099-01-01"
|
||||
origCreated := created.CreatedOn
|
||||
if err := s.SetCertificate(created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, _ := s.GetCertificate(created.ID)
|
||||
if g.ExpiresOn != "2099-01-01" {
|
||||
t.Error("set cert")
|
||||
}
|
||||
if g.CreatedOn != origCreated {
|
||||
t.Error("SetCertificate did not preserve CreatedOn")
|
||||
}
|
||||
_ = s.DeleteCertificate(created.ID)
|
||||
}
|
||||
|
||||
func TestRedirectionDeadHostCRUD(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
|
||||
rh := RedirectionHost{DomainNames: []string{"r.com"}, ForwardDomainName: "t.com", Enabled: true, Meta: map[string]any{}}
|
||||
crh, _ := s.CreateRedirectionHost(rh)
|
||||
crh.ForwardHTTPCode = 301
|
||||
rup, _ := s.UpdateRedirectionHost(crh)
|
||||
if rup.CreatedOn == "" {
|
||||
t.Error("UpdateRedirectionHost did not preserve CreatedOn")
|
||||
}
|
||||
_ = s.SetRedirectionHostEnabled(crh.ID, false)
|
||||
_ = s.DeleteRedirectionHost(crh.ID)
|
||||
|
||||
dh := DeadHost{DomainNames: []string{"d.com"}, Enabled: true, Meta: map[string]any{}}
|
||||
cdh, _ := s.CreateDeadHost(dh)
|
||||
dup, _ := s.UpdateDeadHost(cdh)
|
||||
if dup.CreatedOn == "" {
|
||||
t.Error("UpdateDeadHost did not preserve CreatedOn")
|
||||
}
|
||||
_ = s.SetDeadHostEnabled(cdh.ID, false)
|
||||
_ = s.DeleteDeadHost(cdh.ID)
|
||||
}
|
||||
|
||||
func TestSettingsAudit(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
|
||||
if err := s.SetSetting("foo", "bar"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := s.GetSettings()
|
||||
if m["foo"] != "bar" {
|
||||
t.Errorf("set/get setting: %v", m)
|
||||
}
|
||||
|
||||
log := AuditLog{UserID: 1, ObjectType: "test", ObjectID: 99, Action: "create", Meta: map[string]any{"x": 1}}
|
||||
added, err := s.AddAuditLog(log)
|
||||
if err != nil || added.ID == 0 {
|
||||
t.Fatalf("add audit: %v", err)
|
||||
}
|
||||
if added.CreatedOn == "" {
|
||||
t.Error("AddAuditLog did not populate CreatedOn")
|
||||
}
|
||||
logs := s.GetAuditLogs()
|
||||
if len(logs) < 1 || logs[len(logs)-1].CreatedOn == "" {
|
||||
t.Error("no audit logs or missing CreatedOn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersCRUD(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
|
||||
u := User{Email: "new@example.com", Name: "New", Roles: []string{"user"}, Password: "pw"}
|
||||
created, err := s.CreateUser(u)
|
||||
if err != nil || created.ID == 0 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created.Name = "Renamed"
|
||||
if err := s.UpdateUser(created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.UpdateUserFull(created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
by, ok := s.GetUserByEmail("new@example.com")
|
||||
if !ok || by.Name != "Renamed" {
|
||||
t.Error("get by email or update")
|
||||
}
|
||||
if err := s.DeleteUser(created.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsersAndIsSetup(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
users := s.GetUsers()
|
||||
if len(users) == 0 {
|
||||
t.Error("no users")
|
||||
}
|
||||
if !s.IsSetup() {
|
||||
t.Error("not setup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFidelityMetaSlices(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
|
||||
ph := ProxyHost{DomainNames: nil, Meta: nil}
|
||||
c, _ := s.CreateProxyHost(ph)
|
||||
// impls normalize to non-nil
|
||||
if c.DomainNames == nil || c.Meta == nil {
|
||||
t.Error("should have non-nil slices/maps after create")
|
||||
}
|
||||
g, _ := s.GetProxyHost(c.ID)
|
||||
if g.DomainNames == nil {
|
||||
t.Error("get returned nil domains")
|
||||
}
|
||||
|
||||
// audit fidelity (CreatedOn + Meta)
|
||||
alog := AuditLog{UserID: 1, ObjectType: "t", ObjectID: 1, Action: "a", Meta: map[string]any{"m": 2}}
|
||||
added, _ := s.AddAuditLog(alog)
|
||||
if added.CreatedOn == "" || added.Meta["m"] != 2 {
|
||||
t.Error("audit CreatedOn/Meta fidelity")
|
||||
}
|
||||
gots := s.GetAuditLogs()
|
||||
if len(gots) == 0 || gots[len(gots)-1].Meta == nil {
|
||||
t.Error("get audit meta nil")
|
||||
}
|
||||
|
||||
// access list nested slices fidelity
|
||||
al := AccessList{Name: "al", Clients: []AccessClient{{Address: "1.2.3.4", Directive: "allow"}}, Items: []AccessItem{{Username: "u", Password: "p"}}}
|
||||
ca, _ := s.CreateAccessList(al)
|
||||
if len(ca.Clients) == 0 || len(ca.Items) == 0 {
|
||||
t.Error("access nested create")
|
||||
}
|
||||
ga, _ := s.GetAccessList(ca.ID)
|
||||
if len(ga.Items) == 0 || len(ga.Clients) == 0 {
|
||||
t.Error("access nested get")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrency(t *testing.T) {
|
||||
s := newTestBbolt(t)
|
||||
defer closeIfBbolt(s)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
n := 20
|
||||
var mu sync.Mutex
|
||||
ids := []int{}
|
||||
errs := []error{}
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
h, err := s.CreateProxyHost(ProxyHost{DomainNames: []string{"c" + strconv.Itoa(i) + ".com"}})
|
||||
_ = s.SetSetting("k", i)
|
||||
_, _ = s.AddAuditLog(AuditLog{Action: "cc"})
|
||||
mu.Lock()
|
||||
ids = append(ids, h.ID)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
mu.Unlock()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if len(errs) > 0 {
|
||||
t.Errorf("conc errs: %v", errs)
|
||||
}
|
||||
if len(ids) != n {
|
||||
t.Errorf("expected %d creates, got %d", n, len(ids))
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
t.Errorf("dup ID %d in conc creates", id)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
// final fidelity post-conc
|
||||
final := s.GetProxyHosts()
|
||||
if len(final) != n {
|
||||
t.Errorf("post-conc hosts count %d != %d", len(final), n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user