WGX shares its name with several other WireGuard tools, so the project becomes ihasvpn, alongside ihasmail. - Module github.com/Coffey-Labs/ihasvpn, command cmd/ihasvpn, image ghcr.io/coffey-labs/ihasvpn. - Environment variables move from WGX_* to IHASVPN_*. The default database is ihasvpn.db, the nftables table is `ihasvpn`, metrics are ihasvpn_*, and the session cookie and theme key are renamed, so existing sessions end. - The mark is the ihasmail cat peeking over the edge of a shield, drawn as a vector. docs/brand/generate.py builds the mark, mono mark, wordmarks, social card, favicons and app icons from that one drawing. - The console takes ihasmail's palette: the ihasmail.org teal-navy for dark, its contrast-checked light tiers with the site's light accent, received traffic in the cat's orange and sent in teal. The wordmark weight and font stack follow ihasmail.org. - Detail values wrap at spaces before breaking inside an address, so an IPv6 tunnel address no longer splits mid-number. - The README history note about the earlier WGX installer is gone with the name it explained. Screenshots retaken.
158 lines
4.9 KiB
Go
158 lines
4.9 KiB
Go
// Package store is the SQLite persistence layer. Everything ihasvpn remembers --
|
|
// peers and their keys, admin users, sessions, traffic history and the audit
|
|
// log -- lives in one file under the data directory.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// Store wraps the database handle.
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// Open opens (creating if needed) the database at path and applies the schema.
|
|
func Open(path string) (*Store, error) {
|
|
if path != ":memory:" {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return nil, fmt.Errorf("create data directory: %w", err)
|
|
}
|
|
}
|
|
dsn := path
|
|
if path != ":memory:" {
|
|
// The file holds private keys, so it is created unreadable to anyone
|
|
// but the owner. `_pragma` options ride along in the DSN.
|
|
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open database: %w", err)
|
|
}
|
|
f.Close()
|
|
dsn = "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)&_pragma=synchronous(NORMAL)"
|
|
} else {
|
|
dsn = "file::memory:?cache=shared&_pragma=foreign_keys(ON)"
|
|
}
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// One connection: SQLite serialises writers anyway and a single handle
|
|
// avoids "database is locked" surprises under WAL with the pure-Go driver.
|
|
db.SetMaxOpenConns(1)
|
|
s := &Store{db: db}
|
|
if err := s.migrate(context.Background()); err != nil {
|
|
db.Close()
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Close closes the database.
|
|
func (s *Store) Close() error { return s.db.Close() }
|
|
|
|
// DB exposes the handle for the rare caller that needs raw SQL (tests).
|
|
func (s *Store) DB() *sql.DB { return s.db }
|
|
|
|
const schema = `
|
|
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 NOT NULL UNIQUE COLLATE NOCASE,
|
|
password_hash TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'admin',
|
|
totp_secret TEXT,
|
|
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
|
created_at INTEGER NOT NULL,
|
|
last_login_at INTEGER
|
|
);
|
|
CREATE TABLE IF NOT EXISTS recovery_codes (
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
code_hash TEXT NOT NULL,
|
|
used_at INTEGER
|
|
);
|
|
CREATE INDEX IF NOT EXISTS recovery_codes_user ON recovery_codes(user_id);
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
token_hash TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
created_at INTEGER NOT NULL,
|
|
last_seen_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
ip TEXT NOT NULL DEFAULT '',
|
|
user_agent TEXT NOT NULL DEFAULT '',
|
|
totp_pending INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id);
|
|
CREATE TABLE IF NOT EXISTS peers (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
public_key TEXT NOT NULL UNIQUE,
|
|
private_key TEXT,
|
|
preshared_key TEXT,
|
|
ipv4 TEXT NOT NULL UNIQUE,
|
|
ipv6 TEXT UNIQUE,
|
|
client_routes TEXT NOT NULL,
|
|
dns TEXT NOT NULL DEFAULT '',
|
|
keepalive INTEGER NOT NULL DEFAULT 0,
|
|
mtu INTEGER NOT NULL DEFAULT 0,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
expires_at INTEGER,
|
|
notes TEXT NOT NULL DEFAULT '',
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
rx_total INTEGER NOT NULL DEFAULT 0,
|
|
tx_total INTEGER NOT NULL DEFAULT 0,
|
|
last_handshake INTEGER,
|
|
last_endpoint TEXT NOT NULL DEFAULT ''
|
|
);
|
|
CREATE TABLE IF NOT EXISTS traffic (
|
|
peer_id TEXT NOT NULL REFERENCES peers(id) ON DELETE CASCADE,
|
|
bucket_start INTEGER NOT NULL,
|
|
rx INTEGER NOT NULL DEFAULT 0,
|
|
tx INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (peer_id, bucket_start)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS traffic_bucket ON traffic(bucket_start);
|
|
CREATE TABLE IF NOT EXISTS audit (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
at INTEGER NOT NULL,
|
|
actor TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
target TEXT NOT NULL DEFAULT '',
|
|
detail TEXT NOT NULL DEFAULT '',
|
|
ip TEXT NOT NULL DEFAULT ''
|
|
);
|
|
CREATE INDEX IF NOT EXISTS audit_at ON audit(at);
|
|
`
|
|
|
|
func (s *Store) migrate(ctx context.Context) error {
|
|
if _, err := s.db.ExecContext(ctx, schema); err != nil {
|
|
return fmt.Errorf("apply schema: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetSetting returns the raw value of a key, or "" when unset.
|
|
func (s *Store) GetSetting(ctx context.Context, key string) (string, error) {
|
|
var v string
|
|
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
|
if err == sql.ErrNoRows {
|
|
return "", nil
|
|
}
|
|
return v, err
|
|
}
|
|
|
|
// SetSetting writes a key.
|
|
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
|
_, err := s.db.ExecContext(ctx, `INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
|
|
return err
|
|
}
|