941 lines
27 KiB
Go
941 lines
27 KiB
Go
package store
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
type Store struct {
|
|
DB *sql.DB
|
|
}
|
|
|
|
type Tenant struct {
|
|
ID int64
|
|
Name string
|
|
Status int
|
|
Subnet string
|
|
CreatedAt int64
|
|
}
|
|
|
|
type AuditLog struct {
|
|
ID int64 `json:"id"`
|
|
ActorType string `json:"actor_type"`
|
|
ActorID string `json:"actor_id"`
|
|
Action string `json:"action"`
|
|
TargetType string `json:"target_type"`
|
|
TargetID string `json:"target_id"`
|
|
Detail string `json:"detail"`
|
|
IP string `json:"ip"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
}
|
|
|
|
type User struct {
|
|
ID int64
|
|
TenantID int64
|
|
Role string
|
|
Email string
|
|
PasswordHash string
|
|
Status int
|
|
CreatedAt int64
|
|
}
|
|
|
|
type APIKey struct {
|
|
ID int64
|
|
TenantID int64
|
|
Hash string
|
|
Scope string
|
|
ExpiresAt *int64
|
|
Status int
|
|
CreatedAt int64
|
|
Plain string
|
|
}
|
|
|
|
type NodeCredential struct {
|
|
NodeID int64
|
|
NodeUUID string
|
|
NodeName string
|
|
Alias string
|
|
Secret string
|
|
VirtualIP string
|
|
TenantID int64
|
|
Status int
|
|
CreatedAt int64
|
|
LastSeen *int64
|
|
}
|
|
|
|
type EnrollToken struct {
|
|
ID int64
|
|
TenantID int64
|
|
Hash string
|
|
ExpiresAt int64
|
|
UsedAt *int64
|
|
MaxAttempt int
|
|
Attempts int
|
|
Status int
|
|
CreatedAt int64
|
|
}
|
|
|
|
type SessionToken struct {
|
|
ID int64
|
|
UserID int64
|
|
TenantID int64
|
|
Role string
|
|
TokenHash string
|
|
ExpiresAt int64
|
|
Status int
|
|
CreatedAt int64
|
|
}
|
|
|
|
func Open(dbPath string) (*Store, error) {
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := db.Exec(`PRAGMA journal_mode=WAL;`); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := db.Exec(`PRAGMA foreign_keys=ON;`); err != nil {
|
|
return nil, err
|
|
}
|
|
s := &Store{DB: db}
|
|
if err := s.migrate(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.ensureSubnetPool(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.ensureSettings(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.backfillNodeIdentity(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Store) migrate() error {
|
|
stmts := []string{
|
|
`CREATE TABLE IF NOT EXISTS tenants (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
status INTEGER NOT NULL DEFAULT 1,
|
|
subnet TEXT NOT NULL UNIQUE,
|
|
created_at INTEGER NOT NULL
|
|
);`,
|
|
`CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
tenant_id INTEGER NOT NULL,
|
|
role TEXT NOT NULL,
|
|
email TEXT,
|
|
password_hash TEXT,
|
|
status INTEGER NOT NULL DEFAULT 1,
|
|
created_at INTEGER NOT NULL,
|
|
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
|
|
);`,
|
|
`CREATE TABLE IF NOT EXISTS api_keys (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
tenant_id INTEGER NOT NULL,
|
|
key_hash TEXT NOT NULL UNIQUE,
|
|
scope TEXT,
|
|
expires_at INTEGER,
|
|
status INTEGER NOT NULL DEFAULT 1,
|
|
created_at INTEGER NOT NULL,
|
|
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
|
|
);`,
|
|
`CREATE TABLE IF NOT EXISTS nodes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
tenant_id INTEGER NOT NULL,
|
|
node_uuid TEXT,
|
|
node_name TEXT NOT NULL,
|
|
alias TEXT DEFAULT '',
|
|
node_pubkey TEXT,
|
|
node_secret_hash TEXT,
|
|
virtual_ip TEXT,
|
|
status INTEGER NOT NULL DEFAULT 1,
|
|
last_seen INTEGER,
|
|
created_at INTEGER NOT NULL DEFAULT 0,
|
|
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
|
|
);`,
|
|
`CREATE TABLE IF NOT EXISTS enroll_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
tenant_id INTEGER NOT NULL,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
expires_at INTEGER NOT NULL,
|
|
used_at INTEGER,
|
|
max_attempt INTEGER NOT NULL DEFAULT 5,
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
status INTEGER NOT NULL DEFAULT 1,
|
|
created_at INTEGER NOT NULL,
|
|
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
|
|
);`,
|
|
`CREATE TABLE IF NOT EXISTS peering_policies (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
src_tenant_id INTEGER NOT NULL,
|
|
dst_tenant_id INTEGER NOT NULL,
|
|
rules TEXT,
|
|
expires_at INTEGER,
|
|
status INTEGER NOT NULL DEFAULT 1,
|
|
created_at INTEGER NOT NULL,
|
|
FOREIGN KEY(src_tenant_id) REFERENCES tenants(id),
|
|
FOREIGN KEY(dst_tenant_id) REFERENCES tenants(id)
|
|
);`,
|
|
`CREATE TABLE IF NOT EXISTS audit_logs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
actor_type TEXT,
|
|
actor_id TEXT,
|
|
action TEXT,
|
|
target_type TEXT,
|
|
target_id TEXT,
|
|
detail TEXT,
|
|
ip TEXT,
|
|
created_at INTEGER NOT NULL
|
|
);`,
|
|
`CREATE TABLE IF NOT EXISTS system_settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT,
|
|
updated_at INTEGER NOT NULL
|
|
);`,
|
|
`CREATE TABLE IF NOT EXISTS subnet_pool (
|
|
subnet TEXT PRIMARY KEY,
|
|
status INTEGER NOT NULL DEFAULT 0,
|
|
reserved INTEGER NOT NULL DEFAULT 0,
|
|
tenant_id INTEGER,
|
|
updated_at INTEGER NOT NULL
|
|
);`,
|
|
`CREATE TABLE IF NOT EXISTS session_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
tenant_id INTEGER NOT NULL,
|
|
role TEXT NOT NULL,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
expires_at INTEGER NOT NULL,
|
|
status INTEGER NOT NULL DEFAULT 1,
|
|
created_at INTEGER NOT NULL,
|
|
FOREIGN KEY(user_id) REFERENCES users(id),
|
|
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
|
|
);`,
|
|
}
|
|
for _, stmt := range stmts {
|
|
if _, err := s.DB.Exec(stmt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := s.DB.Exec(`ALTER TABLE nodes ADD COLUMN node_uuid TEXT`); err != nil && !strings.Contains(err.Error(), "duplicate column name") {
|
|
return err
|
|
}
|
|
if _, err := s.DB.Exec(`ALTER TABLE nodes ADD COLUMN alias TEXT DEFAULT ''`); err != nil && !strings.Contains(err.Error(), "duplicate column name") {
|
|
return err
|
|
}
|
|
if _, err := s.DB.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_node_uuid ON nodes(node_uuid)`); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) ensureSubnetPool() error {
|
|
// pool: 10.10.1.0/24 .. 10.10.254.0/24
|
|
// reserve: 10.10.0.0/24 and 10.10.255.0/24
|
|
rows, err := s.DB.Query(`SELECT COUNT(1) FROM subnet_pool;`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
var count int
|
|
if rows.Next() {
|
|
_ = rows.Scan(&count)
|
|
}
|
|
if count > 0 {
|
|
return nil
|
|
}
|
|
now := time.Now().Unix()
|
|
insert := `INSERT INTO subnet_pool(subnet,status,reserved,tenant_id,updated_at) VALUES(?,?,?,?,?)`
|
|
// reserved
|
|
_, _ = s.DB.Exec(insert, "10.10.0.0/24", 0, 1, nil, now)
|
|
_, _ = s.DB.Exec(insert, "10.10.255.0/24", 0, 1, nil, now)
|
|
for i := 1; i <= 254; i++ {
|
|
sn := fmt.Sprintf("10.10.%d.0/24", i)
|
|
_, _ = s.DB.Exec(insert, sn, 0, 0, nil, now)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) AllocateSubnet() (string, error) {
|
|
// find first available subnet
|
|
row := s.DB.QueryRow(`SELECT subnet FROM subnet_pool WHERE status=0 AND reserved=0 ORDER BY subnet LIMIT 1`)
|
|
var subnet string
|
|
if err := row.Scan(&subnet); err != nil {
|
|
return "", err
|
|
}
|
|
if subnet == "" {
|
|
return "", errors.New("no subnet available")
|
|
}
|
|
return subnet, nil
|
|
}
|
|
|
|
func (s *Store) CreateTenant(name string) (*Tenant, error) {
|
|
sn, err := s.AllocateSubnet()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now().Unix()
|
|
res, err := s.DB.Exec(`INSERT INTO tenants(name,status,subnet,created_at) VALUES(?,?,?,?)`, name, 1, sn, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
_, _ = s.DB.Exec(`UPDATE subnet_pool SET status=1, tenant_id=?, updated_at=? WHERE subnet=?`, id, now, sn)
|
|
return &Tenant{ID: id, Name: name, Status: 1, Subnet: sn, CreatedAt: now}, nil
|
|
}
|
|
|
|
func (s *Store) CreateTenantWithUsers(name, adminPassword, operatorPassword string) (*Tenant, *User, *User, error) {
|
|
if adminPassword == "" || operatorPassword == "" {
|
|
return nil, nil, nil, errors.New("password required")
|
|
}
|
|
ten, err := s.CreateTenant(name)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
admin, err := s.CreateUser(ten.ID, "admin", "admin@"+name, adminPassword, 1)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
op, err := s.CreateUser(ten.ID, "operator", "operator@"+name, operatorPassword, 1)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
return ten, admin, op, nil
|
|
}
|
|
|
|
func (s *Store) AllocateNodeIP(tenantID int64) (string, error) {
|
|
ten, err := s.GetTenantByID(tenantID)
|
|
if err != nil || ten == nil {
|
|
return "", errors.New("tenant not found")
|
|
}
|
|
ip, ipnet, err := net.ParseCIDR(ten.Subnet)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
base := ip.To4()
|
|
if base == nil {
|
|
return "", errors.New("only ipv4 subnet supported")
|
|
}
|
|
rows, err := s.DB.Query(`SELECT virtual_ip FROM nodes WHERE tenant_id=? AND virtual_ip IS NOT NULL AND virtual_ip<>''`, tenantID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer rows.Close()
|
|
used := map[string]bool{}
|
|
for rows.Next() {
|
|
var v string
|
|
if rows.Scan(&v) == nil && v != "" {
|
|
used[v] = true
|
|
}
|
|
}
|
|
for host := 2; host <= 254; host++ {
|
|
cand := net.IPv4(base[0], base[1], base[2], byte(host)).String()
|
|
if !ipnet.Contains(net.ParseIP(cand)) {
|
|
continue
|
|
}
|
|
if used[cand] {
|
|
continue
|
|
}
|
|
return cand, nil
|
|
}
|
|
return "", errors.New("no available ip")
|
|
}
|
|
|
|
func (s *Store) CreateNodeCredential(tenantID int64, nodeName string) (*NodeCredential, error) {
|
|
secret := randToken()
|
|
h := hashTokenString(secret)
|
|
now := time.Now().Unix()
|
|
nodeUUID := randUUID()
|
|
vip, err := s.AllocateNodeIP(tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res, err := s.DB.Exec(`INSERT INTO nodes(tenant_id,node_uuid,node_name,alias,node_secret_hash,virtual_ip,status,last_seen,created_at) VALUES(?,?,?,?,?,?,1,?,?)`, tenantID, nodeUUID, nodeName, "", h, vip, now, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return &NodeCredential{NodeID: id, NodeUUID: nodeUUID, NodeName: nodeName, Alias: "", Secret: secret, VirtualIP: vip, TenantID: tenantID, Status: 1, CreatedAt: now, LastSeen: &now}, nil
|
|
}
|
|
|
|
func (s *Store) VerifyNodeSecret(nodeName, secret string) (*Tenant, error) {
|
|
h := hashTokenString(secret)
|
|
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet,t.created_at FROM nodes n JOIN tenants t ON n.tenant_id=t.id WHERE n.node_name=? AND n.node_secret_hash=? AND n.status=1 AND t.status=1`, nodeName, h)
|
|
var t Tenant
|
|
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
func (s *Store) GetNodeCredentialByName(tenantID int64, nodeName string) (*NodeCredential, error) {
|
|
row := s.DB.QueryRow(`SELECT id,node_uuid,node_name,alias,virtual_ip,tenant_id,status,last_seen,created_at FROM nodes WHERE tenant_id=? AND node_name=? ORDER BY id DESC LIMIT 1`, tenantID, nodeName)
|
|
var n NodeCredential
|
|
var seen sql.NullInt64
|
|
if err := row.Scan(&n.NodeID, &n.NodeUUID, &n.NodeName, &n.Alias, &n.VirtualIP, &n.TenantID, &n.Status, &seen, &n.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if seen.Valid {
|
|
v := seen.Int64
|
|
n.LastSeen = &v
|
|
}
|
|
return &n, nil
|
|
}
|
|
|
|
func (s *Store) SetNodeAlias(tenantID int64, nodeUUID, alias string) error {
|
|
alias = strings.TrimSpace(alias)
|
|
res, err := s.DB.Exec(`UPDATE nodes SET alias=? WHERE tenant_id=? AND node_uuid=?`, alias, tenantID, nodeUUID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return errors.New("node not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) SetNodeVirtualIP(tenantID int64, nodeUUID, ip string) error {
|
|
ten, err := s.GetTenantByID(tenantID)
|
|
if err != nil || ten == nil {
|
|
return errors.New("tenant not found")
|
|
}
|
|
if net.ParseIP(ip) == nil {
|
|
return errors.New("invalid ip")
|
|
}
|
|
_, ipnet, err := net.ParseCIDR(ten.Subnet)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ipnet.Contains(net.ParseIP(ip)) {
|
|
return errors.New("ip not in subnet")
|
|
}
|
|
var c int
|
|
if err := s.DB.QueryRow(`SELECT COUNT(1) FROM nodes WHERE tenant_id=? AND virtual_ip=? AND node_uuid<>?`, tenantID, ip, nodeUUID).Scan(&c); err != nil {
|
|
return err
|
|
}
|
|
if c > 0 {
|
|
return errors.New("ip conflict")
|
|
}
|
|
res, err := s.DB.Exec(`UPDATE nodes SET virtual_ip=? WHERE tenant_id=? AND node_uuid=?`, ip, tenantID, nodeUUID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return errors.New("node not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) GetTenantByToken(token uint64) (*Tenant, error) {
|
|
h := hashToken(token)
|
|
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet,t.created_at FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1 AND t.status=1`, h)
|
|
var t Tenant
|
|
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
func (s *Store) GetTenantByID(id int64) (*Tenant, error) {
|
|
row := s.DB.QueryRow(`SELECT id,name,status,subnet,created_at FROM tenants WHERE id=?`, id)
|
|
var t Tenant
|
|
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
func (s *Store) CreateAPIKey(tenantID int64, scope string, ttl time.Duration) (string, error) {
|
|
token := randToken()
|
|
h := hashTokenString(token)
|
|
now := time.Now().Unix()
|
|
if ttl > 0 {
|
|
e := time.Now().Add(ttl)
|
|
_, err := s.DB.Exec(`INSERT INTO api_keys(tenant_id,key_hash,scope,expires_at,status,created_at) VALUES(?,?,?,?,1,?)`, tenantID, h, scope, e.Unix(), now)
|
|
return token, err
|
|
}
|
|
_, err := s.DB.Exec(`INSERT INTO api_keys(tenant_id,key_hash,scope,expires_at,status,created_at) VALUES(?,?,?,?,1,?)`, tenantID, h, scope, nil, now)
|
|
return token, err
|
|
}
|
|
|
|
func (s *Store) CreateEnrollToken(tenantID int64, ttl time.Duration, maxAttempt int) (string, error) {
|
|
code := randToken()
|
|
h := hashTokenString(code)
|
|
exp := time.Now().Add(ttl).Unix()
|
|
now := time.Now().Unix()
|
|
_, err := s.DB.Exec(`INSERT INTO enroll_tokens(tenant_id,token_hash,expires_at,max_attempt,attempts,status,created_at) VALUES(?,?,?,?,0,1,?)`, tenantID, h, exp, maxAttempt, now)
|
|
return code, err
|
|
}
|
|
|
|
func (s *Store) ConsumeEnrollToken(code string) (*EnrollToken, error) {
|
|
h := hashTokenString(code)
|
|
now := time.Now().Unix()
|
|
row := s.DB.QueryRow(`SELECT id,tenant_id,expires_at,used_at,max_attempt,attempts,status,created_at FROM enroll_tokens WHERE token_hash=?`, h)
|
|
var et EnrollToken
|
|
var used sql.NullInt64
|
|
if err := row.Scan(&et.ID, &et.TenantID, &et.ExpiresAt, &used, &et.MaxAttempt, &et.Attempts, &et.Status, &et.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if used.Valid {
|
|
return nil, errors.New("token already used")
|
|
}
|
|
if et.Status != 1 {
|
|
return nil, errors.New("token disabled")
|
|
}
|
|
if et.Attempts >= et.MaxAttempt {
|
|
return nil, errors.New("token attempts exceeded")
|
|
}
|
|
if now > et.ExpiresAt {
|
|
return nil, errors.New("token expired")
|
|
}
|
|
// mark used
|
|
_, err := s.DB.Exec(`UPDATE enroll_tokens SET used_at=?, attempts=attempts+1 WHERE id=?`, now, et.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
et.UsedAt = &now
|
|
return &et, nil
|
|
}
|
|
|
|
func (s *Store) IncEnrollAttempt(code string) {
|
|
h := hashTokenString(code)
|
|
_, _ = s.DB.Exec(`UPDATE enroll_tokens SET attempts=attempts+1 WHERE token_hash=?`, h)
|
|
}
|
|
|
|
func (s *Store) ensureSettings() error {
|
|
defaults := map[string]string{
|
|
"advanced_impersonate": "0",
|
|
"advanced_force_network": "0",
|
|
"advanced_cross_tenant": "0",
|
|
}
|
|
now := time.Now().Unix()
|
|
for k, v := range defaults {
|
|
_, _ = s.DB.Exec(`INSERT OR IGNORE INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, k, v, now)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) GetSetting(key string) (string, bool, error) {
|
|
row := s.DB.QueryRow(`SELECT value FROM system_settings WHERE key=?`, key)
|
|
var v string
|
|
if err := row.Scan(&v); err != nil {
|
|
return "", false, err
|
|
}
|
|
return v, true, nil
|
|
}
|
|
|
|
func (s *Store) ListSettings() (map[string]string, error) {
|
|
rows, err := s.DB.Query(`SELECT key,value FROM system_settings`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := map[string]string{}
|
|
for rows.Next() {
|
|
var k, v string
|
|
if err := rows.Scan(&k, &v); err == nil {
|
|
out[k] = v
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) SetSetting(key, value string) error {
|
|
now := time.Now().Unix()
|
|
_, err := s.DB.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, key, value, now)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) AddAuditLog(actorType, actorID, action, targetType, targetID, detail, ip string) error {
|
|
now := time.Now().Unix()
|
|
_, err := s.DB.Exec(`INSERT INTO audit_logs(actor_type,actor_id,action,target_type,target_id,detail,ip,created_at) VALUES(?,?,?,?,?,?,?,?)`, actorType, actorID, action, targetType, targetID, detail, ip, now)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) ListAuditLogs(tenantID int64, limit, offset int) ([]AuditLog, error) {
|
|
q := `SELECT id,actor_type,actor_id,action,target_type,target_id,detail,ip,created_at FROM audit_logs`
|
|
args := []any{}
|
|
if tenantID > 0 {
|
|
// limit to logs related to this tenant
|
|
q += ` WHERE (target_type='tenant' AND target_id=?)`
|
|
args = append(args, fmt.Sprintf("%d", tenantID))
|
|
}
|
|
q += ` ORDER BY id DESC`
|
|
if limit > 0 {
|
|
q += ` LIMIT ?`
|
|
args = append(args, limit)
|
|
}
|
|
if offset > 0 {
|
|
q += ` OFFSET ?`
|
|
args = append(args, offset)
|
|
}
|
|
rows, err := s.DB.Query(q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []AuditLog{}
|
|
for rows.Next() {
|
|
var a AuditLog
|
|
if err := rows.Scan(&a.ID, &a.ActorType, &a.ActorID, &a.Action, &a.TargetType, &a.TargetID, &a.Detail, &a.IP, &a.CreatedAt); err == nil {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ListTenants returns all tenants (admin)
|
|
func (s *Store) ListTenants() ([]Tenant, error) {
|
|
rows, err := s.DB.Query(`SELECT id,name,status,subnet,created_at FROM tenants ORDER BY id DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Tenant
|
|
for rows.Next() {
|
|
var t Tenant
|
|
if err := rows.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err == nil {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) UpdateTenantStatus(id int64, status int) error {
|
|
_, err := s.DB.Exec(`UPDATE tenants SET status=? WHERE id=?`, status, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) DeleteTenant(id int64) error {
|
|
_, err := s.DB.Exec(`DELETE FROM tenants WHERE id=?`, id)
|
|
return err
|
|
}
|
|
|
|
// ListAPIKeys returns api keys of a tenant (admin)
|
|
func (s *Store) ListAPIKeys(tenantID int64) ([]APIKey, error) {
|
|
rows, err := s.DB.Query(`SELECT id,tenant_id,key_hash,scope,expires_at,status,created_at FROM api_keys WHERE tenant_id=? ORDER BY id DESC`, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []APIKey
|
|
for rows.Next() {
|
|
var k APIKey
|
|
var exp sql.NullInt64
|
|
if err := rows.Scan(&k.ID, &k.TenantID, &k.Hash, &k.Scope, &exp, &k.Status, &k.CreatedAt); err == nil {
|
|
if exp.Valid {
|
|
v := exp.Int64
|
|
k.ExpiresAt = &v
|
|
}
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) UpdateAPIKeyStatus(id int64, status int) error {
|
|
_, err := s.DB.Exec(`UPDATE api_keys SET status=? WHERE id=?`, status, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) DeleteAPIKey(id int64) error {
|
|
_, err := s.DB.Exec(`DELETE FROM api_keys WHERE id=?`, id)
|
|
return err
|
|
}
|
|
|
|
// ListEnrollTokens returns enroll tokens for a tenant (admin)
|
|
func (s *Store) ListEnrollTokens(tenantID int64) ([]EnrollToken, error) {
|
|
rows, err := s.DB.Query(`SELECT id,tenant_id,token_hash,expires_at,used_at,max_attempt,attempts,status,created_at FROM enroll_tokens WHERE tenant_id=? ORDER BY id DESC`, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []EnrollToken
|
|
for rows.Next() {
|
|
var et EnrollToken
|
|
var used sql.NullInt64
|
|
if err := rows.Scan(&et.ID, &et.TenantID, &et.Hash, &et.ExpiresAt, &used, &et.MaxAttempt, &et.Attempts, &et.Status, &et.CreatedAt); err == nil {
|
|
if used.Valid {
|
|
v := used.Int64
|
|
et.UsedAt = &v
|
|
}
|
|
out = append(out, et)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) UpdateEnrollStatus(id int64, status int) error {
|
|
_, err := s.DB.Exec(`UPDATE enroll_tokens SET status=? WHERE id=?`, status, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) DeleteEnrollToken(id int64) error {
|
|
_, err := s.DB.Exec(`DELETE FROM enroll_tokens WHERE id=?`, id)
|
|
return err
|
|
}
|
|
|
|
func hashToken(token uint64) string {
|
|
b := make([]byte, 8)
|
|
for i := uint(0); i < 8; i++ {
|
|
b[7-i] = byte(token >> (i * 8))
|
|
}
|
|
return hashTokenBytes(b)
|
|
}
|
|
|
|
func hashTokenString(token string) string {
|
|
return hashTokenBytes([]byte(token))
|
|
}
|
|
|
|
func (s *Store) VerifyAPIKey(token string) (*Tenant, error) {
|
|
h := hashTokenString(token)
|
|
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet,t.created_at FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1 AND t.status=1`, h)
|
|
var t Tenant
|
|
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
func (s *Store) GetUserByTenant(tenantID int64) (*User, error) {
|
|
row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? ORDER BY id LIMIT 1`, tenantID)
|
|
var u User
|
|
if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return &u, nil
|
|
}
|
|
|
|
func (s *Store) GetUserByToken(token string) (*User, error) {
|
|
h := hashTokenString(token)
|
|
row := s.DB.QueryRow(`SELECT u.id,u.tenant_id,u.role,u.email,u.password_hash,u.status,u.created_at FROM api_keys k JOIN users u ON k.tenant_id=u.tenant_id WHERE k.key_hash=? AND k.status=1`, h)
|
|
var u User
|
|
if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return &u, nil
|
|
}
|
|
|
|
func (s *Store) GetUserByEmail(tenantID int64, email string) (*User, error) {
|
|
row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? AND email=? ORDER BY id LIMIT 1`, tenantID, email)
|
|
var u User
|
|
if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return &u, nil
|
|
}
|
|
|
|
func (s *Store) CreateUser(tenantID int64, role, email, password string, status int) (*User, error) {
|
|
now := time.Now().Unix()
|
|
var hash string
|
|
if password != "" {
|
|
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hash = string(b)
|
|
}
|
|
res, err := s.DB.Exec(`INSERT INTO users(tenant_id,role,email,password_hash,status,created_at) VALUES(?,?,?,?,?,?)`, tenantID, role, email, hash, status, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return &User{ID: id, TenantID: tenantID, Role: role, Email: email, PasswordHash: hash, Status: status, CreatedAt: now}, nil
|
|
}
|
|
|
|
func (s *Store) UserEmailExists(tenantID int64, email string) (bool, error) {
|
|
row := s.DB.QueryRow(`SELECT COUNT(1) FROM users WHERE tenant_id=? AND email=?`, tenantID, email)
|
|
var c int
|
|
if err := row.Scan(&c); err != nil {
|
|
return false, err
|
|
}
|
|
return c > 0, nil
|
|
}
|
|
|
|
func (s *Store) UserEmailExistsGlobal(email string) (bool, error) {
|
|
row := s.DB.QueryRow(`SELECT COUNT(1) FROM users WHERE email=?`, email)
|
|
var c int
|
|
if err := row.Scan(&c); err != nil {
|
|
return false, err
|
|
}
|
|
return c > 0, nil
|
|
}
|
|
|
|
func (s *Store) VerifyUserPassword(tenantID int64, email, password string) (*User, error) {
|
|
u, err := s.GetUserByEmail(tenantID, email)
|
|
if err != nil {
|
|
// compatibility: allow login by role name (admin/operator)
|
|
row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? AND role=? ORDER BY id LIMIT 1`, tenantID, email)
|
|
var byRole User
|
|
if scanErr := row.Scan(&byRole.ID, &byRole.TenantID, &byRole.Role, &byRole.Email, &byRole.PasswordHash, &byRole.Status, &byRole.CreatedAt); scanErr != nil {
|
|
return nil, err
|
|
}
|
|
u = &byRole
|
|
}
|
|
if u.PasswordHash == "" {
|
|
return nil, errors.New("password not set")
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
|
return nil, errors.New("invalid password")
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (s *Store) VerifyUserPasswordGlobal(email, password string) (*User, error) {
|
|
row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE email=? ORDER BY id LIMIT 1`, email)
|
|
var u User
|
|
if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if u.PasswordHash == "" {
|
|
return nil, errors.New("password not set")
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
|
return nil, errors.New("invalid password")
|
|
}
|
|
return &u, nil
|
|
}
|
|
|
|
func (s *Store) CreateSessionToken(userID, tenantID int64, role string, ttl time.Duration) (string, int64, error) {
|
|
tok := randToken()
|
|
h := hashTokenString(tok)
|
|
now := time.Now().Unix()
|
|
exp := time.Now().Add(ttl).Unix()
|
|
_, err := s.DB.Exec(`INSERT INTO session_tokens(user_id,tenant_id,role,token_hash,expires_at,status,created_at) VALUES(?,?,?,?,?,1,?)`, userID, tenantID, role, h, exp, now)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
return tok, exp, nil
|
|
}
|
|
|
|
func (s *Store) VerifySessionToken(token string) (*SessionToken, error) {
|
|
h := hashTokenString(token)
|
|
now := time.Now().Unix()
|
|
row := s.DB.QueryRow(`SELECT id,user_id,tenant_id,role,token_hash,expires_at,status,created_at FROM session_tokens WHERE token_hash=? AND status=1 AND expires_at>?`, h, now)
|
|
var st SessionToken
|
|
if err := row.Scan(&st.ID, &st.UserID, &st.TenantID, &st.Role, &st.TokenHash, &st.ExpiresAt, &st.Status, &st.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return &st, nil
|
|
}
|
|
|
|
func (s *Store) RevokeSessionToken(token string) error {
|
|
h := hashTokenString(token)
|
|
_, err := s.DB.Exec(`UPDATE session_tokens SET status=0 WHERE token_hash=?`, h)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) ListUsers(tenantID int64) ([]User, error) {
|
|
rows, err := s.DB.Query(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? ORDER BY id`, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []User{}
|
|
for rows.Next() {
|
|
var u User
|
|
if err := rows.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) UpdateUserStatus(id int64, status int) error {
|
|
_, err := s.DB.Exec(`UPDATE users SET status=? WHERE id=?`, status, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) UpdateUserPassword(id int64, password string) error {
|
|
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.DB.Exec(`UPDATE users SET password_hash=? WHERE id=?`, string(b), id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) UpdateUserEmail(id int64, email string) error {
|
|
_, err := s.DB.Exec(`UPDATE users SET email=? WHERE id=?`, email, id)
|
|
return err
|
|
}
|
|
|
|
func hashTokenBytes(b []byte) string {
|
|
h := sha256.Sum256(b)
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func randToken() string {
|
|
b := make([]byte, 24)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func randUUID() string {
|
|
b := make([]byte, 16)
|
|
_, _ = rand.Read(b)
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
return fmt.Sprintf("%s-%s-%s-%s-%s",
|
|
hex.EncodeToString(b[0:4]),
|
|
hex.EncodeToString(b[4:6]),
|
|
hex.EncodeToString(b[6:8]),
|
|
hex.EncodeToString(b[8:10]),
|
|
hex.EncodeToString(b[10:16]),
|
|
)
|
|
}
|
|
|
|
func (s *Store) backfillNodeIdentity() error {
|
|
rows, err := s.DB.Query(`SELECT id,tenant_id,node_uuid,virtual_ip FROM nodes ORDER BY id`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
type rowNode struct {
|
|
id int64
|
|
tenantID int64
|
|
uuid string
|
|
vip string
|
|
}
|
|
var list []rowNode
|
|
for rows.Next() {
|
|
var r rowNode
|
|
if err := rows.Scan(&r.id, &r.tenantID, &r.uuid, &r.vip); err == nil {
|
|
list = append(list, r)
|
|
}
|
|
}
|
|
for _, n := range list {
|
|
if n.uuid == "" {
|
|
if _, err := s.DB.Exec(`UPDATE nodes SET node_uuid=? WHERE id=?`, randUUID(), n.id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if strings.TrimSpace(n.vip) == "" {
|
|
vip, err := s.AllocateNodeIP(n.tenantID)
|
|
if err == nil && vip != "" {
|
|
_, _ = s.DB.Exec(`UPDATE nodes SET virtual_ip=? WHERE id=?`, vip, n.id)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// helper to avoid unused import (net)
|
|
var _ = net.IPv4len
|