fix(platform): isolate authentik admin styling

This commit is contained in:
Codex 2026-05-25 01:08:50 +03:00
parent e9def6672c
commit c1ac8b1a10
16 changed files with 670 additions and 61 deletions

View File

@ -61,17 +61,24 @@ https://ops.nodedc.ru -> Tasker / Operational Core
https://ops-agents.nodedc.ru -> Ops Agents Gateway / MCP endpoint
```
`id.nodedc.ru` is only the public OIDC/login host. Authentik Admin is deliberately kept off that public host; `/if/admin/*` returns `404` there.
Local/NAS check domains used during rollout:
```text
auth.nas.nodedc
auth-admin.nas.nodedc
172.22.0.222
launcher.nas.nodedc
task.nas.nodedc
auth.local.nodedc
auth-admin.local.nodedc
launcher.local.nodedc
task.local.nodedc
```
Use `http://auth-admin.nas.nodedc:18080/if/admin/` for technical Authentik Admin access from the NAS/local network. If a workstation does not resolve `auth-admin.nas.nodedc`, use `http://172.22.0.222:18080/if/admin/` as the local IP fallback. Both entrypoints keep Authentik access separate from the public `id.nodedc.ru` session and must not load NODE.DC auth-flow CSS.
### Platform compose project
Compose project:
@ -278,7 +285,14 @@ The platform Authentik service exposes a stable identity-network alias in the Sy
nodedc-platform-authentik-server
```
When changing this alias or Authentik env wiring, recreate `authentik-server`, `authentik-worker`, and `launcher` together. A freshly recreated Authentik server can temporarily return `503 Service Unavailable` until the worker/bootstrap path is ready, so verify with retry from inside the launcher container. For Launcher-only BFF/frontend fixes, rebuild `nodedc/launcher:local` and recreate only `launcher`.
When changing this alias or Authentik env wiring, recreate `authentik-server`, `authentik-worker`, and `launcher` together. A freshly recreated Authentik server can temporarily return `503 Service Unavailable` until the worker/bootstrap path is ready, so verify with retry from inside the launcher container. For Launcher-only BFF/frontend fixes, rebuild `nodedc/launcher:local` with `--no-cache` and recreate only `launcher`.
Authentik Brand custom CSS must stay empty in live DB. NODE.DC auth/login styling is injected only by templates on the public auth-flow host. After any Authentik template rollout run:
```bash
cd /volume1/docker/nodedc-platform/platform
sudo bash clear-authentik-brand-css.sh
```
Launcher image build is done from the Launcher repo when frontend/backend code changes:

View File

@ -1,5 +1,6 @@
# domains
AUTH_DOMAIN=auth.local.nodedc
AUTH_ADMIN_DOMAIN=auth-admin.local.nodedc
LAUNCHER_DOMAIN=launcher.local.nodedc
TASK_DOMAIN=task.local.nodedc

View File

@ -36,10 +36,16 @@ Later phases should add reproducible configuration for:
## NODE.DC branded login
`custom-templates/branding/nodedc-login.css` is mounted into Authentik at `/templates/branding/nodedc-login.css` and applied by `bootstrap-dev.py` through the native Authentik Brand `branding_custom_css` field.
`custom-templates/branding/nodedc-login.css` is mounted into Authentik at `/templates/branding/nodedc-login.css`, but it must not be stored in the native Authentik Brand `branding_custom_css` field.
`custom-templates/base/header_js.html` keeps Authentik's native config script and adds a minimal NODE.DC field enhancement for the email clear control and password placeholder only.
Authentik Brand custom CSS is global. Authentik also passes it into the Admin/User web component runtime, so broad login selectors can break list pages, toolbars, tables, and other Admin interface content. Keep `Brand.branding_custom_css` empty in local bootstrap and Synology deploy scripts.
`custom-templates/base/header_js.html` keeps Authentik's native config script and applies the NODE.DC favicon globally. It renders NODE.DC auth-screen CSS/DOM helpers only on `/if/flow/`, `/flows/`, and `/login/` paths, and excludes technical admin hosts such as `auth-admin.nas.nodedc` / `auth-admin.local.nodedc`. On public auth paths only, it also passes `nodedc-login.css` into `window.authentik.brand` so Authentik's Lit web components can style their shadow DOM. Admin and User paths receive an empty runtime brand CSS value.
`custom-templates/base/skeleton.html` leaves `<style data-id="brand-css">` empty outside flow/login paths as a second guard. `custom-templates/if/admin.html` tracks Authentik's upstream Admin shell while still including `base/header_js.html`, so module execution sees the sanitized brand config without rewriting Admin markup.
OAuth2 providers are assigned Authentik's `default-invalidation-flow` so application logout completes the IdP session and returns through the NODE.DC launcher route instead of showing the default Authentik application logout screen.
The bootstrap admin must belong to the `authentik Admins` group, and that group must have `is_superuser=True`. Without this, `/if/admin/` can render the admin shell while model list pages appear empty because their API calls are denied.
This is intentionally not an HTML-rewriting proxy. Passwords, MFA, recovery, sessions and audit remain inside Authentik; Launcher and Task Manager stay OIDC clients.

View File

@ -1,5 +1,4 @@
from os import environ
from pathlib import Path
from django.db import transaction
@ -21,8 +20,6 @@ from authentik.providers.oauth2.models import (
from authentik.stages.identification.models import IdentificationStage
from authentik.stages.password.models import PasswordStage
BRANDING_CSS_PATH = Path("/templates/branding/nodedc-login.css")
GROUP_SPECS = [
("nodedc:superadmin", False),
("nodedc:launcher:admin", False),
@ -91,7 +88,10 @@ def ensure_groups():
def ensure_user_groups(groups):
admin_email = environ.get("NODEDC_BOOTSTRAP_ADMIN_EMAIL", "").strip()
admin_email = (
environ.get("NODEDC_BOOTSTRAP_ADMIN_EMAIL", "").strip()
or environ.get("AUTHENTIK_BOOTSTRAP_EMAIL", "").strip()
)
if not admin_email:
return None
@ -105,13 +105,19 @@ def ensure_user_groups(groups):
user.email = admin_email
user.is_active = True
user.type = "internal"
if environ.get("NODEDC_BOOTSTRAP_ADMIN_PASSWORD"):
user.set_password(environ["NODEDC_BOOTSTRAP_ADMIN_PASSWORD"])
admin_password = (
environ.get("NODEDC_BOOTSTRAP_ADMIN_PASSWORD")
or environ.get("AUTHENTIK_BOOTSTRAP_PASSWORD")
)
if admin_password:
user.set_password(admin_password)
user.save()
authentik_admins = Group.objects.filter(name="authentik Admins").first()
if authentik_admins:
user.groups.add(authentik_admins)
authentik_admins, _ = Group.objects.get_or_create(name="authentik Admins")
if not authentik_admins.is_superuser:
authentik_admins.is_superuser = True
authentik_admins.save(update_fields=["is_superuser"])
user.groups.add(authentik_admins)
for name in groups:
user.groups.add(groups[name])
@ -174,12 +180,6 @@ def default_scope_mappings():
return mappings
def read_branding_css():
if BRANDING_CSS_PATH.exists():
return BRANDING_CSS_PATH.read_text(encoding="utf-8")
return ""
def ensure_nodedc_brand():
auth_domain = environ.get("AUTH_DOMAIN", "auth.local.nodedc").strip() or "auth.local.nodedc"
authentication_flow = Flow.objects.get(slug="default-authentication-flow")
@ -214,7 +214,9 @@ def ensure_nodedc_brand():
brand.branding_title = "NODE.DC"
brand.branding_logo = ""
brand.branding_favicon = ""
brand.branding_custom_css = read_branding_css()
# Login styling is injected by flow templates only. Brand custom CSS is global
# and Authentik applies it to Admin/User web components as well.
brand.branding_custom_css = ""
brand.flow_authentication = authentication_flow
brand.flow_invalidation = invalidation_flow
brand.attributes = {

View File

@ -21,8 +21,38 @@
})();
</script>
{% with request_host=request.get_host %}
{% if request_host|slice:":11" != "auth-admin." and request_host|slice:":9" != "id-admin." and request_host|slice:":12" != "172.22.0.222" %}
{% if request.path|slice:":9" == "/if/flow/" or request.path|slice:":7" == "/flows/" or request.path|slice:":7" == "/login/" %}
<script data-id="nodedc-auth-flow-scope">
"use strict";
(function () {
const isAuthentikAdminHost =
window.location.hostname.startsWith("auth-admin.") ||
window.location.hostname.startsWith("id-admin.") ||
window.location.hostname === "172.22.0.222";
const isAuthExperiencePage =
!isAuthentikAdminHost &&
(
window.location.pathname.includes("/if/flow/") ||
window.location.pathname.includes("/flows/") ||
window.location.pathname.includes("/login/")
);
document.documentElement.classList.toggle("nodedc-auth-flow-page", isAuthExperiencePage);
window.addEventListener("DOMContentLoaded", function () {
document.body?.classList.toggle("nodedc-auth-flow-page", isAuthExperiencePage);
}, { once: true });
})();
</script>
<style data-id="nodedc-auth-login-css">
{% include "branding/nodedc-login.css" %}
</style>
<style data-id="nodedc-auth-critical-loader">
:root {
html.nodedc-auth-flow-page {
--ak-global--primary: rgb(195, 255, 102) !important;
--ak-global--background: #0e0f10 !important;
--ak-global--background-image: none !important;
@ -37,22 +67,22 @@
--nodedc-auth-bg: #0e0f10 !important;
}
html,
body,
html body .pf-c-login {
html.nodedc-auth-flow-page,
html.nodedc-auth-flow-page body,
html.nodedc-auth-flow-page body .pf-c-login {
background: #0e0f10 !important;
color-scheme: dark !important;
}
html body ak-flow-executor::part(main),
html body .pf-c-login__main,
html body .pf-c-empty-state,
html body .pf-v5-c-empty-state,
html body .pf-v6-c-empty-state,
html body .pf-c-card,
html body .pf-v5-c-card,
html body .pf-v6-c-card,
html body [class*="empty-state"] {
html.nodedc-auth-flow-page body ak-flow-executor::part(main),
html.nodedc-auth-flow-page body .pf-c-login__main,
html.nodedc-auth-flow-page body .pf-c-empty-state,
html.nodedc-auth-flow-page body .pf-v5-c-empty-state,
html.nodedc-auth-flow-page body .pf-v6-c-empty-state,
html.nodedc-auth-flow-page body .pf-c-card,
html.nodedc-auth-flow-page body .pf-v5-c-card,
html.nodedc-auth-flow-page body .pf-v6-c-card,
html.nodedc-auth-flow-page body [class*="empty-state"] {
background: transparent !important;
box-shadow: none !important;
border: 0 !important;
@ -60,11 +90,11 @@
backdrop-filter: none !important;
}
html body .pf-c-spinner,
html body .pf-v5-c-spinner,
html body .pf-v6-c-spinner,
html body [role="progressbar"],
html body ak-spinner {
html.nodedc-auth-flow-page body .pf-c-spinner,
html.nodedc-auth-flow-page body .pf-v5-c-spinner,
html.nodedc-auth-flow-page body .pf-v6-c-spinner,
html.nodedc-auth-flow-page body [role="progressbar"],
html.nodedc-auth-flow-page body ak-spinner {
--pf-c-spinner--Color: rgb(195, 255, 102) !important;
--pf-v5-c-spinner--Color: rgb(195, 255, 102) !important;
--pf-v6-c-spinner--Color: rgb(195, 255, 102) !important;
@ -74,8 +104,8 @@
color: rgb(195, 255, 102) !important;
}
html body #ak-placeholder.ak-c-placeholder,
html body .ak-c-placeholder[slot="placeholder"] {
html.nodedc-auth-flow-page body #ak-placeholder.ak-c-placeholder,
html.nodedc-auth-flow-page body .ak-c-placeholder[slot="placeholder"] {
position: fixed !important;
top: 50% !important;
left: 50% !important;
@ -98,16 +128,16 @@
overflow: visible !important;
}
html body #ak-placeholder.ak-c-placeholder .pf-c-spinner,
html body .ak-c-placeholder[slot="placeholder"] .pf-c-spinner,
html body #ak-placeholder.ak-c-placeholder [role="progressbar"],
html body .ak-c-placeholder[slot="placeholder"] [role="progressbar"] {
html.nodedc-auth-flow-page body #ak-placeholder.ak-c-placeholder .pf-c-spinner,
html.nodedc-auth-flow-page body .ak-c-placeholder[slot="placeholder"] .pf-c-spinner,
html.nodedc-auth-flow-page body #ak-placeholder.ak-c-placeholder [role="progressbar"],
html.nodedc-auth-flow-page body .ak-c-placeholder[slot="placeholder"] [role="progressbar"] {
opacity: 0 !important;
visibility: hidden !important;
}
html body #ak-placeholder.ak-c-placeholder::before,
html body .ak-c-placeholder[slot="placeholder"]::before {
html.nodedc-auth-flow-page body #ak-placeholder.ak-c-placeholder::before,
html.nodedc-auth-flow-page body .ak-c-placeholder[slot="placeholder"]::before {
content: "";
position: absolute;
inset: 0;
@ -118,14 +148,14 @@
pointer-events: none;
}
html body.nodedc-auth-loading ak-flow-executor::part(main),
html body.nodedc-auth-loading .pf-c-login__main {
html.nodedc-auth-flow-page body.nodedc-auth-loading ak-flow-executor::part(main),
html.nodedc-auth-flow-page body.nodedc-auth-loading .pf-c-login__main {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
html body.nodedc-auth-loading::before {
html.nodedc-auth-flow-page body.nodedc-auth-loading::before {
content: "";
position: fixed;
top: 50%;
@ -141,7 +171,7 @@
pointer-events: none;
}
html body.nodedc-auth-card-ready::before {
html.nodedc-auth-flow-page body.nodedc-auth-card-ready::before {
display: none !important;
}
@ -165,14 +195,38 @@
}
}
</style>
{% endif %}
{% endif %}
{% endwith %}
<script data-id="authentik-config">
"use strict";
const isAuthentikAdminHost =
window.location.hostname.startsWith("auth-admin.") ||
window.location.hostname.startsWith("id-admin.") ||
window.location.hostname === "172.22.0.222";
const isAuthExperiencePage =
!isAuthentikAdminHost &&
(
window.location.pathname.includes("/if/flow/") ||
window.location.pathname.includes("/flows/") ||
window.location.pathname.includes("/login/")
);
const authentikBrand = JSON.parse('{{ brand_json|escapejs }}' || "{}");
if (isAuthExperiencePage) {
const nodedcAuthLoginCss = document.querySelector('style[data-id="nodedc-auth-login-css"]')?.textContent || "";
authentikBrand.branding_custom_css = nodedcAuthLoginCss;
authentikBrand.brandingCustomCss = nodedcAuthLoginCss;
} else {
authentikBrand.branding_custom_css = "";
authentikBrand.brandingCustomCss = "";
}
window.authentik = {
locale: "ru",
config: JSON.parse('{{ config_json|escapejs }}' || "{}"),
brand: JSON.parse('{{ brand_json|escapejs }}' || "{}"),
brand: authentikBrand,
versionFamily: "{{ version_family }}",
versionSubdomain: "{{ version_subdomain }}",
build: "{{ build }}",
@ -197,10 +251,31 @@
});
</script>
{% with request_host=request.get_host %}
{% if request_host|slice:":11" != "auth-admin." and request_host|slice:":9" != "id-admin." and request_host|slice:":12" != "172.22.0.222" %}
{% if request.path|slice:":9" == "/if/flow/" or request.path|slice:":7" == "/flows/" or request.path|slice:":7" == "/login/" %}
<script data-id="nodedc-auth-field-enhancements">
"use strict";
(function () {
const isAuthentikAdminHost =
window.location.hostname.startsWith("auth-admin.") ||
window.location.hostname.startsWith("id-admin.") ||
window.location.hostname === "172.22.0.222";
const isAuthExperiencePage =
!isAuthentikAdminHost &&
(
window.location.pathname.includes("/if/flow/") ||
window.location.pathname.includes("/flows/") ||
window.location.pathname.includes("/login/")
);
if (!isAuthExperiencePage) return;
document.documentElement.classList.add("nodedc-auth-flow-page");
window.addEventListener("DOMContentLoaded", function () {
document.body?.classList.add("nodedc-auth-flow-page");
}, { once: true });
const logoSvg = `
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55" aria-hidden="true">
<defs>
@ -254,9 +329,11 @@
["Continue", "Продолжить"],
["Show password", "Показать пароль"],
["Hide password", "Скрыть пароль"],
["Permission denied", "Доступ ограничен"],
["Permission denied", "Доступ запрещён"],
["Not you?", "Сменить пользователя"],
["Request has been denied.", "Доступ к модулю ограничен."],
["Request has been denied.", "Доступ запрещён."],
["The request has been denied.", "Доступ запрещён."],
["В произведении запроса было отказано.", "Доступ запрещён."],
["Go home", "Вернуться назад"],
["Response returned an error code", "Не удалось завершить операцию."],
]);
@ -366,7 +443,11 @@
denied = [
"Permission denied",
"Доступ ограничен",
"Доступ запрещен",
"Доступ запрещён",
"Request has been denied.",
"The request has been denied.",
"В произведении запроса было отказано.",
"Доступ к модулю ограничен.",
].some((message) => text.includes(message));
});
@ -387,8 +468,8 @@
}
function applyPermissionDeniedLayout(root) {
const deniedTitleMessages = ["Permission denied", "Доступ ограничен"];
const deniedSubtitle = "Доступ к модулю NODE.DC ограничен.";
const deniedTitleMessages = ["Permission denied", "Доступ ограничен", "Доступ запрещен", "Доступ запрещён"];
const deniedSubtitle = "Доступ запрещён.";
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const text = node.nodeValue?.trim() || "";
@ -426,7 +507,19 @@
}
function hidePermissionDeniedReason(root) {
const deniedMessages = ["Request has been denied.", "Доступ к модулю ограничен."];
const deniedMessages = [
"Request has been denied.",
"The request has been denied.",
"В произведении запроса было отказано.",
"Доступ к модулю ограничен.",
"Объяснение:",
"Reason:",
"Привязка политики",
"Policy binding",
"returned result",
"returned a result",
"'None' вернула результат",
];
root.querySelectorAll(".pf-c-alert, .pf-v5-c-alert, .pf-c-empty-state__body, [class*='alert']").forEach((element) => {
const text = element.textContent || "";
@ -547,12 +640,35 @@
enhancePasswordFields(root);
}
function dispatchSyntheticInput(input) {
if (!input) return;
try {
input.dispatchEvent(new InputEvent("input", {
bubbles: true,
composed: true,
inputType: "insertReplacementText",
data: input.value || "",
}));
} catch {
input.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
}
input.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
}
function syncFormFieldsBeforeSubmit(form) {
form.querySelectorAll("#ak-identifier-input, #ak-stage-identification-password, #ak-stage-password-input").forEach((input) => {
dispatchSyntheticInput(input);
});
}
function enhanceSubmitHandoff(root) {
root.querySelectorAll("form").forEach((form) => {
if (form.dataset.nodedcSubmitBound === "true") return;
form.dataset.nodedcSubmitBound = "true";
form.addEventListener("submit", () => {
syncFormFieldsBeforeSubmit(form);
document.body?.classList.add("nodedc-auth-submitting");
}, true);
});
@ -823,3 +939,6 @@
});
})();
</script>
{% endif %}
{% endif %}
{% endwith %}

View File

@ -0,0 +1,50 @@
{% load static %}
{% load i18n %}
{% load authentik_core %}
{% get_current_language as LANGUAGE_CODE %}
<!DOCTYPE html>
<html
lang="{{ LANGUAGE_CODE }}"
data-theme="{% if ui_theme == "dark" %}dark{% else %}light{% endif %}"
data-theme-choice="{% if ui_theme == "dark" %}dark{% elif ui_theme == "light" %}light{% else %}auto{% endif %}"
>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
{# Darkreader breaks the site regardless of theme as its not compatible with webcomponents, and we default to a dark theme based on preferred colour-scheme #}
<meta name="darkreader-lock">
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
<link rel="icon" href="{{ brand.branding_favicon_url }}">
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}">
{% block head_before %}
{% endblock %}
{% include "base/theme.html" %}
{% with request_host=request.get_host %}
{% if request_host|slice:":11" != "auth-admin." and request_host|slice:":9" != "id-admin." and request_host|slice:":12" != "172.22.0.222" %}
{% if request.path|slice:":9" == "/if/flow/" or request.path|slice:":7" == "/flows/" or request.path|slice:":7" == "/login/" %}
<style data-id="brand-css">{{ brand_css }}</style>
{% else %}
<style data-id="brand-css"></style>
{% endif %}
{% else %}
<style data-id="brand-css"></style>
{% endif %}
{% endwith %}
<script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script>
{% block head %}
{% endblock %}
{% for key, value in html_meta.items %}
<meta name="{{key}}" content="{{ value }}" />
{% endfor %}
</head>
<body>
{% block body %}
{% endblock %}
{% block scripts %}
{% endblock %}
</body>
</html>

View File

@ -280,7 +280,7 @@ body.nodedc-auth-permission-denied .pf-c-login__main-header::after {
}
body.nodedc-auth-permission-denied .pf-c-login__main-header::after {
content: "Доступ к модулю NODE.DC ограничен." !important;
content: "Доступ запрещён." !important;
}
.pf-c-login__main-header[data-nodedc-permission-denied="true"] .pf-c-title,
@ -292,6 +292,16 @@ body.nodedc-auth-permission-denied h1.pf-c-title {
display: none !important;
}
body.nodedc-auth-permission-denied ak-flow-executor::part(main),
body.nodedc-auth-permission-denied .pf-c-login__main {
background: transparent !important;
border: 0 !important;
outline: 0 !important;
box-shadow: none !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
.pf-c-login__main-body {
padding: 0 !important;
}

View File

@ -0,0 +1,16 @@
{% extends "base/skeleton.html" %}
{% load authentik_core %}
{% block head %}
<script src="{% versioned_script 'dist/admin/AdminInterface-%v.js' %}" type="module"></script>
{% include "base/header_js.html" %}
{% endblock %}
{% block body %}
<ak-skip-to-content></ak-skip-to-content>
<ak-message-container alignment="bottom"></ak-message-container>
<ak-interface-admin>
{% include "base/placeholder.html" %}
</ak-interface-admin>
{% endblock %}

View File

@ -3,6 +3,9 @@
}
http://{$AUTH_DOMAIN:auth.local.nodedc} {
@auth_admin path /if/admin /if/admin/*
redir @auth_admin http://{$AUTH_ADMIN_DOMAIN:auth-admin.local.nodedc}{uri} 302
@auth_root path /
redir @auth_root http://{$LAUNCHER_DOMAIN:launcher.local.nodedc}/ 302
@ -16,6 +19,17 @@ http://{$AUTH_DOMAIN:auth.local.nodedc} {
}
}
http://{$AUTH_ADMIN_DOMAIN:auth-admin.local.nodedc} {
@admin_root path /
redir @admin_root /if/admin/ 302
reverse_proxy authentik-server:9000 {
header_up Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
}
}
http://{$LAUNCHER_DOMAIN:launcher.local.nodedc} {
reverse_proxy {$LOCAL_LAUNCHER_UPSTREAM:host.docker.internal:5173} {
header_up Host localhost:5173

View File

@ -2,6 +2,8 @@
# This intentionally uses high ports and does not touch existing nodedc-demo.
AUTH_DOMAIN=auth.nas.nodedc
AUTH_ADMIN_DOMAIN=auth-admin.nas.nodedc
AUTH_ADMIN_LAN_HOST=172.22.0.222
LAUNCHER_DOMAIN=launcher.nas.nodedc
TASK_DOMAIN=task.nas.nodedc
NODEDC_PUBLIC_HTTP_PORT=18080

View File

@ -3,6 +3,9 @@
}
http://{$AUTH_DOMAIN} {
@auth_admin path /if/admin /if/admin/*
redir @auth_admin http://{$AUTH_ADMIN_DOMAIN:auth-admin.nas.nodedc}:{$NODEDC_PUBLIC_HTTP_PORT}{uri} 302
@auth_root path /
redir @auth_root http://{$LAUNCHER_DOMAIN}:{$NODEDC_PUBLIC_HTTP_PORT}/ 302
@ -16,6 +19,17 @@ http://{$AUTH_DOMAIN} {
}
}
http://{$AUTH_ADMIN_DOMAIN:auth-admin.nas.nodedc}, http://{$AUTH_ADMIN_LAN_HOST:172.22.0.222} {
@admin_root path /
redir @admin_root /if/admin/ 302
reverse_proxy authentik-server:9000 {
header_up Host {http.request.host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
}
}
http://{$LAUNCHER_DOMAIN} {
reverse_proxy launcher:5173 {
header_up Host {http.request.host}
@ -35,6 +49,9 @@ http://{$TASK_DOMAIN} {
}
http://id.nodedc.ru {
@auth_admin path /if/admin /if/admin/*
respond @auth_admin 404
@auth_root path /
redir @auth_root https://hub.nodedc.ru/ 302

View File

@ -19,6 +19,8 @@ https://ops.nodedc.ru -> Tasker / Operational Core
https://ops-agents.nodedc.ru -> Ops Agents Gateway / MCP
```
`id.nodedc.ru` is the user-facing OIDC/login host. Authentik Admin is intentionally not exposed through this public host; `/if/admin/*` returns `404` there.
В `Caddyfile.http` эти домены проксируются через локальный HTTP edge, но upstream получает `X-Forwarded-Proto: https` и `X-Forwarded-Port: 443`.
## Локальные домены для первичной проверки
@ -27,6 +29,7 @@ https://ops-agents.nodedc.ru -> Ops Agents Gateway / MCP
```text
172.22.0.222 auth.nas.nodedc
172.22.0.222 auth-admin.nas.nodedc
172.22.0.222 launcher.nas.nodedc
172.22.0.222 task.nas.nodedc
```
@ -35,15 +38,19 @@ https://ops-agents.nodedc.ru -> Ops Agents Gateway / MCP
```text
http://auth.nas.nodedc:18080
http://auth-admin.nas.nodedc:18080/if/admin/
http://172.22.0.222:18080/if/admin/
http://launcher.nas.nodedc:18080
http://task.nas.nodedc:18080
http://task.nas.nodedc:18090
```
`auth-admin.nas.nodedc` is the technical Authentik Admin entrypoint. `172.22.0.222:18080` is a local IP fallback for workstations without `auth-admin.nas.nodedc` DNS. Both keep Authentik access separate from the public `id.nodedc.ru` login entrypoint and do not load NODE.DC auth-flow CSS.
## Что входит
- `docker-compose.platform-http.yml` поднимает новый Authentik, Launcher и Caddy edge.
- `Caddyfile.http` маршрутизирует локальные `auth/launcher/task.nas.nodedc` и внешние `id/hub/ops.nodedc.ru`.
- `Caddyfile.http` маршрутизирует локальные `auth/auth-admin/launcher/task.nas.nodedc`, IP fallback `172.22.0.222` для Authentik Admin и внешние `id/hub/ops.nodedc.ru`.
- `deploy-current.sh` синхронизирует compose, Caddyfile и опционально Launcher source в NAS mount. Authentik templates синхронизируются только при явном `SYNC_AUTHENTIK_TEMPLATES=1`.
- `backup-current.sh` делает snapshot Launcher runtime/uploads/Auth templates/config и готовит команду `pg_dump` для Authentik Postgres.
- Tasker поднимается отдельным compose из `NODEDC_TASKMANAGER/plane-app/docker-compose.yaml` на порту `18090`.
@ -134,7 +141,7 @@ TASKER_SYNC_SOURCE=1 ./infra/synology/deploy-current.sh
```bash
cd /volume1/docker/nodedc-platform/launcher/source
sudo /usr/local/bin/docker build -t nodedc/launcher:local .
sudo /usr/local/bin/docker build --no-cache -t nodedc/launcher:local .
cd /volume1/docker/nodedc-platform/platform
sudo /usr/local/bin/docker compose \
@ -143,7 +150,61 @@ sudo /usr/local/bin/docker compose \
up -d --force-recreate --no-deps launcher
```
После такого deploy проверить `healthz`, запись в launcher storage/uploads и сценарий пользователя без аппрува: сохранение аватара не должно показывать экран `Заявка ожидает подтверждения`.
После такого deploy проверить `healthz`, запись в launcher storage/uploads и сценарий пользователя без аппрува: сохранение аватара не должно показывать экран `Заявка ожидает подтверждения`. Дополнительно проверить, что live bundle больше не содержит старый pending gate:
```bash
launcher_asset="$(
curl -k -sS --compressed -H 'Accept: text/html' https://hub.nodedc.ru/ \
| grep -aoE 'index-[A-Za-z0-9_-]+\.js' \
| head -n 1
)"
test -n "$launcher_asset"
if curl -k -sS --compressed "https://hub.nodedc.ru/assets/${launcher_asset}" \
| grep -aq 'Заявка ожидает подтверждения'; then
echo 'old pending gate still present'
exit 1
fi
echo 'launcher-pending-gate-ok'
```
## Authentik Admin и Brand CSS
NODE.DC auth-flow CSS must stay template-scoped. Do not store it in Authentik `Brand.branding_custom_css`: Authentik passes that CSS into Admin/User web component runtime and breaks native controls.
After syncing Authentik templates to NAS, recreate `reverse-proxy authentik-server authentik-worker launcher`, then clear existing global Brand CSS in the live DB:
```bash
cd /volume1/docker/nodedc-platform/platform
sudo bash clear-authentik-brand-css.sh
```
Verify:
```bash
id_admin_status="$(
curl -k -sS -o /dev/null -w '%{http_code}' https://id.nodedc.ru/if/admin/
)"
if [[ "$id_admin_status" != "404" ]]; then
echo "public id admin is not closed: status=${id_admin_status}"
exit 1
fi
echo 'public-id-admin-closed-ok'
auth_admin_page="$(
curl -k -fsS --compressed http://auth-admin.nas.nodedc:18080/if/admin/
)"
printf '%s' "$auth_admin_page" \
| grep -aE '<style data-id="brand-css"></style>|authentikBrand.branding_custom_css = ""'
auth_admin_flow="$(
curl -k -fsS --compressed http://auth-admin.nas.nodedc:18080/if/flow/default-authentication-flow/
)"
if printf '%s' "$auth_admin_flow" | grep -aq '<style data-id="nodedc-auth-login-css">'; then
echo 'admin host still has NODE.DC auth CSS'
exit 1
fi
echo 'auth-admin-css-ok'
```
## Backup текущего состояния

View File

@ -0,0 +1,113 @@
#!/usr/bin/env bash
set -euo pipefail
DOCKER_BIN="${DOCKER_BIN:-/usr/local/bin/docker}"
PLATFORM_DIR="${PLATFORM_DIR:-/volume1/docker/nodedc-platform/platform}"
LAUNCHER_SOURCE_DIR="${LAUNCHER_SOURCE_DIR:-/volume1/docker/nodedc-platform/launcher/source}"
ENV_FILE="${ENV_FILE:-${PLATFORM_DIR}/.env.synology}"
COMPOSE_FILE="${COMPOSE_FILE:-${PLATFORM_DIR}/docker-compose.platform-http.yml}"
if [[ ! -x "${DOCKER_BIN}" ]]; then
DOCKER_BIN="$(command -v docker)"
fi
cd "${PLATFORM_DIR}"
echo "== storage permissions =="
mkdir -p ../launcher/server-storage ../launcher/uploads
chown -R 1000:1000 ../launcher/server-storage ../launcher/uploads
chmod -R u+rwX,g+rwX ../launcher/server-storage ../launcher/uploads
echo "== launcher image build =="
cd "${LAUNCHER_SOURCE_DIR}"
"${DOCKER_BIN}" build --no-cache -t nodedc/launcher:local .
echo "== platform services recreate =="
cd "${PLATFORM_DIR}"
"${DOCKER_BIN}" compose \
--env-file "${ENV_FILE}" \
-f "${COMPOSE_FILE}" \
up -d --force-recreate --no-deps reverse-proxy authentik-server authentik-worker launcher
echo "== clear authentik global brand css =="
DOCKER_BIN="${DOCKER_BIN}" bash "${PLATFORM_DIR}/clear-authentik-brand-css.sh"
echo "== launcher storage check =="
"${DOCKER_BIN}" exec nodedc-platform-launcher-1 sh -lc \
'touch /app/server/storage/.write-test /app/server/storage/uploads/.write-test && rm /app/server/storage/.write-test /app/server/storage/uploads/.write-test && echo storage-ok'
echo "== launcher -> authentik api check =="
"${DOCKER_BIN}" exec nodedc-platform-launcher-1 sh -lc '
echo "$NODEDC_AUTHENTIK_BASE_URL"
getent hosts nodedc-platform-authentik-server
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if wget -qSO- \
--header "Authorization: Bearer $NODEDC_AUTHENTIK_SERVICE_TOKEN" \
"$NODEDC_AUTHENTIK_BASE_URL/api/v3/core/groups/?search=nodedc_admin" \
>/tmp/authentik-api-check.json \
2>/tmp/authentik-api-check.headers; then
head -n 25 /tmp/authentik-api-check.headers
exit 0
fi
head -n 25 /tmp/authentik-api-check.headers || true
echo "authentik-api-not-ready attempt=$attempt"
sleep 10
done
exit 1
'
echo "== auth flow check =="
auth_flow=""
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if auth_flow="$(
curl -k -fsS --compressed https://id.nodedc.ru/if/flow/default-authentication-flow/
)"; then
break
fi
echo "auth-flow-not-ready attempt=${attempt}"
sleep 10
done
printf '%s' "$auth_flow" \
| grep -aE 'syncFormFieldsBeforeSubmit|branding_custom_css = ""|Запросить доступ'
echo "== public admin closed check =="
id_admin_status="$(
curl -k -sS -o /dev/null -w '%{http_code}' https://id.nodedc.ru/if/admin/
)"
if [[ "$id_admin_status" != "404" ]]; then
echo "public id admin is not closed: status=${id_admin_status}"
exit 1
fi
echo "public-id-admin-closed-ok"
echo "== technical admin css check =="
auth_admin_page="$(
curl -k -fsS --compressed -H 'Host: auth-admin.nas.nodedc' http://127.0.0.1:18080/if/admin/
)"
printf '%s' "$auth_admin_page" \
| grep -aE '<style data-id="brand-css"></style>|authentikBrand.branding_custom_css = ""'
auth_admin_flow="$(
curl -k -fsS --compressed -H 'Host: auth-admin.nas.nodedc' http://127.0.0.1:18080/if/flow/default-authentication-flow/
)"
if printf '%s' "$auth_admin_flow" | grep -aq '<style data-id="nodedc-auth-login-css">'; then
echo "admin host still has NODE.DC auth CSS"
exit 1
fi
echo "auth-admin-css-ok"
echo "== launcher bundle check =="
launcher_asset="$(
curl -k -fsS --compressed -H 'Accept: text/html' https://hub.nodedc.ru/ \
| grep -aoE 'index-[A-Za-z0-9_-]+\.js' \
| head -n 1
)"
test -n "$launcher_asset"
if curl -k -fsS --compressed "https://hub.nodedc.ru/assets/${launcher_asset}" \
| grep -aq 'Заявка ожидает подтверждения'; then
echo "old pending gate still present"
exit 1
fi
echo "launcher-pending-gate-ok"
echo "runtime-apply-ok"

View File

@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
DOCKER_BIN="${DOCKER_BIN:-/usr/local/bin/docker}"
AUTHENTIK_CONTAINER="${AUTHENTIK_CONTAINER:-nodedc-platform-authentik-server-1}"
"${DOCKER_BIN}" exec "${AUTHENTIK_CONTAINER}" ak shell -c '
from authentik.brands.models import Brand
cleared = []
for brand in Brand.objects.all():
if brand.branding_custom_css:
brand.branding_custom_css = ""
brand.save(update_fields=["branding_custom_css"])
cleared.append(brand.domain)
print("cleared_brand_css=" + str(len(cleared)))
if cleared:
print("domains=" + ",".join(cleared))
'

View File

@ -30,6 +30,9 @@ rsync -av \
rsync -av \
"${PLATFORM_REPO}/infra/synology/deploy-current.sh" \
"${PLATFORM_REPO}/infra/synology/backup-current.sh" \
"${PLATFORM_REPO}/infra/synology/apply-current-runtime.sh" \
"${PLATFORM_REPO}/infra/synology/verify-current-runtime.sh" \
"${PLATFORM_REPO}/infra/synology/clear-authentik-brand-css.sh" \
"${NAS_ROOT}/platform/"
if [[ "${SYNC_AUTHENTIK_TEMPLATES}" == "1" ]]; then
@ -160,13 +163,23 @@ cat <<'EOF'
Synced files to NAS mount. Run on Synology to apply runtime changes:
cd /volume1/docker/nodedc-platform/platform
sudo bash apply-current-runtime.sh
If runtime was already applied and only verification is needed:
cd /volume1/docker/nodedc-platform/platform
sudo bash verify-current-runtime.sh
Manual equivalent:
cd /volume1/docker/nodedc-platform/platform
sudo mkdir -p ../launcher/server-storage ../launcher/uploads
sudo chown -R 1000:1000 ../launcher/server-storage ../launcher/uploads
sudo chmod -R u+rwX,g+rwX ../launcher/server-storage ../launcher/uploads
cd /volume1/docker/nodedc-platform/launcher/source
sudo /usr/local/bin/docker build -t nodedc/launcher:local .
sudo /usr/local/bin/docker build --no-cache -t nodedc/launcher:local .
cd /volume1/docker/nodedc-platform/platform
sudo /usr/local/bin/docker compose \
@ -181,6 +194,12 @@ sudo /usr/local/bin/docker compose \
-f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \
up -d --force-recreate --no-deps reverse-proxy authentik-server authentik-worker launcher
After Authentik template rollout, clear global Brand custom CSS from the live DB.
NODE.DC login CSS must be template-scoped, not stored in Brand.branding_custom_css:
cd /volume1/docker/nodedc-platform/platform
sudo bash clear-authentik-brand-css.sh
Verify:
sudo /usr/local/bin/docker exec nodedc-platform-launcher-1 sh -lc \
@ -201,7 +220,44 @@ sudo /usr/local/bin/docker exec nodedc-platform-launcher-1 sh -lc '
'
curl -k -sS --compressed https://id.nodedc.ru/if/flow/default-authentication-flow/ \
| grep -aE 'hub.nodedc.ru|launcher.local|getLauncherBaseUrl|Запросить доступ'
| grep -aE 'syncFormFieldsBeforeSubmit|branding_custom_css = ""|hub.nodedc.ru|launcher.local|getLauncherBaseUrl|Запросить доступ'
id_admin_status="$(
curl -k -sS -o /dev/null -w '%{http_code}' https://id.nodedc.ru/if/admin/
)"
if [[ "$id_admin_status" != "404" ]]; then
echo "public id admin is not closed: status=${id_admin_status}"
exit 1
fi
echo 'public-id-admin-closed-ok'
auth_admin_page="$(
curl -k -fsS --compressed http://auth-admin.nas.nodedc:18080/if/admin/
)"
printf '%s' "$auth_admin_page" \
| grep -aE '<style data-id="brand-css"></style>|authentikBrand.branding_custom_css = ""'
auth_admin_flow="$(
curl -k -fsS --compressed http://auth-admin.nas.nodedc:18080/if/flow/default-authentication-flow/
)"
if printf '%s' "$auth_admin_flow" | grep -aq '<style data-id="nodedc-auth-login-css">'; then
echo 'admin host still has NODE.DC auth CSS'
exit 1
fi
echo 'auth-admin-css-ok'
launcher_asset="$(
curl -k -sS --compressed -H 'Accept: text/html' https://hub.nodedc.ru/ \
| grep -aoE 'index-[A-Za-z0-9_-]+\.js' \
| head -n 1
)"
test -n "$launcher_asset"
if curl -k -sS --compressed "https://hub.nodedc.ru/assets/${launcher_asset}" \
| grep -aq 'Заявка ожидает подтверждения'; then
echo 'old pending gate still present'
exit 1
fi
echo 'launcher-pending-gate-ok'
Optional Tasker apply after TASKER_REPO sync:

View File

@ -0,0 +1,108 @@
#!/usr/bin/env bash
set -euo pipefail
DOCKER_BIN="${DOCKER_BIN:-/usr/local/bin/docker}"
if [[ ! -x "${DOCKER_BIN}" ]]; then
DOCKER_BIN="$(command -v docker)"
fi
fetch_with_retry() {
local url="$1"
shift
local output=""
local attempt
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if output="$(curl -k -fsS --compressed "$@" "$url")"; then
printf '%s' "$output"
return 0
fi
echo "not-ready url=${url} attempt=${attempt}" >&2
sleep 10
done
return 1
}
echo "== containers =="
"${DOCKER_BIN}" ps --filter 'name=nodedc-platform' --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
echo "== launcher storage check =="
"${DOCKER_BIN}" exec nodedc-platform-launcher-1 sh -lc \
'touch /app/server/storage/.write-test /app/server/storage/uploads/.write-test && rm /app/server/storage/.write-test /app/server/storage/uploads/.write-test && echo storage-ok'
echo "== launcher -> authentik api check =="
"${DOCKER_BIN}" exec nodedc-platform-launcher-1 sh -lc '
echo "$NODEDC_AUTHENTIK_BASE_URL"
getent hosts nodedc-platform-authentik-server
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if wget -qSO- \
--header "Authorization: Bearer $NODEDC_AUTHENTIK_SERVICE_TOKEN" \
"$NODEDC_AUTHENTIK_BASE_URL/api/v3/core/groups/?search=nodedc_admin" \
>/tmp/authentik-api-check.json \
2>/tmp/authentik-api-check.headers; then
head -n 25 /tmp/authentik-api-check.headers
exit 0
fi
head -n 25 /tmp/authentik-api-check.headers || true
echo "authentik-api-not-ready attempt=$attempt"
sleep 10
done
exit 1
'
echo "== auth flow check =="
auth_flow="$(fetch_with_retry https://id.nodedc.ru/if/flow/default-authentication-flow/)"
printf '%s' "$auth_flow" \
| grep -aE 'syncFormFieldsBeforeSubmit|branding_custom_css = ""|Запросить доступ'
echo "== public admin closed check =="
id_admin_status="$(
curl -k -sS -o /dev/null -w '%{http_code}' https://id.nodedc.ru/if/admin/
)"
if [[ "$id_admin_status" != "404" ]]; then
echo "public id admin is not closed: status=${id_admin_status}"
exit 1
fi
echo "public-id-admin-closed-ok"
echo "== technical admin css check =="
auth_admin_page="$(
fetch_with_retry http://127.0.0.1:18080/if/admin/ -H 'Host: auth-admin.nas.nodedc'
)"
printf '%s' "$auth_admin_page" \
| grep -aE '<style data-id="brand-css"></style>|authentikBrand.branding_custom_css = ""'
auth_admin_flow="$(
fetch_with_retry http://127.0.0.1:18080/if/flow/default-authentication-flow/ -H 'Host: auth-admin.nas.nodedc'
)"
if printf '%s' "$auth_admin_flow" | grep -aq '<style data-id="nodedc-auth-login-css">'; then
echo "admin host still has NODE.DC auth CSS"
exit 1
fi
echo "auth-admin-css-ok"
echo "== technical admin ip fallback check =="
ip_admin_page="$(
fetch_with_retry http://172.22.0.222:18080/if/admin/
)"
printf '%s' "$ip_admin_page" \
| grep -aE '<style data-id="brand-css"></style>|authentikBrand.branding_custom_css = ""'
echo "== launcher bundle check =="
launcher_html="$(fetch_with_retry https://hub.nodedc.ru/ -H 'Accept: text/html')"
launcher_asset="$(
printf '%s' "$launcher_html" \
| grep -aoE 'index-[A-Za-z0-9_-]+\.js' \
| head -n 1
)"
test -n "$launcher_asset"
echo "launcher_asset=${launcher_asset}"
if fetch_with_retry "https://hub.nodedc.ru/assets/${launcher_asset}" \
| grep -aq 'Заявка ожидает подтверждения'; then
echo "old pending gate still present"
exit 1
fi
echo "launcher-pending-gate-ok"
echo "runtime-verify-ok"