diff --git a/cmd/server/main.go b/cmd/server/main.go index 33b1959..6c0c3a6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -26,7 +26,7 @@ func main() { "do not mark cookies Secure (plain-HTTP local testing only)") flag.Parse() - db, err := server.OpenDB(*dbPath) + db, err := server.OpenStore(*dbPath) if err != nil { log.Fatalf("open db: %v", err) } diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example index 0891fe9..66f0f5c 100644 --- a/deploy/nginx.conf.example +++ b/deploy/nginx.conf.example @@ -20,8 +20,8 @@ map $http_upgrade $connection_upgrade { } server { - listen 8445 ssl; - server_name REDACTED; + listen 443 ssl; + server_name lpra.minomix.ru; ssl_certificate /etc/letsencrypt/live/REDACTED/fullchain.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/REDACTED/privkey.pem; # managed by Certbot @@ -30,7 +30,7 @@ server { # Files go through the panel in 4 MB pieces; 64m leaves room for a client # that predates the pieces, which takes a file of up to 32 MB in one request. - client_max_body_size 64m; + client_max_body_size 120m; location / { proxy_pass http://127.0.0.1:8091; diff --git a/dist.zip b/dist.zip new file mode 100644 index 0000000..1b033fe Binary files /dev/null and b/dist.zip differ diff --git a/go.mod b/go.mod index 2d22caa..1108d6c 100644 --- a/go.mod +++ b/go.mod @@ -5,20 +5,25 @@ go 1.26 require ( github.com/coder/websocket v1.8.12 github.com/creack/pty v1.1.21 - golang.org/x/crypto v0.31.0 - golang.org/x/sys v0.28.0 + github.com/jackc/pgx/v5 v5.7.5 + golang.org/x/crypto v0.37.0 + golang.org/x/sys v0.32.0 modernc.org/sqlite v1.24.0 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/mattn/go-isatty v0.0.16 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/mod v0.3.0 // indirect - golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect - golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/text v0.24.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect lukechampine.com/uint128 v1.2.0 // indirect modernc.org/cc/v3 v3.40.0 // indirect modernc.org/ccgo/v3 v3.16.13 // indirect diff --git a/go.sum b/go.sum index e75b7a1..efa1826 100644 --- a/go.sum +++ b/go.sum @@ -2,14 +2,25 @@ github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NA github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= @@ -21,35 +32,30 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs= -golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= diff --git a/internal/server/db.go b/internal/server/db.go index 0a51fe3..bcc7d7a 100644 --- a/internal/server/db.go +++ b/internal/server/db.go @@ -11,7 +11,7 @@ import ( // DB wraps the SQLite connection and all queries used by the server. type DB struct { - sql *sql.DB + sql *dbx path string // the file; the key of saved secrets sits beside it } @@ -31,7 +31,7 @@ func OpenDB(path string) (*DB, error) { if err := sdb.Ping(); err != nil { return nil, err } - db := &DB{sql: sdb, path: path} + db := &DB{sql: &dbx{DB: sdb}, path: path} if err := db.migrate(); err != nil { return nil, err } @@ -131,7 +131,7 @@ CREATE TABLE IF NOT EXISTS forward_rules ( `CREATE TABLE IF NOT EXISTS ssh_hosts ( id INTEGER PRIMARY KEY AUTOINCREMENT, admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE, - label TEXT NOT NULL, host TEXT NOT NULL, port INTEGER NOT NULL, user TEXT NOT NULL, + label TEXT NOT NULL, host TEXT NOT NULL, port INTEGER NOT NULL, login TEXT NOT NULL, auth TEXT NOT NULL, secret TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS admin_agents ( admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE, @@ -142,6 +142,10 @@ CREATE TABLE IF NOT EXISTS forward_rules ( return err } } + // ssh_hosts.user (a word PostgreSQL keeps for itself) became login. + if !db.sql.pg { + _, _ = db.sql.Exec(`ALTER TABLE ssh_hosts RENAME COLUMN user TO login`) + } return nil } @@ -178,13 +182,9 @@ func (db *DB) CreateAdmin(username, passHash string) error { // CreateUser adds a panel user with the role given and returns its id. func (db *DB) CreateUser(username, passHash, role string) (int64, error) { - res, err := db.sql.Exec( + return db.sql.insert( `INSERT INTO admins(username, pass_hash, created_at, role) VALUES(?, ?, ?, ?)`, username, passHash, time.Now().Unix(), role) - if err != nil { - return 0, err - } - return res.LastInsertId() } // Roles of panel users: an admin sees and runs everything; a user only the @@ -595,14 +595,10 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) { if r.Proto == "" { r.Proto = "http" } - res, err := db.sql.Exec( + return db.sql.insert( `INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, no_ufw, label, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?)`, - r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, r.NoUfw, r.Label, time.Now().Unix()) - if err != nil { - return 0, err - } - return res.LastInsertId() + r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, boolInt(r.NoUfw), r.Label, time.Now().Unix()) } func (db *DB) ListForwardRules() ([]ForwardRule, error) { diff --git a/internal/server/dbpg.go b/internal/server/dbpg.go new file mode 100644 index 0000000..6abd3ea --- /dev/null +++ b/internal/server/dbpg.go @@ -0,0 +1,245 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +// The panel keeps its data in SQLite by default, or in PostgreSQL when the +// admins choose so in Настройки. The choice lives in a small file beside the +// SQLite database (.backend.json, readable by the server only) and +// takes effect on the next start; the first start on an empty PostgreSQL +// copies everything over from the SQLite file, which stays as it was. + +type PGConf struct { + Host string `json:"host"` + Port int `json:"port"` + Name string `json:"name"` + User string `json:"user"` + Password string `json:"password"` + SSLMode string `json:"sslmode"` +} + +type backendConf struct { + Type string `json:"type"` // sqlite or postgres + PG *PGConf `json:"pg,omitempty"` +} + +func (c PGConf) DSN() string { + port := c.Port + if port == 0 { + port = 5432 + } + ssl := c.SSLMode + if ssl == "" { + ssl = "prefer" + } + u := url.URL{Scheme: "postgres", User: url.UserPassword(c.User, c.Password), Host: c.Host + ":" + strconv.Itoa(port), Path: "/" + c.Name} + u.RawQuery = url.Values{"sslmode": {ssl}, "connect_timeout": {"10"}}.Encode() + return u.String() +} + +func backendPath(dbPath string) string { return dbPath + ".backend.json" } + +func readBackend(dbPath string) backendConf { + var c backendConf + if b, err := os.ReadFile(backendPath(dbPath)); err == nil { + _ = json.Unmarshal(b, &c) + } + if c.Type != "postgres" || c.PG == nil { + c = backendConf{Type: "sqlite"} + } + return c +} + +func writeBackend(dbPath string, c backendConf) error { + b, _ := json.MarshalIndent(c, "", " ") + return os.WriteFile(backendPath(dbPath), b, 0o600) +} + +// OpenStore opens the database the admins chose: SQLite at path, or the +// PostgreSQL named beside it. +func OpenStore(path string) (*DB, error) { + c := readBackend(path) + if c.Type == "postgres" { + return OpenPostgres(*c.PG, path) + } + return OpenDB(path) +} + +// pingPG checks that PostgreSQL c answers. +func pingPG(c PGConf) error { + d, err := sql.Open("pgx", c.DSN()) + if err != nil { + return err + } + defer d.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return d.PingContext(ctx) +} + +// OpenPostgres opens PostgreSQL c, brings its tables up to date and, when it +// has no users yet, copies over the SQLite database at sqlitePath. +func OpenPostgres(c PGConf, sqlitePath string) (*DB, error) { + d, err := sql.Open("pgx", c.DSN()) + if err != nil { + return nil, err + } + d.SetMaxOpenConns(16) + d.SetMaxIdleConns(4) + d.SetConnMaxIdleTime(5 * time.Minute) + if err := d.Ping(); err != nil { + return nil, fmt.Errorf("PostgreSQL: %w", err) + } + db := &DB{sql: &dbx{DB: d, pg: true}, path: sqlitePath} + if err := db.migrate(); err != nil { + return nil, fmt.Errorf("PostgreSQL: %w", err) + } + var n int + _ = db.sql.QueryRow(`SELECT COUNT(*) FROM admins`).Scan(&n) + if _, err := os.Stat(sqlitePath); n == 0 && err == nil { + src, err := OpenDB(sqlitePath) + if err != nil { + return nil, err + } + defer src.Close() + if err := copyDB(src, db); err != nil { + return nil, fmt.Errorf("перенос из SQLite: %w", err) + } + log.Printf("db: data copied from %s to PostgreSQL", sqlitePath) + } + return db, nil +} + +// copyTables are copied parents first, for the foreign keys. +var copyTables = []string{"config", "admins", "agents", "admin_sessions", "audit", "ufw_rules", "admin_keys", + "forward_rules", "admin_agents", "ssh_hosts", "ai_messages"} + +// copyDB copies every row of src into dst (an empty PostgreSQL), in one +// transaction, then moves the id counters past the copied ids. +func copyDB(src, dst *DB) error { + tx, err := dst.sql.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, t := range copyTables { + rows, err := src.sql.Query(`SELECT * FROM ` + t) + if err != nil { + return err + } + cols, _ := rows.Columns() + ph := make([]string, len(cols)) + for i := range ph { + ph[i] = "$" + strconv.Itoa(i+1) + } + ins := `INSERT INTO ` + t + ` (` + strings.Join(cols, ", ") + `) VALUES (` + strings.Join(ph, ", ") + `)` + for rows.Next() { + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rows.Scan(ptrs...); err != nil { + rows.Close() + return err + } + if _, err := tx.Exec(ins, vals...); err != nil { + rows.Close() + return fmt.Errorf("%s: %w", t, err) + } + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + } + for _, t := range copyTables { + var seq sql.NullString + _ = tx.QueryRow(`SELECT pg_get_serial_sequence($1, 'id')`, t).Scan(&seq) + if seq.Valid { + if _, err := tx.Exec(`SELECT setval($1, COALESCE((SELECT MAX(id) FROM `+t+`), 0) + 1, false)`, seq.String); err != nil { + return err + } + } + } + return tx.Commit() +} + +// Backend tells which database is in use and which is chosen for the next +// start. +func (db *DB) Backend() (now string, chosen backendConf) { + now = "sqlite" + if db.sql.pg { + now = "postgres" + } + return now, readBackend(db.path) +} + +var errNoPGHost = errors.New("укажите хост, базу и пользователя PostgreSQL") + +// POST /settings/db - the database for the next start; PostgreSQL is tried +// first and kept only if it answers. +func (s *Server) handleSetDB(w http.ResponseWriter, r *http.Request) { + if !checkCSRF(r) { + http.Error(w, "bad csrf", http.StatusForbidden) + return + } + back := func(q string) { http.Redirect(w, r, "/settings?"+q+"#db", http.StatusSeeOther) } + if r.FormValue("db_type") != "postgres" { + if err := writeBackend(s.db.path, backendConf{Type: "sqlite"}); err != nil { + back("err=" + url.QueryEscape(err.Error())) + return + } + back("db=saved") + return + } + old := readBackend(s.db.path).PG + c := PGConf{Host: strings.TrimSpace(r.FormValue("pg_host")), Name: strings.TrimSpace(r.FormValue("pg_name")), + User: strings.TrimSpace(r.FormValue("pg_user")), Password: r.FormValue("pg_password"), SSLMode: r.FormValue("pg_sslmode")} + c.Port, _ = strconv.Atoi(r.FormValue("pg_port")) + if c.Password == "" && old != nil { + c.Password = old.Password + } + if c.Host == "" || c.Name == "" || c.User == "" { + back("err=" + url.QueryEscape(errNoPGHost.Error())) + return + } + if err := pingPG(c); err != nil { + back("err=" + url.QueryEscape("PostgreSQL не отвечает: "+err.Error())) + return + } + if err := writeBackend(s.db.path, backendConf{Type: "postgres", PG: &c}); err != nil { + back("err=" + url.QueryEscape(err.Error())) + return + } + back("db=saved") +} + +// POST /settings/db/restart - the server ends; its service (systemd, +// Restart=always) starts it again on the database chosen. +func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) { + if !checkCSRF(r) { + http.Error(w, "bad csrf", http.StatusForbidden) + return + } + log.Printf("restart asked from the panel") + http.Redirect(w, r, "/settings?restarted=1#db", http.StatusSeeOther) + go func() { + time.Sleep(700 * time.Millisecond) + os.Exit(0) + }() +} diff --git a/internal/server/dbx.go b/internal/server/dbx.go new file mode 100644 index 0000000..42066a3 --- /dev/null +++ b/internal/server/dbx.go @@ -0,0 +1,93 @@ +package server + +import ( + "database/sql" + "strconv" + "strings" +) + +// dbx is the database the server talks to: SQLite, or PostgreSQL. The +// queries are written once, for SQLite; on PostgreSQL each is rewritten +// (pgSQL) on its way. +type dbx struct { + *sql.DB + pg bool +} + +func (d *dbx) q(query string) string { + if d.pg { + return pgSQL(query) + } + return query +} + +func (d *dbx) Exec(query string, args ...any) (sql.Result, error) { + return d.DB.Exec(d.q(query), args...) +} +func (d *dbx) Query(query string, args ...any) (*sql.Rows, error) { + return d.DB.Query(d.q(query), args...) +} +func (d *dbx) QueryRow(query string, args ...any) *sql.Row { + return d.DB.QueryRow(d.q(query), args...) +} +func (d *dbx) Begin() (*txx, error) { + tx, err := d.DB.Begin() + return &txx{Tx: tx, pg: d.pg}, err +} + +// insert runs an INSERT and gives the new row's id (LastInsertId on SQLite, +// RETURNING id on PostgreSQL, which has no LastInsertId). +func (d *dbx) insert(query string, args ...any) (int64, error) { + if d.pg { + var id int64 + err := d.DB.QueryRow(pgSQL(query)+" RETURNING id", args...).Scan(&id) + return id, err + } + res, err := d.DB.Exec(query, args...) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +type txx struct { + *sql.Tx + pg bool +} + +func (t *txx) Exec(query string, args ...any) (sql.Result, error) { + if t.pg { + query = pgSQL(query) + } + return t.Tx.Exec(query, args...) +} + +// pgSQL rewrites a query for SQLite into PostgreSQL's: $n placeholders, +// INSERT OR IGNORE as ON CONFLICT DO NOTHING, and in table definitions +// BIGSERIAL keys, BIGINT numbers (times are Unix seconds), BYTEA blobs and +// ADD COLUMN IF NOT EXISTS. +func pgSQL(q string) string { + if strings.HasPrefix(q, "INSERT OR IGNORE ") { + q = "INSERT " + strings.TrimPrefix(q, "INSERT OR IGNORE ") + " ON CONFLICT DO NOTHING" + } + if strings.Contains(q, "CREATE TABLE") || strings.Contains(q, "ALTER TABLE") { + q = strings.ReplaceAll(q, "INTEGER PRIMARY KEY AUTOINCREMENT", "BIGSERIAL PRIMARY KEY") + q = strings.ReplaceAll(q, "INTEGER", "BIGINT") + q = strings.ReplaceAll(q, "BLOB", "BYTEA") + q = strings.ReplaceAll(q, "ADD COLUMN ", "ADD COLUMN IF NOT EXISTS ") + } + var b strings.Builder + n, quoted := 0, false + for _, r := range q { + switch { + case r == '\'': + quoted = !quoted + case r == '?' && !quoted: + n++ + b.WriteString("$" + strconv.Itoa(n)) + continue + } + b.WriteRune(r) + } + return b.String() +} diff --git a/internal/server/dbx_test.go b/internal/server/dbx_test.go new file mode 100644 index 0000000..a48c83f --- /dev/null +++ b/internal/server/dbx_test.go @@ -0,0 +1,19 @@ +package server + +import "testing" + +// Queries written for SQLite, as PostgreSQL takes them. +func TestPGRebind(t *testing.T) { + for _, c := range [][2]string{ + {`SELECT a FROM t WHERE x = ? AND y = ?`, `SELECT a FROM t WHERE x = $1 AND y = $2`}, + {`INSERT OR IGNORE INTO admin_agents(admin_id, agent_id) VALUES(?, ?)`, `INSERT INTO admin_agents(admin_id, agent_id) VALUES($1, $2) ON CONFLICT DO NOTHING`}, + {`SELECT '?' , ?`, `SELECT '?' , $1`}, + {"CREATE TABLE IF NOT EXISTS x (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n n INTEGER NOT NULL,\n b BLOB NOT NULL)", + "CREATE TABLE IF NOT EXISTS x (\n id BIGSERIAL PRIMARY KEY,\n n BIGINT NOT NULL,\n b BYTEA NOT NULL)"}, + {`ALTER TABLE agents ADD COLUMN run_user TEXT NOT NULL DEFAULT ''`, `ALTER TABLE agents ADD COLUMN IF NOT EXISTS run_user TEXT NOT NULL DEFAULT ''`}, + } { + if got := pgSQL(c[0]); got != c[1] { + t.Errorf("\n got %s\nwant %s", got, c[1]) + } + } +} diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index c02f0a3..0fc133f 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -424,6 +424,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { code := s.db.InstallCode() ufwInstalled, ufwActive, ufwRunnable := s.ufw.status() ufwRules, _ := s.db.ListUfwRules() + dbNow, dbChosen := s.db.Backend() s.renderPage(w, r, "settings", map[string]any{ "Admin": s.currentAdmin(r), "Host": host, @@ -443,6 +444,8 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { "InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret), "InstallWget": fmt.Sprintf("wget -qO- %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret), "ServerVersion": Version, + "DBNow": dbNow, + "DBChosen": dbChosen, "UfwInstalled": ufwInstalled, "UfwActive": ufwActive, "UfwRunnable": ufwRunnable, diff --git a/internal/server/server.go b/internal/server/server.go index cd960b2..51fd05b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -260,6 +260,8 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/panel/ai/clear", s.requireAdmin(s.handleAIClear)) mux.Handle("POST /api/panel/ai/note", s.requireAdmin(s.handleAINote)) mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI)) + mux.Handle("POST /settings/db", s.adminsOnly(s.handleSetDB)) + mux.Handle("POST /settings/db/restart", s.adminsOnly(s.handleRestart)) mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin)) mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish)) mux.Handle("POST /api/panel/account/keys/{id}/delete", s.requireAdmin(s.handleKeyDelete)) diff --git a/internal/server/sshhosts.go b/internal/server/sshhosts.go index 6230315..3e52b8a 100644 --- a/internal/server/sshhosts.go +++ b/internal/server/sshhosts.go @@ -114,16 +114,12 @@ func (s *Server) saveSSHHost(owner int64, h savedHost, sec sshSecret) (int64, er if err != nil { return 0, err } - res, err := s.db.sql.Exec(`INSERT INTO ssh_hosts (admin_id, label, host, port, user, auth, secret) VALUES (?, ?, ?, ?, ?, ?, ?)`, + return s.db.sql.insert(`INSERT INTO ssh_hosts (admin_id, label, host, port, login, auth, secret) VALUES (?, ?, ?, ?, ?, ?, ?)`, owner, h.Label, h.Host, h.Port, h.User, h.Auth, sealed) - if err != nil { - return 0, err - } - return res.LastInsertId() } func (s *Server) listSSHHosts(owner int64) []savedHost { - rows, err := s.db.sql.Query(`SELECT id, label, host, port, user, auth FROM ssh_hosts WHERE admin_id = ? ORDER BY label, host`, owner) + rows, err := s.db.sql.Query(`SELECT id, label, host, port, login, auth FROM ssh_hosts WHERE admin_id = ? ORDER BY label, host`, owner) list := []savedHost{} if err != nil { return list @@ -146,7 +142,7 @@ func (s *Server) fillSaved(owner int64, t *proto.SSHTarget) error { } var h savedHost var sealed string - err := s.db.sql.QueryRow(`SELECT host, port, user, auth, secret FROM ssh_hosts WHERE id = ? AND admin_id = ?`, t.Saved, owner). + err := s.db.sql.QueryRow(`SELECT host, port, login, auth, secret FROM ssh_hosts WHERE id = ? AND admin_id = ?`, t.Saved, owner). Scan(&h.Host, &h.Port, &h.User, &h.Auth, &sealed) if err != nil { return errors.New("сохранённый хост не найден") diff --git a/internal/server/web/templates/settings.html b/internal/server/web/templates/settings.html index 4fbb65c..e73a4e5 100644 --- a/internal/server/web/templates/settings.html +++ b/internal/server/web/templates/settings.html @@ -116,6 +116,39 @@ {{end}} +
+

База данных

+

Сейчас: {{if eq .DBNow "postgres"}}PostgreSQL{{else}}SQLite{{end}}. + {{if ne .DBNow .DBChosen.Type}}Выбрана {{if eq .DBChosen.Type "postgres"}}PostgreSQL{{else}}SQLite{{end}} - перезапустите сервер, чтобы перейти.{{end}} + При первом запуске на пустой PostgreSQL всё копируется из SQLite-файла (он остаётся как есть).

+
+ + +
+
+
+ + + + +
+ +
+
+ + +
+
+

ИИ-ассистент

Чат в правом нижнем углу панели: видит вкладки и активный терминал, предлагает команды, которые