diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 64aaad3..ad7e19e 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -4,12 +4,36 @@ # знімає з розгортання найчастішу причину нічного дзвінка — прострочений # сертифікат на системі, яка сама має повідомляти про проблеми. + { - email {$ACME_EMAIL} + # Яким сертифікатом відповідати, коли клієнт не назвав імені. + # + # SNI не містить IP-адрес: стандарт дозволяє там лише доменні імена, + # тож браузер, який іде на https://93.171.243.247, не каже нічого — і + # Caddy не має за чим обрати сертифікат. Рукостискання обривається + # «internal error», сайт виглядає мертвим. + # + # З доменом ця настройка нічого не міняє: SNI приходить, і збіг + # знаходиться звичайним шляхом. + default_sni {$NETPULSE_DOMAIN} } +# Одна директива на два випадки. +# +# tls <пошта> — сертифікат від Let's Encrypt на цю адресу; +# tls internal — власний самопідписаний. +# +# Значення приходить із NETPULSE_TLS, яке compose виводить із ACME_EMAIL: +# пошта задана — беремо справжній сертифікат, порожньо — самопідписаний. +# +# Порожнє значення тут не працює: Let's Encrypt не видає сертифікатів на +# публічні IP-адреси, і Caddy вічно повторював би спробу, а сайт лежав +# би без TLS. Перевірено живим розгортанням на голому IP. + # --- інтерфейс і API ------------------------------------------------- {$NETPULSE_DOMAIN} { + tls {$NETPULSE_TLS} + encode zstd gzip # WebSocket живих оновлень. Окремим блоком, бо йому не можна @@ -43,6 +67,8 @@ # Зонди говорять gRPC поверх HTTP/2. Усередині мережі — h2c без TLS: # сертифікат живе тут, а не в кожному примірнику колектора. {$NETPULSE_DOMAIN}:9443 { + tls {$NETPULSE_TLS} + reverse_proxy h2c://collector:9443 { # Потік Control двонаправлений і довгий: агент тримає його # відкритим, доки живий. Буферизація тут ламає саме те, заради diff --git a/deploy/README.md b/deploy/README.md index 3de940a..01a196e 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -102,7 +102,7 @@ docker compose exec -T db \ зашифрованими в базі, і репозиторій повністю відтворюється з неї — ```sh -docker compose run --rm api netpulse-gitsync +docker compose run --rm --entrypoint netpulse-gitsync api ``` Зворотне невірно: з репозиторію базу не відновити. Тому джерелом істини @@ -197,6 +197,17 @@ NETPULSE_DEK=np2=<новий hex>,np1=<старий hex> яка сама має повідомляти про проблеми, — найгірший спосіб дізнатись про проблему. Caddy оновлює його сам. +## Без домену + +Домен потрібен для справжнього сертифіката: Let's Encrypt не видає їх на +IP-адреси. Поки домену немає, лишіть `ACME_EMAIL` порожнім — Caddy +випише самопідписаний, і система працюватиме одразу, з попередженням у +браузері. + +Коли домен зʼявиться: замінити `NETPULSE_DOMAIN`, вписати `ACME_EMAIL` і +`docker compose restart proxy`. Caddyfile змонтований, тож перезбирати +образи не треба — але й `up -d` без `restart` його не перечитає. + ## Дашборд на телевізор В інтерфейсі: **Дашборд → На телевізор → Видати посилання**. Отриману diff --git a/docker-compose.yml b/docker-compose.yml index 07ff46e..b9e2edc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,8 +52,10 @@ services: cache: image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.25.5 - ulimits: - memlock: -1 + # memlock без обмеження прискорює Dragonfly, але дозволений не всюди: + # у контейнерній віртуалізації (LXC, частина VPS) ядро відмовляє, і + # контейнер не стартує взагалі з «error setting rlimit type 8». + # Швидкість того не варта — обмеження знято. volumes: - cache-data:/data restart: unless-stopped @@ -133,10 +135,11 @@ services: image: caddy:2.8-alpine environment: NETPULSE_DOMAIN: ${NETPULSE_DOMAIN:?потрібен NETPULSE_DOMAIN} - # Порожній рядок Caddy не приймає: директива email без значення — - # помилка розбору, і проксі не підніметься взагалі. Тому підстановка - # спрацьовує і на незадану змінну, і на задану порожньою. - ACME_EMAIL: ${ACME_EMAIL:-admin@${NETPULSE_DOMAIN}} + # Порожня пошта означає «домену ще немає» — тоді самопідписаний + # сертифікат. Інакше Caddy нескінченно проситиме Let's Encrypt + # видати сертифікат на IP, чого той не робить, і сайт лишиться + # без TLS зовсім. + NETPULSE_TLS: ${ACME_EMAIL:-internal} volumes: - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data diff --git a/server/cmd/netpulse-user/main.go b/server/cmd/netpulse-user/main.go index 6205afb..10efb94 100644 --- a/server/cmd/netpulse-user/main.go +++ b/server/cmd/netpulse-user/main.go @@ -33,6 +33,8 @@ 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", "", "повне ім'я") @@ -57,7 +59,7 @@ func run() error { } defer conn.Close(ctx) - tenantID, tenantName, err := resolveTenant(ctx, conn, *tenant) + tenantID, tenantName, err := resolveTenant(ctx, conn, *tenant, *newTenant) if err != nil { return err } @@ -156,15 +158,36 @@ func run() error { return nil } -func resolveTenant(ctx context.Context, conn *pgx.Conn, ref string) (id, name string, err error) { +// 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 "", "", fmt.Errorf("тенанта %q не знайдено", ref) + if !errors.Is(err, pgx.ErrNoRows) { + return id, name, err } - 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 {