mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-19 16:38:31 +00:00
- Cluster: forwardToNext offline detection (leader+non-leader), WatchLeader 1s heartbeat fallback, 409 for standalone nodes, Node.NodeKey key exchange via token ring, ClusterPeers persistence + auto-rejoin, Forward delegates to forwardToNext (bugfix) - Auth: Basic Auth (flag-creds fast path) + bcrypt users (admin/viewer) + Bearer API keys (read/write/admin scope) - Frontend: UsersView (accounts+API keys), ClusterView (ring/nodeKey/tasks/topology/log), StatusView (group management, per-proxy status), CanvasView (edge toggle/group), PortEdge (disabled/group labels) - API: handlers split (canvas/forwards/users/logs), canvas export/import, forwards group start/stop/assign/delete, cluster endpoints - Docs: comprehensive README rewrite (all flags/APIs/auth/cluster), docs/cluster-api.md (cluster management API reference) - Deploy: run-cluster.sh now 4-node ring + 1 isolated standalone, test-forward.sh updated for 4 nodes - Removed plan.md (design notes consolidated into README + API docs)
985 lines
36 KiB
Go
985 lines
36 KiB
Go
// Package store implements the persistence layer for webui-frpc.
|
|
package store
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// Local is a local forward service node on the canvas.
|
|
type Local struct {
|
|
Name string `json:"name"`
|
|
IP string `json:"ip"`
|
|
Port int `json:"port"`
|
|
Protocol string `json:"protocol"` // tcp | udp | http | https
|
|
|
|
// Advanced transport knobs (M1). All optional; empty/zero = frpc defaults.
|
|
UseEncryption bool `json:"useEncryption,omitempty"`
|
|
UseCompression bool `json:"useCompression,omitempty"`
|
|
BandwidthLimit string `json:"bandwidthLimit,omitempty"` // e.g. "1MB", "2KB"
|
|
PoolCount int `json:"poolCount,omitempty"`
|
|
Metadatas map[string]string `json:"metadatas,omitempty"`
|
|
Annotations map[string]string `json:"annotations,omitempty"`
|
|
|
|
// M2 HTTP/HTTPS routing (http/https only).
|
|
CustomDomains string `json:"customDomains,omitempty"` // comma-separated
|
|
SubDomain string `json:"subdomain,omitempty"`
|
|
Locations []string `json:"locations,omitempty"` // path routing
|
|
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
|
|
HTTPHeaders map[string]string `json:"httpHeaders,omitempty"`
|
|
BasicAuthUser string `json:"basicAuthUser,omitempty"`
|
|
BasicAuthPassword string `json:"basicAuthPassword,omitempty"`
|
|
|
|
// M3 Load balancing: group / groupKey turn this local into a group member.
|
|
// The frps load balances the group's proxies (many-to-one backend set).
|
|
LBGroup string `json:"lbGroup,omitempty"`
|
|
LBGroupKey string `json:"lbGroupKey,omitempty"`
|
|
// LocalOnly: when true the forward is created directly on this node
|
|
// (loopback stays 127.0.0.1), it does not enter the cluster token/topology,
|
|
// and is only visible in this node webui.
|
|
LocalOnly bool `json:"localOnly,omitempty"`
|
|
|
|
// Health check (frpc healthCheck): tcp probes LocalIP:LocalPort or an
|
|
// http GET to LocalIP:LocalPort + HealthCheckPath. When a group member
|
|
// fails its check, frpc stops routing to it; the server falls back to the
|
|
// surviving group members.
|
|
HealthCheckType string `json:"healthCheckType,omitempty"` // "" (off) | tcp | http
|
|
HealthCheckPath string `json:"healthCheckPath,omitempty"`
|
|
HealthCheckTimeout int `json:"healthCheckTimeout,omitempty"` // seconds, default 3
|
|
HealthCheckMaxFailed int `json:"healthCheckMaxFailed,omitempty"` // default 1
|
|
HealthCheckInterval int `json:"healthCheckInterval,omitempty"` // seconds, default 10
|
|
}
|
|
|
|
// Remote is a remote server node on the canvas.
|
|
type Remote struct {
|
|
Name string `json:"name"`
|
|
IP string `json:"ip"`
|
|
Port int `json:"port"` // port to connect to frps
|
|
Token string `json:"token,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
|
|
// Transport section (M1). Protocol: tcp | quic | kcp | websocket.
|
|
TransportProtocol string `json:"transportProtocol,omitempty"`
|
|
TransportTLS bool `json:"transportTls,omitempty"`
|
|
TransportPool int `json:"transportPool,omitempty"`
|
|
TransportTLSServerName string `json:"transportTlsServerName,omitempty"`
|
|
|
|
// M2: frps vhostHTTPPort (optional, displayed on the status page as the
|
|
// HTTP access port for http/https services).
|
|
VhostHTTPPort int `json:"vhostHttpPort,omitempty"`
|
|
|
|
// M3: local frpc admin API (webServer). When AdminPort > 0 the worker
|
|
// exposes GET /api/status so we can report true per-proxy state
|
|
// (including health check results) instead of guessing from logs.
|
|
AdminAddr string `json:"adminAddr,omitempty"` // listen address, default 127.0.0.1
|
|
AdminPort int `json:"adminPort,omitempty"`
|
|
AdminUser string `json:"adminUser,omitempty"`
|
|
AdminPassword string `json:"adminPassword,omitempty"`
|
|
}
|
|
|
|
// Link connects one local to one remote.
|
|
type Link struct {
|
|
ID int64 `json:"id,omitempty"`
|
|
Local string `json:"local"`
|
|
Remote string `json:"remote"`
|
|
RemotePort int `json:"remotePort"`
|
|
OffsetX int `json:"offsetX,omitempty"`
|
|
OffsetY int `json:"offsetY,omitempty"`
|
|
// Group is a user-facing management label for one-click group start/stop on
|
|
// the forwards page (unrelated to frps load-balancing LBGroup on Local).
|
|
Group string `json:"group,omitempty"`
|
|
// Disabled marks a forward as stopped. renderRemote skips it (so a stopped
|
|
// local-only forward drops just its own proxy), and applyCanvas reconciles
|
|
// to topology respecting it (disabled forwards are not re-submitted). This
|
|
// makes per-forward stop durable across canvas saves. Zero value = enabled.
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
}
|
|
|
|
// Settings holds runtime options.
|
|
type Settings struct {
|
|
AutoStartProfiles bool `json:"autoStartProfiles"`
|
|
RestartOnExit bool `json:"restartOnExit"`
|
|
RestartIntervalSeconds int `json:"restartIntervalSeconds"`
|
|
BinaryPath string `json:"binaryPath,omitempty"`
|
|
// NodeKey is this node's cluster admission key. A newcomer must present
|
|
// the sponsor's NodeKey to join via it (handleClusterJoin verifies).
|
|
// Generated on first startup, persisted, stable across restarts so the
|
|
// -join-key bootstrap path stays valid. Unrelated to the webui Basic-Auth
|
|
// creds (-user/-password), which remain the transport-level credential.
|
|
NodeKey string `json:"nodeKey,omitempty"`
|
|
// ClusterPeers is a JSON array of {addr,key} pairs for all known cluster
|
|
// peers, persisted on every token cycle. On crash/restart the node reads
|
|
// this and tries to rejoin via any cached peer (presenting that peer's
|
|
// key). Cleared on explicit detach (detachAsStandalone) so a node that
|
|
// intentionally left does NOT auto-rejoin.
|
|
ClusterPeers string `json:"clusterPeers,omitempty"`
|
|
}
|
|
|
|
// Forward is a rendered link row attached to a remote.
|
|
type Forward struct {
|
|
Service string `json:"service"`
|
|
RemotePort int `json:"remotePort"`
|
|
LocalPort int `json:"localPort,omitempty"`
|
|
OffsetX int `json:"offsetX,omitempty"`
|
|
OffsetY int `json:"offsetY,omitempty"`
|
|
// Disabled mirrors the link's Disabled so renderRemote can skip stopped
|
|
// forwards when building the frpc proxy list for a remote.
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
}
|
|
|
|
// User is an authenticated account. Role gates UI/API access (admin = full,
|
|
// viewer = read-only + exports, for auditors). System users are synced from
|
|
// the -user/-password flags and are read-only in the account-management UI.
|
|
type User struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
PasswordHash string `json:"-"` // never serialized to clients
|
|
Role string `json:"role"` // "admin" | "viewer"
|
|
Enabled bool `json:"enabled"`
|
|
System bool `json:"system"` // true = flag-synced, UI read-only
|
|
CreatedAt int64 `json:"createdAt"`
|
|
LastLoginAt int64 `json:"lastLoginAt"`
|
|
}
|
|
|
|
// ApiKey is a bearer token bound to a user with an explicit scope. The
|
|
// plaintext key is returned exactly once at creation; only its sha256 hash
|
|
// and an 8-char display prefix are persisted.
|
|
type ApiKey struct {
|
|
ID int64 `json:"id"`
|
|
UserID int64 `json:"userId"`
|
|
Prefix string `json:"prefix"` // first 8 chars of plaintext, for display
|
|
Label string `json:"label"`
|
|
Scope string `json:"scope"` // "read" | "write" | "admin"
|
|
CreatedAt int64 `json:"createdAt"`
|
|
LastUsedAt int64 `json:"lastUsedAt"`
|
|
ExpiresAt int64 `json:"expiresAt"` // 0 = never expires
|
|
}
|
|
|
|
const schema = `
|
|
CREATE TABLE IF NOT EXISTS locals (
|
|
name TEXT PRIMARY KEY,
|
|
ip TEXT NOT NULL,
|
|
port INTEGER NOT NULL,
|
|
protocol TEXT NOT NULL DEFAULT 'tcp',
|
|
local_only INTEGER NOT NULL DEFAULT 0,
|
|
use_encryption INTEGER NOT NULL DEFAULT 0,
|
|
use_compression INTEGER NOT NULL DEFAULT 0,
|
|
bandwidth_limit TEXT NOT NULL DEFAULT '',
|
|
pool_count INTEGER NOT NULL DEFAULT 0,
|
|
metadatas TEXT NOT NULL DEFAULT '',
|
|
annotations TEXT NOT NULL DEFAULT '',
|
|
custom_domains TEXT NOT NULL DEFAULT '',
|
|
subdomain TEXT NOT NULL DEFAULT '',
|
|
locations TEXT NOT NULL DEFAULT '',
|
|
host_header_rewrite TEXT NOT NULL DEFAULT '',
|
|
http_headers TEXT NOT NULL DEFAULT '',
|
|
basic_auth_user TEXT NOT NULL DEFAULT '',
|
|
basic_auth_password TEXT NOT NULL DEFAULT '',
|
|
lb_group TEXT NOT NULL DEFAULT '',
|
|
lb_group_key TEXT NOT NULL DEFAULT ''
|
|
);
|
|
CREATE TABLE IF NOT EXISTS remotes (
|
|
name TEXT PRIMARY KEY,
|
|
ip TEXT NOT NULL,
|
|
port INTEGER NOT NULL,
|
|
token TEXT NOT NULL DEFAULT '',
|
|
url TEXT NOT NULL DEFAULT '',
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
transport_protocol TEXT NOT NULL DEFAULT '',
|
|
transport_tls INTEGER NOT NULL DEFAULT 0,
|
|
transport_pool INTEGER NOT NULL DEFAULT 0,
|
|
transport_tls_server_name TEXT NOT NULL DEFAULT '',
|
|
vhost_http_port INTEGER NOT NULL DEFAULT 0,
|
|
admin_addr TEXT NOT NULL DEFAULT '',
|
|
admin_port INTEGER NOT NULL DEFAULT 0,
|
|
admin_user TEXT NOT NULL DEFAULT '',
|
|
admin_password TEXT NOT NULL DEFAULT ''
|
|
);
|
|
CREATE TABLE IF NOT EXISTS links (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
local TEXT NOT NULL REFERENCES locals(name) ON DELETE CASCADE,
|
|
remote TEXT NOT NULL REFERENCES remotes(name) ON DELETE CASCADE,
|
|
remote_port INTEGER NOT NULL DEFAULT 0,
|
|
offset_x INTEGER NOT NULL DEFAULT 0,
|
|
offset_y INTEGER NOT NULL DEFAULT 0,
|
|
grp TEXT NOT NULL DEFAULT '',
|
|
disabled INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_links_remote ON links(remote);
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'admin', -- 'admin' | 'viewer'
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
system INTEGER NOT NULL DEFAULT 0, -- 1 = synced from -user/-password flags, UI read-only
|
|
created_at INTEGER NOT NULL DEFAULT 0,
|
|
last_login_at INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE TABLE IF NOT EXISTS api_keys (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
key_hash TEXT UNIQUE NOT NULL, -- sha256(plaintext) hex
|
|
prefix TEXT NOT NULL DEFAULT '', -- first 8 chars of plaintext, for display
|
|
label TEXT NOT NULL DEFAULT '',
|
|
scope TEXT NOT NULL DEFAULT 'read', -- 'read' | 'write' | 'admin'
|
|
created_at INTEGER NOT NULL DEFAULT 0,
|
|
last_used_at INTEGER NOT NULL DEFAULT 0,
|
|
expires_at INTEGER NOT NULL DEFAULT 0 -- 0 = never expires
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id);
|
|
`
|
|
|
|
// Store is the SQLite persistence layer.
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// New opens the database at path, creating the schema if needed.
|
|
func New(path string) (*Store, error) {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return nil, fmt.Errorf("create dir: %w", err)
|
|
}
|
|
db, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
if _, err := db.Exec(schema); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("init schema: %w", err)
|
|
}
|
|
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("enable fk: %w", err)
|
|
}
|
|
st := &Store{db: db}
|
|
if err := st.migrate(); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("migrate schema: %w", err)
|
|
}
|
|
if err := os.Chmod(path, 0o600); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("secure db: %w", err)
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
// migrate adds columns introduced after the initial schema so old databases
|
|
// keep working (CREATE TABLE IF NOT EXISTS does not touch existing tables).
|
|
func (s *Store) migrate() error {
|
|
tables := map[string][]string{
|
|
"locals": {
|
|
"use_encryption INTEGER NOT NULL DEFAULT 0",
|
|
"use_compression INTEGER NOT NULL DEFAULT 0",
|
|
"bandwidth_limit TEXT NOT NULL DEFAULT ''",
|
|
"pool_count INTEGER NOT NULL DEFAULT 0",
|
|
"metadatas TEXT NOT NULL DEFAULT ''",
|
|
"annotations TEXT NOT NULL DEFAULT ''",
|
|
"custom_domains TEXT NOT NULL DEFAULT ''",
|
|
"subdomain TEXT NOT NULL DEFAULT ''",
|
|
"locations TEXT NOT NULL DEFAULT ''",
|
|
"host_header_rewrite TEXT NOT NULL DEFAULT ''",
|
|
"http_headers TEXT NOT NULL DEFAULT ''",
|
|
"basic_auth_user TEXT NOT NULL DEFAULT ''",
|
|
"basic_auth_password TEXT NOT NULL DEFAULT ''",
|
|
"lb_group TEXT NOT NULL DEFAULT ''",
|
|
"lb_group_key TEXT NOT NULL DEFAULT ''",
|
|
"health_check_type TEXT NOT NULL DEFAULT ''",
|
|
"health_check_path TEXT NOT NULL DEFAULT ''",
|
|
"health_check_timeout INTEGER NOT NULL DEFAULT 0",
|
|
"health_check_max_failed INTEGER NOT NULL DEFAULT 0",
|
|
"health_check_interval INTEGER NOT NULL DEFAULT 0",
|
|
"local_only INTEGER NOT NULL DEFAULT 0",
|
|
},
|
|
"remotes": {
|
|
"transport_protocol TEXT NOT NULL DEFAULT ''",
|
|
"transport_tls INTEGER NOT NULL DEFAULT 0",
|
|
"transport_pool INTEGER NOT NULL DEFAULT 0",
|
|
"transport_tls_server_name TEXT NOT NULL DEFAULT ''",
|
|
"vhost_http_port INTEGER NOT NULL DEFAULT 0",
|
|
"admin_addr TEXT NOT NULL DEFAULT ''",
|
|
"admin_port INTEGER NOT NULL DEFAULT 0",
|
|
"admin_user TEXT NOT NULL DEFAULT ''",
|
|
"admin_password TEXT NOT NULL DEFAULT ''",
|
|
},
|
|
"links": {
|
|
"grp TEXT NOT NULL DEFAULT ''",
|
|
"disabled INTEGER NOT NULL DEFAULT 0",
|
|
},
|
|
}
|
|
for table, cols := range tables {
|
|
rows, err := s.db.Query("PRAGMA table_info(" + table + ")")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
have := map[string]bool{}
|
|
for rows.Next() {
|
|
var cid int
|
|
var name, typ string
|
|
var notnull, pk int
|
|
var dflt sql.NullString
|
|
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
have[name] = true
|
|
}
|
|
rows.Close()
|
|
for _, def := range cols {
|
|
name := def[:strings.Index(def, " ")]
|
|
if !have[name] {
|
|
if _, err := s.db.Exec("ALTER TABLE " + table + " ADD COLUMN " + def); err != nil {
|
|
return fmt.Errorf("migrate %s.%s: %w", table, name, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) Close() error { return s.db.Close() }
|
|
|
|
// ---- Locals ----
|
|
|
|
func (s *Store) ListLocals() ([]Local, error) {
|
|
rows, err := s.db.Query("SELECT name, ip, port, protocol, local_only, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval FROM locals ORDER BY name")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Local
|
|
for rows.Next() {
|
|
var l Local
|
|
var enc, comp, localOnly int
|
|
var metaRaw, annoRaw, hdrRaw, locRaw string
|
|
if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &localOnly, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword, &l.LBGroup, &l.LBGroupKey, &l.HealthCheckType, &l.HealthCheckPath, &l.HealthCheckTimeout, &l.HealthCheckMaxFailed, &l.HealthCheckInterval); err != nil {
|
|
return nil, err
|
|
}
|
|
l.UseEncryption = enc != 0
|
|
l.UseCompression = comp != 0
|
|
l.LocalOnly = localOnly != 0
|
|
l.Metadatas = decodeMap(metaRaw)
|
|
l.Annotations = decodeMap(annoRaw)
|
|
l.Locations = decodeSlice(locRaw)
|
|
l.HTTPHeaders = decodeMap(hdrRaw)
|
|
out = append(out, l)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) GetLocal(name string) (Local, bool) {
|
|
var l Local
|
|
var enc, comp, localOnly int
|
|
var metaRaw, annoRaw, hdrRaw, locRaw string
|
|
row := s.db.QueryRow("SELECT name, ip, port, protocol, local_only, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval FROM locals WHERE name = ?", name)
|
|
if err := row.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &localOnly, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword, &l.LBGroup, &l.LBGroupKey, &l.HealthCheckType, &l.HealthCheckPath, &l.HealthCheckTimeout, &l.HealthCheckMaxFailed, &l.HealthCheckInterval); err != nil {
|
|
return Local{}, false
|
|
}
|
|
l.UseEncryption = enc != 0
|
|
l.UseCompression = comp != 0
|
|
l.LocalOnly = localOnly != 0
|
|
l.Metadatas = decodeMap(metaRaw)
|
|
l.Annotations = decodeMap(annoRaw)
|
|
l.Locations = decodeSlice(locRaw)
|
|
l.HTTPHeaders = decodeMap(hdrRaw)
|
|
return l, true
|
|
}
|
|
|
|
func (s *Store) UpsertLocal(l Local) error {
|
|
_, err := s.db.Exec(
|
|
"INSERT INTO locals(name, ip, port, protocol, local_only, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "+
|
|
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, protocol=excluded.protocol, local_only=excluded.local_only, use_encryption=excluded.use_encryption, use_compression=excluded.use_compression, bandwidth_limit=excluded.bandwidth_limit, pool_count=excluded.pool_count, metadatas=excluded.metadatas, annotations=excluded.annotations, custom_domains=excluded.custom_domains, subdomain=excluded.subdomain, locations=excluded.locations, host_header_rewrite=excluded.host_header_rewrite, http_headers=excluded.http_headers, basic_auth_user=excluded.basic_auth_user, basic_auth_password=excluded.basic_auth_password, lb_group=excluded.lb_group, lb_group_key=excluded.lb_group_key, health_check_type=excluded.health_check_type, health_check_path=excluded.health_check_path, health_check_timeout=excluded.health_check_timeout, health_check_max_failed=excluded.health_check_max_failed, health_check_interval=excluded.health_check_interval",
|
|
l.Name, l.IP, l.Port, l.Protocol, boolToInt(l.LocalOnly), boolToInt(l.UseEncryption), boolToInt(l.UseCompression), l.BandwidthLimit, l.PoolCount, encodeMap(l.Metadatas), encodeMap(l.Annotations), l.CustomDomains, l.SubDomain, encodeSlice(l.Locations), l.HostHeaderRewrite, encodeMap(l.HTTPHeaders), l.BasicAuthUser, l.BasicAuthPassword, l.LBGroup, l.LBGroupKey, l.HealthCheckType, l.HealthCheckPath, l.HealthCheckTimeout, l.HealthCheckMaxFailed, l.HealthCheckInterval,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// encodeSlice serializes an optional []string as JSON (empty -> "").
|
|
func encodeSlice(s []string) string {
|
|
if len(s) == 0 {
|
|
return ""
|
|
}
|
|
b, err := json.Marshal(s)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// decodeSlice reads a JSON []string column; empty/invalid -> nil.
|
|
func decodeSlice(raw string) []string {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
var s []string
|
|
if err := json.Unmarshal([]byte(raw), &s); err != nil {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
// encodeMap serializes an optional JSON map so nil stays an empty string.
|
|
func encodeMap(m map[string]string) string {
|
|
if len(m) == 0 {
|
|
return ""
|
|
}
|
|
b, err := json.Marshal(m)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// decodeMap reads a JSON map column; empty/invalid becomes nil.
|
|
func decodeMap(raw string) map[string]string {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
var m map[string]string
|
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
|
return nil
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (s *Store) DeleteLocal(name string) error {
|
|
_, err := s.db.Exec("DELETE FROM locals WHERE name = ?", name)
|
|
return err
|
|
}
|
|
|
|
// ---- Remotes ----
|
|
|
|
func (s *Store) ListRemotes() ([]Remote, error) {
|
|
rows, err := s.db.Query("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password FROM remotes ORDER BY name")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Remote
|
|
for rows.Next() {
|
|
var r Remote
|
|
var en, tls int
|
|
if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.VhostHTTPPort, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil {
|
|
return nil, err
|
|
}
|
|
r.Enabled = en != 0
|
|
r.TransportTLS = tls != 0
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) GetRemote(name string) (Remote, bool) {
|
|
var r Remote
|
|
var en, tls int
|
|
row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password FROM remotes WHERE name = ?", name)
|
|
if err := row.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.VhostHTTPPort, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil {
|
|
return Remote{}, false
|
|
}
|
|
r.Enabled = en != 0
|
|
r.TransportTLS = tls != 0
|
|
return r, true
|
|
}
|
|
|
|
func (s *Store) UpsertRemote(r Remote) error {
|
|
_, err := s.db.Exec(
|
|
"INSERT INTO remotes(name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "+
|
|
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, token=excluded.token, url=excluded.url, enabled=excluded.enabled, transport_protocol=excluded.transport_protocol, transport_tls=excluded.transport_tls, transport_pool=excluded.transport_pool, transport_tls_server_name=excluded.transport_tls_server_name, vhost_http_port=excluded.vhost_http_port, admin_addr=excluded.admin_addr, admin_port=excluded.admin_port, admin_user=excluded.admin_user, admin_password=excluded.admin_password",
|
|
r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled), r.TransportProtocol, boolToInt(r.TransportTLS), r.TransportPool, r.TransportTLSServerName, r.VhostHTTPPort, r.AdminAddr, r.AdminPort, r.AdminUser, r.AdminPassword,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) DeleteRemote(name string) error {
|
|
_, err := s.db.Exec("DELETE FROM remotes WHERE name = ?", name)
|
|
return err
|
|
}
|
|
|
|
// ---- Links ----
|
|
|
|
func (s *Store) ListLinks() ([]Link, error) {
|
|
rows, err := s.db.Query("SELECT id, local, remote, remote_port, offset_x, offset_y, grp, disabled FROM links")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Link
|
|
for rows.Next() {
|
|
var l Link
|
|
if err := rows.Scan(&l.ID, &l.Local, &l.Remote, &l.RemotePort, &l.OffsetX, &l.OffsetY, &l.Group, &l.Disabled); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, l)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// AddLink inserts a single link row and returns it with the new id filled in.
|
|
func (s *Store) AddLink(l Link) (Link, error) {
|
|
res, err := s.db.Exec(
|
|
"INSERT INTO links(local, remote, remote_port, offset_x, offset_y, grp, disabled) VALUES(?,?,?,?,?,?,?)",
|
|
l.Local, l.Remote, l.RemotePort, l.OffsetX, l.OffsetY, l.Group, l.Disabled,
|
|
)
|
|
if err != nil {
|
|
return Link{}, err
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
l.ID = id
|
|
return l, nil
|
|
}
|
|
|
|
// GetLink returns a single link by id.
|
|
func (s *Store) GetLink(id int64) (Link, bool) {
|
|
var l Link
|
|
err := s.db.QueryRow("SELECT id, local, remote, remote_port, offset_x, offset_y, grp, disabled FROM links WHERE id = ?", id).
|
|
Scan(&l.ID, &l.Local, &l.Remote, &l.RemotePort, &l.OffsetX, &l.OffsetY, &l.Group, &l.Disabled)
|
|
if err != nil {
|
|
return Link{}, false
|
|
}
|
|
return l, true
|
|
}
|
|
|
|
// DeleteLink removes a single link by id.
|
|
func (s *Store) DeleteLink(id int64) error {
|
|
_, err := s.db.Exec("DELETE FROM links WHERE id = ?", id)
|
|
return err
|
|
}
|
|
|
|
// LocalTarget describes one outgoing forward of a local service.
|
|
type LocalTarget struct {
|
|
Remote string `json:"remote"`
|
|
RemotePort int `json:"remotePort"`
|
|
}
|
|
|
|
// LinksForLocal returns the targets a local service forwards to.
|
|
func (s *Store) LinksForLocal(local string) ([]LocalTarget, error) {
|
|
links, err := s.ListLinks()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []LocalTarget
|
|
for _, l := range links {
|
|
if l.Local != local {
|
|
continue
|
|
}
|
|
out = append(out, LocalTarget{Remote: l.Remote, RemotePort: l.RemotePort})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// LinksForRemote returns forwards of one remote with local port resolved.
|
|
func (s *Store) LinksForRemote(remote string) ([]Forward, error) {
|
|
links, err := s.ListLinks()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []Forward
|
|
for _, l := range links {
|
|
if l.Remote != remote {
|
|
continue
|
|
}
|
|
loc, ok := s.GetLocal(l.Local)
|
|
if !ok {
|
|
continue
|
|
}
|
|
out = append(out, Forward{
|
|
Service: l.Local,
|
|
RemotePort: l.RemotePort,
|
|
LocalPort: loc.Port,
|
|
OffsetX: l.OffsetX,
|
|
OffsetY: l.OffsetY,
|
|
Disabled: l.Disabled,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ReplaceLinks clears all links and inserts the given set in one transaction.
|
|
func (s *Store) ReplaceLinks(links []Link) error {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if _, err := tx.Exec("DELETE FROM links"); err != nil {
|
|
return err
|
|
}
|
|
for _, l := range links {
|
|
if _, err := tx.Exec(
|
|
"INSERT INTO links(local, remote, remote_port, offset_x, offset_y, grp, disabled) VALUES(?,?,?,?,?,?,?)",
|
|
l.Local, l.Remote, l.RemotePort, l.OffsetX, l.OffsetY, l.Group, l.Disabled,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// SetLinkDisabled flips the disabled flag of a forward identified by its
|
|
// (local, remote, remotePort) natural key. This is the persistence half of the
|
|
// forwards-page start/stop toggle; the caller also drives the worker/ring side.
|
|
func (s *Store) SetLinkDisabled(local, remote string, port int, disabled bool) error {
|
|
_, err := s.db.Exec(
|
|
"UPDATE links SET disabled = ? WHERE local = ? AND remote = ? AND remote_port = ?",
|
|
disabled, local, remote, port,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// SetLinkGroup assigns a management group label to a forward identified by its
|
|
// (local, remote, remotePort) natural key. Empty string clears the group
|
|
// (moves the forward to 未分组). This is the persistence half of the
|
|
// status-page group chip edit; the canvas editor also writes group via
|
|
// saveCanvas. Group is for one-click start/stop on the forwards page only
|
|
// (unrelated to frps load-balancing lbGroup on Local).
|
|
func (s *Store) SetLinkGroup(local, remote string, port int, group string) error {
|
|
_, err := s.db.Exec(
|
|
"UPDATE links SET grp = ? WHERE local = ? AND remote = ? AND remote_port = ?",
|
|
group, local, remote, port,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// ---- Settings ----
|
|
|
|
func (s *Store) Settings() (Settings, error) {
|
|
var st Settings
|
|
row := s.db.QueryRow("SELECT value FROM settings WHERE key = 'settings'")
|
|
var raw string
|
|
if err := row.Scan(&raw); err != nil {
|
|
return Settings{AutoStartProfiles: true, RestartOnExit: true, RestartIntervalSeconds: 5}, nil
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &st); err != nil {
|
|
return Settings{AutoStartProfiles: true, RestartOnExit: true, RestartIntervalSeconds: 5}, nil
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
func (s *Store) UpdateSettings(st Settings) error {
|
|
raw, err := json.Marshal(st)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.db.Exec(
|
|
"INSERT INTO settings(key, value) VALUES('settings', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
string(raw),
|
|
)
|
|
return err
|
|
}
|
|
|
|
// SetNodeKey persists just the cluster admission key, preserving all other
|
|
// settings. Used on first startup when the key is generated.
|
|
func (s *Store) SetNodeKey(key string) error {
|
|
st, err := s.Settings()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
st.NodeKey = key
|
|
return s.UpdateSettings(st)
|
|
}
|
|
|
|
// SetClusterPeers persists the cached peer list (JSON) so a crashed node can
|
|
// auto-rejoin on restart. Pass "" to clear (explicit detach).
|
|
func (s *Store) SetClusterPeers(peersJSON string) error {
|
|
st, err := s.Settings()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
st.ClusterPeers = peersJSON
|
|
return s.UpdateSettings(st)
|
|
}
|
|
|
|
var (
|
|
ErrNotFound = errors.New("not found")
|
|
ErrInvalid = errors.New("invalid argument")
|
|
ErrAlreadyExists = errors.New("already exists")
|
|
)
|
|
|
|
func boolToInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// ---- Users ----
|
|
|
|
func (s *Store) ListUsers() ([]User, error) {
|
|
rows, err := s.db.Query("SELECT id, username, password_hash, role, enabled, system, created_at, last_login_at FROM users ORDER BY id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []User
|
|
for rows.Next() {
|
|
var u User
|
|
var en, sys int
|
|
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &en, &sys, &u.CreatedAt, &u.LastLoginAt); err != nil {
|
|
return nil, err
|
|
}
|
|
u.Enabled = en != 0
|
|
u.System = sys != 0
|
|
out = append(out, u)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) GetUser(username string) (User, bool) {
|
|
var u User
|
|
var en, sys int
|
|
row := s.db.QueryRow("SELECT id, username, password_hash, role, enabled, system, created_at, last_login_at FROM users WHERE username = ?", username)
|
|
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &en, &sys, &u.CreatedAt, &u.LastLoginAt); err != nil {
|
|
return User{}, false
|
|
}
|
|
u.Enabled = en != 0
|
|
u.System = sys != 0
|
|
return u, true
|
|
}
|
|
|
|
func (s *Store) GetUserByID(id int64) (User, bool) {
|
|
var u User
|
|
var en, sys int
|
|
row := s.db.QueryRow("SELECT id, username, password_hash, role, enabled, system, created_at, last_login_at FROM users WHERE id = ?", id)
|
|
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &en, &sys, &u.CreatedAt, &u.LastLoginAt); err != nil {
|
|
return User{}, false
|
|
}
|
|
u.Enabled = en != 0
|
|
u.System = sys != 0
|
|
return u, true
|
|
}
|
|
|
|
// CreateUser inserts a new user, hashing the plaintext password with bcrypt.
|
|
func (s *Store) CreateUser(username, plainPassword, role string) (User, error) {
|
|
if username == "" || plainPassword == "" {
|
|
return User{}, ErrInvalid
|
|
}
|
|
if role != "admin" && role != "viewer" {
|
|
return User{}, ErrInvalid
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return User{}, err
|
|
}
|
|
now := time.Now().Unix()
|
|
res, err := s.db.Exec(
|
|
"INSERT INTO users(username, password_hash, role, enabled, system, created_at, last_login_at) VALUES(?,?,?,1,0,?,0)",
|
|
username, string(hash), role, now,
|
|
)
|
|
if err != nil {
|
|
return User{}, err
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return User{
|
|
ID: id, Username: username, PasswordHash: string(hash),
|
|
Role: role, Enabled: true, System: false, CreatedAt: now,
|
|
}, nil
|
|
}
|
|
|
|
// UpdateUser modifies role/enabled and optionally resets the password.
|
|
// System (flag-synced) users refuse password changes.
|
|
func (s *Store) UpdateUser(id int64, role string, enabled bool, plainPassword string) error {
|
|
u, ok := s.GetUserByID(id)
|
|
if !ok {
|
|
return ErrNotFound
|
|
}
|
|
if role != "admin" && role != "viewer" {
|
|
return ErrInvalid
|
|
}
|
|
if u.System && plainPassword != "" {
|
|
return ErrInvalid
|
|
}
|
|
if plainPassword != "" {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.db.Exec(
|
|
"UPDATE users SET password_hash=?, role=?, enabled=? WHERE id=?",
|
|
string(hash), role, boolToInt(enabled), id,
|
|
)
|
|
return err
|
|
}
|
|
_, err := s.db.Exec(
|
|
"UPDATE users SET role=?, enabled=? WHERE id=?",
|
|
role, boolToInt(enabled), id,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// DeleteUser removes a user. System users are protected. Callers should guard
|
|
// the last remaining admin with CountAdmins before deleting an admin.
|
|
func (s *Store) DeleteUser(id int64) error {
|
|
u, ok := s.GetUserByID(id)
|
|
if !ok {
|
|
return ErrNotFound
|
|
}
|
|
if u.System {
|
|
return ErrInvalid
|
|
}
|
|
_, err := s.db.Exec("DELETE FROM users WHERE id = ?", id)
|
|
return err
|
|
}
|
|
|
|
// CountAdmins returns the count of enabled admin users (for the last-admin guard).
|
|
func (s *Store) CountAdmins() (int, error) {
|
|
var n int
|
|
err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'admin' AND enabled = 1").Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// SyncSystemUser upserts the flag-synced built-in admin account on every
|
|
// startup so -user/-password changes propagate to the users table. A
|
|
// pre-existing non-system row with the same name is left untouched; the
|
|
// flag-creds fallback in the auth middleware still authenticates it.
|
|
func (s *Store) SyncSystemUser(username, plainPassword string) error {
|
|
if username == "" || plainPassword == "" {
|
|
return ErrInvalid
|
|
}
|
|
existing, ok := s.GetUser(username)
|
|
if ok && !existing.System {
|
|
return nil
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := time.Now().Unix()
|
|
if ok {
|
|
_, err = s.db.Exec(
|
|
"UPDATE users SET password_hash=?, role='admin', enabled=1, system=1 WHERE id=?",
|
|
string(hash), existing.ID,
|
|
)
|
|
return err
|
|
}
|
|
_, err = s.db.Exec(
|
|
"INSERT INTO users(username, password_hash, role, enabled, system, created_at, last_login_at) VALUES(?,?,?,1,1,?,0)",
|
|
username, string(hash), "admin", now,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// TouchUserLogin records a successful login timestamp.
|
|
func (s *Store) TouchUserLogin(id int64) error {
|
|
_, err := s.db.Exec("UPDATE users SET last_login_at = ? WHERE id = ?", time.Now().Unix(), id)
|
|
return err
|
|
}
|
|
|
|
// VerifyUserPassword returns the user when the bcrypt hash matches. Used by
|
|
// the auth middleware's Basic branch.
|
|
func (s *Store) VerifyUserPassword(username, plainPassword string) (User, bool) {
|
|
u, ok := s.GetUser(username)
|
|
if !ok || !u.Enabled {
|
|
return User{}, false
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(plainPassword)) != nil {
|
|
return User{}, false
|
|
}
|
|
return u, true
|
|
}
|
|
|
|
// ---- API keys ----
|
|
|
|
func (s *Store) ListApiKeys() ([]ApiKey, error) {
|
|
rows, err := s.db.Query("SELECT id, user_id, prefix, label, scope, created_at, last_used_at, expires_at FROM api_keys ORDER BY id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []ApiKey
|
|
for rows.Next() {
|
|
var k ApiKey
|
|
if err := rows.Scan(&k.ID, &k.UserID, &k.Prefix, &k.Label, &k.Scope, &k.CreatedAt, &k.LastUsedAt, &k.ExpiresAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, k)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// CreateApiKey generates a 32-byte random key, stores its sha256 hash, and
|
|
// returns the plaintext exactly once.
|
|
func (s *Store) CreateApiKey(userID int64, label, scope string) (ApiKey, string, error) {
|
|
if scope != "read" && scope != "write" && scope != "admin" {
|
|
return ApiKey{}, "", ErrInvalid
|
|
}
|
|
if _, ok := s.GetUserByID(userID); !ok {
|
|
return ApiKey{}, "", ErrNotFound
|
|
}
|
|
raw := make([]byte, 32)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return ApiKey{}, "", err
|
|
}
|
|
// "w4f_" prefix makes keys greppable/recognizable; base64 RawURL = no padding.
|
|
plaintext := "w4f_" + base64.RawURLEncoding.EncodeToString(raw)
|
|
hash := HashApiKey(plaintext)
|
|
prefix := plaintext[:8]
|
|
now := time.Now().Unix()
|
|
res, err := s.db.Exec(
|
|
"INSERT INTO api_keys(user_id, key_hash, prefix, label, scope, created_at, last_used_at, expires_at) VALUES(?,?,?,?,?,?,0,0)",
|
|
userID, hash, prefix, label, scope, now,
|
|
)
|
|
if err != nil {
|
|
return ApiKey{}, "", err
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return ApiKey{
|
|
ID: id, UserID: userID, Prefix: prefix, Label: label,
|
|
Scope: scope, CreatedAt: now,
|
|
}, plaintext, nil
|
|
}
|
|
|
|
// LookupApiKey finds a key by the sha256 hex of its plaintext, validating
|
|
// expiry and the owning user's enabled flag. Used by the Bearer branch.
|
|
func (s *Store) LookupApiKey(hashHex string) (ApiKey, User, bool) {
|
|
var k ApiKey
|
|
row := s.db.QueryRow("SELECT id, user_id, prefix, label, scope, created_at, last_used_at, expires_at FROM api_keys WHERE key_hash = ?", hashHex)
|
|
if err := row.Scan(&k.ID, &k.UserID, &k.Prefix, &k.Label, &k.Scope, &k.CreatedAt, &k.LastUsedAt, &k.ExpiresAt); err != nil {
|
|
return ApiKey{}, User{}, false
|
|
}
|
|
if k.ExpiresAt != 0 && time.Now().Unix() > k.ExpiresAt {
|
|
return ApiKey{}, User{}, false
|
|
}
|
|
u, ok := s.GetUserByID(k.UserID)
|
|
if !ok || !u.Enabled {
|
|
return ApiKey{}, User{}, false
|
|
}
|
|
return k, u, true
|
|
}
|
|
|
|
// TouchApiKey records the last-used timestamp for a key.
|
|
func (s *Store) TouchApiKey(id int64) error {
|
|
_, err := s.db.Exec("UPDATE api_keys SET last_used_at = ? WHERE id = ?", time.Now().Unix(), id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) DeleteApiKey(id int64) error {
|
|
_, err := s.db.Exec("DELETE FROM api_keys WHERE id = ?", id)
|
|
return err
|
|
}
|
|
|
|
// HashApiKey computes the sha256 hex of a plaintext key (middleware helper).
|
|
func HashApiKey(plaintext string) string {
|
|
sum := sha256.Sum256([]byte(plaintext))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|