Fix settings handler build
This commit is contained in:
1 parent
ce7c417884
commit
b272aed8fd
13 files changed
+458
-60
No files matched your search
+10
-14
@@ -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) {
|
||||
|
||||
@@ -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 (<db>.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)
|
||||
}()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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("сохранённый хост не найден")
|
||||
|
||||
@@ -116,6 +116,39 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" id="db">
|
||||
<h2>База данных</h2>
|
||||
<p class="muted">Сейчас: <b>{{if eq .DBNow "postgres"}}PostgreSQL{{else}}SQLite{{end}}</b>.
|
||||
{{if ne .DBNow .DBChosen.Type}}<span class="err" style="display:inline">Выбрана {{if eq .DBChosen.Type "postgres"}}PostgreSQL{{else}}SQLite{{end}} - перезапустите сервер, чтобы перейти.</span>{{end}}
|
||||
При первом запуске на пустой PostgreSQL всё копируется из SQLite-файла (он остаётся как есть).</p>
|
||||
<form method="post" action="/settings/db" class="ai-form db-form{{if eq .DBChosen.Type "postgres"}} pg{{end}}">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Хранить в<select name="db_type" onchange="this.form.classList.toggle('pg', this.value === 'postgres')">
|
||||
<option value="sqlite"{{if ne .DBChosen.Type "postgres"}} selected{{end}}>SQLite (файл рядом с сервером)</option>
|
||||
<option value="postgres"{{if eq .DBChosen.Type "postgres"}} selected{{end}}>PostgreSQL</option>
|
||||
</select></label>
|
||||
<div class="pg-fields">
|
||||
<div class="frow2"><label>Хост<input type="text" name="pg_host" value="{{with .DBChosen.PG}}{{.Host}}{{end}}" placeholder="localhost"></label>
|
||||
<label class="port">Порт<input type="number" name="pg_port" min="1" max="65535" value="{{with .DBChosen.PG}}{{if .Port}}{{.Port}}{{end}}{{end}}" placeholder="5432"></label></div>
|
||||
<label>База<input type="text" name="pg_name" value="{{with .DBChosen.PG}}{{.Name}}{{end}}" placeholder="lpra"></label>
|
||||
<label>Пользователь<input type="text" name="pg_user" value="{{with .DBChosen.PG}}{{.User}}{{end}}" autocomplete="off"></label>
|
||||
<label>Пароль<input type="password" name="pg_password" placeholder="{{with .DBChosen.PG}}{{if .Password}}задан - пусто, чтобы оставить{{end}}{{end}}" autocomplete="new-password"></label>
|
||||
<label>SSL<select name="pg_sslmode">
|
||||
{{$m := "prefer"}}{{with .DBChosen.PG}}{{if .SSLMode}}{{$m = .SSLMode}}{{end}}{{end}}
|
||||
<option value="disable"{{if eq $m "disable"}} selected{{end}}>disable</option>
|
||||
<option value="prefer"{{if eq $m "prefer"}} selected{{end}}>prefer</option>
|
||||
<option value="require"{{if eq $m "require"}} selected{{end}}>require</option>
|
||||
<option value="verify-full"{{if eq $m "verify-full"}} selected{{end}}>verify-full</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<button type="submit">Проверить и сохранить</button>
|
||||
</form>
|
||||
<form method="post" action="/settings/db/restart" data-confirm="Перезапустить сервер? Терминалы и передачи прервутся, страница переподключится сама." data-danger style="margin-top:10px">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button type="submit" class="btn ghost">Перезапустить сервер</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" id="ai">
|
||||
<h2>ИИ-ассистент</h2>
|
||||
<p class="muted">Чат в правом нижнем углу панели: видит вкладки и активный терминал, предлагает команды, которые
|
||||
|
||||
Reference in new issue
Block a user