Netpulse_SasS/server/cmd/netpulse-user/main.go
byrsapty 32b0b003df
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Дрібний борг: модуль http/ssl і зрозумілі помилки на мапі
Плагін http стояв у сіді як базовий — тобто обіцяний усім одразу, — а
модуля не існувало. Тепер є: код відповіді, час, збіг слова в тілі й
залишок днів до кінця сертифіката.

Недоступність повертається нулем, а не помилкою чека: на графіку це
читається як провал, і саме за цим ставлять тригер. Довіру до ланцюга
сертифікатів свідомо не перевіряємо — питають строк, а самопідписаний
теж має дату.

Повторна лінія між вузлами й повторно доданий хост давали однакове
«такий запис уже існує». Тепер кожен випадок каже, що робити далі.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:37:58 +03:00

242 lines
8.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Команда 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
}