feat: PostgreSQL as the panel's database, chosen in Настройки, the SQLite data copied over on its first start; tested on a real PostgreSQL
This commit is contained in:
1 parent
055020c3bb
commit
187dc2ae97
3 files changed
+105
-5
No files matched your search
@@ -68,6 +68,15 @@
|
|||||||
Там же галочка «Пароль» включает или выключает вход по паролю (выключить можно, когда у человека есть ключ доступа),
|
Там же галочка «Пароль» включает или выключает вход по паролю (выключить можно, когда у человека есть ключ доступа),
|
||||||
а «Закрепить» не даёт ему поменять это самому. Последнего администратора нельзя удалить или понизить.
|
а «Закрепить» не даёт ему поменять это самому. Последнего администратора нельзя удалить или понизить.
|
||||||
|
|
||||||
|
## База данных
|
||||||
|
По умолчанию - SQLite (файл из `-db`, по умолчанию `lpra.db`). В «Настройки › База данных» можно выбрать
|
||||||
|
PostgreSQL: хост, порт, база, пользователь, пароль, SSL; кнопка «Проверить и сохранить» подключается и
|
||||||
|
записывает выбор в `<база>.backend.json` рядом с SQLite-файлом (права 0600), «Перезапустить сервер»
|
||||||
|
завершает процесс - systemd (`Restart=always`) поднимает его уже на PostgreSQL. При первом запуске на
|
||||||
|
пустой PostgreSQL все данные копируются из SQLite-файла, сам файл остаётся как был (к нему можно
|
||||||
|
вернуться тем же выбором). История ИИ-ассистента тоже хранится в базе - у каждого пользователя
|
||||||
|
последние 200 сообщений.
|
||||||
|
|
||||||
## Компоненты
|
## Компоненты
|
||||||
- **Сервер** (`cmd/server`) - панель и точка встречи для агентов. Ставится за nginx (TLS).
|
- **Сервер** (`cmd/server`) - панель и точка встречи для агентов. Ставится за nginx (TLS).
|
||||||
- **Агент** (`cmd/agent`) - бинарь для Linux (amd64/arm64), systemd-сервис от root.
|
- **Агент** (`cmd/agent`) - бинарь для Linux (amd64/arm64), systemd-сервис от root.
|
||||||
|
|||||||
+11
-5
@@ -167,16 +167,22 @@ func copyDB(src, dst *DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The id counters past the copied ids, where a table has one.
|
||||||
for _, t := range copyTables {
|
for _, t := range copyTables {
|
||||||
var seq sql.NullString
|
var seq sql.NullString
|
||||||
_ = tx.QueryRow(`SELECT pg_get_serial_sequence($1, 'id')`, t).Scan(&seq)
|
err := dst.sql.DB.QueryRow(`SELECT pg_get_serial_sequence($1, column_name) FROM information_schema.columns
|
||||||
if seq.Valid {
|
WHERE table_name = $1 AND column_name = 'id' AND column_default LIKE 'nextval%'`, t).Scan(&seq)
|
||||||
if _, err := tx.Exec(`SELECT setval($1, COALESCE((SELECT MAX(id) FROM `+t+`), 0) + 1, false)`, seq.String); err != nil {
|
if err != nil || !seq.Valid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := dst.sql.DB.Exec(`SELECT setval($1, COALESCE((SELECT MAX(id) FROM `+t+`), 0) + 1, false)`, seq.String); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return nil
|
||||||
return tx.Commit()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend tells which database is in use and which is chosen for the next
|
// Backend tells which database is in use and which is chosen for the next
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// On a real PostgreSQL (LPRA_TEST_PG: {"host","port","name","user","password",
|
||||||
|
// "sslmode"} of an empty database): the first start copies the SQLite data
|
||||||
|
// over, and the panel's queries work there.
|
||||||
|
func TestPostgres(t *testing.T) {
|
||||||
|
raw := os.Getenv("LPRA_TEST_PG")
|
||||||
|
if raw == "" {
|
||||||
|
t.Skip("LPRA_TEST_PG not set")
|
||||||
|
}
|
||||||
|
var c PGConf
|
||||||
|
if err := json.Unmarshal([]byte(raw), &c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// SQLite with a user, a machine, a rule, a saved host and some talk.
|
||||||
|
s, _ := newTestServer(t)
|
||||||
|
a, _ := s.db.AdminByUsername("adm")
|
||||||
|
if _, err := s.db.CreateForwardRule(ForwardRule{AgentID: "x1", ClientPort: 80, HostPort: 18080, ListenAddr: "0.0.0.0", Proto: "tcp", NoUfw: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.saveSSHHost(a.ID, savedHost{Label: "web", Host: "h", Port: 22, User: "root", Auth: "password"}, sshSecret{Password: "pw"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.aiAppend(a.ID, "user", "привет")
|
||||||
|
_ = s.db.SetUserAgents(a.ID, []string{"x1"})
|
||||||
|
|
||||||
|
pg, err := OpenPostgres(c, s.db.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer pg.Close()
|
||||||
|
// Clean up for the next run.
|
||||||
|
defer func() {
|
||||||
|
for i := len(copyTables) - 1; i >= 0; i-- {
|
||||||
|
_, _ = pg.sql.Exec(`DROP TABLE IF EXISTS ` + copyTables[i] + ` CASCADE`)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
p := &Server{db: pg}
|
||||||
|
if got, _ := pg.AdminByUsername("adm"); got == nil || got.ID != a.ID {
|
||||||
|
t.Fatalf("admin not copied: %+v", got)
|
||||||
|
}
|
||||||
|
if ag, _ := pg.GetAgent("x1"); ag == nil {
|
||||||
|
t.Fatal("agent not copied")
|
||||||
|
}
|
||||||
|
rules, _ := pg.ListForwardRules()
|
||||||
|
if len(rules) != 1 || !rules[0].NoUfw {
|
||||||
|
t.Fatalf("rules: %+v", rules)
|
||||||
|
}
|
||||||
|
tg := &proto.SSHTarget{Saved: p.listSSHHosts(a.ID)[0].ID}
|
||||||
|
if err := p.fillSaved(a.ID, tg); err != nil || tg.Password != "pw" {
|
||||||
|
t.Fatalf("saved host: %+v %v", tg, err)
|
||||||
|
}
|
||||||
|
if h := p.aiHistory(a.ID, 10); len(h) != 1 || h[0].Content != "привет" {
|
||||||
|
t.Fatalf("talk: %+v", h)
|
||||||
|
}
|
||||||
|
// New rows go on after the copied ids.
|
||||||
|
id, err := pg.CreateUser("bob", "x", RoleUser)
|
||||||
|
if err != nil || id <= a.ID {
|
||||||
|
t.Fatalf("new user id %d, %v", id, err)
|
||||||
|
}
|
||||||
|
if err := pg.SetConfig("k", "v1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pg.SetConfig("k", "v2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v, _ := pg.GetConfig("k"); v != "v2" {
|
||||||
|
t.Fatalf("config upsert: %q", v)
|
||||||
|
}
|
||||||
|
if err := pg.SetUserAgents(id, []string{"x1", "x1"}); err != nil {
|
||||||
|
t.Fatalf("grants: %v", err)
|
||||||
|
}
|
||||||
|
p.aiAppend(a.ID, "assistant", "ответ")
|
||||||
|
if h := p.aiHistory(a.ID, 10); len(h) != 2 {
|
||||||
|
t.Fatalf("talk after: %+v", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user