panel: shared nav, account settings, backup page, connection settings

Phase 12 (UI/UX). The navigation bar now renders once from layout.html
instead of being copied into each content template, so it is present on
every authenticated page — including the domain page and its delete
confirmation, which had no links at all — and the current page is
highlighted via .Active rather than quietly dropping out of the list.

New /account page changes the administrator's username and/or password:
the current password is required and the attempt is throttled on the same
limiter as the login form, so this route cannot be used to brute-force
past that limit. A password change invalidates every other session while
keeping the one performing it; a rename carries that session over.

Backup and domain import move from a card in the middle of the domain
list to their own /backup page, one card each; the handlers themselves
are unchanged, only the page the import form renders its errors on.

The domain page gains a "Sending server settings" card (server, port,
encryption) so a client can be configured without reading the docs; 587
is listed only when SUBMISSION_ENABLE is true for this deployment, which
is a deploy-time flag the panel cannot verify at runtime.

Client-side (static/panel.js, no libraries): Copy buttons on the values
that get carried elsewhere (DKIM record, new application credentials,
server name), and the Addresses field is hidden while the address mode is
wildcard, where the server ignores it.

Verified in a container on the dev server: setup, login, every page's
nav and active item, domain and application creation, all account-form
paths including cross-session invalidation, import errors, full backup
download. gofmt/vet/test/docker build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 21:34:59 +03:00
parent 558c059341
commit fc53ae1314
28 changed files with 720 additions and 282 deletions
+16
View File
@@ -5,6 +5,22 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
- panel: navigation bar is now rendered once from the shared layout, so every
authenticated page has it — including the domain page and the delete
confirmation, which had no navigation links at all — and the current page is
highlighted instead of silently missing from the list.
- panel: new *Account* page to change the administrator's username and/or
password (the current password is required, throttled on the same limiter as
the login form). Changing the password invalidates all other sessions.
- panel: *Backup & migration* moved off the domain list onto its own *Backup*
page, with the full backup and the domain import as two separate cards.
- panel: the domain page now shows the *Sending server settings* (server,
port and encryption) needed to configure a mail client; port 587 is listed
only when `SUBMISSION_ENABLE=true` for this deployment.
- panel: *Copy* buttons on the DKIM record, on a newly issued application
login/password and on the sending server name.
- panel: the *Addresses* field is hidden while an application's address mode is
*Any address of the domain*, where the server ignores it.
- ci: disable provenance attestation on release image push, so the ghcr.io - ci: disable provenance attestation on release image push, so the ghcr.io
manifest list shows only `linux/amd64`/`linux/arm64` (no `unknown/unknown`). manifest list shows only `linux/amd64`/`linux/arm64` (no `unknown/unknown`).
- security: optionally honour `X-Forwarded-For` for login/setup rate-limiting - security: optionally honour `X-Forwarded-For` for login/setup rate-limiting
+6 -4
View File
@@ -39,7 +39,9 @@ docker compose up -d
This starts SelfPost alone; it assumes Apache is already installed on the host This starts SelfPost alone; it assumes Apache is already installed on the host
as the reverse proxy (see below) and expects certificates at `./certs`. The as the reverse proxy (see below) and expects certificates at `./certs`. The
first log line (`docker compose logs -f`) prints the one-time setup link — first log line (`docker compose logs -f`) prints the one-time setup link —
open it to create the admin account. open it to create the admin account. That username and password can be changed
later from the panel's *Account* page (changing the password signs out every
other session).
## Reverse proxy (mandatory) ## Reverse proxy (mandatory)
@@ -101,7 +103,7 @@ Two related but distinct operations — spec 7.5:
- **Full backup** (whole `/data`: SQLite, all domains' DKIM keys, all - **Full backup** (whole `/data`: SQLite, all domains' DKIM keys, all
applications' SASL credentials, `manifest.json` with the version that applications' SASL credentials, `manifest.json` with the version that
created it): panel button (dashboard → *Backup & migration*), or from the created it): panel button (*Backup* → *Full backup*), or from the
host: host:
```sh ```sh
docker exec <container> selfpost-backup > selfpost-backup.tar.gz docker exec <container> selfpost-backup > selfpost-backup.tar.gz
@@ -113,8 +115,8 @@ Two related but distinct operations — spec 7.5:
known version, there'd be no way to tell which image restoring a given known version, there'd be no way to tell which image restoring a given
backup actually requires. backup actually requires.
- **Export/import a single domain** (dashboard → domain page → *Export - **Export/import a single domain** (domain page → *Export domain* to write the
domain*): moves one domain — its DKIM key and its applications' **working** file, *Backup* → *Import a domain* to read it back in): moves one domain — its DKIM key and its applications' **working**
SASL passwords — to a different SelfPost instance without regenerating SASL passwords — to a different SelfPost instance without regenerating
anything, so DNS (the DKIM TXT record) doesn't need to change. Unlike a full anything, so DNS (the DKIM TXT record) doesn't need to change. Unlike a full
restore, this works across different hostnames/instances. restore, this works across different hostnames/instances.
+1
View File
@@ -28,6 +28,7 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
srvApp, err := web.New(st, domains, apps, web.Config{ srvApp, err := web.New(st, domains, apps, web.Config{
Hostname: cfg.hostname, Hostname: cfg.hostname,
CookieSecure: cfg.cookieSecure, CookieSecure: cfg.cookieSecure,
SubmissionEnabled: cfg.submissionEnabled,
MailLogPath: cfg.mailLog, MailLogPath: cfg.mailLog,
DataDir: cfg.dataDir, DataDir: cfg.dataDir,
DBPath: cfg.dbPath, DBPath: cfg.dbPath,
+12 -7
View File
@@ -52,13 +52,14 @@ type config struct {
mailLog string mailLog string
retentionDays int retentionDays int
dataDir string dataDir string
dbPath string dbPath string
manifestPath string manifestPath string
setupTokenPath string setupTokenPath string
hostname string hostname string
cookieSecure bool cookieSecure bool
trustedProxies []*net.IPNet submissionEnabled bool
trustedProxies []*net.IPNet
opendkimDir string opendkimDir string
dkimSelectorDef string dkimSelectorDef string
@@ -86,6 +87,10 @@ func loadConfig() config {
// Secure cookies by default (spec 7.6.6); PANEL_COOKIE_SECURE=false is a // Secure cookies by default (spec 7.6.6); PANEL_COOKIE_SECURE=false is a
// development-only escape hatch for testing over plain HTTP. // development-only escape hatch for testing over plain HTTP.
cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false", cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false",
// Whether this deployment also runs the 587 submission listener. The
// panel only displays it as a client connection setting; the comparison
// matches postfix-config.sh, which enables the listener on "true" alone.
submissionEnabled: os.Getenv("SUBMISSION_ENABLE") == "true",
// Reverse-proxy addresses allowed to supply X-Forwarded-For for // Reverse-proxy addresses allowed to supply X-Forwarded-For for
// rate-limiting (plan.md item A.1). Empty by default: an untrusted peer's // rate-limiting (plan.md item A.1). Empty by default: an untrusted peer's
// XFF header is trivially forgeable, so it's ignored unless the panel is // XFF header is trivially forgeable, so it's ignored unless the panel is
+8 -118
View File
@@ -7,6 +7,10 @@
Ниже остаётся только то, что **ещё не сделано**: открытые вопросы для Ниже остаётся только то, что **ещё не сделано**: открытые вопросы для
согласования и опциональная линия 2.x.x. согласования и опциональная линия 2.x.x.
**Фаза 12 (UI/UX: общий nav-partial, `/account`, `/backup`, параметры
подключения, кнопки Copy, скрытие поля адресов) выполнена** — детали в
[CHANGELOG.md](../CHANGELOG.md), здесь не повторяется.
**Основа:** [specification.md](specification.md) v1.0. **Основа:** [specification.md](specification.md) v1.0.
--- ---
@@ -27,7 +31,7 @@
3. **CSRF — только `SameSite=Lax`, без токенов.** Достаточно для современных браузеров (все мутации — POST, все GET read-only), но не защищает при downgrade до старого браузера/особых прокси и не даёт защиты на уровне «per-request». **Вопрос:** считать `SameSite=Lax` достаточным для single-admin панели (моя рекомендация — да) или добавить double-submit CSRF-токен. 3. **CSRF — только `SameSite=Lax`, без токенов.** Достаточно для современных браузеров (все мутации — POST, все GET read-only), но не защищает при downgrade до старого браузера/особых прокси и не даёт защиты на уровне «per-request». **Вопрос:** считать `SameSite=Lax` достаточным для single-admin панели (моя рекомендация — да) или добавить double-submit CSRF-токен.
4. **Cookie без префикса `__Host-`.** Сейчас `selfpost_session` (`Secure`/`HttpOnly`/`SameSite=Lax`/`Path=/`). Префикс `__Host-` дал бы браузерный гарант «только HTTPS, только этот хост, без Domain». Мелочь, но бесплатная. **Вопрос:** переименовать (учесть dev-режим `PANEL_COOKIE_SECURE=false``__Host-` требует `Secure`, т.е. только когда secure включён). 4. **Cookie без префикса `__Host-`.** Сейчас `selfpost_session` (`Secure`/`HttpOnly`/`SameSite=Lax`/`Path=/`). Префикс `__Host-` дал бы браузерный гарант «только HTTPS, только этот хост, без Domain». Мелочь, но бесплатная. **Вопрос:** переименовать (учесть dev-режим `PANEL_COOKIE_SECURE=false``__Host-` требует `Secure`, т.е. только когда secure включён).
5. **Setup-ссылка печатается в stdout контейнера.** По ТЗ (7.6.1) — так и задумано, но если логи контейнера уезжают в агрегатор, токен там осядет на 10 минут. Файл `/data/setup-token` (0600) — альтернатива, уже реализована в коде ([internal/web/setup.go](../internal/web/setup.go) `announce`/token file). **Решено:** базовый вариант объявления — stdout (по ТЗ), код не меняется. Остаётся только осветить это в пользовательской документации и указать на уже существующий файл `/data/setup-token` как более защищённую альтернативу для тех, у кого логи контейнера уезжают в централизованный агрегатор. **Реализация — см. Фазу 14.B.** 5. **Setup-ссылка печатается в stdout контейнера.** По ТЗ (7.6.1) — так и задумано, но если логи контейнера уезжают в агрегатор, токен там осядет на 10 минут. Файл `/data/setup-token` (0600) — альтернатива, уже реализована в коде ([internal/web/setup.go](../internal/web/setup.go) `announce`/token file). **Решено:** базовый вариант объявления — stdout (по ТЗ), код не меняется. Остаётся только осветить это в пользовательской документации и указать на уже существующий файл `/data/setup-token` как более защищённую альтернативу для тех, у кого логи контейнера уезжают в централизованный агрегатор. **Реализация — см. Фазу 14.B.**
6. **Нет 2FA / смены пароля админа / нескольких админов из UI.** ТЗ этого не требует (один админ, secret-link). Смена пароля сейчас — только пересоздание состояния. **Решено:** добавить раздел настроек аккаунта (логин + пароль) — см. Фазу 12 ниже. 2FA и мульти-админ остаются явно 2.x/вне объёма. 6. **Нет 2FA / смены пароля админа / нескольких админов из UI.** ТЗ этого не требует (один админ, secret-link). Смена пароля сейчас — только пересоздание состояния. **Решено и реализовано (Фаза 12):** добавлен раздел настроек аккаунта `/account` (логин + пароль, с проверкой текущего пароля и инвалидацией остальных сессий). 2FA и мульти-админ остаются явно 2.x/вне объёма.
### B. Надёжность и эксплуатация ### B. Надёжность и эксплуатация
@@ -46,120 +50,6 @@
--- ---
## Фаза 12 (v1.x) — UI/UX: навигация, аккаунт, backup и параметры подключения
**Статус:** запланирована, не начата.
**Цель:** устранить конкретные недостатки панели, замеченные при использовании: (1) навигационная шапка не показывает, на какой странице находится админ; (2) нет способа сменить логин/пароль администратора после `/setup`, кроме пересоздания состояния; (3) полный бэкап и импорт домена свалены в один блок на дашборде, хотя это два разных по смыслу и риску действия; (4) страница домена не показывает сервер/порт/шифрование, нужные для настройки клиента отправки; (5) значения, которые нужно переносить во внешние сервисы (DKIM-запись, пароль приложения), нельзя скопировать одной кнопкой; (6) поле «Addresses» показывается даже в режиме, где оно не используется.
### A. Навигационная шапка обязательна на каждой странице панели
**Правило (закладывается на будущее, не только для текущих страниц):** навигационная шапка —
обязательный элемент **каждой** аутентифицированной страницы панели, без исключений. Чтобы это не
превращалось в чек-лист «не забыть добавить нав в очередной новый шаблон» (как уже случилось с
`domain_detail.html`/`domain_delete.html`, см. ниже, и как иначе случилось бы с будущими `/account`,
`/backup`, `/status` из этой же и следующей фазы) — нав должен рендериться **структурно**, из
[layout.html](../internal/web/templates/layout.html) (общей обёртки всех страниц), а не копипастой
в каждом content-шаблоне. Тогда гарантия «шапка есть везде» не зависит от того, вспомнил ли автор
конкретной страницы её вставить.
Сейчас же топбар (`Domains`/`Send log`/`Queue`/`Log`) продублирован вручную и по-разному в разных
content-шаблонах (каждый определяет `{{define "content"}}` независимо, `layout.html` их просто
оборачивает, см. [layout.html:81](../internal/web/templates/layout.html)):
- [dashboard.html](../internal/web/templates/dashboard.html), [queue.html](../internal/web/templates/queue.html), [sendlog.html](../internal/web/templates/sendlog.html), [logtail.html](../internal/web/templates/logtail.html) содержат нав-ссылки, но без выделения текущей страницы, и текущая страница обычно **не включает ссылку на саму себя** (например, `queue.html` не содержит пункта «Queue»), из-за чего создаётся впечатление, что кнопка «пропадает», а не выделяется;
- [domain_detail.html](../internal/web/templates/domain_detail.html) и [domain_delete.html](../internal/web/templates/domain_delete.html) — **самая посещаемая страница панели** (там живут DKIM-запись, приложения, лимиты) — вообще **не содержат нав-ссылок**: топбар там ограничен `{{.User}}`/«Sign out», и чтобы перейти на Send log/Queue/Log, нужно сначала вернуться на «← All domains». Именно это отсутствие и стало поводом сформулировать правило выше как общее, а не как точечный фикс двух файлов.
**Реализация:**
- Вынести `{{define "nav"}}` и вызвать его **из `layout.html`**, один раз, до `{{template "content" .}}` — не из отдельных content-шаблонов. Заодно туда же уходит и `{{.User}}`/«Sign out», которые сейчас тоже продублированы в топбаре каждого шаблона — так они тоже перестают зависеть от того, вспомнили их вставить или нет.
- Content-шаблоны сохраняют только то, что специфично для страницы (заголовок `<h1>`, специфичные действия вроде будущего «Reload» на `/status`) — без списка нав-ссылок и без `{{.User}}`/Sign out.
- Добавить в данные каждого шаблона поле `Active string` (`"domains"`/`"queue"`/`"sendlog"`/`"logtail"`, позже — `"status"`/`"account"`/`"backup"`) — простая правка в `handleDashboard`, `handleQueue`, `handleSendLog`, `handleLogTail`, `handleDomainDetail`, `handleDeleteConfirm`, а также во всех новых хендлерах, которые появятся в этой и следующей фазе. `layout.html` получает `.Active` наравне с остальными полями (`Title`, `User`), т.к. оборачивает все content-шаблоны одним и тем же кодом — ничего специально прокидывать через `content` не нужно.
- Активный пункт рендерится как `<span aria-current="page" class="active">`, а не `<a>` (не ссылка сама на себя), остальные — обычные ссылки.
- CSS: `.nav [aria-current]` — визуально выделенное состояние (цвет/подчёркивание/фон), уже в стиле остальной палитры `layout.html`.
### B. Раздел настроек аккаунта (логин, пароль)
Сейчас нет способа сменить логин или пароль администратора после `/setup` — только пересоздание состояния БД. `internal/store/admin.go` содержит только `CreateAdmin`/`GetAdmin`, без `Update*`.
- **Store:** `UpdateAdmin(username, passwordHash string) error` (один UPDATE по `id = 1`).
- **Web:** новый маршрут `GET/POST /account` в authed-группе ([web.go](../internal/web/web.go)); `handleAccount` показывает форму (текущий логин, поля «новый логин», «текущий пароль», «новый пароль», «подтверждение нового пароля»); `handlePostAccount` проверяет текущий пароль (`bcrypt.CompareHashAndPassword`, как в `handleLogin`) перед применением изменений, затем вызывает `UpdateAdmin`.
- Ссылка «Account» — в общей навигации (пункт A), рядом с `{{.User}}`/«Sign out».
- **Security:** та же lockout-защита, что у логина (rate-limit по неверному «текущему паролю», чтобы `/account` нельзя было использовать для брутфорса пароля обходным путём); при успешной смене пароля — инвалидировать все активные in-memory сессии, кроме текущей (заставить перелогиниться остальные при параллельном доступе — здесь один админ, так что риск минимален, но защищает от кражи старой cookie).
- **Шаблон:** `account.html` по образцу `login.html`/`setup.html` (`.card.narrow`).
### C. Backup/migration — отдельная страница, раздельные блоки
Сейчас на [dashboard.html](../internal/web/templates/dashboard.html) (строки 57–82) полный бэкап
(«Download full backup») и импорт домена («Import a domain») — **два разных по смыслу и риску
действия** — свалены в одну карточку «Backup & migration» посреди страницы со списком доменов.
Экспорт одного домена при этом уже (правильно) живёт отдельно, на самой странице домена
([domain_detail.html:181](../internal/web/templates/domain_detail.html)).
- Новый маршрут `GET /backup` (authed) с собственным пунктом в общей навигации (пункт A) — например «Backup».
- На дашборде вместо целой карточки остаётся одна ссылка/карточка-указатель на `/backup` (или пункт навигации целиком заменяет карточку — решить при реализации, что выглядит чище).
- На странице `/backup`**две отдельные карточки**, не одна:
1. **Full backup** — тот же текст и форма (`POST /backup`), что сейчас, без изменений в логике.
2. **Import a domain** — та же форма (`POST /domains/import`), без изменений в логике.
- Домен-экспорт на `domain_detail.html` не переносится — он уже привязан к конкретному домену и корректно расположен рядом с его данными; трогать не нужно.
- Handler: новый `handleBackupPage` рендерит `backup.html`; существующие `handleBackup`/`handleImportDomain` не меняются (те же POST-маршруты).
**Готово, когда:** нав рендерится один раз из `layout.html` (не копипастой по content-шаблонам), поэтому присутствует на каждой странице панели, включая `domain_detail.html`/`domain_delete.html` (где нав-ссылок сейчас нет вовсе) и на всех будущих страницах этой и следующей фазы без отдельной правки каждой из них; текущий пункт визуально выделен и не дублируется как кликабельная ссылка на себя; `/account` позволяет сменить логин и/или пароль администратора с проверкой текущего пароля; после смены пароля старые сессии, кроме текущей, недействительны; `/backup` — отдельная страница с двумя визуально раздельными карточками (полный бэкап; импорт домена), дашборд больше не показывает эти формы напрямую; `gofmt`/`vet`/`test`/`docker build` зелёные.
**Риски:** смена логина не должна затронуть ничего, кроме таблицы `admin` (это учётная запись панели, не SASL-логины приложений — они хранятся отдельно в `applications`, см. Фазу 4); нужно убедиться, что rate-limit смены пароля не создаёт новый вектор lockout-DoS сверх уже описанного в п. A.1 открытых вопросов; вынос backup/import на отдельную страницу — чисто вёрстка, логика обработчиков `/backup` и `/domains/import` не меняется, так что риск регресса минимален.
### D. Параметры подключения клиента на странице домена
Сейчас [domain_detail.html](../internal/web/templates/domain_detail.html) показывает DKIM-запись и
таблицу приложений (логин/режим адресов), но нигде на странице нет данных, нужных, чтобы **настроить
почтовый клиент/скрипт для отправки** — сервер, порт, тип шифрования. Карточка «New application
password» (строки 17–27) показывает только логин/пароль конкретного приложения — этого недостаточно
без сервера/порта/шифрования рядом.
- Новая карточка **«Sending server settings»** на `domain_detail.html`, рядом с карточкой DKIM (до или после — решить при вёрстке), с фиксированными для всего инстанса значениями:
- **Server:** `{{.Hostname}}` (то же значение `SELFPOST_HOSTNAME`, которое уже используется для setup-ссылки и SASL realm — сервер уже хранит его в `Server.cfg.Hostname`, [web.go:25](../internal/web/web.go); прокинуть в данные шаблона `domain_detail`, аналогично тому, как это уже сделано для `dashboard`/`setup`).
- **Port 465 — SSL/TLS (implicit)** — всегда доступен (primary listener, spec 5).
- **Port 587 — STARTTLS (submission)** — показывать строку только если включён `SUBMISSION_ENABLE` (сейчас это чисто deploy-время env, [.env.example:10](../deploy/.env.example); нужно завести `Config.SubmissionEnabled bool` в [web.go](../internal/web/web.go), прокинуть из `os.Getenv("SUBMISSION_ENABLE")` в [cmd/panel/main.go](../cmd/panel/main.go) по аналогии с `Hostname`). Если submission выключен — строку не показывать вовсе, а не показывать «недоступно», чтобы не путать.
- **Username:** логин конкретного приложения — не общий для домена; сослаться на таблицу «Applications» ниже на той же странице, а не дублировать значение здесь (оно меняется per-application).
- **Password:** пояснение, что пароль отображается один раз при создании/регенерации приложения (уже описано в карточке `NewCred`) — здесь не показываем.
- Значения read-only (`.code`, как остальные DNS-блоки), без форм — это справочная информация, не настройка.
**Готово (доп. к критерию блока A/B/C):** страница домена показывает сервер/порт(ы)/шифрование, необходимые для настройки клиента отправки, без необходимости смотреть в документацию или `.env`; порт 587 отображается только когда submission действительно включён на этом инстансе.
**Риски:** `SubmissionEnabled` — deploy-time флаг (`docker-compose.yml`), а не что-то, что панель может проверить рантаймом (например, слушает ли порт 587 реально) — если оператор выставил `SUBMISSION_ENABLE=true` в `.env`, но не перезапустил compose с проброшенным портом 587, страница покажет строку, которая не работает. Отметить это как известное ограничение (как и остальные deploy/env-производные показатели), не решать в рамках этой фазы усложнением (например, реальной проверкой прослушивания порта — это ближе к `/status`, не к странице домена).
### E. Кнопка «Copy» на полях, которые переносятся во внешние сервисы
Значения из карточек `.code` сейчас нужно выделять мышью вручную — DKIM-запись (name/value),
логин/пароль нового приложения, будущие поля из пункта D (сервер) регулярно копируются в другой
интерфейс (DNS-панель регистратора, почтовый клиент, скрипт). Тривиально добавить копирование одной
кнопкой.
- Общий JS-хелпер в [layout.html](../internal/web/templates/layout.html) (без библиотек): `navigator.clipboard.writeText(text)`, вызываемый по клику маленькой кнопки/иконки рядом с каждым `.code`-элементом; кратковременная визуальная обратная связь (например, текст кнопки на 1–2 секунды меняется на «Copied»).
- Разметка: небольшая обёртка `.code-row` (код + кнопка `Copy`) вместо голого `.code`, значение бралось из `textContent` элемента (не из отдельного JS-массива — тогда не рассинхронизируется с тем, что реально показано).
- Применить везде, где сейчас есть `.code` для значений, которые логично куда-то переносить: DKIM `Record.Name`/`Record.Value` ([domain_detail.html:35](../internal/web/templates/domain_detail.html), [domain_detail.html:41](../internal/web/templates/domain_detail.html)), `NewCred.Login`/`NewCred.Password` ([domain_detail.html:23](../internal/web/templates/domain_detail.html), [domain_detail.html:25](../internal/web/templates/domain_detail.html)), поля карточки «Sending server settings» из пункта D. Таблица приложений (`td.code` с логином) — тоже кандидат, но менее приоритетно (короткое значение, легко выделить руками).
- **Секьюрити:** `navigator.clipboard.writeText` требует secure context (HTTPS или `localhost`) — в проде это всегда так (панель форсирует HTTPS), в dev-режиме с `PANEL_COOKIE_SECURE=false` по голому HTTP кнопка может тихо не сработать в некоторых браузерах. Не городить `document.execCommand('copy')`-fallback ради dev-режима — задокументировать как известное ограничение и/или обернуть в `try/catch`, чтобы отсутствие Clipboard API не ломало страницу, а просто оставляло текст доступным для ручного выделения.
**Готово:** у DKIM-записи, нового пароля приложения и параметров подключения (сервер) есть кнопка «Copy», по клику значение оказывается в буфере обмена и показывается краткое подтверждение.
**Риски:** нет побочных эффектов на бэкенд — чистая клиентская правка; единственный нюанс — secure-context ограничение Clipboard API в dev, описанное выше.
### F. Скрывать поле адресов в режиме «Any address»
Обе формы с выбором режима адресов — «Add an application» ([domain_detail.html:163171](../internal/web/templates/domain_detail.html)) и «Edit mode» на существующем приложении ([domain_detail.html:7279](../internal/web/templates/domain_detail.html)) — всегда показывают textarea «Addresses», даже когда выбран `Any address of the domain` (wildcard), где это поле не используется и просто игнорируется сервером. Из-за этого неочевидно, что поле относится только к режиму «List».
- Чистый клиентский JS (без изменений на сервере — валидация `mode`/`addresses` в `internal/app` не трогается, т.к. в wildcard-режиме бэкенд и так игнорирует содержимое textarea): на `change` у `<select name="mode">` показывать/прятать соответствующий `<label>`+`<textarea addresses>` через `hidden`/`display:none`, в зависимости от того, выбран ли `$.Wildcard` или `$.List`.
- Начальное состояние при загрузке страницы — тоже должно учитывать текущее значение `select` (важно для формы «Edit mode», где `select` уже может быть предзаполнен значением `List`, а не только для формы добавления, где по умолчанию `Wildcard`).
- Реализовать один небольшой переиспользуемый скрипт (или `<script>` внизу `domain_detail.html`), т.к. на странице несколько экземпляров этой пары select+textarea (форма добавления + одна на каждое существующее приложение в таблице).
**Готово:** при выборе «Any address of the domain» поле «Addresses» скрывается (и в форме добавления, и в «Edit mode» для существующих приложений); при выборе «Specific addresses (list)» — снова показывается с уже введённым значением, если было.
**Риски:** нет — чисто клиентское поведение, серверная валидация уже игнорирует `addresses` для wildcard-режима, так что скрытие поля ничего не меняет по сути обработки формы.
**Модель:** Sonnet (UI + рутинный CRUD, не риск-критичный тракт доставки).
**Зависимости:** не блокируется другими фазами; вводит общий `nav`-partial и `Active`-механизм, которым позже пользуется Фаза 13 (добавляет туда пункт «Status» и переносит «Domains» на `/domains`) — предпочтительно делать Фазу 12 первой.
---
## Фаза 13 (v1.x) — Страница статуса сервиса + DNS-проверка доменов ## Фаза 13 (v1.x) — Страница статуса сервиса + DNS-проверка доменов
**Статус:** запланирована, не начата. **Статус:** запланирована, не начата.
@@ -197,7 +87,7 @@ password» (строки 17–27) показывает только логин/
- Список доменов (карточки «Add a sending domain» + «Domains» из [dashboard.html](../internal/web/templates/dashboard.html)) переезжает на новый маршрут `GET /domains` (тот же `handleDashboard`, просто перевешенный на другой путь; `POST /domains` для добавления домена остаётся как есть — коллизий с `GET /domains` нет, разные методы). - Список доменов (карточки «Add a sending domain» + «Domains» из [dashboard.html](../internal/web/templates/dashboard.html)) переезжает на новый маршрут `GET /domains` (тот же `handleDashboard`, просто перевешенный на другой путь; `POST /domains` для добавления домена остаётся как есть — коллизий с `GET /domains` нет, разные методы).
- Корневой `GET /{$}` начинает рендерить `/status` (страницу из блока A) — либо редиректом `/{$}``/status`, либо status-хендлер напрямую вешается и на `/`, и на `/status` (без редиректа, чуть дешевле). Редирект проще и не создаёт двух путей для одного контента — предпочтительный вариант. - Корневой `GET /{$}` начинает рендерить `/status` (страницу из блока A) — либо редиректом `/{$}``/status`, либо status-хендлер напрямую вешается и на `/`, и на `/status` (без редиректа, чуть дешевле). Редирект проще и не создаёт двух путей для одного контента — предпочтительный вариант.
- **Логин:** `handleLogin` после успешной аутентификации сейчас редиректит на `/` — поведение не меняется (просто `/` теперь означает статус, а не домены), правки в `handlers_auth.go` не требуется. - **Логин:** `handleLogin` после успешной аутентификации сейчас редиректит на `/` — поведение не меняется (просто `/` теперь означает статус, а не домены), правки в `handlers_auth.go` не требуется.
- **Навигация (Фаза 12.A):** пункт «Status» становится первым в общем `nav`-partial и получает `Active == "status"`; пункт «Domains» указывает на `/domains` вместо `/`. Это расширяет список `Active`-значений, заведённый в Фазе 12, а не меняет его архитектуру. - **Навигация:** пункт «Status» становится первым в общем `nav`-partial ([layout.html](../internal/web/templates/layout.html), Фаза 12) и получает `Active == "status"`; пункт «Domains» указывает на `/domains` вместо `/`. Это расширяет список `Active`-значений, заведённый в Фазе 12, а не меняет его архитектуру.
- Везде, где по коду сейчас зашит редирект/ссылка на `/` как «страница доменов» (например, `<a class="back" href="/">← Domains</a>` в `queue.html`, `sendlog.html`, `logtail.html`, `domain_detail.html`, `domain_delete.html`), ссылку нужно поменять на `/domains`. - Везде, где по коду сейчас зашит редирект/ссылка на `/` как «страница доменов» (например, `<a class="back" href="/">← Domains</a>` в `queue.html`, `sendlog.html`, `logtail.html`, `domain_detail.html`, `domain_delete.html`), ссылку нужно поменять на `/domains`.
**Готово (доп. к критерию блока A/B):** `GET /` открывает `/status`; список доменов доступен по `GET /domains`; все ссылки «← Domains» и пункт навигации «Domains» ведут на `/domains`; логин после успешной аутентификации попадает на страницу статуса. **Готово (доп. к критерию блока A/B):** `GET /` открывает `/status`; список доменов доступен по `GET /domains`; все ссылки «← Domains» и пункт навигации «Domains» ведут на `/domains`; логин после успешной аутентификации попадает на страницу статуса.
@@ -237,7 +127,7 @@ password» (строки 17–27) показывает только логин/
**Модель:** Sonnet (UI + рутинные DNS-lookup, не риск-критичный тракт доставки/безопасности). **Модель:** Sonnet (UI + рутинные DNS-lookup, не риск-критичный тракт доставки/безопасности).
**Зависимости:** использует общий `nav`-partial и `Active`-механизм навигации, вводимые Фазой 12 — реализовывать после неё (или расширить тот же PR); использует уже реализованные `internal/postfix.Queue()`, DKIM-логику домена, паттерны HTMX-фрагментов из Фазы 7. **Зависимости:** нет — общий `nav`-partial и `Active`-механизм навигации уже введены Фазой 12; использует также уже реализованные `internal/postfix.Queue()`, DKIM-логику домена, паттерны HTMX-фрагментов из Фазы 7.
--- ---
@@ -248,7 +138,7 @@ password» (строки 17–27) показывает только логин/
**Цель:** довести до кода два уже принятых, но пока не реализованных решения из **Цель:** довести до кода два уже принятых, но пока не реализованных решения из
раздела [A. Безопасность](#a-безопасность--hardening-сверх-обязательного-76) раздела [A. Безопасность](#a-безопасность--hardening-сверх-обязательного-76)
(остальные пункты раздела A либо уже реализованы — п.1, либо являются открытыми (остальные пункты раздела A либо уже реализованы — п.1, либо являются открытыми
вопросами без решения — п.3/4, либо уже вынесены в свою фазу — п.6/Фаза 12). вопросами без решения — п.3/4, либо уже реализованы в Фазе 12 — п.6).
### A. Security-заголовки ответа (пункт A.2) ### A. Security-заголовки ответа (пункт A.2)
+3 -2
View File
@@ -35,9 +35,10 @@
## Текущее состояние ## Текущее состояние
- **Базовый линейный план 0→11 (v1.0) полностью выполнен и принят** (аудит безопасности ТЗ 7.6 — полное соответствие, деплой в проде подтверждён). Подробности — в git-истории и `CHANGELOG.md`. - **Базовый линейный план 0→11 (v1.0) полностью выполнен и принят** (аудит безопасности ТЗ 7.6 — полное соответствие, деплой в проде подтверждён). Подробности — в git-истории и `CHANGELOG.md`.
- **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы (раздел A-D — hardening сверх обязательного 7.6, надёжность, CI/тесты), опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования) и **Фаза 12 (v1.x, запланирована, не начата)** — страница статуса `/status` (процессы/очередь/TLS/milter-сокеты/PTR) + DNS-статус домена (DKIM/SPF-эвристика/DMARC) на странице домена; согласована с пользователем, готова к реализации. - **Фаза 12 (v1.x, UI/UX) выполнена:** общий `nav`-partial в `layout.html` (нав на каждой аутентифицированной странице + подсветка текущей через `Active`), `/account` (смена логина/пароля админа с проверкой текущего пароля и инвалидацией остальных сессий, `store.UpdateAdmin`), `/backup` (полный бэкап и импорт домена — две отдельные карточки, убраны с дашборда), карточка «Sending server settings» на странице домена (сервер/465/587 по `SUBMISSION_ENABLE`), кнопки Copy у DKIM-записи и нового пароля приложения, скрытие поля «Addresses» в режиме wildcard (`static/panel.js`). Проверено в контейнере на dev-сервере (setup→login→домен→приложение→смена пароля→импорт), `gofmt`/`vet`/`test`/`docker build` зелёные.
- **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы (раздел A-D — hardening сверх обязательного 7.6, надёжность, CI/тесты), **Фаза 13 (v1.x, запланирована, не начата)** — страница статуса `/status` (процессы/очередь/TLS/milter-сокеты/PTR) + DNS-статус домена (DKIM/SPF-эвристика/DMARC), `/status` как стартовая страница и перенос кнопки Reload; **Фаза 14** — security-заголовки + документация про `/data/setup-token`; опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования).
- **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). - **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass).
## Рабочая петля (dev loop) — ВАЖНО ## Рабочая петля (dev loop) — ВАЖНО
Локально (Windows, `<repo>`) **нет Go и Docker** — только редактирование и git. Вся сборка/тесты идут на dev-сервере `selfpost.example.com` (Debian 12 bookworm, тот же, что базовый образ; провижён под разработку). Цикл: править локально → `rsync` дерева на сервер → `go build`/`go vet`/`docker build`/тесты там. Источник истины и git-история — локальный репозиторий; сервер — только исполнитель сборки/тестов. Подключение: `ssh root@selfpost.example.com` (по ключу). Локально (Windows, `<repo>`) **нет Go и Docker** — только редактирование и git. Вся сборка/тесты идут на dev-сервере `selfpost.example.com` (Debian 12 bookworm, тот же, что базовый образ; провижён под разработку). Цикл: править локально → залить дерево на сервер → `go build`/`go vet`/`docker build`/тесты там. `rsync` в локальном git-bash нет, поэтому дерево едет tar'ом по ssh: `tar -czf - --exclude=.git . | ssh root@selfpost.example.com 'rm -rf /root/selfpost-src && mkdir -p /root/selfpost-src && tar -xzf - -C /root/selfpost-src'`; Go на сервере — в `/usr/local/go/bin` (не в `PATH` по умолчанию); образ — `docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .`. Источник истины и git-история — локальный репозиторий; сервер — только исполнитель сборки/тестов. Подключение: `ssh root@selfpost.example.com` (по ключу).
+23
View File
@@ -42,6 +42,29 @@ func (s *Store) CreateAdmin(username, passwordHash string) error {
return nil return nil
} }
// UpdateAdmin replaces the administrator's username and password hash. It
// touches only the admin row (id = 1): panel credentials are unrelated to the
// SASL logins applications authenticate with, which live in their own table.
// ErrNoAdmin is returned if setup has not happened yet, so a change can never
// silently create an account.
func (s *Store) UpdateAdmin(username, passwordHash string) error {
res, err := s.db.Exec(
"UPDATE admin SET username = ?, password_hash = ? WHERE id = 1",
username, passwordHash,
)
if err != nil {
return fmt.Errorf("update admin: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update admin: %w", err)
}
if n == 0 {
return ErrNoAdmin
}
return nil
}
// GetAdmin returns the administrator account, or ErrNoAdmin if setup is pending. // GetAdmin returns the administrator account, or ErrNoAdmin if setup is pending.
func (s *Store) GetAdmin() (Admin, error) { func (s *Store) GetAdmin() (Admin, error) {
var ( var (
+45
View File
@@ -0,0 +1,45 @@
package store
import (
"errors"
"testing"
)
func TestUpdateAdmin(t *testing.T) {
st := openTestStore(t)
if err := st.CreateAdmin("admin", "hash-one"); err != nil {
t.Fatalf("CreateAdmin: %v", err)
}
if err := st.UpdateAdmin("operator", "hash-two"); err != nil {
t.Fatalf("UpdateAdmin: %v", err)
}
a, err := st.GetAdmin()
if err != nil {
t.Fatalf("GetAdmin: %v", err)
}
if a.Username != "operator" || a.PasswordHash != "hash-two" {
t.Fatalf("unexpected admin after update: %+v", a)
}
if a.CreatedAt.IsZero() {
t.Fatal("update dropped created_at")
}
}
// An update before setup must not create the account: only the one-time setup
// flow may do that (spec 7.6.1).
func TestUpdateAdminWithoutAdmin(t *testing.T) {
st := openTestStore(t)
if err := st.UpdateAdmin("operator", "hash"); !errors.Is(err, ErrNoAdmin) {
t.Fatalf("UpdateAdmin without admin = %v, want ErrNoAdmin", err)
}
exists, err := st.AdminExists()
if err != nil {
t.Fatalf("AdminExists: %v", err)
}
if exists {
t.Fatal("UpdateAdmin created an administrator")
}
}
+168
View File
@@ -0,0 +1,168 @@
package web
import (
"errors"
"net/http"
"strings"
"codeberg.org/mix/selfpost/internal/store"
"golang.org/x/crypto/bcrypt"
)
// handleAccount serves the administrator's own account settings: the username
// and password chosen during setup are the only panel credentials (spec 7.6.1),
// and until now they could be changed only by recreating the state. Changing
// them here never touches application SASL logins, which are a separate
// identity system (spec 5.1).
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.renderAccount(w, r, http.StatusOK, "", currentUser(r))
case http.MethodPost:
s.submitAccount(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// renderAccount draws the settings form. formUsername repopulates the username
// field after a rejected submission; the password fields are never repopulated.
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) {
s.render(w, status, "account", map[string]any{
"Title": "SelfPost — account",
"User": currentUser(r),
"Active": "account",
"FormUsername": formUsername,
"Error": formErr,
"Flash": accountFlash(r),
})
}
// accountFlash maps a fixed redirect flag to a fixed message, so status text
// after a redirect is never attacker-influenced.
func accountFlash(r *http.Request) string {
switch r.URL.Query().Get("updated") {
case "username":
return "Username changed."
case "password":
return "Password changed. Any other signed-in sessions were signed out."
case "both":
return "Username and password changed. Any other signed-in sessions were signed out."
default:
return ""
}
}
// submitAccount applies a username and/or password change. The current password
// is always required, so a stolen session alone cannot lock the administrator
// out of their own panel, and the attempt is throttled on the same limiter as
// the login form so this route cannot be used to brute-force the password past
// that limit (spec 7.6.5).
func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
s.renderAccount(w, r, http.StatusTooManyRequests,
"Too many attempts. Please wait and try again.", currentUser(r))
return
}
if err := r.ParseForm(); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", currentUser(r))
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
current := r.PostFormValue("current_password")
password := r.PostFormValue("new_password")
confirm := r.PostFormValue("new_password_confirm")
admin, err := s.store.GetAdmin()
if err != nil {
logf("panel: account: get admin failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if username == "" {
username = admin.Username
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil {
s.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username)
return
}
renaming := username != admin.Username
if renaming {
if err := validateUsername(username); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
return
}
}
// An empty pair of new-password fields means "leave the password alone", so
// the username can be changed on its own.
repassword := password != "" || confirm != ""
if repassword {
if password != confirm {
s.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username)
return
}
if err := validateAdminPassword(password); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
return
}
}
if !renaming && !repassword {
s.renderAccount(w, r, http.StatusBadRequest,
"Nothing to change: enter a new username, a new password, or both.", username)
return
}
hash := admin.PasswordHash
if repassword {
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logf("panel: account: hashing password failed: %v", err)
s.renderAccount(w, r, http.StatusInternalServerError,
"Internal error. Please try again.", username)
return
}
hash = string(newHash)
}
if err := s.store.UpdateAdmin(username, hash); err != nil {
logf("panel: account: update admin failed: %v", err)
msg := "Could not save the changes. Please check the logs and try again."
if errors.Is(err, store.ErrNoAdmin) {
msg = "There is no administrator account to update."
}
s.renderAccount(w, r, http.StatusInternalServerError, msg, username)
return
}
// Keep this session usable under the new name, and — when the password
// changed — drop every other session so a cookie captured under the old
// password stops working.
if c, err := r.Cookie(sessionCookie); err == nil {
if renaming {
s.sessions.Rename(c.Value, username)
}
if repassword {
s.sessions.DestroyOthers(c.Value)
}
}
logf("panel: administrator account updated (username changed: %t, password changed: %t)", renaming, repassword)
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword), http.StatusSeeOther)
}
// updatedFlag names what changed, for the fixed post-redirect flash message.
func updatedFlag(renamed, repassword bool) string {
switch {
case renamed && repassword:
return "both"
case renamed:
return "username"
default:
return "password"
}
}
+24 -18
View File
@@ -95,24 +95,30 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
} }
s.render(w, status, "domain_detail", map[string]any{ s.render(w, status, "domain_detail", map[string]any{
"Title": "SelfPost — " + d.Name, "Title": "SelfPost — " + d.Name,
"User": currentUser(r), "User": currentUser(r),
"Domain": d, "Active": "domains",
"Record": record, "Domain": d,
"Apps": appViews, "Record": record,
"Error": view.FormErr, // Client connection settings (the same for every domain on this
"FormLogin": view.FormLogin, // instance): the hostname clients connect to, and whether the optional
"FormMode": view.FormMode, // submission listener is enabled in this deployment.
"FormAddrs": view.FormAddrs, "Hostname": s.cfg.Hostname,
"NewCred": view.NewCred, "SubmissionEnabled": s.cfg.SubmissionEnabled,
"Flash": detailFlash(r), "Apps": appViews,
"Wildcard": store.AddressModeWildcard, "Error": view.FormErr,
"List": store.AddressModeList, "FormLogin": view.FormLogin,
"RateLimitErr": view.RateLimitErr, "FormMode": view.FormMode,
"DomainHasRL": domainRLok && domainRL.Active(), "FormAddrs": view.FormAddrs,
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"), "NewCred": view.NewCred,
"DomainRLMax": intOrBlank(domainRL.MaxMessages), "Flash": detailFlash(r),
"DomainRLWin": windowOrDefault(domainRL.WindowSeconds), "Wildcard": store.AddressModeWildcard,
"List": store.AddressModeList,
"RateLimitErr": view.RateLimitErr,
"DomainHasRL": domainRLok && domainRL.Active(),
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"),
"DomainRLMax": intOrBlank(domainRL.MaxMessages),
"DomainRLWin": windowOrDefault(domainRL.WindowSeconds),
}) })
} }
+25 -7
View File
@@ -17,6 +17,24 @@ import (
// room while refusing anything large enough to be an abuse attempt. // room while refusing anything large enough to be an abuse attempt.
const maxImportBytes = 1 << 20 // 1 MiB const maxImportBytes = 1 << 20 // 1 MiB
// handleBackupPage renders the backup/migration screen: the full-server backup
// and the domain import are separate actions with different risk, so each gets
// its own card here rather than sharing a block on the domain list.
func (s *Server) handleBackupPage(w http.ResponseWriter, r *http.Request) {
s.renderBackupPage(w, r, http.StatusOK, "")
}
// renderBackupPage draws the page; importErr surfaces a failed domain import
// (spec 7.5.B) next to the form that produced it.
func (s *Server) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) {
s.render(w, status, "backup", map[string]any{
"Title": "SelfPost — backup",
"User": currentUser(r),
"Active": "backup",
"ImportErr": importErr,
})
}
// handleBackup streams a full-server backup as a download (spec 7.5.A). It is an // handleBackup streams a full-server backup as a download (spec 7.5.A). It is an
// authenticated admin action (this handler sits behind the auth middleware). The // authenticated admin action (this handler sits behind the auth middleware). The
// archive carries DKIM private keys, the admin password hash and SASL // archive carries DKIM private keys, the admin password hash and SASL
@@ -75,17 +93,17 @@ func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
// domain on this instance (spec 7.5.B). The domain name is normalised and // domain on this instance (spec 7.5.B). The domain name is normalised and
// validated here (spec 7.6.2); the domain service validates the selector, each // validated here (spec 7.6.2); the domain service validates the selector, each
// login and address, and the DKIM key before writing anything. On success it // login and address, and the DKIM key before writing anything. On success it
// redirects to the new domain's page; on failure it re-renders the dashboard // redirects to the new domain's page; on failure it re-renders the backup page,
// with a friendly message. // where the import form lives, with a friendly message.
func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) { func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes) r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
if err := r.ParseMultipartForm(maxImportBytes); err != nil { if err := r.ParseMultipartForm(maxImportBytes); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, "", "", "Could not read the uploaded file (too large or not a valid upload).") s.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).")
return return
} }
file, _, err := r.FormFile("file") file, _, err := r.FormFile("file")
if err != nil { if err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, "", "", "Choose a domain export file to import.") s.renderBackupPage(w, r, http.StatusBadRequest, "Choose a domain export file to import.")
return return
} }
defer file.Close() defer file.Close()
@@ -94,7 +112,7 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
dec := json.NewDecoder(file) dec := json.NewDecoder(file)
dec.DisallowUnknownFields() dec.DisallowUnknownFields()
if err := dec.Decode(&exp); err != nil { if err := dec.Decode(&exp); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, "", "", "That file is not a valid SelfPost domain export.") s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.")
return return
} }
@@ -102,7 +120,7 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
// same gate the add-domain form uses (spec 7.6.2). // same gate the add-domain form uses (spec 7.6.2).
exp.Domain = normalizeDomain(exp.Domain) exp.Domain = normalizeDomain(exp.Domain)
if err := validateDomain(exp.Domain); err != nil { if err := validateDomain(exp.Domain); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, "", "", "Invalid domain in export file: "+err.Error()) s.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error())
return return
} }
@@ -110,7 +128,7 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
logf("panel: import domain %q: %v", exp.Domain, err) logf("panel: import domain %q: %v", exp.Domain, err)
status, msg := importErrorMessage(err) status, msg := importErrorMessage(err)
s.renderDashboard(w, r, status, "", "", msg) s.renderBackupPage(w, r, status, msg)
return return
} }
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
+16 -16
View File
@@ -13,14 +13,13 @@ import (
// domains with their DKIM/selector and application counts, plus the add-domain // domains with their DKIM/selector and application counts, plus the add-domain
// form (spec 7.2.2). Applications and the send log arrive in later phases. // form (spec 7.2.2). Applications and the send log arrive in later phases.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
s.renderDashboard(w, r, http.StatusOK, "", "", "") s.renderDashboard(w, r, http.StatusOK, "", "")
} }
// renderDashboard renders the domain list. formErr and formName repopulate the // renderDashboard renders the domain list. formErr and formName repopulate the
// add-domain form after a rejected submission; importErr surfaces a failed // add-domain form after a rejected submission; flash surfaces a one-shot status
// domain import (spec 7.5.B); flash surfaces a one-shot status message keyed by // message keyed by a redirect query flag (never reflected user input).
// a redirect query flag (never reflected user input). func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName, importErr string) {
domains, err := s.domains.List() domains, err := s.domains.List()
if err != nil { if err != nil {
logf("panel: dashboard: list domains: %v", err) logf("panel: dashboard: list domains: %v", err)
@@ -28,13 +27,13 @@ func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status
return return
} }
s.render(w, status, "dashboard", map[string]any{ s.render(w, status, "dashboard", map[string]any{
"Title": "SelfPost", "Title": "SelfPost",
"User": currentUser(r), "User": currentUser(r),
"Domains": domains, "Active": "domains",
"Error": formErr, "Domains": domains,
"FormName": formName, "Error": formErr,
"ImportErr": importErr, "FormName": formName,
"Flash": dashboardFlash(r), "Flash": dashboardFlash(r),
}) })
} }
@@ -56,25 +55,25 @@ func dashboardFlash(r *http.Request) string {
// publish is shown (spec 7.2.3). // publish is shown (spec 7.2.3).
func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "", "") s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
return return
} }
raw := r.PostFormValue("name") raw := r.PostFormValue("name")
name := normalizeDomain(raw) name := normalizeDomain(raw)
if err := validateDomain(name); err != nil { if err := validateDomain(name); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw, "") s.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
return return
} }
d, err := s.domains.Add(name) d, err := s.domains.Add(name)
if err != nil { if err != nil {
if errors.Is(err, store.ErrDomainExists) { if errors.Is(err, store.ErrDomainExists) {
s.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw, "") s.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
return return
} }
logf("panel: add domain %q: %v", name, err) logf("panel: add domain %q: %v", name, err)
s.renderDashboard(w, r, http.StatusInternalServerError, s.renderDashboard(w, r, http.StatusInternalServerError,
"Could not add the domain. Please check the logs and try again.", raw, "") "Could not add the domain. Please check the logs and try again.", raw)
return return
} }
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
@@ -90,6 +89,7 @@ func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
s.render(w, http.StatusOK, "domain_delete", map[string]any{ s.render(w, http.StatusOK, "domain_delete", map[string]any{
"Title": "SelfPost — delete " + d.Name, "Title": "SelfPost — delete " + d.Name,
"User": currentUser(r), "User": currentUser(r),
"Active": "domains",
"Domain": d, "Domain": d,
}) })
} }
+7 -4
View File
@@ -30,6 +30,7 @@ func (s *Server) handleSendLog(w http.ResponseWriter, r *http.Request) {
} }
data["Title"] = "SelfPost — send log" data["Title"] = "SelfPost — send log"
data["User"] = currentUser(r) data["User"] = currentUser(r)
data["Active"] = "sendlog"
s.render(w, http.StatusOK, "sendlog", data) s.render(w, http.StatusOK, "sendlog", data)
} }
@@ -112,6 +113,7 @@ func (s *Server) handleQueue(w http.ResponseWriter, r *http.Request) {
s.render(w, http.StatusOK, "queue", map[string]any{ s.render(w, http.StatusOK, "queue", map[string]any{
"Title": "SelfPost — mail queue", "Title": "SelfPost — mail queue",
"User": currentUser(r), "User": currentUser(r),
"Active": "queue",
"Output": out, "Output": out,
"Error": errText, "Error": errText,
}) })
@@ -142,10 +144,11 @@ func readQueue() (string, string) {
func (s *Server) handleLogTail(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogTail(w http.ResponseWriter, r *http.Request) {
lines, errText := s.readLogTail() lines, errText := s.readLogTail()
s.render(w, http.StatusOK, "logtail", map[string]any{ s.render(w, http.StatusOK, "logtail", map[string]any{
"Title": "SelfPost — mail log", "Title": "SelfPost — mail log",
"User": currentUser(r), "User": currentUser(r),
"Lines": lines, "Active": "logtail",
"Error": errText, "Lines": lines,
"Error": errText,
}) })
} }
+26
View File
@@ -54,6 +54,32 @@ func (s *sessionStore) Lookup(token string) (string, bool) {
return sess.username, true return sess.username, true
} }
// Rename updates the username carried by a session, keeping its expiry. It is
// used when the administrator renames their own account so the current session
// keeps working under the new name.
func (s *sessionStore) Rename(token, username string) {
s.mu.Lock()
defer s.mu.Unlock()
if sess, ok := s.sessions[token]; ok {
sess.username = username
s.sessions[token] = sess
}
}
// DestroyOthers invalidates every session except keep. It is called when the
// administrator changes their password: a stolen cookie issued under the old
// password must stop working, while the admin performing the change stays
// signed in.
func (s *sessionStore) DestroyOthers(keep string) {
s.mu.Lock()
defer s.mu.Unlock()
for token := range s.sessions {
if token != keep {
delete(s.sessions, token)
}
}
}
// Destroy invalidates a session token (logout). // Destroy invalidates a session token (logout).
func (s *sessionStore) Destroy(token string) { func (s *sessionStore) Destroy(token string) {
s.mu.Lock() s.mu.Lock()
+36
View File
@@ -0,0 +1,36 @@
package web
import "testing"
func TestSessionRename(t *testing.T) {
s := newSessionStore()
token := s.Create("admin")
s.Rename(token, "operator")
name, ok := s.Lookup(token)
if !ok {
t.Fatal("session lost after rename")
}
if name != "operator" {
t.Fatalf("session username = %q, want %q", name, "operator")
}
}
// A password change must invalidate every other session (so a cookie captured
// under the old password stops working) while keeping the one performing the
// change signed in.
func TestSessionDestroyOthers(t *testing.T) {
s := newSessionStore()
keep := s.Create("admin")
other := s.Create("admin")
s.DestroyOthers(keep)
if _, ok := s.Lookup(keep); !ok {
t.Fatal("current session was destroyed")
}
if _, ok := s.Lookup(other); ok {
t.Fatal("other session survived")
}
}
+64
View File
@@ -0,0 +1,64 @@
// Panel progressive enhancement. Everything here is optional convenience: the
// pages are fully usable with JavaScript disabled or blocked, and nothing is
// sent to the server from this file.
(function () {
"use strict";
// --- Copy buttons on .code values ------------------------------------
// Values that get carried into another interface (a DNS panel, a mail
// client) sit in a .code-row wrapper next to a Copy button. The text is read
// from the .code element itself, so it can never drift from what is shown.
// navigator.clipboard needs a secure context (HTTPS or localhost); over plain
// HTTP in development it is simply absent, in which case the value stays
// selectable by hand.
document.addEventListener("click", function (ev) {
var button = ev.target.closest("button.copy");
if (!button) {
return;
}
var row = button.closest(".code-row");
var code = row && row.querySelector(".code");
if (!code || !navigator.clipboard) {
return;
}
navigator.clipboard.writeText(code.textContent).then(function () {
var original = button.textContent;
button.textContent = "Copied";
setTimeout(function () {
button.textContent = original;
}, 1500);
}, function () {
/* Clipboard refused (permissions, insecure context): leave the page be. */
});
});
// --- Address list shown only in list mode -----------------------------
// The "Addresses" field applies to list mode only; in wildcard mode the
// server ignores it, so hiding it removes a field that does nothing. The
// toggle runs on load too, because the edit form of an existing application
// may already be set to list mode.
function syncAddressField(select) {
var form = select.closest("form");
var field = form && form.querySelector("[data-addresses]");
if (!field) {
return;
}
// The mode values come from the server (store.AddressModeList), so the
// select carries the one that means "list" rather than this script
// hard-coding it.
field.hidden = select.value !== select.dataset.listMode;
}
function initAddressFields(root) {
root.querySelectorAll("select[data-list-mode]").forEach(function (select) {
syncAddressField(select);
select.addEventListener("change", function () {
syncAddressField(select);
});
});
}
document.addEventListener("DOMContentLoaded", function () {
initAddressFields(document);
});
})();
+10
View File
@@ -26,6 +26,8 @@ var pageFiles = map[string][]string{
"setup": {"templates/setup.html"}, "setup": {"templates/setup.html"},
"login": {"templates/login.html"}, "login": {"templates/login.html"},
"dashboard": {"templates/dashboard.html"}, "dashboard": {"templates/dashboard.html"},
"account": {"templates/account.html"},
"backup": {"templates/backup.html"},
"domain_detail": {"templates/domain_detail.html"}, "domain_detail": {"templates/domain_detail.html"},
"domain_delete": {"templates/domain_delete.html"}, "domain_delete": {"templates/domain_delete.html"},
"sendlog": {"templates/sendlog.html", "templates/sendlog_rows.html"}, "sendlog": {"templates/sendlog.html", "templates/sendlog_rows.html"},
@@ -72,6 +74,14 @@ func (s *Server) render(w http.ResponseWriter, status int, page string, data any
http.Error(w, "template not found", http.StatusInternalServerError) http.Error(w, "template not found", http.StatusInternalServerError)
return return
} }
// The layout's navigation compares .Active against each item, so the key
// must exist on every authenticated page. Defaulting it here keeps a page
// that forgets it from failing to render — it simply highlights nothing.
if m, ok := data.(map[string]any); ok {
if _, has := m["Active"]; !has {
m["Active"] = ""
}
}
var buf bytes.Buffer var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil { if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
logf("panel: render %s: %v", page, err) logf("panel: render %s: %v", page, err)
+32
View File
@@ -0,0 +1,32 @@
{{define "content"}}
<h1>Account</h1>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
<div class="card narrow">
<h2>Panel credentials</h2>
<p class="muted">These are the credentials for this control panel only.
Applications keep their own logins and passwords, which are not affected.</p>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/account">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username"
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
<label for="current_password">Current password</label>
<input id="current_password" name="current_password" type="password"
autocomplete="current-password" required>
<label for="new_password">New password</label>
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
<label for="new_password_confirm">Confirm new password</label>
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
<button type="submit">Save changes</button>
</form>
<p class="muted">Leave both new-password fields empty to change the username
only. Changing the password signs out every other session; this one stays
signed in.</p>
</div>
{{end}}
+32
View File
@@ -0,0 +1,32 @@
{{define "content"}}
<h1>Backup &amp; migration</h1>
<div class="card">
<h2>Full backup</h2>
<p class="muted">Download a full backup of all persistent state — the database,
every domain's DKIM key and the application credentials. Use it to move the
whole server to a new machine: restore it into a container of the
<strong>same SelfPost version</strong>, with the same data mount, before first
start. TLS certificates and the mail queue are not included.</p>
<p class="muted"><strong>The backup file is a secret</strong> (it contains
private keys and credentials). Store and transfer it securely and delete it
once the restore succeeds.</p>
<form class="inline" method="post" action="/backup">
<button type="submit">Download full backup</button>
</form>
</div>
<div class="card">
<h2>Import a domain</h2>
<p class="muted">Move a single domain here from another SelfPost instance using
a domain export file (from that domain's page). Its DKIM key and application
passwords come across, so the published DNS record needs no change. The export
file is a secret, like a full backup.</p>
{{if .ImportErr}}<p class="error">{{.ImportErr}}</p>{{end}}
<form method="post" action="/domains/import" enctype="multipart/form-data">
<label for="importfile">Domain export file</label>
<input id="importfile" name="file" type="file" accept=".json,application/json" required>
<button type="submit">Import domain</button>
</form>
</div>
{{end}}
-34
View File
@@ -2,16 +2,9 @@
<div class="topbar"> <div class="topbar">
<h1>SelfPost</h1> <h1>SelfPost</h1>
<div class="actions muted"> <div class="actions muted">
<span>{{.User}}</span>
<a href="/sendlog">Send log</a>
<a href="/queue">Queue</a>
<a href="/logtail">Log</a>
<form class="inline" method="post" action="/reload"> <form class="inline" method="post" action="/reload">
<button type="submit">Reload</button> <button type="submit">Reload</button>
</form> </form>
<form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button>
</form>
</div> </div>
</div> </div>
@@ -53,31 +46,4 @@
<p class="muted">No domains yet. Add one above to get started.</p> <p class="muted">No domains yet. Add one above to get started.</p>
{{end}} {{end}}
</div> </div>
<div class="card">
<h2>Backup &amp; migration</h2>
<p class="muted">Download a full backup of all persistent state — the database,
every domain's DKIM key and the application credentials. Use it to move the
whole server to a new machine: restore it into a container of the
<strong>same SelfPost version</strong>, with the same data mount, before first
start. TLS certificates and the mail queue are not included.</p>
<p class="muted"><strong>The backup file is a secret</strong> (it contains
private keys and credentials). Store and transfer it securely and delete it
once the restore succeeds.</p>
<form class="inline" method="post" action="/backup">
<button type="submit">Download full backup</button>
</form>
<h2>Import a domain</h2>
<p class="muted">Move a single domain here from another SelfPost instance using
a domain export file (from that domain's page). Its DKIM key and application
passwords come across, so the published DNS record needs no change. The export
file is a secret, like a full backup.</p>
{{if .ImportErr}}<p class="error">{{.ImportErr}}</p>{{end}}
<form method="post" action="/domains/import" enctype="multipart/form-data">
<label for="importfile">Domain export file</label>
<input id="importfile" name="file" type="file" accept=".json,application/json" required>
<button type="submit">Import domain</button>
</form>
</div>
{{end}} {{end}}
+1 -9
View File
@@ -1,13 +1,5 @@
{{define "content"}} {{define "content"}}
<div class="topbar"> <h1>Delete {{.Domain.Name}}</h1>
<h1>Delete {{.Domain.Name}}</h1>
<div class="actions muted">
<span>{{.User}}</span>
<form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button>
</form>
</div>
</div>
<a class="back" href="/domains/{{.Domain.ID}}">&larr; Back to {{.Domain.Name}}</a> <a class="back" href="/domains/{{.Domain.ID}}">&larr; Back to {{.Domain.Name}}</a>
+51 -20
View File
@@ -1,13 +1,5 @@
{{define "content"}} {{define "content"}}
<div class="topbar"> <h1>{{.Domain.Name}}</h1>
<h1>{{.Domain.Name}}</h1>
<div class="actions muted">
<span>{{.User}}</span>
<form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button>
</form>
</div>
</div>
<a class="back" href="/">&larr; All domains</a> <a class="back" href="/">&larr; All domains</a>
@@ -20,9 +12,15 @@
<p class="muted">This password is shown <strong>once only</strong> and is not <p class="muted">This password is shown <strong>once only</strong> and is not
stored. Copy it now — if it is lost, regenerate a new one.</p> stored. Copy it now — if it is lost, regenerate a new one.</p>
<label>Login</label> <label>Login</label>
<span class="code">{{.NewCred.Login}}</span> <div class="code-row">
<span class="code">{{.NewCred.Login}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>Password</label> <label>Password</label>
<span class="code">{{.NewCred.Password}}</span> <div class="code-row">
<span class="code">{{.NewCred.Password}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div> </div>
{{end}} {{end}}
@@ -32,18 +30,47 @@
It is not a secret and can be viewed at any time.</p> It is not a secret and can be viewed at any time.</p>
<label>Host / name</label> <label>Host / name</label>
<span class="code">{{.Record.Name}}</span> <div class="code-row">
<span class="code">{{.Record.Name}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>Type</label> <label>Type</label>
<span class="code">TXT</span> <span class="code">TXT</span>
<label>Value</label> <label>Value</label>
<span class="code">{{.Record.Value}}</span> <div class="code-row">
<span class="code">{{.Record.Value}}</span>
<button type="button" class="copy">Copy</button>
</div>
<p class="muted">Also configure SPF and DMARC for the domain (see the <p class="muted">Also configure SPF and DMARC for the domain (see the
documentation). Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p> documentation). Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
</div> </div>
<div class="card">
<h2>Sending server settings</h2>
<p class="muted">Point the mail client or script at these settings and
authenticate with an application login and password from the
<strong>Applications</strong> section below. They are the same for every
domain on this server.</p>
<label>Server</label>
<div class="code-row">
<span class="code">{{.Hostname}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>Port and encryption</label>
<span class="code">465 — SSL/TLS (implicit){{if .SubmissionEnabled}}
587 — STARTTLS (submission){{end}}</span>
<p class="muted">Authentication is required on every port. The username is the
application's login (see the table below) and the password is the one shown
once when that application was created or its password regenerated — if it was
lost, generate a new one.</p>
</div>
<div class="card"> <div class="card">
<h2>Applications</h2> <h2>Applications</h2>
<p class="muted">Each application is a SASL login/password an app or script <p class="muted">Each application is a SASL login/password an app or script
@@ -70,13 +97,15 @@
<summary>Edit mode</summary> <summary>Edit mode</summary>
<form method="post" action="/applications/{{.ID}}/mode"> <form method="post" action="/applications/{{.ID}}/mode">
<label>Address mode</label> <label>Address mode</label>
<select name="mode"> <select name="mode" data-list-mode="{{$.List}}">
<option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option> <option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option>
<option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option> <option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option>
</select> </select>
<label>Addresses (for list mode; one per line or comma-separated)</label> <div data-addresses>
<textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}} <label>Addresses (one per line or comma-separated)</label>
<textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}}
{{end}}{{$a}}{{end}}</textarea> {{end}}{{$a}}{{end}}</textarea>
</div>
<button type="submit">Save mode</button> <button type="submit">Save mode</button>
</form> </form>
</details> </details>
@@ -161,14 +190,16 @@
value="{{.FormLogin}}" required> value="{{.FormLogin}}" required>
<label for="mode">Address mode</label> <label for="mode">Address mode</label>
<select id="mode" name="mode"> <select id="mode" name="mode" data-list-mode="{{.List}}">
<option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option> <option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option>
<option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option> <option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option>
</select> </select>
<label for="addresses">Addresses (for list mode; one per line or comma-separated)</label> <div data-addresses>
<textarea id="addresses" name="addresses" rows="3" <label for="addresses">Addresses (one per line or comma-separated)</label>
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea> <textarea id="addresses" name="addresses" rows="3"
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
</div>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}} {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<button type="submit">Create application</button> <button type="submit">Create application</button>
+53
View File
@@ -6,6 +6,7 @@
<title>{{.Title}}</title> <title>{{.Title}}</title>
<link rel="icon" href="/static/favicon.png" type="image/png"> <link rel="icon" href="/static/favicon.png" type="image/png">
<script src="/static/htmx.min.js" defer></script> <script src="/static/htmx.min.js" defer></script>
<script src="/static/panel.js" defer></script>
<style> <style>
:root { color-scheme: light dark; } :root { color-scheme: light dark; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -75,11 +76,63 @@
details form { margin-top: 0.6rem; } details form { margin-top: 0.6rem; }
.credential { border-color: #f5c518; background: #fffbeb; } .credential { border-color: #f5c518; background: #fffbeb; }
@media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } } @media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } }
/* Panel navigation: rendered once from this layout, so it is present on every
authenticated page without each content template having to include it. */
.nav {
display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline;
gap: 0.4rem 1rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
border-bottom: 1px solid #e2e5e9;
}
@media (prefers-color-scheme: dark) { .nav { border-color: #2b3138 !important; } }
.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: baseline; }
.nav .links a, .nav .links [aria-current] { padding: 0.2rem 0.5rem; border-radius: 6px; }
.nav .links [aria-current] {
font-weight: 600; color: #1b1f24; background: #e6ebf5; box-shadow: inset 0 -2px 0 #2563eb;
}
@media (prefers-color-scheme: dark) {
.nav .links [aria-current] { color: #e6e8eb !important; background: #22303f !important; }
}
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
.code-row .code { flex: 1; min-width: 0; }
button.copy {
flex: none; margin-top: 0.3rem; padding: 0.45rem 0.7rem; font-size: 0.8rem;
background: #eef1f5; color: #2563eb; border: 1px solid #cfd4da;
}
button.copy:hover { background: #e2e7ee; }
@media (prefers-color-scheme: dark) {
button.copy { background: #22262b !important; border-color: #2b3138 !important; }
button.copy:hover { background: #2b3138 !important; }
}
</style> </style>
</head> </head>
<body> <body>
<main> <main>
{{if .User}}{{template "nav" .}}{{end}}
{{template "content" .}} {{template "content" .}}
</main> </main>
</body> </body>
</html>{{end}} </html>{{end}}
{{/* nav is the panel's navigation bar. It is rendered here, from the layout, so
every authenticated page has it without the page's own template having to
remember to include it; .Active names the current page so it is highlighted
instead of linking to itself. Unauthenticated pages (login, setup) carry no
.User and get no nav. */}}
{{define "nav"}}
<nav class="nav">
<div class="links">
{{if eq .Active "domains"}}<span aria-current="page">Domains</span>{{else}}<a href="/">Domains</a>{{end}}
{{if eq .Active "sendlog"}}<span aria-current="page">Send log</span>{{else}}<a href="/sendlog">Send log</a>{{end}}
{{if eq .Active "queue"}}<span aria-current="page">Queue</span>{{else}}<a href="/queue">Queue</a>{{end}}
{{if eq .Active "logtail"}}<span aria-current="page">Log</span>{{else}}<a href="/logtail">Log</a>{{end}}
{{if eq .Active "backup"}}<span aria-current="page">Backup</span>{{else}}<a href="/backup">Backup</a>{{end}}
</div>
<div class="session">
<span class="muted">{{.User}}</span>
{{if eq .Active "account"}}<span aria-current="page">Account</span>{{else}}<a href="/account">Account</a>{{end}}
<form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button>
</form>
</div>
</nav>
{{end}}
+1 -14
View File
@@ -1,18 +1,5 @@
{{define "content"}} {{define "content"}}
<div class="topbar"> <h1>Mail log</h1>
<h1>Mail log</h1>
<div class="actions muted">
<span>{{.User}}</span>
<a href="/">Domains</a>
<a href="/sendlog">Send log</a>
<a href="/queue">Queue</a>
<form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button>
</form>
</div>
</div>
<a class="back" href="/">&larr; Domains</a>
<div class="card"> <div class="card">
<h2>mail.log tail</h2> <h2>mail.log tail</h2>
+1 -14
View File
@@ -1,18 +1,5 @@
{{define "content"}} {{define "content"}}
<div class="topbar"> <h1>Mail queue</h1>
<h1>Mail queue</h1>
<div class="actions muted">
<span>{{.User}}</span>
<a href="/">Domains</a>
<a href="/sendlog">Send log</a>
<a href="/logtail">Log</a>
<form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button>
</form>
</div>
</div>
<a class="back" href="/">&larr; Domains</a>
<div class="card"> <div class="card">
<h2>postqueue -p</h2> <h2>postqueue -p</h2>
+1 -14
View File
@@ -1,18 +1,5 @@
{{define "content"}} {{define "content"}}
<div class="topbar"> <h1>Send log</h1>
<h1>Send log</h1>
<div class="actions muted">
<span>{{.User}}</span>
<a href="/">Domains</a>
<a href="/queue">Queue</a>
<a href="/logtail">Log</a>
<form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button>
</form>
</div>
</div>
<a class="back" href="/">&larr; Domains</a>
<div class="card"> <div class="card">
<h2>Filter</h2> <h2>Filter</h2>
+47
View File
@@ -0,0 +1,47 @@
package web
import (
"bytes"
"strings"
"testing"
)
// The navigation is rendered from the layout, not copied into each page, so
// every page template must resolve it. This is what makes "the nav is on every
// authenticated page" a structural property instead of a checklist item.
func TestEveryPageResolvesNav(t *testing.T) {
tmpl, err := loadTemplates()
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
for name, page := range tmpl.pages {
if page.Lookup("nav") == nil {
t.Errorf("page %q does not resolve the shared nav template", name)
}
}
}
func TestNavMarksActivePage(t *testing.T) {
tmpl, err := loadTemplates()
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
var buf bytes.Buffer
err = tmpl.pages["dashboard"].ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "queue",
})
if err != nil {
t.Fatalf("execute nav: %v", err)
}
out := buf.String()
if !strings.Contains(out, `<span aria-current="page">Queue</span>`) {
t.Errorf("active page is not marked:\n%s", out)
}
if strings.Contains(out, `href="/queue"`) {
t.Errorf("active page still links to itself:\n%s", out)
}
if !strings.Contains(out, `href="/sendlog"`) {
t.Errorf("inactive pages are not linked:\n%s", out)
}
}
+11 -1
View File
@@ -28,6 +28,11 @@ type Config struct {
// to true (spec 7.6.6); it exists as a knob only so the panel can be tested // to true (spec 7.6.6); it exists as a knob only so the panel can be tested
// over plain HTTP in development, never for production. // over plain HTTP in development, never for production.
CookieSecure bool CookieSecure bool
// SubmissionEnabled mirrors SUBMISSION_ENABLE: whether this deployment also
// runs the 587/STARTTLS submission listener next to the primary 465 one
// (spec 5). The panel only reports it on the domain page's connection
// settings; it is a deploy-time flag, not something the panel can verify.
SubmissionEnabled bool
// MailLogPath is where Postfix's delivery log lives, read by the mail.log // MailLogPath is where Postfix's delivery log lives, read by the mail.log
// monitoring view (spec 7.2.13). It is the same path the log-tailer role // monitoring view (spec 7.2.13). It is the same path the log-tailer role
// follows in cmd/panel. // follows in cmd/panel.
@@ -132,7 +137,12 @@ func (s *Server) Handler() http.Handler {
authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication) authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication)
authed.HandleFunc("POST /reload", s.handleReload) authed.HandleFunc("POST /reload", s.handleReload)
// Full-server backup download (spec 7.5.A). // Administrator's own panel credentials.
authed.HandleFunc("/account", s.handleAccount)
// Backup and migration: the page with both actions (spec 7.5.A-B), and the
// full-server backup download itself.
authed.HandleFunc("GET /backup", s.handleBackupPage)
authed.HandleFunc("POST /backup", s.handleBackup) authed.HandleFunc("POST /backup", s.handleBackup)
// Monitoring screens (spec 7.2.11-13): each page and its HTMX polling // Monitoring screens (spec 7.2.11-13): each page and its HTMX polling