Образи зібрались із першого разу, але далі знайшлось три речі. memlock без обмеження для Dragonfly дозволений не всюди: у контейнерній віртуалізації ядро відмовляє, і контейнер не стартує взагалі. Швидкість того не варта — обмеження знято. Caddy нескінченно просив Let's Encrypt видати сертифікат на IP, чого той не робить, і сайт лишався без TLS. Тепер порожня ACME_EMAIL означає самопідписаний сертифікат. Плюс default_sni: SNI не містить IP-адрес, тож без нього рукостискання обривалось «internal error». У README команда заведення власника не працювала: у образу ENTRYPOINT, і без --entrypoint ім'я команди йшло аргументом до netpulse-api. Заразом netpulse-user навчився створювати перший кабінет — свіжа база не має жодного, а користувач без кабінету нікуди не належить. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
242 lines
8.1 KiB
Go
242 lines
8.1 KiB
Go
// Команда netpulse-user — заведення першого власника й керування
|
||
// користувачами з командного рядка.
|
||
//
|
||
// Потрібна через «проблему курки і яйця»: щоб зайти в панель, треба
|
||
// бути користувачем, а щоб створити користувача через API — треба
|
||
// зайти. Цей інструмент розриває коло, і він же лишається способом
|
||
// відновити доступ, коли єдиний адміністратор загубив пароль.
|
||
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"errors"
|
||
"flag"
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
"syscall"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/netpulse/netpulse/server/internal/auth"
|
||
"golang.org/x/term"
|
||
)
|
||
|
||
func main() {
|
||
if err := run(); err != nil {
|
||
fmt.Fprintln(os.Stderr, "netpulse-user:", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
func run() error {
|
||
var (
|
||
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
|
||
tenant = flag.String("tenant", "", "slug або uuid тенанта")
|
||
newTenant = flag.String("create-tenant", "",
|
||
"створити тенанта з таким іменем, якщо його ще немає")
|
||
login = flag.String("login", "", "логін користувача")
|
||
email = flag.String("email", "", "email (необовʼязково — для сповіщень)")
|
||
fullName = flag.String("name", "", "повне ім'я")
|
||
role = flag.String("role", "owner", "owner|admin|engineer|operator|viewer")
|
||
password = flag.String("password", "", "пароль; порожній — спитати інтерактивно")
|
||
list = flag.Bool("list", false, "показати користувачів тенанта")
|
||
reset = flag.Bool("reset-password", false, "змінити пароль наявного користувача")
|
||
)
|
||
flag.Parse()
|
||
|
||
if *dsn == "" {
|
||
return errors.New("не вказано -dsn (або NETPULSE_DSN)")
|
||
}
|
||
if *tenant == "" {
|
||
return errors.New("не вказано -tenant")
|
||
}
|
||
|
||
ctx := context.Background()
|
||
conn, err := pgx.Connect(ctx, *dsn)
|
||
if err != nil {
|
||
return fmt.Errorf("підключення: %w", err)
|
||
}
|
||
defer conn.Close(ctx)
|
||
|
||
tenantID, tenantName, err := resolveTenant(ctx, conn, *tenant, *newTenant)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if *list {
|
||
return listUsers(ctx, conn, tenantID, tenantName)
|
||
}
|
||
|
||
if *login == "" {
|
||
return errors.New("не вказано -login")
|
||
}
|
||
|
||
pw := *password
|
||
if pw == "" {
|
||
pw, err = askPassword()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if len(pw) < 8 {
|
||
return errors.New("пароль коротший за 8 символів")
|
||
}
|
||
|
||
hash, err := auth.HashPassword(pw)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if *reset {
|
||
tag, err := conn.Exec(ctx, `
|
||
UPDATE core.users SET password_hash = $2, updated_at = now()
|
||
WHERE username = $1 AND deleted_at IS NULL
|
||
`, *login, hash)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return fmt.Errorf("користувача %s не знайдено", *login)
|
||
}
|
||
// Зміна пароля адміністратором — це реакція на втрату доступу
|
||
// або на компрометацію. В обох випадках старі сесії мають померти.
|
||
if _, err := conn.Exec(ctx, `
|
||
UPDATE core.sessions SET revoked_at = now()
|
||
WHERE user_id = (SELECT id FROM core.users WHERE email = $1)
|
||
AND revoked_at IS NULL
|
||
`, *email); err != nil {
|
||
return err
|
||
}
|
||
fmt.Printf("пароль змінено, усі сесії %s відкликано\n", *email)
|
||
return nil
|
||
}
|
||
|
||
tx, err := conn.Begin(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func() { _ = tx.Rollback(ctx) }()
|
||
|
||
var userID string
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO core.users (username, email, password_hash, full_name, email_verified_at)
|
||
VALUES ($1, NULLIF($2,'')::citext, $3, NULLIF($4,''),
|
||
CASE WHEN NULLIF($2,'') IS NULL THEN NULL ELSE now() END)
|
||
ON CONFLICT (username) DO UPDATE
|
||
SET password_hash = EXCLUDED.password_hash,
|
||
full_name = COALESCE(NULLIF(EXCLUDED.full_name,''), core.users.full_name),
|
||
updated_at = now()
|
||
RETURNING id::text
|
||
`, *login, *email, hash, *fullName).Scan(&userID); err != nil {
|
||
return fmt.Errorf("користувач: %w", err)
|
||
}
|
||
|
||
var roleID string
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT id::text FROM core.roles
|
||
WHERE key = $1 AND (tenant_id IS NULL OR tenant_id = $2)
|
||
ORDER BY tenant_id NULLS LAST LIMIT 1
|
||
`, *role, tenantID).Scan(&roleID); err != nil {
|
||
return fmt.Errorf("роль %q: %w", *role, err)
|
||
}
|
||
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO core.memberships (tenant_id, user_id, role_id, accepted_at)
|
||
VALUES ($1, $2, $3, now())
|
||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id
|
||
`, tenantID, userID, roleID); err != nil {
|
||
return fmt.Errorf("членство: %w", err)
|
||
}
|
||
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return err
|
||
}
|
||
|
||
fmt.Printf("користувач %s (%s) доданий до «%s» з роллю %s\n",
|
||
*login, userID, tenantName, *role)
|
||
return nil
|
||
}
|
||
|
||
// resolveTenant знаходить кабінет або створює перший.
|
||
//
|
||
// Свіжа інсталяція не має жодного тенанта, а користувач без кабінету
|
||
// нікуди не належить. Виходить те саме коло, заради розриву якого існує
|
||
// ця команда, лише рівнем вище: щоб завести власника, потрібен кабінет,
|
||
// а щоб завести кабінет — панель, у яку ще нікому зайти.
|
||
//
|
||
// Створення за окремим прапорцем, а не мовчки: друкарська помилка в
|
||
// -tenant інакше плодила б порожні кабінети замість зрозумілої відмови.
|
||
func resolveTenant(ctx context.Context, conn *pgx.Conn, ref, createName string) (id, name string, err error) {
|
||
err = conn.QueryRow(ctx, `
|
||
SELECT id::text, name FROM core.tenants
|
||
WHERE (slug::text = $1 OR id::text = $1) AND deleted_at IS NULL
|
||
`, ref).Scan(&id, &name)
|
||
if !errors.Is(err, pgx.ErrNoRows) {
|
||
return id, name, err
|
||
}
|
||
|
||
if createName == "" {
|
||
return "", "", fmt.Errorf(
|
||
"тенанта %q не знайдено; для першого запуску додайте -create-tenant \"Назва\"", ref)
|
||
}
|
||
if err := conn.QueryRow(ctx, `
|
||
INSERT INTO core.tenants (slug, name) VALUES ($1, $2)
|
||
RETURNING id::text, name
|
||
`, ref, createName).Scan(&id, &name); err != nil {
|
||
return "", "", fmt.Errorf("створення тенанта: %w", err)
|
||
}
|
||
fmt.Printf("створено кабінет %q (%s)\n", name, ref)
|
||
return id, name, nil
|
||
}
|
||
|
||
func listUsers(ctx context.Context, conn *pgx.Conn, tenantID, tenantName string) error {
|
||
rows, err := conn.Query(ctx, `
|
||
SELECT u.username::text, COALESCE(u.full_name,''), r.key::text,
|
||
COALESCE(u.last_login_at::text, '—')
|
||
FROM core.memberships m
|
||
JOIN core.users u ON u.id = m.user_id
|
||
JOIN core.roles r ON r.id = m.role_id
|
||
WHERE m.tenant_id = $1 AND u.deleted_at IS NULL
|
||
ORDER BY u.username
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
fmt.Printf("Користувачі «%s»:\n", tenantName)
|
||
n := 0
|
||
for rows.Next() {
|
||
var login, name, role, last string
|
||
if err := rows.Scan(&login, &name, &role, &last); err != nil {
|
||
return err
|
||
}
|
||
fmt.Printf(" %-30s %-12s %-20s вхід: %s\n", login, role, name, last)
|
||
n++
|
||
}
|
||
if n == 0 {
|
||
fmt.Println(" (порожньо)")
|
||
}
|
||
return rows.Err()
|
||
}
|
||
|
||
// askPassword читає пароль без відлуння в терміналі.
|
||
func askPassword() (string, error) {
|
||
fmt.Print("Пароль: ")
|
||
if term.IsTerminal(int(syscall.Stdin)) {
|
||
b, err := term.ReadPassword(int(syscall.Stdin))
|
||
fmt.Println()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return string(b), nil
|
||
}
|
||
|
||
// Не термінал (скрипт, конвеєр) — читаємо рядок як є.
|
||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||
if err != nil && line == "" {
|
||
return "", err
|
||
}
|
||
return strings.TrimRight(line, "\r\n"), nil
|
||
}
|