Compare commits
5 Commits
v1.0.0
...
7eb168f418
| Author | SHA1 | Date | |
|---|---|---|---|
| 7eb168f418 | |||
| a1b6209470 | |||
| 44683a4996 | |||
| 870012514a | |||
| 012802d83d |
@@ -5,6 +5,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The panel's **Account** entry is now called **Settings** — nav link, page
|
||||||
|
heading, and browser title. The route stays `/account`, so existing links
|
||||||
|
and bookmarks are unaffected.
|
||||||
|
- The signed-in name in the panel's nav is now labelled `User:`, so it reads as
|
||||||
|
the current account rather than as a stray word above the Settings link.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Release CI: retry `docker push` / `imagetools create` on transient GHCR
|
- Release CI: retry `docker push` / `imagetools create` on transient GHCR
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ send log and DNS checks in the panel, encrypted backups.
|
|||||||
| [Architecture](docs/architecture.md) | As-built technical design |
|
| [Architecture](docs/architecture.md) | As-built technical design |
|
||||||
| [Security](docs/security.md) | Accepted security trade-offs and requirements |
|
| [Security](docs/security.md) | Accepted security trade-offs and requirements |
|
||||||
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
|
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
|
||||||
| [Roadmap](docs/roadmap.md) | Open work (v1.x tail, 2.x) — internal, Russian |
|
| [Roadmap](docs/roadmap.md) | Open work (1.x+) — internal, Russian |
|
||||||
| [CHANGELOG](CHANGELOG.md) | Release history |
|
| [CHANGELOG](CHANGELOG.md) | Release history |
|
||||||
|
|
||||||
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
|
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
|
||||||
|
|||||||
+10
-6
@@ -1,8 +1,9 @@
|
|||||||
# SelfPost — development
|
# SelfPost — development
|
||||||
|
|
||||||
**What this file is.** How to build, test, document, and ship changes. Open
|
**What this file is.** How to build, test, document, and ship changes. Open
|
||||||
work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries:
|
work after 1.0 (1.x+) lives in [roadmap.md](roadmap.md) and linked
|
||||||
[product.md](product.md). As-built layout: [architecture.md](architecture.md).
|
[plans/](plans/). Product boundaries: [product.md](product.md). As-built layout:
|
||||||
|
[architecture.md](architecture.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,10 +12,12 @@ work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries:
|
|||||||
After `/clear` or a fresh chat:
|
After `/clear` or a fresh chat:
|
||||||
|
|
||||||
1. Read this file (process, docs rules, model routing).
|
1. Read this file (process, docs rules, model routing).
|
||||||
2. Open [roadmap.md](roadmap.md) for open work. Accepted risks —
|
2. Open [roadmap.md](roadmap.md) for the index of open work; follow the linked
|
||||||
[security.md](security.md); as-built — [architecture.md](architecture.md).
|
plan file for the active item. Accepted risks — [security.md](security.md);
|
||||||
|
as-built — [architecture.md](architecture.md).
|
||||||
3. Skim [product.md](product.md) if scope is in doubt.
|
3. Skim [product.md](product.md) if scope is in doubt.
|
||||||
4. Continue from the next unchecked step in the active plan.
|
4. Continue from the next unchecked step in the **active** plan file (not the
|
||||||
|
roadmap index).
|
||||||
|
|
||||||
History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md),
|
History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md),
|
||||||
not duplicated here.
|
not duplicated here.
|
||||||
@@ -273,7 +276,8 @@ There is no `docs/archive/` directory.
|
|||||||
| As-built design | [architecture.md](architecture.md) |
|
| As-built design | [architecture.md](architecture.md) |
|
||||||
| Development process (this file) | [development.md](development.md) |
|
| Development process (this file) | [development.md](development.md) |
|
||||||
| Security requirements and accepted risks | [security.md](security.md) |
|
| Security requirements and accepted risks | [security.md](security.md) |
|
||||||
| Internal roadmap (2.x) | [roadmap.md](roadmap.md) |
|
| Internal roadmap (1.x+) | [roadmap.md](roadmap.md) |
|
||||||
|
| Active design plans | [plans/](plans/) |
|
||||||
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
||||||
|
|
||||||
### User-facing deliverables
|
### User-facing deliverables
|
||||||
|
|||||||
+1
-1
@@ -223,7 +223,7 @@ service healthy and will mail be accepted?"
|
|||||||
- **Backup** (`/backup`) — download a full-server backup; the same page hosts
|
- **Backup** (`/backup`) — download a full-server backup; the same page hosts
|
||||||
the domain-import form (`POST /domains/import`). See
|
the domain-import form (`POST /domains/import`). See
|
||||||
[Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain).
|
[Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain).
|
||||||
- **Account** (`/account`) — change the administrator username and/or password.
|
- **Settings** (`/account`) — change the administrator username and/or password.
|
||||||
Application SASL logins are separate and are not changed here.
|
Application SASL logins are separate and are not changed here.
|
||||||
|
|
||||||
**Sessions.** A login survives a container restart: sessions live in SQLite, not
|
**Sessions.** A login survives a container restart: sessions live in SQLite, not
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# План: domain-admin (роль администратора домена)
|
||||||
|
|
||||||
|
**Статус:** согласовано
|
||||||
|
**Версия:** целевой bump **1.x** MINOR при совместимой миграции текущего админа
|
||||||
|
в глобального.
|
||||||
|
**Порядок:** рекомендуется после [web-split](web-split.md), до
|
||||||
|
[inbound-relay](inbound-relay.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль
|
||||||
|
([web.go](../../internal/web/web.go) — обёртка
|
||||||
|
`mux.Handle("/", s.requireAuth(authed))`), сессия не несёт ничего, кроме факта
|
||||||
|
входа.
|
||||||
|
|
||||||
|
Роль выдаёт доступ к **явно назначенным доменам** (одному или нескольким);
|
||||||
|
перечень доменов определяет **глобальный администратор**. Для каждого домена из
|
||||||
|
списка:
|
||||||
|
|
||||||
|
- приложения этого домена (создание, режим отправителя, перегенерация пароля,
|
||||||
|
удаление, свой L2-лимит);
|
||||||
|
- DKIM/DNS-статус домена;
|
||||||
|
- журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть
|
||||||
|
([sendLogData](../../internal/web/handlers_monitor.go)).
|
||||||
|
|
||||||
|
Вне роли остаётся то, что глобально по своей природе:
|
||||||
|
|
||||||
|
- добавление и удаление доменов;
|
||||||
|
- создание domain-admin пользователей и назначение им доменов;
|
||||||
|
- `/reload`;
|
||||||
|
- полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены
|
||||||
|
сразу);
|
||||||
|
- очередь и хвост `mail.log` — они серверные и к домену не привязаны.
|
||||||
|
|
||||||
|
## Почему расширение v1.0
|
||||||
|
|
||||||
|
[product.md](../product.md) относит «несколько пользователей панели, роли» к
|
||||||
|
out of scope (один администратор). Появление второго субъекта — сознательное
|
||||||
|
расширение границ проекта, как и inbound-relay.
|
||||||
|
|
||||||
|
Цена — уровня фазы, а не патча:
|
||||||
|
|
||||||
|
- таблица пользователей и их привязка к доменам;
|
||||||
|
- роль в сессии;
|
||||||
|
- авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}`
|
||||||
|
не сверяются ни с чем, кроме существования);
|
||||||
|
- пересмотр первичного setup'а и смены пароля под нескольких пользователей;
|
||||||
|
- учёт нового субъекта в бэкапе и экспорте домена.
|
||||||
|
|
||||||
|
*(Прежняя формулировка этого пункта — «2FA и несколько администраторов» —
|
||||||
|
заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до
|
||||||
|
одной конкретной роли, потому что нужна не вторая копия всевластного админа, а
|
||||||
|
ограниченный доступ владельца одного или нескольких доменов — перечень задаёт
|
||||||
|
глобальный администратор.)*
|
||||||
|
|
||||||
|
## Готово, когда
|
||||||
|
|
||||||
|
- Глобальный администратор и domain-admin с разными правами работают через
|
||||||
|
панель; domain-admin не может выйти за пределы **назначенных** доменов;
|
||||||
|
- текущий единственный админ мигрирует в глобального без потери доступа;
|
||||||
|
- бэкап/восстановление учитывает пользователей и привязки;
|
||||||
|
- `build`/`vet`/`test`/образ зелёные.
|
||||||
|
|
||||||
|
## Риски
|
||||||
|
|
||||||
|
- Неполная проверка `{id}`/`{aid}` в хендлерах — утечка доступа к чужому
|
||||||
|
домену;
|
||||||
|
- breaking setup/бэкап — тогда semver major, не 1.x.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# План: inbound-relay (входящий релей)
|
||||||
|
|
||||||
|
**Статус:** согласовано
|
||||||
|
**Версия:** целевой bump **1.x** MINOR; **возможен 2.x** — требует уточнения по
|
||||||
|
итогам реализации (не фиксировать major заранее).
|
||||||
|
**Модель:** Opus (инфра/безопасность, риск open relay).
|
||||||
|
**Порядок:** рекомендуется после [web-split](web-split.md) и
|
||||||
|
[domain-admin](domain-admin.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Возможность принимать почту на порт 25 для явно настроенных доменов и пересылать
|
||||||
|
её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как
|
||||||
|
выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность
|
||||||
|
атаки базового исходящего релея.
|
||||||
|
|
||||||
|
## Зачем это нужно (сценарии)
|
||||||
|
|
||||||
|
- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно
|
||||||
|
недоступен, и передать её, когда он вернётся.
|
||||||
|
- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер,
|
||||||
|
который по каким-то причинам **сам не может принимать почту из интернета**
|
||||||
|
(нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на
|
||||||
|
входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает
|
||||||
|
публичным входным узлом для домена (MX указывает на него) и пересылает почту
|
||||||
|
на этот внутренний/недоступный извне сервер.
|
||||||
|
|
||||||
|
## Граница объёма (критично — что это НЕ)
|
||||||
|
|
||||||
|
- **ЭТО:** приём на 25 для доменов из явного списка + пересылка (relay/forward)
|
||||||
|
на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`).
|
||||||
|
Postfix здесь — чистый пересыльщик, без локальной доставки.
|
||||||
|
- **ЭТО НЕ (out of scope, [product.md](../product.md)):** локальная доставка в
|
||||||
|
почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost
|
||||||
|
также **не реализует и не тянет в свой образ** движок антиспама/антивируса
|
||||||
|
(rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не**
|
||||||
|
перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет
|
||||||
|
точку подключения внешнего фильтра.
|
||||||
|
|
||||||
|
## Почему как опция/плагин
|
||||||
|
|
||||||
|
- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter,
|
||||||
|
spam-ingress). Поэтому по умолчанию **выключено** флагом env
|
||||||
|
`INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора.
|
||||||
|
- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели,
|
||||||
|
отдельная ветка генерации конфига. При выключенном флаге входной listener,
|
||||||
|
таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен.
|
||||||
|
|
||||||
|
## Что делать
|
||||||
|
|
||||||
|
- Env-флаг `INBOUND_RELAY_ENABLE` (default false); при `true` — генерировать
|
||||||
|
входной сервис и его конфиг из состояния панели тем же путём, что остальной
|
||||||
|
конфиг (`postfix-config.sh`).
|
||||||
|
- **`master.cf`:** входной `smtp inet` на 25 для приёма из интернета (сейчас 25
|
||||||
|
используется только на исходящую доставку). Отдельный от 465/587: на 25 **не**
|
||||||
|
предлагается SASL и **не** разрешается отправка наружу — только приём для
|
||||||
|
`relay_domains`.
|
||||||
|
- **Анти-open-relay для входящей (обязательно):**
|
||||||
|
`smtpd_relay_restrictions`/`smtpd_recipient_restrictions` входного smtpd
|
||||||
|
принимают почту **только** для доменов из `relay_domains` и **только** для
|
||||||
|
известных получателей (`relay_recipient_maps`); всё прочее —
|
||||||
|
`reject_unauth_destination`/`reject_unlisted_recipient`. Открытый релей и приём
|
||||||
|
«для кого угодно» невозможны.
|
||||||
|
- **Backscatter:** предпочтительно знать валидных получателей (reject unknown
|
||||||
|
recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса.
|
||||||
|
- **Панель управляет:** список входящих доменов; для каждого — upstream
|
||||||
|
destination (`host:port`, транспорт), опциональный список валидных
|
||||||
|
получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта
|
||||||
|
(whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе
|
||||||
|
4), `os/exec` без shell ([security.md](../security.md)).
|
||||||
|
- **Милтеры:** OpenDKIM на входящем тракте не нужен (чужую входящую не
|
||||||
|
подписываем). journal-milter опционально переиспользовать для журнала входящих
|
||||||
|
(доп. работа) либо на первом этапе оставить входящий без него; поведение
|
||||||
|
fail-open сохраняется.
|
||||||
|
- **Rate-limit/размер:** грубый лимит по client IP (`anvil`, как L1) и
|
||||||
|
`message_size_limit` на входном smtpd.
|
||||||
|
|
||||||
|
## Антиспам (важная, но опциональная возможность)
|
||||||
|
|
||||||
|
Это ценная опция, но она **не обязательна**: часть операторов вполне устроит
|
||||||
|
**слепая пересылка без фильтрации** — например, когда backend сам умеет
|
||||||
|
фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик.
|
||||||
|
Поэтому антиспам-хук по умолчанию **выключен** (пустой
|
||||||
|
`INBOUND_ANTISPAM_MILTER`), и входящий релей полностью работоспособен без него.
|
||||||
|
|
||||||
|
Важно другое — где фильтрация возможна технически: при «слепом» relay целевой
|
||||||
|
backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя,
|
||||||
|
поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация
|
||||||
|
проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF
|
||||||
|
домена-отправителя). **Единственная точка, где ещё виден настоящий client IP —
|
||||||
|
входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть
|
||||||
|
*подключаема именно здесь*, а не переложена на backend, который эту информацию
|
||||||
|
уже потерял.
|
||||||
|
|
||||||
|
Дизайн подключения:
|
||||||
|
|
||||||
|
- **Движок антиспама — отдельный опциональный контейнер** (rspamd и т.п.),
|
||||||
|
который оператор запускает **только если нужна эта опция** (тот же принцип,
|
||||||
|
что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его
|
||||||
|
**не содержит и не запускает** — образ и принцип «один контейнер, три
|
||||||
|
процесса» неизменны, [product.md](../product.md) out of scope не нарушается
|
||||||
|
(SelfPost не реализует антиспам).
|
||||||
|
- **SelfPost предоставляет точку подключения:** milter-хук на входном smtpd.
|
||||||
|
Адрес движка задаётся env (например,
|
||||||
|
`INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, пусто → хук выключен) и
|
||||||
|
добавляется в `smtpd_milters` **только входного** тракта (не на 465/587).
|
||||||
|
Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит
|
||||||
|
истинный origin. `milter_default_action` для этого milter'а — конфигурируемый
|
||||||
|
(fail-open vs tempfail); дефолт определить при реализации.
|
||||||
|
- **Нативный backstop без зависимостей:** на том же входном хопе доступны
|
||||||
|
средства Postfix по origin IP — `reject_rbl_client` (DNSBL), проверки
|
||||||
|
HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение
|
||||||
|
аутентификации для downstream через ARC/`Received` там, где часть фильтрации
|
||||||
|
всё же остаётся на backend.
|
||||||
|
- **docker-compose:** задокументировать опциональный фрагмент antispam-сайдкара
|
||||||
|
(как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со
|
||||||
|
стеком только при включённой опции.
|
||||||
|
- **Персистентность:** новые таблицы и map-файлы под `/data` — попадают в полный
|
||||||
|
бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей
|
||||||
|
конфигурацией — опционально, пометить.
|
||||||
|
- **DNS-документация:** для входящего домена нужна `MX`-запись, указывающая на
|
||||||
|
сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS
|
||||||
|
README.
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
[security.md](../security.md): валидация ввода на сервере, экранирование записи
|
||||||
|
в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter.
|
||||||
|
|
||||||
|
## Готово, когда
|
||||||
|
|
||||||
|
При `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого
|
||||||
|
домена пересылается на заданный upstream; почта для ненастроенных
|
||||||
|
доменов/получателей отклоняется (не open relay, не backscatter); при заданном
|
||||||
|
`INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим
|
||||||
|
origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при
|
||||||
|
`INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый
|
||||||
|
исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные.
|
||||||
|
|
||||||
|
## Риски
|
||||||
|
|
||||||
|
- open relay/backscatter — снимается `relay_domains` + `relay_recipient_maps` +
|
||||||
|
`reject_unauth_destination`;
|
||||||
|
- потеря origin IP для фильтрации на backend'е при пересылке — снимается
|
||||||
|
milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё
|
||||||
|
виден;
|
||||||
|
- порт 25 на приём расширяет поверхность атаки (по умолчанию выключено);
|
||||||
|
- semver: при несовместимости контракта (порты, бэкап, поведение без флага) —
|
||||||
|
возможен major `2.x`; решение после реализации.
|
||||||
|
|
||||||
|
**Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа
|
||||||
|
SelfPost, поднимается оператором при включении опции.
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
Готовый исходящий тракт (уже реализован). Согласование получено — см. статус
|
||||||
|
выше.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# План: web-split (разбиение `internal/web`)
|
||||||
|
|
||||||
|
**Статус:** согласовано
|
||||||
|
**Версия:** `1.x`; внутренний рефакторинг, сам по себе breaking не тянет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
`internal/web` — самый крупный пакет проекта: ~50 файлов (включая шаблоны и
|
||||||
|
static), ~25 `.go` / ~4000 строк Go, в одной плоскости лежат хендлеры всех
|
||||||
|
разделов панели, сессии, security-заголовки, проверка Origin, валидация форм и
|
||||||
|
рендер шаблонов.
|
||||||
|
|
||||||
|
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
|
||||||
|
панели.
|
||||||
|
|
||||||
|
## Почему сейчас
|
||||||
|
|
||||||
|
На нынешнем размере плоский пакет читается: имена файлов (`handlers_domains.go`,
|
||||||
|
`handlers_apps.go`, `handlers_monitor.go`) работают не хуже каталогов, а
|
||||||
|
разбиение потянуло бы за собой экспорт того, что сейчас пакетно-приватно, — то
|
||||||
|
есть расширение внутреннего API ради косметики.
|
||||||
|
|
||||||
|
Смысл появляется, когда пакет начнёт расти: **domain-admin** и **inbound-relay**
|
||||||
|
добавляют в него код — роль приносит авторизацию в каждый хендлер, входящий
|
||||||
|
релей — отдельные страницы и хендлеры входящих доменов. Рефакторинг дешевле
|
||||||
|
делать перед этим ростом, чем после.
|
||||||
|
|
||||||
|
## Рекомендуемый порядок
|
||||||
|
|
||||||
|
**web-split → domain-admin → inbound-relay** (см. [roadmap](../roadmap.md)).
|
||||||
|
|
||||||
|
1. **web-split** — заложить структуру пакета (в т.ч. место под `web/auth`), пока
|
||||||
|
нет сквозных правок от роли и новых inbound-хендлеров.
|
||||||
|
2. **domain-admin** — авторизация в каждом хендлере опирается на уже выбранную
|
||||||
|
схему пакета.
|
||||||
|
3. **inbound-relay** — новый вертикальный срез; проще добавить в уже разрезанный
|
||||||
|
пакет, чем рефакторить вместе с двумя предыдущими фичами.
|
||||||
|
|
||||||
|
Порядок рекомендация, не блокер.
|
||||||
|
|
||||||
|
## Готово, когда
|
||||||
|
|
||||||
|
Решение принято осознанно в момент старта работ — либо пакет разрезан по
|
||||||
|
выбранной схеме, либо зафиксировано, что он остаётся плоским. После разрезки:
|
||||||
|
`build`/`vet`/`test` зелёные, поведение панели неизменно.
|
||||||
|
|
||||||
|
## Риски
|
||||||
|
|
||||||
|
- Преждевременное разбиение — лишний внутренний API и churn без выгоды;
|
||||||
|
- откладывание до после роста — сложнее рефакторинг в перемешку с фичами.
|
||||||
+5
-2
@@ -53,8 +53,11 @@ Explicitly excluded to prevent scope creep:
|
|||||||
- A custom MTA — Postfix is used as-is
|
- A custom MTA — Postfix is used as-is
|
||||||
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
||||||
|
|
||||||
Future line **2.x.x** (optional inbound relay, domain-admin role) is tracked in
|
Agreed **1.x+** extensions (optional inbound relay, domain-admin role) are
|
||||||
[roadmap.md](roadmap.md) and requires explicit approval before implementation.
|
tracked in [roadmap.md](roadmap.md) and [plans/](plans/). Inbound relay targets
|
||||||
|
a 1.x MINOR bump by default; a 2.x major remains possible pending
|
||||||
|
implementation. Items still marked *candidate* in the roadmap require explicit
|
||||||
|
approval before coding.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+67
-100
@@ -1,133 +1,100 @@
|
|||||||
# Дорожная карта: SelfPost 2.x.x
|
# Дорожная карта: открытая работа (1.x+)
|
||||||
|
|
||||||
**Статус:** здесь собран объём, отнесённый к релизной линии **2.x.x** — вне
|
**Статус:** внутренний трекер расширений границ v1.0 после явного согласования
|
||||||
базового объёма v1.0/v1.x (v1.x — только исходящий релей). Реализация —
|
([product.md](product.md), [.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)).
|
||||||
только после явного согласования ([product.md](product.md),
|
Детальный дизайн — в [plans/](plans/). Пункты со статусом `кандидат` требуют OK
|
||||||
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)):
|
до кодирования.
|
||||||
[product.md](product.md) явно исключает часть этого объёма (приём входящей
|
|
||||||
почты; несколько пользователей/роли), поэтому включение — сознательное
|
|
||||||
расширение границ проекта, а не доработка по своей инициативе. Присутствие
|
|
||||||
пункта здесь фиксирует намерение и дизайн; кодирование начинается отдельным
|
|
||||||
решением.
|
|
||||||
|
|
||||||
**Основа:** [product.md](product.md) v1.0. Процесс и правила документации —
|
**Версионирование:** по умолчанию SemVer MINOR в линии **1.x+** (`1.1.0`…), если
|
||||||
[development.md](development.md). История закрытых фаз v1.x — в `git log` и
|
дефолты и миграции совместимы с `1.0.0`. Major `2.x` — только при явном breaking.
|
||||||
[CHANGELOG.md](../CHANGELOG.md).
|
|
||||||
|
**Процесс:** [development.md](development.md). История закрытых фаз — в `git log`
|
||||||
|
и [CHANGELOG.md](../CHANGELOG.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.x — хвост документации и деплоя
|
## Индекс
|
||||||
|
|
||||||
**Статус: закрыто** в релизе `1.0.0` / git-тег `v1.0.0`
|
| ID | Тема | Статус | План |
|
||||||
(`ghcr.io/mixeme/selfpost:1.0.0`). План закрытия и `implementation-plan.md`
|
|---|---|---|---|
|
||||||
удалены — история в git и CHANGELOG; `docs/archive/` не храним.
|
| web-split | Разбиение `internal/web` | **согласовано** | [plans/web-split.md](plans/web-split.md) |
|
||||||
|
| domain-admin | Роль администратора домена | **согласовано** | [plans/domain-admin.md](plans/domain-admin.md) |
|
||||||
|
| inbound-relay | Входящий релей (backup-MX / пересылка) | **согласовано** | [plans/inbound-relay.md](plans/inbound-relay.md) |
|
||||||
|
| contributing | `CONTRIBUTING.md` | кандидат | — |
|
||||||
|
|
||||||
| Тема | Итог |
|
**Рекомендуемый порядок** (не обязателен): **web-split → domain-admin →
|
||||||
|---|---|
|
inbound-relay** — сначала разрез пакета, затем сквозная авторизация роли, затем
|
||||||
| Адаптивный опрос мониторинга | 5 с / 30 с / 0 (скрытая вкладка) в `panel.js` |
|
новый вертикальный срез входящего релея. Отклонение допустимо; жёстких фаз нет.
|
||||||
| `mail.log` + reconcile | `/data/log/mail.log`; сверка с `postqueue -p` |
|
|
||||||
| Docs consolidation | процесс в [development.md](development.md); README Documentation |
|
|
||||||
| Compose pin + git tag | `1.0.0` / `v1.0.0` в одном релизном коммите |
|
|
||||||
|
|
||||||
Открытая работа дальше — только секции 2.x ниже.
|
После `/clear` — пункт со статусом `согласовано` или `в работе`, затем чеклист в
|
||||||
|
linked plan.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Фаза O1 (→ 2.x.x) — Входящий релей (backup-MX / пересылка) — опция/плагин
|
## inbound-relay
|
||||||
|
|
||||||
**Цель:** возможность принимать почту на порт 25 для явно настроенных доменов и пересылать её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность атаки базового исходящего релея.
|
**Цель:** опциональный приём почты на порт 25 для явно настроенных доменов и
|
||||||
|
пересылка на upstream (backup-MX / relay-forwarder). По умолчанию выключено
|
||||||
|
(`INBOUND_RELAY_ENABLE=false`); исходящий тракт без флага не меняется.
|
||||||
|
|
||||||
**Зачем это нужно (сценарии):**
|
**Граница:** расширение v1.0 — [product.md](product.md) исключает приём входящей
|
||||||
- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно недоступен, и передать её, когда он вернётся.
|
почты и mailbox'ы. Это relay/forward, не IMAP/POP3/webmail; антиспам-движок — вне
|
||||||
- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер, который по каким-то причинам **сам не может принимать почту из интернета** (нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает публичным входным узлом для домена (MX указывает на него) и пересылает почту на этот внутренний/недоступный извне сервер.
|
образа, только точка подключения.
|
||||||
|
|
||||||
**Граница объёма (критично — что это НЕ):**
|
**Готово, когда:** см. критерии в [plans/inbound-relay.md](plans/inbound-relay.md).
|
||||||
- **ЭТО:** приём на 25 для доменов из явного списка + пересылка (relay/forward) на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`). Postfix здесь — чистый пересыльщик, без локальной доставки.
|
|
||||||
- **ЭТО НЕ (out of scope, [product.md](product.md)):** локальная доставка в почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost также **не реализует и не тянет в свой образ** движок антиспама/антивируса (rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не** перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет точку подключения внешнего фильтра.
|
|
||||||
|
|
||||||
**Почему как опция/плагин:**
|
**Зависимости / риски:** готовый исходящий тракт; open relay/backscatter;
|
||||||
- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter, spam-ingress). Поэтому по умолчанию **выключено** флагом env `INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора.
|
расширение поверхности атаки (порт 25 на приём). Модель: Opus.
|
||||||
- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели, отдельная ветка генерации конфига. При выключенном флаге входной listener, таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен.
|
**Порядок:** рекомендуется после [web-split](plans/web-split.md) и
|
||||||
|
[domain-admin](plans/domain-admin.md).
|
||||||
**Что делать:**
|
**Версия:** целевой bump `1.x`; возможен `2.x` — уточнить по итогам реализации.
|
||||||
- Env-флаг `INBOUND_RELAY_ENABLE` (default false); при `true` — генерировать входной сервис и его конфиг из состояния панели тем же путём, что остальной конфиг (`postfix-config.sh`).
|
|
||||||
- **`master.cf`:** входной `smtp inet` на 25 для приёма из интернета (сейчас 25 используется только на исходящую доставку). Отдельный от 465/587: на 25 **не** предлагается SASL и **не** разрешается отправка наружу — только приём для `relay_domains`.
|
|
||||||
- **Анти-open-relay для входящей (обязательно):** `smtpd_relay_restrictions`/`smtpd_recipient_restrictions` входного smtpd принимают почту **только** для доменов из `relay_domains` и **только** для известных получателей (`relay_recipient_maps`); всё прочее — `reject_unauth_destination`/`reject_unlisted_recipient`. Открытый релей и приём «для кого угодно» невозможны.
|
|
||||||
- **Backscatter:** предпочтительно знать валидных получателей (reject unknown recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса.
|
|
||||||
- **Панель управляет:** список входящих доменов; для каждого — upstream destination (`host:port`, транспорт), опциональный список валидных получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта (whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе 4), `os/exec` без shell ([security.md](security.md)).
|
|
||||||
- **Милтеры:** OpenDKIM на входящем тракте не нужен (чужую входящую не подписываем). journal-milter опционально переиспользовать для журнала входящих (доп. работа) либо на первом этапе оставить входящий без него; поведение fail-open сохраняется.
|
|
||||||
- **Rate-limit/размер:** грубый лимит по client IP (`anvil`, как L1) и `message_size_limit` на входном smtpd.
|
|
||||||
|
|
||||||
**Антиспам (важная, но опциональная возможность).** Это ценная опция, но она **не обязательна**: часть операторов вполне устроит **слепая пересылка без фильтрации** — например, когда backend сам умеет фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик. Поэтому антиспам-хук по умолчанию **выключен** (пустой `INBOUND_ANTISPAM_MILTER`), и входящий релей полностью работоспособен без него. Важно другое — где фильтрация возможна технически: при «слепом» relay целевой backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя, поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF домена-отправителя). **Единственная точка, где ещё виден настоящий client IP — входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть *подключаема именно здесь*, а не переложена на backend, который эту информацию уже потерял. Дизайн подключения:
|
|
||||||
- **Движок антиспама — отдельный опциональный контейнер** (rspamd и т.п.), который оператор запускает **только если нужна эта опция** (тот же принцип, что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его **не содержит и не запускает** — образ и принцип «один контейнер, три процесса» неизменны, [product.md](product.md) out of scope не нарушается (SelfPost не реализует антиспам).
|
|
||||||
- **SelfPost предоставляет точку подключения:** milter-хук на входном smtpd. Адрес движка задаётся env (например, `INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, пусто → хук выключен) и добавляется в `smtpd_milters` **только входного** тракта (не на 465/587). Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит истинный origin. `milter_default_action` для этого milter'а — конфигурируемый (fail-open vs tempfail); дефолт определить при реализации.
|
|
||||||
- **Нативный backstop без зависимостей:** на том же входном хопе доступны средства Postfix по origin IP — `reject_rbl_client` (DNSBL), проверки HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение аутентификации для downstream через ARC/`Received` там, где часть фильтрации всё же остаётся на backend.
|
|
||||||
- **docker-compose:** задокументировать опциональный фрагмент antispam-сайдкара (как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со стеком только при включённой опции.
|
|
||||||
- **Персистентность:** новые таблицы и map-файлы под `/data` — попадают в полный бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей конфигурацией — опционально, пометить.
|
|
||||||
- **DNS-документация:** для входящего домена нужна `MX`-запись, указывающая на сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS README.
|
|
||||||
|
|
||||||
**Безопасность ([security.md](security.md)):** валидация ввода на сервере, экранирование записи в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter.
|
|
||||||
|
|
||||||
**Готово, когда:** при `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого домена пересылается на заданный upstream; почта для ненастроенных доменов/получателей отклоняется (не open relay, не backscatter); при заданном `INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при `INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные.
|
|
||||||
|
|
||||||
**Риски:** open relay/backscatter (снимается `relay_domains` + `relay_recipient_maps` + `reject_unauth_destination`); потеря origin IP для фильтрации на backend'е при пересылке (снимается milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё виден); порт 25 на приём расширяет поверхность атаки (по умолчанию выключено). **Модель:** Opus (инфра/безопасность, риск open relay). **Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа SelfPost, поднимается оператором при включении опции.
|
|
||||||
|
|
||||||
**Зависимости:** не является частью v1.0, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования ([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)) до кодирования.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Роль администратора домена — кандидат на 2.x
|
## domain-admin
|
||||||
|
|
||||||
**Что это.** Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль ([web.go](../internal/web/web.go) — обёртка `mux.Handle("/", s.requireAuth(authed))`), сессия не несёт ничего, кроме факта входа. Роль выдаёт доступ к одному домену и только к нему: приложения этого домена (создание, режим отправителя, перегенерация пароля, удаление, свой L2-лимит), DKIM/DNS-статус домена и журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть ([sendLogData](../internal/web/handlers_monitor.go)). Вне роли остаётся то, что глобально по своей природе: добавление и удаление доменов, `/reload`, полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены сразу), очередь и хвост `mail.log` — они серверные и к домену не привязаны.
|
**Цель:** роль с доступом к одному или нескольким назначенным доменам
|
||||||
|
(перечень задаёт глобальный администратор) — приложения, DKIM/DNS, журнал
|
||||||
|
отправки по каждому из них; без глобальных операций (добавление доменов, полный
|
||||||
|
бэкап, очередь, `mail.log`).
|
||||||
|
|
||||||
**Почему 2.x, а не v1.x.** [product.md](product.md) относит «несколько пользователей
|
**Граница:** расширение v1.0 — [product.md](product.md) фиксирует одного
|
||||||
панели, роли» к out of scope (один администратор), поэтому появление второго
|
администратора. Не вторая копия всевластного админа, а ограниченный доступ к
|
||||||
субъекта — расширение границ проекта, как и Фаза O1: сначала согласование
|
назначенным доменам (одному или нескольким).
|
||||||
([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)), только потом код. Цена — уровня фазы, а не патча: таблица пользователей и их привязка к доменам, роль в сессии, авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}` не сверяются ни с чем, кроме существования), пересмотр первичного setup'а и смены пароля под нескольких пользователей, учёт нового субъекта в бэкапе и экспорте домена.
|
|
||||||
|
|
||||||
*(Прежняя формулировка этого пункта — «2FA и несколько администраторов» — заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до одной конкретной роли, потому что нужна не вторая копия всевластного админа, а ограниченный доступ владельца отдельного домена.)*
|
**Готово, когда:** см. [plans/domain-admin.md](plans/domain-admin.md).
|
||||||
|
|
||||||
|
**Зависимости / риски:** таблица пользователей, роль в сессии, авторизация в
|
||||||
|
каждом хендлере, setup/бэкап. **Порядок:** рекомендуется после
|
||||||
|
[web-split](plans/web-split.md), до [inbound-relay](plans/inbound-relay.md).
|
||||||
|
**Версия:** `1.x` MINOR при совместимой миграции текущего админа в глобального.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## `CONTRIBUTING.md` — кандидат на 2.x
|
## web-split
|
||||||
|
|
||||||
**Что это.** Точка входа для стороннего контрибьютора: dev loop, маршрутизация
|
**Цель:** осознанно разрезать `internal/web` (или зафиксировать плоский пакет)
|
||||||
моделей по типу работы, протокол коммитов, требование
|
перед ростом от inbound-relay и domain-admin.
|
||||||
`gofmt`/`vet`/`test`/`make e2e` до PR. Сейчас всё это есть в
|
|
||||||
[development.md](development.md) (английский процесс) и в этом файле (открытая
|
|
||||||
работа, русский).
|
|
||||||
|
|
||||||
**Почему 2.x, а не v1.x.** Файл имеет смысл, когда есть кому его читать: у
|
**Граница:** внутренний рефакторинг; поведение панели для оператора не меняется.
|
||||||
проекта один разработчик и внешнего потока PR нет, поэтому сейчас
|
|
||||||
`CONTRIBUTING.md` был бы документом без аудитории и ещё одним местом, где
|
|
||||||
расходится правда о dev loop. Уместен вместе с тем, что реально открывает
|
|
||||||
проект вовне: английская документация процесса ([development.md](development.md),
|
|
||||||
README, `architecture.md`; [roadmap.md](roadmap.md) — внутренний трекер, на
|
|
||||||
русском) и первый внешний интерес после публикации релиза.
|
|
||||||
|
|
||||||
**Готово, когда:** `CONTRIBUTING.md` в корне описывает dev loop, требования к
|
**Готово, когда:** пакет разрезан по выбранной схеме или зафиксировано, что
|
||||||
проверкам перед PR и протокол коммитов; [development.md](development.md) не
|
остаётся плоским — см. [plans/web-split.md](plans/web-split.md).
|
||||||
дублирует его, а ссылается.
|
|
||||||
|
**Зависимости / риски:** экспорт пакетно-приватного API. **Порядок:** рекомендуется
|
||||||
|
**первым** среди согласованных фич (до domain-admin и inbound-relay).
|
||||||
|
**Версия:** `1.x`, сам по себе breaking не тянет.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Разбиение `internal/web` на подпакеты — кандидат на 2.x
|
## contributing
|
||||||
|
|
||||||
**Что это.** `internal/web` — самый крупный пакет проекта: ~50 файлов
|
**Цель:** `CONTRIBUTING.md` в корне — dev loop, проверки перед PR, протокол
|
||||||
(включая шаблоны и static), ~25 `.go` / ~4000 строк Go, в одной плоскости
|
коммитов; [development.md](development.md) ссылается, не дублирует.
|
||||||
лежат хендлеры всех разделов панели, сессии,
|
|
||||||
security-заголовки, проверка Origin, валидация форм и рендер шаблонов.
|
|
||||||
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
|
|
||||||
панели.
|
|
||||||
|
|
||||||
**Почему 2.x, а не сейчас.** На нынешнем размере плоский пакет читается: имена
|
**Граница:** документация процесса; уместна при внешнем потоке PR.
|
||||||
файлов (`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`)
|
|
||||||
работают не хуже каталогов, а разбиение потянуло бы за собой экспорт того, что
|
|
||||||
сейчас пакетно-приватно, — то есть расширение внутреннего API ради
|
|
||||||
косметики. Смысл появляется ровно тогда, когда пакет начнёт расти: обе задачи
|
|
||||||
2.x выше добавляют в него код — роль администратора домена приносит
|
|
||||||
авторизацию в каждый хендлер, входящий релей — отдельные страницы и хендлеры
|
|
||||||
входящих доменов. Рефакторинг дешевле делать перед этим ростом, чем после.
|
|
||||||
|
|
||||||
**Готово, когда:** решение принято осознанно в момент старта 2.x — либо пакет
|
**Готово, когда:** файл в корне; development.md не дублирует его.
|
||||||
разрезан, либо зафиксировано, что он остаётся плоским.
|
|
||||||
|
**Зависимости / риски:** пока один разработчик и нет PR — низкий приоритет.
|
||||||
|
**Версия:** без значения для semver.
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
// field after a rejected submission; the password fields are never repopulated.
|
// 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) {
|
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) {
|
||||||
s.render(w, status, "account", map[string]any{
|
s.render(w, status, "account", map[string]any{
|
||||||
"Title": "SelfPost — account",
|
"Title": "SelfPost — settings",
|
||||||
"User": currentUser(r),
|
"User": currentUser(r),
|
||||||
"Active": "account",
|
"Active": "account",
|
||||||
"FormUsername": formUsername,
|
"FormUsername": formUsername,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Account</h1>
|
<h1>Settings</h1>
|
||||||
|
|
||||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{{template "sections" .}}
|
{{template "sections" .}}
|
||||||
<div class="session">
|
<div class="session">
|
||||||
<span class="muted">{{.User}}</span>
|
<span class="muted">User: {{.User}}</span>
|
||||||
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Account</span>{{else}}<a href="/account">{{template "icon-account"}}Account</a>{{end}}
|
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Settings</span>{{else}}<a href="/account">{{template "icon-account"}}Settings</a>{{end}}
|
||||||
<form class="inline" method="post" action="/logout">
|
<form class="inline" method="post" action="/logout">
|
||||||
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
|
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user