import-stage: .wiki/concepts/ via split (temp prefix for merge into existing dir)
git-subtree-dir: .tmp-concepts git-subtree-mainline:98bcc37d32git-subtree-split:c727aaa1b6
This commit is contained in:
0
.tmp-concepts/.gitkeep
Normal file
0
.tmp-concepts/.gitkeep
Normal file
21
.tmp-concepts/bootstrap-manifest.md
Normal file
21
.tmp-concepts/bootstrap-manifest.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: Bootstrap Manifest
|
||||
type: concept
|
||||
updated: 2026-05-18
|
||||
generator: project-bootstrap@1.11.0
|
||||
---
|
||||
|
||||
# Bootstrap Manifest
|
||||
|
||||
Skills used to initialize this project's `.wiki/` and `.tasks/` layout, with their versions at install time.
|
||||
|
||||
| Skill | Version | Role |
|
||||
|---|---|---|
|
||||
| `project-bootstrap` | 1.11.0 | orchestrator |
|
||||
| `setup-wiki` | 1.0.0 | wiki canonical layout |
|
||||
| `setup-tasks` | 1.0.0 | tasks canonical layout |
|
||||
| `project-discipline` | 0.1.1 | cross-project policy |
|
||||
| `setup-interns` | 0.3.0 | interns MCP server install (one-time, per machine) |
|
||||
| `using-interns` | 0.2.0 | interns runtime policy + per-session permission grant |
|
||||
|
||||
This file is overwritten if `project-bootstrap` is re-run on the same project. For history, use `git log .wiki/concepts/bootstrap-manifest.md`.
|
||||
92
.tmp-concepts/cms-admin-assets-root-folder-seed.md
Normal file
92
.tmp-concepts/cms-admin-assets-root-folder-seed.md
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: Admin assets crash — missing root AssetsFolder seed
|
||||
type: concept
|
||||
tags: [cms, admin, data-seed, mssql, gotcha, null-check]
|
||||
sources: [../sources/iis-host-migration-2026-05-19.md]
|
||||
updated: 2026-05-19
|
||||
---
|
||||
|
||||
# CMS admin assets crash — root AssetsFolder seed
|
||||
|
||||
`AssetsJsonViewModelBuilder.cs:22` (admin /admin/assets/.../getList) падает NullReferenceException на любом site, где **root row `Folders` (Discriminator='AssetsFolder', LoweredPath='/', OwnerId=SiteId)** отсутствует. Root создаётся lazy (при first asset upload через admin), и sites которые никогда не использовали admin assets UI — без root.
|
||||
|
||||
Связано: [[cms-server-port-leak-fix]] (нашли при verification после port-leak fix), [[recovery-architecture-snapshot]] (был open issue #8).
|
||||
|
||||
## Симптом
|
||||
|
||||
```
|
||||
GET /admin/assets/<siteId>/getList?path=
|
||||
→ 500 NullReferenceException
|
||||
|
||||
[NullReferenceException]
|
||||
MoreThenCms.Admin.ViewModels.Builders.AssetsJsonViewModelBuilder.Build(...)
|
||||
AssetsJsonViewModelBuilder.cs:22
|
||||
MoreThenCms.Web.Admin.Controllers.AssetsController.GetList(Guid ownerId, String path)
|
||||
AssetsController.cs:46
|
||||
```
|
||||
|
||||
В `AssetsController.GetList` (line 46):
|
||||
```csharp
|
||||
var model = _assetsViewModelBuilder.Build(
|
||||
_assetsFoldersService.GetFolderByPath(ExecutionContext, ownerId, path), ...);
|
||||
```
|
||||
|
||||
`GetFolderByPath` (`MoreThenCms\Assets\Services\AssetsFoldersService.cs:47-54`) делает `repo.Find(p => p.LoweredPath == path && p.OwnerId == ownerId)` — возвращает **null** если row нет. `Build` дальше делает `new JValue(model.ParentPath)` без null-check → crash.
|
||||
|
||||
Frontend код (`AssetsAppFunc.cs:66-86`) делает proper null-check → HTTP 404. Только admin view-model builder упустил.
|
||||
|
||||
## Кого затрагивает (snapshot 2026-05-19)
|
||||
|
||||
После recovery: **15 sites без root** (из ~50). Sites где admin assets когда-либо открывался → root есть. Остальные:
|
||||
- emspb.ru, pilorama98.ru, labtools.pro, kupimknigi.spb.ru, sestech.ru, aquamax.spb.ru, artmone.pro, priemka-kvartiry.ru, profund.spb.ru, ics-artmaterials.com, _voda-indigo.ru
|
||||
- 4 sites с NULL `PrimaryDomain` (legacy/test data)
|
||||
|
||||
Проверочный запрос:
|
||||
```sql
|
||||
SELECT s.SiteId, s.PrimaryDomain,
|
||||
CASE WHEN EXISTS (SELECT 1 FROM Folders f
|
||||
WHERE f.OwnerId = s.SiteId
|
||||
AND f.LoweredPath = '/'
|
||||
AND f.Discriminator = 'AssetsFolder')
|
||||
THEN 'OK' ELSE 'NO ROOT' END AS RootStatus
|
||||
FROM Sites s
|
||||
ORDER BY RootStatus, s.PrimaryDomain;
|
||||
```
|
||||
|
||||
## Fix (применён 2026-05-19)
|
||||
|
||||
DB seed — один INSERT для всех missing roots, идемпотентный (`WHERE NOT EXISTS`):
|
||||
|
||||
```sql
|
||||
DECLARE @CreatedById uniqueidentifier = 'E4CC416B-5D13-4757-8EFE-03CBEA15B18C'; -- existing admin user
|
||||
DECLARE @Inserted TABLE (FolderId uniqueidentifier, OwnerId uniqueidentifier);
|
||||
|
||||
INSERT INTO Folders (FolderId, OwnerId, Path, LoweredPath,
|
||||
DateCreated, UtcDateCreated, CreatedById, Discriminator)
|
||||
OUTPUT INSERTED.FolderId, INSERTED.OwnerId INTO @Inserted
|
||||
SELECT NEWID(), s.SiteId, '/', '/',
|
||||
SYSDATETIME(), SYSUTCDATETIME(), @CreatedById, 'AssetsFolder'
|
||||
FROM Sites s
|
||||
WHERE NOT EXISTS (SELECT 1 FROM Folders f
|
||||
WHERE f.OwnerId = s.SiteId
|
||||
AND f.LoweredPath = '/'
|
||||
AND f.Discriminator = 'AssetsFolder');
|
||||
|
||||
SELECT FolderId, OwnerId FROM @Inserted; -- save for atomic revert
|
||||
```
|
||||
|
||||
15 rows вставлены. Inserted FolderId/OwnerId сохранены в `.tasks/cms-admin-assets-root-folders-seed.inserted-rows.txt` для atomic revert (`DELETE FROM Folders WHERE FolderId IN (...)`).
|
||||
|
||||
После: `/admin/assets/<siteId>/getList?path=` возвращает HTTP 302 → /login для unauth (normal auth path), вместо 500. Authorized users видят empty assets list.
|
||||
|
||||
## Долгосрочный TODO (не сделано — нет рабочего build env)
|
||||
|
||||
`AssetsJsonViewModelBuilder.Build` должна null-guard'иться — возвращать empty JObject ({parentPath:"", path:"/", folders:[], files:[]}) когда model null. Это **defensive code**, плюс auto-create root в `GetFolderByPath` для root path (как обычно делают админ-репозитории).
|
||||
|
||||
Требует recompile `MoreThenCms.Admin.dll` — отложено до восстановления полного build pipeline (gitea/build/registry — см. [[vds-kzntsv-bootstrap]] task).
|
||||
|
||||
## Возможно затрагивает ещё (open для следующей сессии)
|
||||
|
||||
`Folders` table — polymorphic: `AssetsFolder`, `ImagesFolder` (OwnerId=ThemeId), `ScriptsFolder` (ThemeId), `StylesheetsFolder` (ThemeId). Аналогичная null-falling логика может быть в admin views для Themes (Stylesheets/Scripts/Images). Не проверено, не reported user'ом.
|
||||
|
||||
Если будет — same pattern: `SELECT s.SiteId vs Folders root по соответствующему Discriminator` + seed.
|
||||
125
.tmp-concepts/cms-config-rewrite-pattern.md
Normal file
125
.tmp-concepts/cms-config-rewrite-pattern.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: Web.config rewrite — кодировка и connection-string patterns
|
||||
type: concept
|
||||
tags: [iis, webconfig, encoding, powershell, traceback]
|
||||
sources: [../sources/nas-recovery-session-2026-05-18.md]
|
||||
updated: 2026-05-19
|
||||
---
|
||||
|
||||
# Web.config rewrite на Windows: encoding pitfall
|
||||
|
||||
## Сценарий
|
||||
|
||||
После cross-host миграции CMS в VM, нужно переписать connection strings (`192.168.1.10` → `10.0.2.2` или другой host). Делается через SSH/PowerShell `Get-Content`+`-replace`+`Set-Content`.
|
||||
|
||||
## Pitfall: Set-Content без -Encoding
|
||||
|
||||
```powershell
|
||||
# WRONG — на PowerShell 5.1 пишет UTF-16 LE
|
||||
(Get-Content $f -Raw) -replace 'old', 'new' | Set-Content $f -NoNewline
|
||||
```
|
||||
|
||||
**Симптом** после этого:
|
||||
- IIS возвращает `HTTP 500.19 - Internal Server Error`, "Файл конфигурации создан в неправильном формате XML", error code `0x8007000d`.
|
||||
- В детале: первые строки файла показывают артефакты `????????` (UTF-16 в окне UTF-8 ожидаемой проверки).
|
||||
|
||||
**Корень:** PowerShell 5.1 (Windows встроенный) `Set-Content` без `-Encoding` использует **default = UTF-16 LE** (Unicode). IIS ожидает UTF-8 (BOM или без BOM).
|
||||
|
||||
## Правильный способ
|
||||
|
||||
```powershell
|
||||
$utf8WithBom = New-Object System.Text.UTF8Encoding($true)
|
||||
$content = Get-Content $file -Raw
|
||||
$new = $content -replace 'old', 'new'
|
||||
[System.IO.File]::WriteAllText($file, $new, $utf8WithBom)
|
||||
```
|
||||
|
||||
Или, если хочется без BOM:
|
||||
```powershell
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($file, $new, $utf8NoBom)
|
||||
```
|
||||
|
||||
ASP.NET / IIS работают и с BOM, и без — но для дефолтного XML конфига Microsoft предпочитает с BOM.
|
||||
|
||||
### PowerShell 7+ workaround
|
||||
|
||||
В Core: `Set-Content -Encoding utf8` пишет UTF-8 БЕЗ BOM (отличается от PS 5!). `-Encoding utf8BOM` — с BOM.
|
||||
|
||||
## Connection string patterns в CMS
|
||||
|
||||
Нашли в `MoreThenCms.WebUI/Web.config` source (development):
|
||||
|
||||
```xml
|
||||
<add name="MoreThenCmsEntities"
|
||||
connectionString="Data Source=DESKTOP-XYZ\SQLEXPRESS;Initial Catalog=MoreThenCms;
|
||||
Integrated Security=True;MultipleActiveResultSets=True"
|
||||
providerName="System.Data.SqlClient" />
|
||||
```
|
||||
|
||||
В production (внутри OVA, после восстановления):
|
||||
|
||||
```xml
|
||||
<add name="MoreThenCmsEntities"
|
||||
connectionString="Data Source=192.168.1.10;Initial Catalog=MoreThenCms;
|
||||
Integrated Security=False;User Id=snolla;
|
||||
Password=fXkH4@8O%3pc;MultipleActiveResultSets=True"
|
||||
providerName="System.Data.SqlClient" />
|
||||
```
|
||||
|
||||
То есть в prod CMS использует **SQL login `snolla`**, не Integrated Security. Сам логин остался в `master.mdf` после восстановления — никаких дополнительных шагов не понадобилось.
|
||||
|
||||
## Mass-edit нескольких сайтов
|
||||
|
||||
Под Windows VM было 4 Web.config с одинаковым паттерном:
|
||||
|
||||
- `C:\inetpub\wwwroot\MoreThenCms.Web\Web.config`
|
||||
- `C:\inetpub\wwwroot\Snolla.IdentityManager\Web.config`
|
||||
- `C:\stayer\stostayer.old\web.config`
|
||||
- (плюс `C:\stayer\MoreThenCms.Web\` — main stostayer без 192.168.1.10 reference)
|
||||
|
||||
Скрипт через SSH в VM:
|
||||
|
||||
```powershell
|
||||
$files = @(
|
||||
'C:\inetpub\wwwroot\MoreThenCms.Web\Web.config',
|
||||
'C:\inetpub\wwwroot\Snolla.IdentityManager\Web.config',
|
||||
'C:\stayer\stostayer.old\web.config'
|
||||
)
|
||||
$utf8WithBom = New-Object System.Text.UTF8Encoding($true)
|
||||
foreach ($f in $files) {
|
||||
$content = Get-Content $f -Raw
|
||||
$new = $content -replace [regex]::Escape('192.168.1.10'), '10.0.2.2'
|
||||
[System.IO.File]::WriteAllText($f, $new, $utf8WithBom)
|
||||
}
|
||||
# затем
|
||||
iisreset
|
||||
```
|
||||
|
||||
## Storage providers (Azure / MinIO)
|
||||
|
||||
В `appSettings` нашли:
|
||||
|
||||
```xml
|
||||
<add name="azureGalleries" storageType="MoreThenCms.FileStorage.Azure.AzureCloudStorage, ...">
|
||||
<settings>
|
||||
<add name="connectionString"
|
||||
value="DefaultEndpointsProtocol=http;AccountName=snolla;
|
||||
AccountKey=<base64>" />
|
||||
<add name="container" value="galleries" />
|
||||
</settings>
|
||||
</add>
|
||||
```
|
||||
|
||||
Используется **Azure Storage SDK с custom endpoint**. В production endpoint указывает на **MinIO** (S3-compat но **не** Azure-compat — пользователь упомянул "не так всё" и обещал пояснить). Точная схема подключения — TBD, надо изучать `MoreThenCms.FileStorage.Azure.AzureCloudStorage` класс.
|
||||
|
||||
## `[regex]::Escape` для безопасной замены IP
|
||||
|
||||
`192.168.1.10` без escape — точки в regex matchят любой char. Лучше escape:
|
||||
|
||||
```powershell
|
||||
$pattern = [regex]::Escape('192.168.1.10')
|
||||
$new = $content -replace $pattern, '10.0.2.2'
|
||||
```
|
||||
|
||||
Связано: [[snolla-recovery-vm]], [[traefik-on-windows-docker-desktop]].
|
||||
102
.tmp-concepts/cms-maljarka-https-mode-crash.md
Normal file
102
.tmp-concepts/cms-maljarka-https-mode-crash.md
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: CMS 502 при HTTPS-mode для конкретного hostname (maljarka.tandemmebel.ru)
|
||||
type: concept
|
||||
tags: [cms, iis, gotcha, url-rewrite, https]
|
||||
sources: []
|
||||
updated: 2026-05-21
|
||||
---
|
||||
|
||||
# CMS 502 при HTTPS-mode для maljarka.tandemmebel.ru
|
||||
|
||||
CMS код (или CMS DB config) для `maljarka.tandemmebel.ru` падает когда IIS request попадает с включённым HTTPS-context (`HTTPS=on`/`SERVER_PORT=443`). Тот же hostname работает (200 OK) когда request интерпретируется как plain HTTP. Другие cms hosts (`tandemmebel.ru`, `emspb.ru`, `pilorama98.ru` и т.д.) работают нормально в HTTPS-mode.
|
||||
|
||||
## Симптом
|
||||
|
||||
Через traefik HTTPS chain:
|
||||
```
|
||||
GET https://maljarka.tandemmebel.ru/ → 502 Bad Gateway
|
||||
```
|
||||
|
||||
## Bisect-trace
|
||||
|
||||
| Direct backend request (внутри traefik container) | Headers | Result |
|
||||
|---|---|---|
|
||||
| `wget http://host.docker.internal:8089/ -H 'Host: maljarka.tandemmebel.ru'` | base | **200 OK** (43730 bytes, "Малярка от Тандеммебель") |
|
||||
| same + `X-Forwarded-For: 1.2.3.4` | XFF only | **200 OK** |
|
||||
| same + `X-Forwarded-Proto: https` | XFP=https | **502** ← триггер |
|
||||
| same + ALL X-Forwarded-* | full | **502** |
|
||||
| `Host: tandemmebel.ru` + XFP=https | sibling control | 301 (correct) |
|
||||
|
||||
`X-Forwarded-Proto: https` — единственная переменная которая триггерит 502 для maljarka.tandemmebel.ru.
|
||||
|
||||
## Mechanism
|
||||
|
||||
После [[cms-server-port-leak-fix]] в `C:\sites\snolla\Web.config` добавлен URL Rewrite rule:
|
||||
|
||||
```xml
|
||||
<rule name="ForwardedProto-HTTPS" stopProcessing="false">
|
||||
<match url=".*" />
|
||||
<conditions>
|
||||
<add input="{HTTP_X_FORWARDED_PROTO}" pattern="^https$" />
|
||||
</conditions>
|
||||
<action type="None" />
|
||||
<serverVariables>
|
||||
<set name="HTTPS" value="on" />
|
||||
<set name="SERVER_PORT" value="443" />
|
||||
<set name="SERVER_PORT_SECURE" value="1" />
|
||||
</serverVariables>
|
||||
</rule>
|
||||
```
|
||||
|
||||
Когда traefik проксит request через HTTPS entrypoint, он добавляет `X-Forwarded-Proto: https` (стандартное поведение). IIS URL Rewrite rule срабатывает → ставит `HTTPS=on` / `SERVER_PORT=443`. CMS код (`MoreThenCms.Web`) видит request как HTTPS.
|
||||
|
||||
Для большинства hostname'ов CMS строит URLs корректно в HTTPS-context. Для `maljarka.tandemmebel.ru` — что-то падает (NullRef в URL builder, missing config, redirect loop, etc.) → ASP.NET unhandled exception → IIS возвращает 502.
|
||||
|
||||
## Where to investigate (next session)
|
||||
|
||||
1. **IIS Logs** — `C:\inetpub\logs\LogFiles\W3SVC*\` для site `snolla` — найти request с `Host: maljarka.tandemmebel.ru` + XFP=https → status code + sub-status.
|
||||
2. **ASP.NET Event Log** — `eventvwr.msc` → Application log → ASP.NET 4.0 errors с stack trace.
|
||||
3. **CMS DB** — table с per-host config (если есть field "https URL" / "base URL"); может для maljarka.tandemmebel.ru эта запись `NULL`/empty.
|
||||
4. **CMS source** — grep по `siteRoot`, `BaseUrl`, `HttpsUrl` в коде CMS; искать где условие `IsSecureConnection` или `Request.IsHttps` ветвит логику.
|
||||
5. **`Url.SiteRoot()`** — уже паттерн из [[cms-server-port-leak-fix]]; возможно тут другой helper падает specifically на этом host.
|
||||
|
||||
## Workarounds
|
||||
|
||||
### Workaround A: Strip X-Forwarded-Proto для maljarka в traefik (быстро, но скрывает CMS bug)
|
||||
|
||||
В `maljarka.yml` добавить middleware:
|
||||
```yaml
|
||||
http:
|
||||
routers:
|
||||
maljarka:
|
||||
...
|
||||
middlewares: [maljarka-strip-xfp]
|
||||
middlewares:
|
||||
maljarka-strip-xfp:
|
||||
headers:
|
||||
customRequestHeaders:
|
||||
X-Forwarded-Proto: ""
|
||||
```
|
||||
|
||||
Side effect: maljarka страница может содержать `http://...` asset links вместо `https://...` — mixed content warnings в браузере.
|
||||
|
||||
### Workaround B: Per-host bypass в URL Rewrite rule
|
||||
|
||||
В `C:\sites\snolla\Web.config` обновить rule:
|
||||
```xml
|
||||
<conditions>
|
||||
<add input="{HTTP_X_FORWARDED_PROTO}" pattern="^https$" />
|
||||
<add input="{HTTP_HOST}" pattern="^maljarka\.tandemmebel\.ru$" negate="true" />
|
||||
</conditions>
|
||||
```
|
||||
|
||||
Workaround C: hot-fix DB / Web.config с правильным базовым URL для maljarka.tandemmebel.ru (нужно исследование CMS).
|
||||
|
||||
## Применено
|
||||
|
||||
Не применено. Diagnosed only — fix отложен до CMS code investigation. Side-finding из [[traefik-maljarka-502-bug]].
|
||||
|
||||
## Ссылки
|
||||
|
||||
- URL Rewrite rule origin: [[cms-server-port-leak-fix]]
|
||||
- Other CMS-side 5xx bugs (паттерн): [[cms-admin-assets-root-folder-seed]] (другой NullRef), `emspb /admin/assets 500` (snapshot open issue #8).
|
||||
174
.tmp-concepts/cms-server-port-leak-fix.md
Normal file
174
.tmp-concepts/cms-server-port-leak-fix.md
Normal file
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: CMS port-leak fix — URL Rewrite serverVariables на host IIS
|
||||
type: concept
|
||||
tags: [iis, url-rewrite, traefik, x-forwarded, asp-net, gotcha]
|
||||
sources: [../sources/iis-host-migration-2026-05-19.md]
|
||||
updated: 2026-05-19
|
||||
---
|
||||
|
||||
# CMS port-leak fix
|
||||
|
||||
Решение для open issue #1 из [[recovery-architecture-snapshot]] (раньше «X-Forwarded headers не настроены, :4443 leak») и админ-utечки `:8089` обнаруженной 2026-05-19 вечером после attempt 2 host-IIS миграции. Документирует **root cause**, **почему VM работала**, **почему обходной path-B на Windows Docker Desktop невозможен**, и **что в итоге применено**.
|
||||
|
||||
Связано: [[traefik-on-windows-docker-desktop]] Pitfall 5, [[iis-host-migration-2026-05-19]] Phase 10, [[docker-host-loopback-detect]].
|
||||
|
||||
## Симптом
|
||||
|
||||
Admin URLs формата (после attempt 2 миграции на host-IIS:8089):
|
||||
- `https://emspb.snolla.com:8089/admin/assets/<guid>/getList?path=`
|
||||
- `https://emspb.snolla.com:8089/admin/themes/getImageSizes/`
|
||||
- `https://emspb.snolla.com:8089/admin/templates/editors.tmpl.html?v=2.006`
|
||||
|
||||
Browser HSTS upgrade'ит `http://...:8089` → `https://...:8089` → TCP open, TLS handshake fails (8089 = plain HTTP) → admin SPA ломается.
|
||||
|
||||
Аналогично, ранее (issue #1) — CMS делал HTTP→HTTPS redirect с `:4443` (traefik external port).
|
||||
|
||||
## Root cause
|
||||
|
||||
`MoreThenCms.Admin\Mis\Web\Mvc\UrlHelpers\UrlHelpers.cs:14-37` `Url.SiteRoot()`:
|
||||
|
||||
```csharp
|
||||
var port = context.Request.ServerVariables["SERVER_PORT"];
|
||||
if (usePort) {
|
||||
if (port == null || port == "80" || port == "443") port = "";
|
||||
else port = ":" + port;
|
||||
}
|
||||
var protocol = context.Request.ServerVariables["SERVER_PORT_SECURE"];
|
||||
if (protocol == null || protocol == "0") protocol = "http://"; else protocol = "https://";
|
||||
var sOut = protocol + context.Request.ServerVariables["SERVER_NAME"] + port + appPath;
|
||||
```
|
||||
|
||||
Читает **socket-level** server variables. На IIS site `snolla` binding `*:8089` HTTP → `SERVER_PORT=8089`, `SERVER_PORT_SECURE=0` → формирует `http://emspb.snolla.com:8089` → рендерится в Razor:
|
||||
|
||||
```cshtml
|
||||
@* C:\sites\snolla\Views\Shared\_Layout.cshtml:229 *@
|
||||
mis.siteRoot = '@Url.SiteRoot().Replace("http:", "https:")' + '/admin';
|
||||
@* и _LogInLayout.cshtml:156 *@
|
||||
mis.siteRoot = '@Url.SiteRoot()';
|
||||
```
|
||||
|
||||
Замена `http:→https:` в Layout была *прошлым* частичным patch'ем — не убирает порт. Десятки .cshtml в admin также используют `Url.SiteRoot()` для inline `<style background: url(... + "/images/loading2.gif")>` — все utекают `:8089`.
|
||||
|
||||
## Почему VM работала (без всяких rewrite)
|
||||
|
||||
SSH probe VM (parallel running snolla-recovery): `appcmd list site /xml` → `MoreThenCms.Web bindings="http/*:80:"`. **`SERVER_PORT=80`** → `port` пустеет (whitelist `{80,443}`), `mis.siteRoot = 'http://emspb.snolla.com'` (no port, http scheme). Browser HSTS upgrade'ит до https и попадает обратно через router-NAT-traefik-chain в backend. Работает.
|
||||
|
||||
VM `applicationHost.config` проверен — **никаких** rewrite rules, никакого URL Rewrite/ARR модуля. Production magic = просто IIS бинд на `:80`.
|
||||
|
||||
## Почему path B (host IIS :80 + traefik backend :80) НЕ работает на Windows Docker Desktop
|
||||
|
||||
Pre-flight 2026-05-19 вечер:
|
||||
|
||||
```
|
||||
docker exec traefik wget -qS http://host.docker.internal:80/admin/account/login
|
||||
→ HTTP/1.1 301 Moved Permanently
|
||||
Content-Length: 17
|
||||
Content-Type: text/plain
|
||||
Location: https://emspb.snolla.com/admin/account/login
|
||||
(НЕТ Server: header — не IIS, не traefik)
|
||||
```
|
||||
|
||||
`Get-NetTCPConnection -LocalPort 80 -State Listen` → только `PID=4 NAME=System` (http.sys/IIS). Никакого другого listener'а. Direct `Invoke-WebRequest http://127.0.0.1/admin/...` с правильным Host header возвращает 200 OK с `Server: Microsoft-IIS/10.0`.
|
||||
|
||||
Но через **`host.docker.internal:80`**, **`gateway.docker.internal:80`** (192.168.65.1), **`192.168.1.143:80`** (LAN IP) — все три возвращают тот же 17-byte 301 plain text. То есть **Docker Desktop's WSL2 NAT proxy на Windows перехватывает container-→host:80 traffic и подменяет ответ HTTP→HTTPS redirect'ом**.
|
||||
|
||||
Подтверждение что **не** traefik: после смены traefik http entrypoint `:80 → :8090` (`docker-compose.yml ports: 8000:8090`, `traefik.yml http.address: ":8090"`) и `docker compose up -d --force-recreate` — `netstat -tlnp` в контейнере показывает `:8090 LISTEN`, **нет** `:80`. И всё равно `host.docker.internal:80` возвращает тот же 17-byte 301.
|
||||
|
||||
**Этого quirk'а нет на Linux Docker** (synology) — там `host.docker.internal` либо не существует, либо ведёт себя стандартно. Поэтому VM-стек работал, host-Windows-Docker — нет.
|
||||
|
||||
## Применённое решение (path C)
|
||||
|
||||
URL Rewrite 2.1 + `<serverVariables>` rule на host IIS — переписывает `SERVER_PORT/SERVER_PORT_SECURE/HTTPS` ДО того как ASP.NET их читает.
|
||||
|
||||
### Шаги
|
||||
|
||||
1. **MSI install URL Rewrite 2.1** (elevated):
|
||||
```powershell
|
||||
$msi = "$env:TEMP\rewrite_amd64_en-US.msi"
|
||||
Invoke-WebRequest 'https://download.microsoft.com/download/1/2/8/128E2E22-C1B9-44A4-BE2A-5859ED1D4592/rewrite_amd64_en-US.msi' -OutFile $msi
|
||||
Start-Process msiexec -ArgumentList "/i `"$msi`" /quiet /norestart" -Verb RunAs -Wait
|
||||
```
|
||||
Verify: `Get-Item C:\Windows\System32\inetsrv\rewrite.dll` (FileVersion 7.1.1993.2351).
|
||||
|
||||
2. **applicationHost.config — `<allowedServerVariables>`** (elevated, через appcmd):
|
||||
```powershell
|
||||
$appcmd = 'C:\Windows\System32\inetsrv\appcmd.exe'
|
||||
foreach ($n in 'HTTPS', 'SERVER_PORT', 'SERVER_PORT_SECURE') {
|
||||
& $appcmd set config -section:system.webServer/rewrite/allowedServerVariables "/+[name='$n']" /commit:apphost
|
||||
}
|
||||
```
|
||||
**Built-in IIS server variables** (HTTPS/SERVER_PORT/*) MUST be в apphost-allowedServerVariables, **не** site Web.config. Иначе HTTP 500 "URL Rewrite Module Error" без body.
|
||||
|
||||
3. **Site `C:\sites\snolla\Web.config`** (UTF-8 BOM!) — добавить в `<system.webServer>`:
|
||||
```xml
|
||||
<rewrite>
|
||||
<rules>
|
||||
<rule name="ForwardedProto-HTTPS" stopProcessing="false">
|
||||
<match url=".*" />
|
||||
<conditions>
|
||||
<add input="{HTTP_X_FORWARDED_PROTO}" pattern="^https$" />
|
||||
</conditions>
|
||||
<serverVariables>
|
||||
<set name="HTTPS" value="on" />
|
||||
<set name="SERVER_PORT" value="443" />
|
||||
<set name="SERVER_PORT_SECURE" value="1" />
|
||||
</serverVariables>
|
||||
<action type="None" />
|
||||
</rule>
|
||||
</rules>
|
||||
</rewrite>
|
||||
```
|
||||
`<action type="None"/>` — rule не редиректит/переписывает URL, только сидит как side-effect ставящая server vars. `stopProcessing="false"` чтобы другие rules могли продолжить (на случай если добавим).
|
||||
|
||||
4. **Recycle** — IIS auto-recycle при изменении Web.config (~3 сек).
|
||||
|
||||
5. **Verify**:
|
||||
```bash
|
||||
docker exec traefik sh -c "wget -qS --no-check-certificate -O- \
|
||||
--header='Host: emspb.snolla.com' \
|
||||
https://127.0.0.1:443/admin/account/login 2>&1 | grep siteRoot"
|
||||
# Expected: mis.siteRoot = 'https://emspb.snolla.com'; (БЕЗ :8089)
|
||||
```
|
||||
|
||||
Traefik 2.x по-default шлёт `X-Forwarded-Proto: https` для requests через https entrypoint — наш `HTTP_X_FORWARDED_PROTO` condition fires автоматически.
|
||||
|
||||
### Atomic revert
|
||||
|
||||
```powershell
|
||||
Copy-Item C:\sites\snolla\Web.config.bak-pre-portleak-2026-05-19 C:\sites\snolla\Web.config -Force
|
||||
# ~3 сек на app pool reload
|
||||
# apphost можно оставить (allowedServerVariables inert без rule)
|
||||
# MSI URL Rewrite можно оставить (модуль без rule = no behavior)
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### `customErrors mode="off"` lowercase — fatal regression
|
||||
|
||||
`C:\sites\snolla\Web.config:43` имел `<customErrors mode="off" />` (lowercase). ASP.NET до моего edit'а **lazily** валидировал это — работало. После моего Web.config edit'а (добавил `<rewrite>` block) → ASP.NET full-reload → enum parse fail:
|
||||
|
||||
```
|
||||
Значение свойства 'mode' не может быть проанализировано.
|
||||
Ошибка: Значение перечисления должно быть одним из следующих: RemoteOnly, On, Off.
|
||||
```
|
||||
|
||||
YSOD config error блокирует **весь** site. Fix: `mode="Off"` (capital O). Урок: при любом Web.config edit'е сначала grep на enum-style attributes на корректный casing.
|
||||
|
||||
### `[xml]` save в PowerShell flattens applicationHost.config
|
||||
|
||||
`$cfg.OuterXml` сбрасывает все пробелы/переносы → файл 937 строк превращается в 1 строку. IIS читает (whitespace для XML parser не важен), но файл нечитаем для людей. Backup и `appcmd set config /+...` — единственно правильный способ редактировать apphost programmatically.
|
||||
|
||||
### Site-level `<allowedServerVariables>` для built-in vars
|
||||
|
||||
Site `Web.config` MOJET содержать `<rewrite><allowedServerVariables>` ТОЛЬКО для custom variables (HTTP_X_*, etc). Для built-in IIS vars (HTTPS, SERVER_PORT, SERVER_PORT_SECURE) — apphost OR HTTP 500. Не дублировать в site-level — конфликт.
|
||||
|
||||
## Сделано на host'е (2026-05-19 вечер)
|
||||
|
||||
- `C:\Windows\System32\inetsrv\config\applicationHost.config` — `<rewrite>/<allowedServerVariables>` += HTTPS, SERVER_PORT, SERVER_PORT_SECURE. Backup `.bak-pre-portleak-2026-05-19`.
|
||||
- `C:\sites\snolla\Web.config` — `<rewrite>/<rules>` += rule "ForwardedProto-HTTPS"; `customErrors mode="off"` → `"Off"`. Backup `.bak-pre-portleak-2026-05-19`.
|
||||
- URL Rewrite 2.1 MSI installed.
|
||||
- Затрагивает **все 11 cms hosts** (общий site `snolla`): emspb, labtools, labtoolspro, pilorama98, tandemmebel, kupimknigi, maljarka, sestech, isc-artmaterials, rimiz, plus snolla.com sub-domains.
|
||||
|
||||
## Sibling fix (отдельная task, тот же день)
|
||||
|
||||
После port-leak fix'а surfaced `/admin/assets/<guid>/getList → 500 NullReferenceException` на admin assets. Оказался **не site-specific** — общий для 15 sites без root AssetsFolder в DB. Решён DB seed'ом, см. [[cms-admin-assets-root-folder-seed]].
|
||||
116
.tmp-concepts/compose-bcrypt-escape-trap.md
Normal file
116
.tmp-concepts/compose-bcrypt-escape-trap.md
Normal file
@@ -0,0 +1,116 @@
|
||||
---
|
||||
title: Docker Compose bcrypt `$` escape trap
|
||||
type: concept
|
||||
tags: [docker-compose, bcrypt, password, escape, gotcha]
|
||||
sources: [../sources/vds-kzntsv-bootstrap-2026-05-20.md]
|
||||
updated: 2026-05-20
|
||||
---
|
||||
|
||||
# Docker Compose ест `$` в bcrypt hashes
|
||||
|
||||
## Симптом
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
command: --admin-password '$2y$05$abc123...'
|
||||
```
|
||||
|
||||
При `docker compose up` Compose выдаёт warning:
|
||||
```
|
||||
warning msg="The \"r9BLFM98iCLTskjgj7Y3teOACut3Nxk\" variable is not set. Defaulting to a blank string."
|
||||
```
|
||||
|
||||
И в контейнер передаётся пустая строка вместо bcrypt hash.
|
||||
|
||||
## Root cause
|
||||
|
||||
Compose interpolates `${VAR}` и `$VAR` syntax из env variables **в YAML values**. Bcrypt hash начинается с `$2y$` или `$2a$` — выглядит как `$NAME` шаблоны для compose'а:
|
||||
|
||||
- `$2y` → попытка expand env var `2y` → не определена → empty string
|
||||
- `$05` → expand env var `05` → empty string
|
||||
- Остаток до точки/конца строки → имя var → empty
|
||||
- Точки/слэши прерывают имя var
|
||||
|
||||
Результат — обрезанный/пустой hash.
|
||||
|
||||
## Решение 1 — double-escape `$` → `$$`
|
||||
|
||||
В YAML literal compose treats `$$` как escape для `$`. Нужно sed-replace перед записью:
|
||||
|
||||
```bash
|
||||
BCRYPT=$(htpasswd -nbB user pass | cut -d':' -f2)
|
||||
BCRYPT_ESCAPED=$(echo "$BCRYPT" | sed 's/\$/\$\$/g')
|
||||
cat > docker-compose.yml <<COMPOSE
|
||||
services:
|
||||
app:
|
||||
command: --admin-password '$BCRYPT_ESCAPED'
|
||||
COMPOSE
|
||||
```
|
||||
|
||||
`docker compose config` покажет `$$2y$$05$$...` — это нормально, в runtime expand'ится в `$2y$05$...`.
|
||||
|
||||
**Но осторожно с кавычками**: YAML string form `command: "... '<hash>'"` — после tokenization shell видит **literal** одинарные кавычки внутри значения. Argument приходит как `'$2y$05$...'` (с кавычками вокруг hash) → bcrypt verify fails из-за паразитных `'` char'ов.
|
||||
|
||||
## Решение 2 — YAML list form
|
||||
|
||||
```yaml
|
||||
command:
|
||||
- "--admin-password"
|
||||
- "$$2y$$05$$abc123..."
|
||||
```
|
||||
|
||||
Каждый list-element — отдельный argv element, без shell tokenization. Кавычки не утекают.
|
||||
|
||||
**Но опять же** — некоторые версии Compose (2024+) могут иметь регрессии где YAML list form **не unescape'ит** `$$` обратно в `$`. Проверять через `docker compose config | grep command` после написания.
|
||||
|
||||
## Решение 3 — `docker run` напрямую, без compose
|
||||
|
||||
Bypass проблему. Shell escape'ы (одинарные кавычки) предсказуемы:
|
||||
|
||||
```bash
|
||||
BCRYPT=$(htpasswd -nbB user pass | cut -d':' -f2)
|
||||
docker run -d --name app \
|
||||
-v ... \
|
||||
app/app:tag \
|
||||
--admin-password "$BCRYPT" # bash interpolation одного слоя
|
||||
```
|
||||
|
||||
Bash `"$BCRYPT"` expand'ится один раз. Внутри expansion'а `$` уже не парсится. Container получает чистый hash.
|
||||
|
||||
Trade-off: лишаемся compose файла → нет declarative restart, нужно `docker run --restart unless-stopped`. Управляемо.
|
||||
|
||||
## Решение 4 — env file with single var
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
env_file: ./bcrypt.env
|
||||
command: --admin-password ${BCRYPT}
|
||||
```
|
||||
|
||||
`bcrypt.env`:
|
||||
```
|
||||
BCRYPT=$2y$05$abc123...
|
||||
```
|
||||
|
||||
Env file читается raw, без compose interpolation. Тогда `${BCRYPT}` в command expand'ится из этого env. Работает.
|
||||
|
||||
Но менее transparent — debugger должен смотреть в env file.
|
||||
|
||||
## Похожие traps
|
||||
|
||||
Любые значения с `$`:
|
||||
- Postgres password `secret$pass$word`
|
||||
- API keys с `$` (rare)
|
||||
- Cron syntax в command (`$1` etc.)
|
||||
- Bash variable substitutions внутри command
|
||||
|
||||
## Где применено
|
||||
|
||||
`portainer/portainer-ce:2.21.5` на [`vds-kzntsv`](../entities/vds-kzntsv.md) — финальный путь = решение 3 (`docker run` direct). Хотя в этой же сессии оказалось что `--admin-password` flag broken в Portainer 2.21+ regardless escape — см. [`portainer-2.21-admin-password-regression`](portainer-2.21-admin-password-regression.md).
|
||||
|
||||
## Ссылки
|
||||
|
||||
- Compose docs: [Variable interpolation](https://docs.docker.com/compose/compose-file/12-interpolation/)
|
||||
- Сессия: [`vds-kzntsv-bootstrap-2026-05-20`](../sources/vds-kzntsv-bootstrap-2026-05-20.md) Phase 1 cont.
|
||||
147
.tmp-concepts/db-tls-self-signed-via-traefik-raw-tcp.md
Normal file
147
.tmp-concepts/db-tls-self-signed-via-traefik-raw-tcp.md
Normal file
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: DB TLS через traefik raw TCP с self-signed certs
|
||||
type: concept
|
||||
tags: [traefik, tls, postgres, mariadb, mongo, redis, self-signed, pattern]
|
||||
sources: [../sources/vds-kzntsv-bootstrap-2026-05-20.md]
|
||||
updated: 2026-05-20
|
||||
---
|
||||
|
||||
# Self-signed DB TLS через Traefik raw TCP
|
||||
|
||||
Pattern для выставления нескольких DB-контейнеров наружу (public internet) через traefik с минимумом сложности cert management. Self-signed на старте, LE-extraction позже.
|
||||
|
||||
Сочетается с [`traefik-tcp-passthrough-vs-starttls`](traefik-tcp-passthrough-vs-starttls.md) — там объяснено почему `HostSNI(*)` + raw TCP, не SNI passthrough.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
Internet client
|
||||
↓ TLS handshake (порт зависит от DB)
|
||||
↓ <db>.vds.kzntsv.site:5432/3306/27017/6379
|
||||
Traefik (raw TCP forward, без TLS inspection)
|
||||
↓ docker network `proxy`
|
||||
DB container (терминирует TLS своим self-signed cert)
|
||||
↓ docker network `shared-dbs`
|
||||
Other VDS containers (могут ходить без TLS по dns name `postgres`/`mariadb`/...)
|
||||
```
|
||||
|
||||
Каждая DB:
|
||||
- Подключена к двум networks: `proxy` (для traefik) и `shared-dbs` (для других контейнеров VDS).
|
||||
- Имеет свой self-signed cert (CN = `<db>.vds.kzntsv.site`) в `./certs/`.
|
||||
- Конфигурируется для TLS-required.
|
||||
- Объявляет traefik label с `HostSNI(\`*\`)` на dedicated entrypoint port.
|
||||
|
||||
Traefik static config:
|
||||
```yaml
|
||||
entryPoints:
|
||||
postgres: { address: ":5432" }
|
||||
mariadb: { address: ":3306" }
|
||||
mongo: { address: ":27017" }
|
||||
redis: { address: ":6379" }
|
||||
```
|
||||
|
||||
ufw: allow на эти 4 порта.
|
||||
|
||||
## Cert generation
|
||||
|
||||
```bash
|
||||
for db in postgres mariadb mongo redis; do
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout /opt/stacks/databases/$db/certs/server.key \
|
||||
-out /opt/stacks/databases/$db/certs/server.crt \
|
||||
-days 3650 -nodes \
|
||||
-subj "/CN=$db.vds.kzntsv.site/O=kzntsv.site/C=RU" \
|
||||
-addext "subjectAltName=DNS:$db.vds.kzntsv.site"
|
||||
chmod 600 /opt/stacks/databases/$db/certs/server.key
|
||||
done
|
||||
```
|
||||
|
||||
## DB-specific quirks
|
||||
|
||||
### Postgres 16 (non-alpine, uid 999)
|
||||
|
||||
Postgres key file перм-checks: must be 600 + owned by postgres user OR root. Postgres alpine использует **uid 70**, не 999. Не-alpine Debian — **uid 999**. Если используем alpine — `chown -R 70:70 certs/server.key`; если debian — `chown 999:999`. Лучше debian (`postgres:16`) для совместимости с прочими стеками. Команда:
|
||||
|
||||
```yaml
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- ssl=on
|
||||
- -c
|
||||
- ssl_cert_file=/etc/postgres-certs/server.crt
|
||||
- -c
|
||||
- ssl_key_file=/etc/postgres-certs/server.key
|
||||
```
|
||||
|
||||
### MariaDB 11.4
|
||||
|
||||
```yaml
|
||||
command:
|
||||
- --ssl-cert=/etc/mariadb-certs/server.crt
|
||||
- --ssl-key=/etc/mariadb-certs/server.key
|
||||
- --require-secure-transport=ON # форсит TLS для всех клиентов
|
||||
```
|
||||
|
||||
### MongoDB 7
|
||||
|
||||
Cert + key должны быть **в одном PEM-файле** (`cat server.crt server.key > server.pem`). Плюс Mongo 7 enforce'ит "chain of trust" — нужен `--tlsCAFile` (для self-signed — указываем server.crt сам как CA). `--tlsAllowConnectionsWithoutCertificates` нужен иначе сервер требует client cert.
|
||||
|
||||
```yaml
|
||||
command:
|
||||
- --tlsMode=requireTLS
|
||||
- --tlsCertificateKeyFile=/etc/mongo-certs/server.pem
|
||||
- --tlsCAFile=/etc/mongo-certs/server.crt
|
||||
- --tlsAllowConnectionsWithoutCertificates
|
||||
- --bind_ip_all
|
||||
```
|
||||
|
||||
### Redis 7 alpine
|
||||
|
||||
```yaml
|
||||
command:
|
||||
- redis-server
|
||||
- --port
|
||||
- "0" # disable non-TLS port
|
||||
- --tls-port
|
||||
- "6379"
|
||||
- --tls-cert-file
|
||||
- /etc/redis-certs/server.crt
|
||||
- --tls-key-file
|
||||
- /etc/redis-certs/server.key
|
||||
- --tls-auth-clients
|
||||
- "no" # без mTLS
|
||||
- --requirepass
|
||||
- <password>
|
||||
```
|
||||
|
||||
## Client connection examples
|
||||
|
||||
```bash
|
||||
# Postgres (sslmode=require, не verify-full — self-signed)
|
||||
psql "postgresql://postgres:$PG_PASS@postgres.vds.kzntsv.site:5432/postgres?sslmode=require"
|
||||
|
||||
# MariaDB (--ssl + --ssl-verify-server-cert=0)
|
||||
mariadb -h mariadb.vds.kzntsv.site -P 3306 -u root -p$MARIA_PASS --ssl --ssl-verify-server-cert=0
|
||||
|
||||
# Mongo (tls=true + tlsAllowInvalidCertificates)
|
||||
mongosh "mongodb://root:$MONGO_PASS@mongo.vds.kzntsv.site:27017/admin?tls=true&tlsAllowInvalidCertificates=true"
|
||||
|
||||
# Redis (--tls --insecure)
|
||||
redis-cli --tls --insecure -h redis.vds.kzntsv.site -p 6379 -a $REDIS_PASS
|
||||
```
|
||||
|
||||
## Trade-off
|
||||
|
||||
- ✔ Уровень входа: 5 минут на cert + 5 минут на compose. Никаких lego/cert-manager headache на старте.
|
||||
- ✔ Каждый клиент явно отключает verify — predictable failure mode (если cert меняется случайно — клиент сразу пишет в логи).
|
||||
- ✗ Клиенты должны помнить `verify=disable`. Production-tier клиенты обычно фейлят на self-signed по defaultу.
|
||||
- ✗ Cert не ротируется автоматически. 10-year validity = временное обходное.
|
||||
- ✗ Для каждой DB — отдельный cert (не wildcard). Расширяемо, но если будет mongo + mongo-readonly — у каждого свой.
|
||||
|
||||
## Roadmap к LE certs
|
||||
|
||||
Sidecar контейнер с lego (готовый container `goacme/lego` или собственный) который watch'ит traefik `acme.json` и каждый раз когда меняется (i.e. cert ротировался) — извлекает PEM-bundle на disk + триггерит `docker exec <db> kill -HUP 1` для reload. Каждый DB получает auto-renewed LE cert. Это deferred — см. follow-up task в [`vds-kzntsv`](../entities/vds-kzntsv.md).
|
||||
|
||||
## Где применено
|
||||
|
||||
4 shared DBs на [`vds-kzntsv`](../entities/vds-kzntsv.md): postgres / mariadb / mongo / redis. Self-signed 10-year certs в `/opt/stacks/databases/<engine>/certs/`. Strong random hex32 пароли в `~/projects/.common/secrets/vds-kzntsv.env`.
|
||||
128
.tmp-concepts/docker-host-loopback-detect.md
Normal file
128
.tmp-concepts/docker-host-loopback-detect.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Docker host-loopback detection — как доказать что host.docker.internal:N не петля в traefik
|
||||
type: concept
|
||||
tags: [docker, traefik, debugging, recipe, gotcha, networking]
|
||||
sources: [../sources/iis-host-migration-2026-05-19.md]
|
||||
updated: 2026-05-19
|
||||
---
|
||||
|
||||
# Docker host-loopback detection
|
||||
|
||||
Конкретная техника **доказать что `host.docker.internal:<port>` внутри docker-контейнера действительно резолвится в host-уровневый процесс**, а не возвращается обратно в тот же docker-контейнер (Docker Desktop NAT loopback). Эта проверка обязательна **прежде traefik patch'a** на host-backend, иначе можно повторить incident attempt 1 из [[iis-migration-2026-05-19-postmortem]] (traefik backend `host.docker.internal:80` → Docker NAT loop в собственный HTTP entrypoint → 301 от http-catchall middleware → TOO_MANY_REDIRECTS).
|
||||
|
||||
## Когда применять
|
||||
|
||||
- Любой раз когда **traefik** (или другой reverse-proxy в docker container) должен идти **на host** (нативный IIS, nginx, Postgres, etc.).
|
||||
- Особенно когда backend port **совпадает** с одним из traefik publish-ports или потенциально может маршрутизироваться обратно в traefik через Docker Desktop port-mapping.
|
||||
|
||||
## Recipe
|
||||
|
||||
### Шаг 1 — выбрать backend port вне traefik publish-set
|
||||
|
||||
Запомни traefik publish-ports — это **запретный список** для backend. Например при наших traefik publish'ах `:8000, :4443, :8080` — backend `:80` тоже опасен (Docker Desktop NAT loopback creates implicit mapping в нижележащих случаях).
|
||||
|
||||
Безопасные porter — те что **никем другим в docker не публикуются** и **не совпадают** с traefik publish-set. Прежде выбора — `Test-NetConnection -Port N` на host, чтобы убедиться нет другого listener'а.
|
||||
|
||||
### Шаг 2 — IIS binding и Stop/Start
|
||||
|
||||
```powershell
|
||||
# 🖥️ ELEVATED PowerShell on Windows host
|
||||
Import-Module WebAdministration
|
||||
$site = 'snolla'; $port = 8089
|
||||
New-WebBinding -Name $site -Protocol http -Port $port -IPAddress '*'
|
||||
Stop-Website -Name $site
|
||||
Start-Website -Name $site # ← КРИТИЧНО, без restart binding не активируется
|
||||
Get-NetTCPConnection -LocalPort $port -State Listen # verify
|
||||
```
|
||||
|
||||
### Шаг 3 — probe изнутри traefik container (НЕ через локальный curl)
|
||||
|
||||
**КЛЮЧЕВОЕ:** probe должен идти **изнутри docker-контейнера**, чтобы воспроизвести точно тот же путь что traefik будет использовать.
|
||||
|
||||
```powershell
|
||||
# 💻 Windows host (non-elevated)
|
||||
docker exec traefik wget --spider -S --header="Host: <domain>" "http://host.docker.internal:8089/" 2>&1 | Select-Object -First 10
|
||||
```
|
||||
|
||||
`--spider` = HEAD-only, не follow redirects. `-S` = show response headers. Без этих флагов wget может **уйти follow public DNS → router → traefik → backend** → искусственная петля через интернет (не имеет отношения к Docker NAT).
|
||||
|
||||
### Шаг 4 — интерпретировать
|
||||
|
||||
**Хороший знак (traefik найдёт host IIS):**
|
||||
|
||||
```
|
||||
Connecting to host.docker.internal:8089 (192.168.65.254:8089)
|
||||
HTTP/1.1 200 OK
|
||||
Server: Microsoft-IIS/10.0 ← НАШ IIS отвечает
|
||||
X-Powered-By: ASP.NET
|
||||
Content-Length: 28413
|
||||
```
|
||||
|
||||
Признаки:
|
||||
- IP-резолв `host.docker.internal` = **192.168.65.254** (Docker Desktop host gateway, может быть другой адрес в зависимости от версии).
|
||||
- `Server: Microsoft-IIS/10.0` ⇒ это IIS, не traefik.
|
||||
- `X-Powered-By: ASP.NET` ⇒ ASP.NET runtime обработал.
|
||||
- Content-Length ≠ 17 (traefik «Moved Permanently» body длиной 17 = подозрительный знак, см. ниже).
|
||||
|
||||
**Плохой знак (Docker NAT loopback):**
|
||||
|
||||
```
|
||||
HTTP/1.1 301 Moved Permanently
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
Content-Length: 17 ← traefik signature (= "Moved Permanently\n")
|
||||
Location: https://<host>/ ← redirect-to-https middleware
|
||||
```
|
||||
|
||||
Признаки:
|
||||
- **Нет** `Server: Microsoft-IIS/10.0` (или `Server: traefik`).
|
||||
- `Content-Length: 17` — общая длина text/plain "Moved Permanently".
|
||||
- 301 на `https://<входной-host>/` — это traefik http-catchall middleware (см. `https.yml` `redirect-to-https`).
|
||||
|
||||
Если видишь второй паттерн — **STOP**. Backend port пересекается с traefik. Не делай traefik patch, выбери другой port.
|
||||
|
||||
### Шаг 5 — public-path verification
|
||||
|
||||
После step 4 — проверь по реальному пути client → traefik → backend:
|
||||
|
||||
```powershell
|
||||
# 💻 Windows host
|
||||
curl.exe -k -sS -I --max-redirs 0 -m 10 -H "Host: <domain>" "https://localhost:4443/"
|
||||
```
|
||||
|
||||
Должно быть first response = `Server: Microsoft-IIS/10.0` (либо CMS canonical 301 от IIS — main thing — Server header указывает на IIS, не на traefik).
|
||||
|
||||
## Pitfall — WinHTTP proxy на хосте strip's headers
|
||||
|
||||
Локальный `curl.exe http://localhost:<host-port>/` на Windows может ходить **через WinHTTP system proxy** который **strips `Server` / `X-Powered-By` headers** на response (плюс часто добавляет `Proxy-Connection: keep-alive`). Это даёт ложное впечатление «не IIS отвечает», хотя на самом деле IIS работает корректно.
|
||||
|
||||
**Признаки** что probe идёт через WinHTTP proxy:
|
||||
- Нет `Server:` в response, но контент корректный (например HTML страница сайта).
|
||||
- `Proxy-Connection: keep-alive` в response.
|
||||
- Headers выглядят «обрезанными».
|
||||
|
||||
**Решение:** для loop-detect / IIS-confirmation тестов используй **`docker exec traefik wget`** (изнутри docker, минует Windows-уровневые proxy) или **`Invoke-WebRequest` через PS** с `-Proxy ''`. НЕ используй `curl.exe` как единственный источник truth для headers.
|
||||
|
||||
## Подтверждённый рабочий пример (2026-05-19 attempt 2)
|
||||
|
||||
```
|
||||
docker exec traefik wget --spider -S --header="Host: emspb.ru" "http://host.docker.internal:8089/"
|
||||
→ Connecting to host.docker.internal:8089 (192.168.65.254:8089)
|
||||
→ HTTP/1.1 301 Moved Permanently
|
||||
Server: Microsoft-IIS/10.0
|
||||
X-Powered-By: ASP.NET
|
||||
Location: http://www.emspb.ru/
|
||||
|
||||
docker exec traefik wget --spider -S --header="Host: localhost:8089" "http://host.docker.internal:8089/"
|
||||
→ HTTP/1.1 200 OK
|
||||
Server: Microsoft-IIS/10.0
|
||||
X-Powered-By: ASP.NET
|
||||
Content-Length: 28413
|
||||
```
|
||||
|
||||
Заключение: `host.docker.internal:8089` → `192.168.65.254:8089` (Docker Desktop host gateway) → host IIS. **NO Docker NAT loop.** 301 — это CMS canonical (CMS-side www-redirect), не traefik http-catchall (тот бы дал `Content-Length: 17` text/plain без `Server: Microsoft-IIS/10.0`).
|
||||
|
||||
## Связано
|
||||
|
||||
- [[iis-migration-2026-05-19-postmortem]] — почему attempt 1 сломался (этот же loop, но с `:80` который пересекался с docker-NAT mapping).
|
||||
- [[traefik-on-windows-docker-desktop]] — общие traefik pitfalls на Docker Desktop.
|
||||
- [[iis-host-migration-2026-05-19]] Phase 10 — где этот recipe применён первый раз и подтверждён.
|
||||
111
.tmp-concepts/future-resilient-architecture-goals.md
Normal file
111
.tmp-concepts/future-resilient-architecture-goals.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: Future Resilient Architecture — цели на потом
|
||||
type: concept
|
||||
tags: [planning, architecture, resilience, roadmap, todo]
|
||||
sources: [../sources/nas-recovery-session-2026-05-18.md]
|
||||
updated: 2026-05-19
|
||||
---
|
||||
|
||||
# Future Resilient Architecture
|
||||
|
||||
Placeholder для глобальной задачи "выстроить отказоустойчивую архитектуру" — обсуждается **после** того как recovery полностью устаканится.
|
||||
|
||||
Эта страница — **черновик целей**, не план. По ходу обсуждения распилится на специфичные концепты + ADR (architectural decision records).
|
||||
|
||||
## Что сейчас не так
|
||||
|
||||
См. [[recovery-architecture-snapshot]] → раздел "Single Points of Failure". Кратко:
|
||||
|
||||
- ⚠️ Windows-PC = SPOF
|
||||
- ⚠️ Один public IP
|
||||
- ⚠️ Один MSSQL primary без replica
|
||||
- ⚠️ Один MinIO single-node
|
||||
- ⚠️ Backup только Hyper Backup на одну синку (Kreknin), сейчас она cold
|
||||
- ⚠️ Backup нового рабочего состояния (CMS data на recovery-host) **отсутствует**
|
||||
- ⚠️ Нет off-site backup (cloud)
|
||||
|
||||
## Цели верхнего уровня
|
||||
|
||||
1. **RTO** (Recovery Time Objective) — сколько максимум **downtime** клиенты должны видеть при отказе.
|
||||
- Текущий по факту: ~15 часов (что и было).
|
||||
- Целевой: **< 1 час** для одиночного отказа, **< 4 часов** для каскадного.
|
||||
2. **RPO** (Recovery Point Objective) — сколько максимум **данных потерять** при отказе.
|
||||
- Текущий: до 24 часов (если успеет бэкап после изменения) — больше если бэкап не успел.
|
||||
- Целевой: **< 1 час**, в идеале continuous (replication).
|
||||
3. **Стоимость** — bounded by разумным % от выручки клиентов.
|
||||
4. **Operational simplicity** — никаких heroic ops по 15 часов в случае проблемы.
|
||||
|
||||
## Технические направления (наброски)
|
||||
|
||||
### Backup-стратегия 3-2-1
|
||||
|
||||
Стандарт: **3** копии данных, **2** разных media, **1** off-site.
|
||||
|
||||
- **Копия 1:** primary storage (live) на NAS / Windows-PC
|
||||
- **Копия 2:** local backup target ([[kreknin-synology]] остаётся, плюс отдельный)
|
||||
- **Копия 3:** **cloud off-site** — Backblaze B2 / Yandex Object Storage / AWS S3 Deep Archive / Hetzner Storage Box
|
||||
- Все backups encrypted client-side
|
||||
- Retention: 7 daily / 4 weekly / 12 monthly
|
||||
|
||||
### Compute resilience
|
||||
|
||||
Варианты:
|
||||
|
||||
A. **Multi-host on-prem:** 2-3 физических машины с виртуализацией (Proxmox VE), HA-кластер, VMотом migration. Дорого, но автономно.
|
||||
|
||||
B. **Cloud-first:** перенос CMS-стека в managed Kubernetes (Yandex Cloud / VK Cloud / hetzner) — managed MSSQL / managed S3 / managed Postgres. Простота операций, но vendor lock-in.
|
||||
|
||||
C. **Гибрид:** primary on-prem (Synology с CMR), warm standby в cloud (готовый к failover за 5 мин). Compromise.
|
||||
|
||||
### Database resilience
|
||||
|
||||
MSSQL options:
|
||||
- **Always On Availability Groups** (требует Enterprise license — недёшево)
|
||||
- **Log Shipping** (бесплатно, но manual failover)
|
||||
- **MSSQL → PostgreSQL миграция?** (если есть полная свобода — выгоды Open Source + распространённые managed предложения).
|
||||
|
||||
Для recovery-сценария: **continuous backup** через VDI + native MSSQL TDE backup → S3.
|
||||
|
||||
### Network resilience
|
||||
|
||||
- **Multi-WAN на OpenWRT:** primary провайдер + 4G/LTE USB-modem как failover. OpenWRT mwan3 package.
|
||||
- **Cloudflare Proxy / Tunnel:** перенаправление публичного трафика через Cloudflare edge. Помогает с DDoS и переключением IP без DNS-changes.
|
||||
- **Static IP пересмотр:** разные провайдеры → multi-homed setup.
|
||||
|
||||
### Monitoring + auto-recovery
|
||||
|
||||
- **Healthchecks** на каждый layer (TCP, HTTP, deep DB query) — Prometheus + Alertmanager или Uptime Kuma.
|
||||
- **Watchdog скрипты** — auto `ipconfig /release/renew` в VM, auto `docker compose restart` контейнера если healthcheck падает > N min.
|
||||
- **Telegram-уведомления** на инциденты (для пользователя в реал-тайм когда что-то фейлится).
|
||||
|
||||
### Документация и runbook
|
||||
|
||||
- Runbook на каждый процедурный сценарий (failover, restore, network swap).
|
||||
- Регулярные **failover drills** раз в квартал — буквально нажать "восстановиться" и засечь время.
|
||||
- Wiki эта — стартовая точка.
|
||||
|
||||
## Что НЕ цели
|
||||
|
||||
- Перестройка с нуля «потому что сейчас не идеально». Текущая архитектура работает, замена должна быть инкрементальной.
|
||||
- 99.99% uptime — нереалистично для one-person infrastructure, и не нужно для CMS-сайтов клиентов.
|
||||
|
||||
## Следующие шаги (когда дойдут руки)
|
||||
|
||||
1. Заменить WD40EFAX на CMR-диски, пересобрать пул [[dead-synology-diskstation]].
|
||||
2. Включить ежедневный Hyper Backup из нового пула на [[kreknin-synology]].
|
||||
3. Добавить cloud-backup target (Yandex Object Storage наиболее очевидный для RU-юрисдикции).
|
||||
4. Настроить monitoring/alerts.
|
||||
5. Документировать runbooks.
|
||||
|
||||
Связано со всеми остальными страницами этого wiki.
|
||||
|
||||
## Прогресс 2026-05-20
|
||||
|
||||
✅ **Decouple инфра-сервисов от windows-recovery-host SPOF.** Поднят отдельный облачный VDS [[vds-kzntsv]] (Rusonyx 160 NVMe), туда мигрированы gitea / verdaccio / docker-registry + shared DB park (Postgres/MariaDB/Mongo/Redis). [[windows-recovery-host]] теперь хостит **только** production CMS. Это не полный multi-host resilience (VDS сам по себе SPOF), но critical infra decoupling сделан.
|
||||
|
||||
Запланированы follow-up tasks (см. `.tasks/`):
|
||||
- `vds-backup-rsync-kreknin` — ежедневный 05:00 MSK rsync VDS → kreknin с email-нотификацией.
|
||||
- `vds-ntfy-push` — self-hosted push на Android для backup status и monitoring.
|
||||
- `vds-gc-cron` — cron GC для verdaccio + registry чтобы не повторился incident 99G на registry.
|
||||
|
||||
Следующая фаза по originally outlined ladder — добавить **cloud off-site backup target** (Backblaze B2 / Yandex Object Storage) поверх kreknin'а, и replicated MSSQL для production CMS.
|
||||
101
.tmp-concepts/hyper-backup-structure-and-recovery.md
Normal file
101
.tmp-concepts/hyper-backup-structure-and-recovery.md
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: Hyper Backup — структура репо и стратегия восстановления
|
||||
type: concept
|
||||
tags: [backup, synology, hyperbackup, recovery, lessons]
|
||||
sources: [../sources/nas-recovery-session-2026-05-18.md]
|
||||
updated: 2026-05-19
|
||||
---
|
||||
|
||||
# Hyper Backup — структура и recovery
|
||||
|
||||
## Структура `.hbk` репозитория
|
||||
|
||||
Каталог `<task-name>.hbk` на target-NAS содержит:
|
||||
|
||||
```
|
||||
<task>.hbk/
|
||||
├── Config/ — метаданные задачи (план бэкапа)
|
||||
│ ├── @Share/<sharename>/ — список файлов в каждой бэкапленной шаре
|
||||
│ ├── target_info.db.<N> — SQLite, инфа о target
|
||||
│ ├── version_info.db.<N> — версии бэкапов
|
||||
│ ├── file_chunk*.index — индексы для дедупликации
|
||||
│ ├── virtual_file.index — карта файлов
|
||||
│ └── _Syno_TaskConfig — **plain text JSON конфиг задачи** (имя, source-host, encryption flag, schedule, backup_folders[], backup_apps[], backup_volumes[])
|
||||
├── Control/ — управление (lock, @writer)
|
||||
├── Guard/ — служебное
|
||||
├── Pool/ — дедуплицированные chunks данных
|
||||
├── synobkpinfo.db — SQLite с backup_info_tb, task_id_tb
|
||||
├── _Syno_TaskConfig — копия конфига в корне
|
||||
└── SynologyHyperBackup.bkpi — маркер
|
||||
```
|
||||
|
||||
**`_Syno_TaskConfig`** — самый ценный файл для оценки бэкапа без полной распаковки. JSON в нём содержит:
|
||||
- `name` — имя задачи (в нашем кейсе `rsync Server 1`)
|
||||
- `host_name` — имя source-NAS
|
||||
- `enable_data_encrypt` (true/false) — шифрование клиентским паролем
|
||||
- `backup_folders[]` — список путей, которые бэкапились
|
||||
- `backup_apps[]` — DSM-пакеты (если бэкапили их данные)
|
||||
- `backup_volumes[]` — VM-снимки (Synology VMM)
|
||||
|
||||
## Hyper Backup Vault vs Hyper Backup
|
||||
|
||||
- **Hyper Backup** (`/var/packages/HyperBackup/`) — клиент. Бэкапит ИЗ этого DSM.
|
||||
- **Hyper Backup Vault** (`/var/packages/HyperBackupVault/`) — сервер. Принимает бэкап от других DSM.
|
||||
|
||||
На target-NAS (где лежит репо) обычно установлен **Vault**. Сам он не имеет UI для restore — только receive. Restore инициируется из **Hyper Backup** (тот же пакет, но в другом режиме).
|
||||
|
||||
## Стратегии восстановления
|
||||
|
||||
Когда мёртвый NAS — source, репо на живом target:
|
||||
|
||||
### A. **Restore через DSM UI на target-NAS** (применили мы)
|
||||
|
||||
1. Открыть DSM web UI на target.
|
||||
2. Hyper Backup → **Restore** → **Data Restore Wizard**.
|
||||
3. Список задач **пустой** (этот NAS — приёмник, не source). Снизу-слева: **"Восстановить из существующих репозиториев"**.
|
||||
4. Server type: **"В локальную папку и на USB"**.
|
||||
5. Указать путь к `.hbk` (`/volume1/NetBackup/diskstation_1.hbk`).
|
||||
6. Если шифрования нет (`enable_data_encrypt=false`) — сразу к выбору данных.
|
||||
7. **Системную конфигурацию НЕ восстанавливать** (иначе наложатся пользователи/сеть/шары source-NAS на target).
|
||||
8. Selective restore — пик нужные подпапки.
|
||||
9. **Restore destination:** "Restore to another location" → новая папка (`/volume1/NetBackup/restore-tmp/` или просто root тома → создаст шары с именами как на source).
|
||||
10. Версия: топовая (свежая дата).
|
||||
|
||||
**Гочча:** при restore "to original location" DSM создаст **новые SMB-шары** на target c именами как на source (`backup/`, `docker/`, `work/`). Это **меняет state target-NAS**. Если важно — выбирать "another location".
|
||||
|
||||
### B. **Hyper Backup Explorer (HBE)** — офлайн на Windows/Mac/Linux
|
||||
|
||||
GUI-приложение от Synology, читает `.hbk` напрямую (требует password если шифрован). Подходит когда target-NAS недоступен и есть только файлы репо.
|
||||
|
||||
**Минусы:** GUI, тысячи кликов для многих файлов, нет batch-restore.
|
||||
|
||||
### C. **Restore + tar | ssh pull** (бекап-копия на чужую машину)
|
||||
|
||||
Если уже restored to a target-NAS:
|
||||
|
||||
```
|
||||
ssh user@target "tar cf - -C /volume1/backup ." | tar xf - -C /local/path
|
||||
```
|
||||
|
||||
При chrooted SFTP (типичный DSM) — нельзя через scp/sftp пройти за пределы home. Решение: **`scp -O`** (legacy SCP protocol через чистый SSH-канал). Или `ssh ... 'cat file' > local-file` для одиночных файлов.
|
||||
|
||||
## ACL/permissions особенности (DSM 7)
|
||||
|
||||
- DSM 7 использует **Synology ACL** поверх POSIX. Видно `+` после permissions: `d---------+`.
|
||||
- POSIX-биты могут показывать `0` для всех, но реально ACL может open file для специфичных users/groups.
|
||||
- Файлы в Hyper Backup-репо могут иметь permission `-rw-------` (owner-only) — тогда другому юзеру `scp -O` не достанет, требуется `chmod` от root.
|
||||
|
||||
## SFTP-jailed subsystem
|
||||
|
||||
DSM SFTP-subsystem **chroot'ит** пользователя в home (`/var/services/homes/<user>/`). Пути за пределами не видны через SFTP-клиент.
|
||||
|
||||
**Обход:** `scp -O` (флаг "use legacy SCP protocol") — обходит SFTP-subsystem, использует raw SSH-channel + shell-команды target-side. Тогда видно всё, что shell-пользователь видит.
|
||||
|
||||
## Стратегии для будущего
|
||||
|
||||
- **Hyper Backup ежедневно**, не "по триггеру" (как было). Окно потерь = 1 день.
|
||||
- **Retention** 30+ дней — даёт возможность откатиться при позднем обнаружении проблем.
|
||||
- **Test restore раз в квартал** — простейшая дисциплина: пик одну папку, восстанови в temp, проверь что файлы корректны. Иначе бэкап может тихо умереть без вашего ведома.
|
||||
- **Многослойный backup:** не только Synology→Synology. Дополнительно cloud (Backblaze B2, AWS S3 Glacier, Yandex Object Storage) — на случай если оба NAS физически рядом и сгорят вместе.
|
||||
|
||||
Связано: [[dead-synology-diskstation]], [[kreknin-synology]].
|
||||
227
.tmp-concepts/iis-migration-2026-05-19-postmortem.md
Normal file
227
.tmp-concepts/iis-migration-2026-05-19-postmortem.md
Normal file
@@ -0,0 +1,227 @@
|
||||
---
|
||||
title: IIS host-migration session 2026-05-19 — post-mortem
|
||||
type: concept
|
||||
tags: [migration, iis, traefik, docker, postmortem, lessons-learned, gotcha]
|
||||
sources: [../sources/iis-host-migration-2026-05-19.md]
|
||||
updated: 2026-05-19
|
||||
---
|
||||
|
||||
# Post-mortem миграции CMS на нативный IIS, 2026-05-19
|
||||
|
||||
Сессия закончилась **откатом на VM** после ~3 часов сломанного prod-трафика. Этот документ — честный разбор что пошло не так и как избежать в следующей попытке. Авторство ошибок — мои; пользователю — за быстрое обнаружение и за то что заставил откатить.
|
||||
|
||||
## Хронология провала
|
||||
|
||||
1. **Phase 1-3** (утро 2026-05-19) — миграция выполнена технически. Smoke test `Invoke-WebRequest -MaximumRedirection 5` через `https://localhost:4443/` показал 10/11 хостов → 200 OK. **Я объявил success.**
|
||||
2. **Phase 4-8** — реорг `C:\sites\`, stayer DB-fix, traefik для stayer'ов отключен, VM savestate, wiki commit (`🟢 done`).
|
||||
3. **Через ~10 минут после commit** пользователь открыл `https://www.pilorama98.ru/` в браузере → `502 Bad Gateway`, потом `TOO_MANY_REDIRECTS` для остальных.
|
||||
4. Я ~1 час паниковал и каскадно ломал.
|
||||
5. Пользователь сказал revert. Откат до VM-backend → 200 OK через router. Prod вернулся.
|
||||
|
||||
## Что было реальным корнем
|
||||
|
||||
### 1. Docker port-collision invariant — НЕ ЗНАЛ
|
||||
|
||||
Внутри traefik-контейнера `host.docker.internal:80` резолвится через Docker Desktop NAT **обратно в сам traefik**, потому что traefik publish'ит `host:8000 → container:80`. Docker Desktop port-mapping создаёт замкнутую петлю на published-портах.
|
||||
|
||||
**Симптом:** traefik backend `host.docker.internal:80` (наша host-IIS) фактически возвращает request на traefik's own HTTP entrypoint (:80 inside container). Там действует middleware из `https.yml`:
|
||||
|
||||
```yaml
|
||||
http-catchall:
|
||||
rule: hostregexp(`{host:.+}`)
|
||||
entryPoints: [http]
|
||||
middlewares: [redirect-to-https]
|
||||
redirect-to-https:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
permanent: true
|
||||
```
|
||||
|
||||
→ traefik отвечает `301 Location: https://<host>/` → клиент follows → traefik HTTPS entrypoint → backend `:80` → loop. Browser: `TOO_MANY_REDIRECTS`.
|
||||
|
||||
**Опознавательный знак:** `Content-Length: 17` body "Moved Permanently", отсутствует `Server: Microsoft-IIS` — это traefik response, не IIS. Я заметил только под конец.
|
||||
|
||||
### 2. Phase 3 smoke test — ложный позитив
|
||||
|
||||
`Invoke-WebRequest -MaximumRedirection 5` следовал по редиректам и где-то на 2-3-м шаге случайно landed на 200 (вероятно, при определённой комбинации Host header'а CMS возвращал контент). Я принял это за work-good baseline. На самом деле уже тогда был partial loop.
|
||||
|
||||
**Урок:** smoke test должен:
|
||||
- Использовать `-MaximumRedirection 0` или `--max-redirs 0` чтобы видеть **первый ответ** (без авто-следования).
|
||||
- Логировать **всю цепочку редиректов** (curl `-L -v`, считать `num_redirects`).
|
||||
- Распознавать loop по `num_redirects > 3` как warning.
|
||||
|
||||
### 3. Все мои тесты обходили router
|
||||
|
||||
- `curl --resolve domain:4443:127.0.0.1` бьёт traefik **напрямую** на host loopback. Router не в пути.
|
||||
- Тест **из router'а** `ssh root@192.168.1.1 curl https://snolla.com/` тоже ложный — router DNS resolve'ит в own WAN IP, connect direct без DNAT (hairpin issue) → попадает на router LuCI web :443. Я получил "200 OK" от LuCI и принял за CMS.
|
||||
|
||||
**Реальный путь клиента из публичного интернета:**
|
||||
|
||||
```
|
||||
Browser → DNS → public IP 94.19.247.14 (router WAN)
|
||||
→ router NAT PREROUTING DNAT 443 → 192.168.1.143:4443
|
||||
→ traefik:4443 → backend → ...
|
||||
```
|
||||
|
||||
**Урок:** тестировать с НЕ-LAN машины (телефон через мобильный интернет, VPS curl, etc.). Любой тест внутри LAN сети — потенциально ложный.
|
||||
|
||||
### 4. Перепутал источник 301
|
||||
|
||||
Долго копал в CMS `MoreThenCms.Web\Global.asax.cs` и `MoreThenCms.Api.WebUI\Global.asax.cs` на предмет "force HTTPS"/"primary domain redirect". Это ВСЁ существует в CMS-коде, но не было активным источником loop'а — там logic для www-stripping и canonical, не для HTTP→HTTPS scheme.
|
||||
|
||||
**Реальный источник** был в `traefik/data/custom/https.yml` (middleware). Уже задокументирован в wiki как Pitfall 5 в [[traefik-on-windows-docker-desktop]], но я не сложил 2+2.
|
||||
|
||||
**Урок:** headers != lying. Если response без `Server: Microsoft-IIS` — это не IIS отвечает. Прежде чем копать application код, проверить что response действительно от application.
|
||||
|
||||
### 5. Каскадное реактивное ломание
|
||||
|
||||
После первого `502 Bad Gateway` сделал в течение часа:
|
||||
- `Restart-WebAppPool snolla` (не помогло — pool жив)
|
||||
- `Stop-Process w3wp -Force` + restart (не помогло)
|
||||
- `docker restart traefik` — сделал **хуже** (после рестарта 503, потеряли warm cache)
|
||||
- Patched traefik 11 yml: `host.docker.internal:80 → 192.168.1.143:80` (то же самое — `192.168.1.143:80` тоже резолвится через NAT обратно в traefik, тот же loop)
|
||||
- Patched `:80 → :8088` + добавил IIS binding на :8088 (правильное направление в принципе, но без понимания root cause работал вслепую; забыл `Stop+Start Website` чтобы binding applied; потом IIS не listened → 503)
|
||||
|
||||
**Каждый шаг без понимания root cause только ухудшал state.** Должно было быть: при первом 502 → `git stash` traefik yml + revert на `.bak-phase3` + понять разницу между working и broken state. Вместо этого — random fixes.
|
||||
|
||||
**Урок:** **первое непонимание = STOP. Revert. Reproduce. Understand. Then fix.**
|
||||
|
||||
## Бонус-провалы
|
||||
|
||||
### 6. Wiki status "🟢 done" — преждевременный
|
||||
|
||||
Объявил task done через ~5 минут после Phase 3 smoke. Wrote 5 wiki files, 2 commits. На деле prod был сломан через 10 минут — traefik state какое-то время держал работающий ответ (cached connections?), потом deteriorated.
|
||||
|
||||
**Урок:** "done" — это **48+ часов uptime под реальным трафиком** + проверка из публичной сети + zero rollbacks. Не immediate smoke pass.
|
||||
|
||||
### 7. VM savestate — слишком рано
|
||||
|
||||
Phase 8 я заморозил VM **через ~30 минут после Phase 3**. Пользователь сразу сказал mostly "не торопись", но я proceed-нул. Лучшая практика: **держать VM running параллельно как hot fallback** на N часов/дней, только тогда savestate.
|
||||
|
||||
### 8. Реорг C:\sites\ + IIS rename — лишняя работа в той же сессии
|
||||
|
||||
Phase 4 (rename folders, sites, pools, recreate AppPool) добавил **много моментов где могло сломаться**, и сделан в той же сессии что и actual migration. Лучше: миграция → стабильность 24h → реорг + rename как **отдельная мелкая task**.
|
||||
|
||||
### 9. Web.config patch для stostayer.old пропущен в Phase 2
|
||||
|
||||
В Phase 2 я patched только `C:\sites\MoreThenCms.Web\Web.config` (`sitePath` + conn → localhost). Пропустил `C:\stayer\stostayer.old\web.config` где conn был `Data Source=10.0.2.2` (NAT gateway, не работает на хосте). Это вылезло только в Phase 5 когда я начал stayer'ы дебажить.
|
||||
|
||||
**Урок:** **сначала grep ВСЕ Web.config'и** на patterns (`10.0.2.2`, `host.docker.internal`, абсолютные пути), сделать таблицу "файл → что patch", выполнить **batch**, потом сразу тестировать. Не делать item-by-item.
|
||||
|
||||
### 10. Не использовал git stash / branch protection
|
||||
|
||||
Все patches шли прямо на master (`project-discipline` rule). Это правильно для проекта, но для **prod-trafficchanging operations** (traefik patches) — лучше **сначала bak копия + явный grep diff**, **затем** commit. Я делал именно так с traefik (bak-stamps), но не делал atomic revert на первое 502.
|
||||
|
||||
## Recipe для следующей попытки миграции
|
||||
|
||||
Если делать миграцию заново, **избежать всех 5 главных ошибок**:
|
||||
|
||||
### A. Backend port — НЕ :80
|
||||
|
||||
Использовать любой порт **отличный от traefik publish-ports** (`:8000, :4443, :8080`). Predictable choices:
|
||||
|
||||
- `:18080` (исторически = VM NAT, теперь свободен после VM-down) — `host.docker.internal:18080` не конфликтует с traefik internals.
|
||||
- `:8088`, `:8181`, etc. — любой свободный, главное **не совпадающий** с traefik publish.
|
||||
|
||||
Добавить IIS binding к site:
|
||||
```powershell
|
||||
New-WebBinding -Name snolla -Protocol http -Port 18080 -IPAddress '*'
|
||||
Stop-Website snolla; Start-Website snolla # ← КРИТИЧНО, без restart binding не activates
|
||||
Get-NetTCPConnection -LocalPort 18080 -State Listen # verify
|
||||
```
|
||||
|
||||
### B. Smoke test с no-follow
|
||||
|
||||
```powershell
|
||||
# WRONG (auto-follows, скрывает loop):
|
||||
Invoke-WebRequest -UseBasicParsing -Headers @{Host=$h} -Uri 'https://localhost:4443/' -MaximumRedirection 5
|
||||
|
||||
# RIGHT (раскрывает loop):
|
||||
Invoke-WebRequest -UseBasicParsing -Headers @{Host=$h} -Uri 'https://localhost:4443/' -MaximumRedirection 0
|
||||
# или curl:
|
||||
curl -ksI -H "Host: $h" --max-redirs 0 https://localhost:4443/
|
||||
# и проверить FIRST response code + Location header. Если Location == входной URL → loop.
|
||||
```
|
||||
|
||||
### C. Тест из НЕ-LAN сети
|
||||
|
||||
Перед commit:
|
||||
- Тест с **телефона через мобильный интернет** (наибыстрее).
|
||||
- Или curl с VPS (~$5/mo) через cron-job.
|
||||
- Или `curl -x` через прокси публичного интернета.
|
||||
- Хост-side smoke и router-side smoke = **только sanity**, не production-confirmation.
|
||||
|
||||
### D. Параллельный run VM x N часов
|
||||
|
||||
После переключения traefik backend → host:
|
||||
- VM **не глушим**.
|
||||
- Мониторим N часов (минимум 24h) логи host-IIS + Application event log + traefik logs.
|
||||
- Только если ZERO incidents → savestate VM.
|
||||
- Cleanup VM (`unregistervm --delete`) — через дополнительные несколько дней.
|
||||
|
||||
### E. Atomic revert plan ДО старта
|
||||
|
||||
До любой prod-changing операции:
|
||||
- `*.bak-pre-<operation>-<date>` backup для каждого touched-файла.
|
||||
- Заранее написать revert-скрипт ("если что — paste this").
|
||||
- Заявить user'у: "вот revert. Если что — кричи слово stop".
|
||||
- При первом любом anomaly → revert немедленно, разбираться post-mortem.
|
||||
|
||||
### F. Headers checklist при дебаге
|
||||
|
||||
Когда видим 301/302/502:
|
||||
1. **Server header** есть `Microsoft-IIS/10.0`? Нет → не IIS отвечает (traefik / какой-то прокси).
|
||||
2. **Content-Length** какой? Если короткий (~17, ~100) + `text/plain` body — generic redirect от framework, не IIS rendered response.
|
||||
3. **X-Powered-By: ASP.NET** есть? Нет → не ASP.NET.
|
||||
4. Сравнить с known-good response (direct `curl http://localhost/`).
|
||||
|
||||
### G. Web.config grep batch перед patches
|
||||
|
||||
```powershell
|
||||
# найти все hardcoded refs в одном проходе
|
||||
Get-ChildItem C:\sites,C:\nas-recovery\vm-sites -Recurse -Include *.config | % {
|
||||
Select-String -Path $_.FullName -Pattern 'Data Source=|inetpub|stayer|sitePath' -EA Silent
|
||||
} | ft Path, LineNumber, Line -a
|
||||
```
|
||||
|
||||
Сделать таблицу `{файл, текущее, нужно}` → проверить с user → одним PowerShell-блоком patch ВСЁ → одним smoke.
|
||||
|
||||
## Что было сделано правильно (хотя бы)
|
||||
|
||||
- **UTF-8 BOM Web.config patches** (паттерн из [[cms-config-rewrite-pattern]]) — работали стабильно.
|
||||
- **XML-escape `&` в password** для stostayer Web.config — поймали и задокументировали в [[webconfig-password-xml-escape]].
|
||||
- **Backup-stamping** (`.bak-phase3`, `.bak-stayer-switch`, `.bak-hostip`) — позволили чисто откатиться.
|
||||
- **VM не удалена** — savestate сохранил состояние, восстановилось за `startvm` + 90s + `ipconfig /release /renew` recipe из [[vbox-windows-stability-tuning]].
|
||||
- **Wiki как append-only журнал** — этот post-mortem пишется тут же, не теряется.
|
||||
|
||||
## Open: что осталось на хосте после revert
|
||||
|
||||
- `C:\sites\{snolla, stostayer, stostayer.old, snolla-identity-manager}` — ~11 GB, неиспользуется prod.
|
||||
- IIS sites `snolla` :80+:8088, `stostayer` :8090, `stostayer.old` :8091 + AppPools — нерабочие, не мешают (не на prod-пути).
|
||||
- traefik backup-stamps (`*.bak-phase3-2026-05-19`, `*.bak-stayer-switch-2026-05-19`, `*.bak-hostip-2026-05-19`) — оставлены.
|
||||
|
||||
Эти артефакты — основа для следующей попытки. Не очищать, пока не сделаем чистую миграцию по recipe выше.
|
||||
|
||||
## Связано
|
||||
|
||||
[[iis-host-migration-2026-05-19]] — chronology до и включая ошибки. [[traefik-on-windows-docker-desktop]] Pitfall 5 — знал, не применил. [[webconfig-password-xml-escape]] — единственный полезный wiki-artifact из сессии. [[recovery-architecture-snapshot]] — обновлён обратно под VM-chain.
|
||||
|
||||
---
|
||||
|
||||
## Attempt 2 — succeeded (2026-05-19 вечер, тот же день)
|
||||
|
||||
После прочтения этого post-mortem — **повторная попытка миграции выполнена по recipe и завершилась без incidents**. Все 7 пунктов recipe (A-G) применены:
|
||||
|
||||
- **A (backend port ≠ :80):** `:8089` (свободен, вне traefik publish-set).
|
||||
- **B (smoke с `-MaximumRedirection 0`):** через `curl.exe -k -I --max-redirs 0` + `wget --spider`. Первый response = `Server: Microsoft-IIS/10.0` ⇒ IIS отвечает, нет traefik loop.
|
||||
- **C (тест НЕ-LAN):** 2 phone-test'а от пользователя через мобильный интернет (`emspb.ru`, `labtools.ru`) — passed.
|
||||
- **D (parallel VM):** VM `snolla-recovery` running, **НЕ savestate** до 24h+ soak.
|
||||
- **E (atomic revert ДО старта):** bak-серия `.bak-pre-attempt2-2026-05-19` для 13 yml + paste-ready команда восстановления в `.tasks/STATUS.md`.
|
||||
- **F (headers checklist):** на каждом smoke step verify `Server: Microsoft-IIS/10.0` + `X-Powered-By: ASP.NET` — где этих headers нет (например через локальный `curl.exe` который шёл через WinHTTP proxy), смена probe-method на `docker exec` который видит real response (см. [[docker-host-loopback-detect]]).
|
||||
- **G (Web.config grep batch):** не было нужды — Web.config'и patches из attempt 1 переиспользованы без изменений.
|
||||
|
||||
Финал: 14 cms hostnames через host IIS:8089, stayer routes `.yml.disabled` (как было решение Phase 7 prev session, user re-confirmed). Подробности в [[iis-host-migration-2026-05-19]] Phase 10.
|
||||
|
||||
**Новые pitfalls найдены в attempt 2:** WinHTTP proxy на Windows host stripping `Server`/`X-Powered-By` headers на response — `curl.exe http://localhost:...` не достоверный probe; `docker exec traefik wget` показывает реальный path. Зафиксировано как [[docker-host-loopback-detect]].
|
||||
|
||||
Memory: `feedback-migrate-semantics` — урок про неоднозначность слова «мигрировать» для internal/low-traffic сервисов; всегда переспрашивать прежде prod-changing.
|
||||
151
.tmp-concepts/mssql-container-data-restore.md
Normal file
151
.tmp-concepts/mssql-container-data-restore.md
Normal file
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: MSSQL контейнер с восстановленными production data — паттерн
|
||||
type: concept
|
||||
tags: [mssql, docker, recovery, named-volume, chown]
|
||||
sources: [../sources/nas-recovery-session-2026-05-18.md]
|
||||
updated: 2026-05-19
|
||||
---
|
||||
|
||||
# MSSQL container с production data
|
||||
|
||||
## Проблема
|
||||
|
||||
Восстановить MSSQL базу в Docker-контейнере на Windows-хосте из:
|
||||
- Готовых `.mdf/.ldf` файлов production (взяты из tar `/var/opt/mssql/` source-контейнера)
|
||||
- + 5 user-databases + системные (master/model/msdb/tempdb)
|
||||
|
||||
## Что НЕ работает: bind-mount production data
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./production-data/mssql:/var/opt/mssql
|
||||
```
|
||||
|
||||
**Не работает** на Windows Docker Desktop. MSSQL-контейнер требует:
|
||||
- `master.mdf` owned by `mssql` user (UID 10001) или root
|
||||
- `chmod` на файлах внутри `/var/opt/mssql/log/` (логи, .xel, .trc)
|
||||
|
||||
На Windows DD bind-mount через WSL2-слой не позволяет:
|
||||
- Файлы видятся как owned by root or other UID — MSSQL отказывается: `Your master database file is owned by root.`
|
||||
- `chmod` внутри контейнера фейлится: `Operation not permitted`
|
||||
|
||||
Симптом: MSSQL стартует, в логах ругается на chmod, потом стартует ещё раз и зависает.
|
||||
|
||||
## Что работает: named volume + chown через temp container
|
||||
|
||||
Идея: создать named volume, скопировать данные внутрь с правильным chown, потом примонтировать к MSSQL.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml — финальная версия
|
||||
services:
|
||||
mssql:
|
||||
image: mcr.microsoft.com/mssql/server:2019-latest
|
||||
container_name: mssql
|
||||
environment:
|
||||
ACCEPT_EULA: "Y"
|
||||
MSSQL_SA_PASSWORD: ${SA_PASSWORD}
|
||||
MSSQL_PID: Developer
|
||||
ports:
|
||||
- "1433:1433"
|
||||
volumes:
|
||||
- mssql_data:/var/opt/mssql
|
||||
networks:
|
||||
- proxy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
mssql_data:
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
```
|
||||
|
||||
Заполнение volume (один раз перед `docker compose up -d`):
|
||||
|
||||
```powershell
|
||||
docker volume create mssql_mssql_data
|
||||
docker run --rm \
|
||||
-v mssql_mssql_data:/dest \
|
||||
-v <local-path-to-production-data>/mssql:/src:ro \
|
||||
alpine cp -a /src/. /dest/
|
||||
|
||||
docker run --rm -v mssql_mssql_data:/dest \
|
||||
alpine chown -R 10001:0 /dest
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
После этого MSSQL стартует с production data, делает crash recovery в master.mdf, поднимается за 30-60 секунд (если CU контейнера == CU source) или 5-30 минут (если CU source старше — script upgrade mode).
|
||||
|
||||
## sqlcmd "$( var-substitution" pitfall
|
||||
|
||||
Альтернативный путь — restore из `.sql` script (export через "Generate Scripts" в SSMS). Файл 547 MB с CREATE+INSERT'ами всей БД.
|
||||
|
||||
**Не запустить через `sqlcmd -i file.sql`** без флага `-x`. Потому что:
|
||||
|
||||
- В данных встречаются jQuery JS-сниппеты типа `INSERT ... VALUES (N'$(function(){ ... })')`
|
||||
- sqlcmd по умолчанию интерпретирует `$(varname)` как переменную для подстановки.
|
||||
- На `$(function(){` парсер ломается → "Syntax error near command '('" в середине файла.
|
||||
|
||||
Фикс: `sqlcmd -x` (disable variable substitution).
|
||||
|
||||
```
|
||||
docker exec mssql /opt/mssql-tools18/bin/sqlcmd \
|
||||
-S localhost -U sa -P '<pw>' -C -x \
|
||||
-i /var/opt/mssql/backup/MoreThenCms.sql
|
||||
```
|
||||
|
||||
## SA-аккаунт disabled в production
|
||||
|
||||
Production SQL Server конфигурации часто **отключают `sa`** (security best practice). Пароль может быть прав, но account disabled → `Login failed for user 'sa'. Reason: The account is disabled.`
|
||||
|
||||
Фикс: **`mssql-conf set-sa-password`** в offline-mode (server stopped) под root:
|
||||
|
||||
```
|
||||
# Stop running container
|
||||
docker compose stop
|
||||
|
||||
# Run offline mssql-conf in temp container с тем же volume
|
||||
docker run --rm --user 0:0 \
|
||||
-e ACCEPT_EULA=Y \
|
||||
-e MSSQL_SA_PASSWORD='<new-pw>' \
|
||||
-v mssql_mssql_data:/var/opt/mssql \
|
||||
mcr.microsoft.com/mssql/server:2019-latest \
|
||||
/opt/mssql/bin/mssql-conf set-sa-password
|
||||
|
||||
# Запуск нормального container
|
||||
docker compose start
|
||||
```
|
||||
|
||||
`mssql-conf set-sa-password` сбрасывает пароль И **enables sa**. Если env-pw совпадает с тем что ожидает приложение — приложение продолжает работать.
|
||||
|
||||
## Production passwords reuse
|
||||
|
||||
Замеченный паттерн: у пользователя один пароль `fXkH4@8O%3pc` используется как:
|
||||
- SA password (MSSQL)
|
||||
- Connection string password для user `snolla` (CMS DB user)
|
||||
- Возможно где-то ещё
|
||||
|
||||
Лучше: rotate after recovery, разделить.
|
||||
|
||||
## CU mismatch warning
|
||||
|
||||
Если master.mdf был из старой CU (например 2019 CU14), а контейнер `2019-latest` (например CU32-GDR), MSSQL после старта войдёт в **script upgrade mode** на 10-30 минут:
|
||||
|
||||
```
|
||||
Server is in script upgrade mode. Only administrator can connect at this time.
|
||||
```
|
||||
|
||||
Видно в логах сотни строк `spid9s ... Deleting AlwaysOnAgReplicas...` — это нормальный msdb-upgrade. Подождать. Один раз. После завершения — никаких задержек на следующих стартах.
|
||||
|
||||
## Healthcheck path для 2019-latest image
|
||||
|
||||
В современных image SQL Server инструменты лежат в `/opt/mssql-tools18/bin/sqlcmd` (с TLS-флагом `-C`), а не `/opt/mssql-tools/bin/sqlcmd`:
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$$MSSQL_SA_PASSWORD\" -C -Q 'SELECT 1' -b -o /dev/null || exit 1"]
|
||||
```
|
||||
|
||||
Связано: [[snolla-recovery-vm]] (Web.config указывает на `10.0.2.2:1433` = host из VM-NAT), [[windows-recovery-host]].
|
||||
85
.tmp-concepts/portainer-2.21-admin-password-regression.md
Normal file
85
.tmp-concepts/portainer-2.21-admin-password-regression.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: Portainer 2.21 `--admin-password` regression + min 12-char policy
|
||||
type: concept
|
||||
tags: [portainer, password, bcrypt, regression, gotcha]
|
||||
sources: [../sources/vds-kzntsv-bootstrap-2026-05-20.md]
|
||||
updated: 2026-05-20
|
||||
---
|
||||
|
||||
# Portainer 2.20+ admin-password regression
|
||||
|
||||
## Симптом 1 — API init policy
|
||||
|
||||
Portainer 2.20.0+ форсит **минимум 12 chars** на admin password при создании через API endpoint `POST /api/users/admin/init`. Короче — `{"message":"Password does not meet the requirements"}`.
|
||||
|
||||
## Симптом 2 — CLI flag `--admin-password` broken
|
||||
|
||||
CLI flag `--admin-password "<bcrypt-hash>"` (документированный путь init без API) в 2.21.5 ведёт себя странно:
|
||||
- Лог сервера показывает «created admin user with the given password.» — то есть admin **записывается**.
|
||||
- Login с паролем → `Invalid credentials`. Bcrypt verify fails.
|
||||
|
||||
Пробовали и `$2y$` (htpasswd default), и `$2a$` (Go bcrypt), и YAML list form чтобы избежать compose env-interp escape (`$` → `$$`), и docker run direct без compose. Bcrypt hash в `docker inspect` Cmd корректный. Но login всё равно fails.
|
||||
|
||||
Не покопали глубоко (возможно — flag тупо игнорируется и admin создаётся с auto-generated password, либо хеш re-hashится сервером).
|
||||
|
||||
## Решение
|
||||
|
||||
Bypass CLI flag. Использовать API init с **длинным паролем (12+ chars)**:
|
||||
|
||||
```bash
|
||||
# Run portainer без --admin-password (fresh data dir)
|
||||
docker run -d --name portainer --network proxy \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v $PWD/data:/data \
|
||||
portainer/portainer-ce:2.21.5
|
||||
|
||||
# API admin/init available 5 minutes after start
|
||||
curl -sk -X POST https://portainer.vds.kzntsv.site/api/users/admin/init \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"Username":"vitya","Password":"Pryakhin9-VDS-2026"}'
|
||||
|
||||
# Login → JWT
|
||||
JWT=$(curl -sk -X POST https://portainer.vds.kzntsv.site/api/auth \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"Username":"vitya","Password":"Pryakhin9-VDS-2026"}' \
|
||||
| jq -r .jwt)
|
||||
|
||||
# Generate API key
|
||||
APIKEY=$(curl -sk -X POST https://portainer.vds.kzntsv.site/api/users/1/tokens \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"description":"automation","password":"Pryakhin9-VDS-2026"}' \
|
||||
| jq -r .rawAPIKey)
|
||||
```
|
||||
|
||||
После init — local docker endpoint надо создать отдельным POST'ом:
|
||||
```bash
|
||||
curl -sk -X POST https://portainer.vds.kzntsv.site/api/endpoints \
|
||||
-H "X-API-Key: $APIKEY" \
|
||||
-F "Name=local" \
|
||||
-F "EndpointCreationType=1" # LocalDockerEnvironment
|
||||
```
|
||||
|
||||
## Trade-off
|
||||
|
||||
- ✗ Имя пользователя пароль user'а — приходится менять на «12+ chars» (`Pryakhin9-VDS-2026` вместо `Pryakhin9`).
|
||||
- ✔ Single source of truth — admin live в DB только через API, всегда последовательное состояние.
|
||||
- ✔ API token доступен сразу для дальнейшей автоматизации stacks через REST.
|
||||
|
||||
## Compose эскейп bcrypt — separate gotcha
|
||||
|
||||
При попытках использовать `--admin-password` в compose столкнулись с classic `$` escape trap:
|
||||
- В YAML string form `command: "... --admin-password '$$2y$$05$$abc'"` — compose unwraps `$$` → `$`, передаёт правильный hash.
|
||||
- В YAML list form `command: ["...", "--admin-password", "$$2y$$05$$abc"]` — compose unwraps аналогично, hash виден в `docker inspect` корректный.
|
||||
- Кавычки `'...'` вокруг hash в string form **становятся literal частью value** (shell tokenize'ит, не shell-context'ит), bcrypt получает паразитные `'` → broken hash.
|
||||
|
||||
Решение для подобных escape-проблем — `docker run` direct (нет compose env-interp) или `--admin-password-file` (passes plain text from file, no escape) — но last не bypass'ит policy, ждёт plain text 12+ chars.
|
||||
|
||||
## Где применено
|
||||
|
||||
Portainer на [`vds-kzntsv`](../entities/vds-kzntsv.md), запущен через `docker run` (не compose) чтобы зафиксировать конкретный набор labels. Кред live в `~/projects/.common/secrets/vds-kzntsv.env`.
|
||||
|
||||
## Ссылки
|
||||
|
||||
- Portainer changelog 2.20.0 — введён password policy + auth refactor (https://github.com/portainer/portainer/releases/tag/2.20.0).
|
||||
- Сессия где упоролись: [`vds-kzntsv-bootstrap-2026-05-20`](../sources/vds-kzntsv-bootstrap-2026-05-20.md) Phase 1 cont.
|
||||
169
.tmp-concepts/recovery-architecture-snapshot.md
Normal file
169
.tmp-concepts/recovery-architecture-snapshot.md
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
title: Recovery architecture — текущая инфраструктура (2026-05-19, attempt 2)
|
||||
type: concept
|
||||
tags: [architecture, current-state, snapshot, recovery]
|
||||
sources: [../sources/nas-recovery-session-2026-05-18.md, ../sources/iis-host-migration-2026-05-19.md]
|
||||
updated: 2026-05-19
|
||||
---
|
||||
|
||||
# Recovery Architecture Snapshot
|
||||
|
||||
Снимок production-инфраструктуры на конец **второй (успешной) попытки** миграции 2026-05-19. 14 cms hostnames теперь идут через native IIS на [[windows-recovery-host]] напрямую. VM `snolla-recovery` — **parallel-fallback**, running но больше не на prod-пути (24h+ soak, потом savestate). 2 stayer routes окончательно **disabled** через traefik (host IIS sites живут для прямого доступа).
|
||||
|
||||
История: attempt 1 в этот же день сломал prod, был revert; recipe — в [[iis-migration-2026-05-19-postmortem]]. Attempt 2 выполнен по recipe — см. [[iis-host-migration-2026-05-19]] Phase 10.
|
||||
|
||||
Это **рабочее, но всё ещё временное** состояние — single-point-of-failure на бытовом железе. Замечания по улучшению — [[future-resilient-architecture-goals]].
|
||||
|
||||
## Цепочка запроса от клиента до CMS (host-IIS chain, attempt 2)
|
||||
|
||||
```
|
||||
Клиент (browser)
|
||||
→ DNS resolve (REGRU): *.snolla.com (включая on.snolla.com — default subdomain),
|
||||
labtools.ru/pro, pilorama98.ru, tandemmebel.ru, emspb.ru, kupimknigi.spb.ru,
|
||||
maljarka.tandemmebel.ru, sestech.ru, ics-artmaterials.com, rimiz.ru (404 CMS-side)
|
||||
→ 94.19.247.14 (public IP, статический у провайдера)
|
||||
→ router OpenWRT (192.168.1.1) [[openwrt-router]]
|
||||
→ NAT 443 → 192.168.1.143:4443
|
||||
→ NAT 80 → 192.168.1.143:8000
|
||||
→ Windows-PC (192.168.1.143) [[windows-recovery-host]]
|
||||
→ traefik 2.6.6 на 4443/8000
|
||||
→ TLS termination, certResolver=letsEncrypt из acme.json
|
||||
→ match по Host header (file-provider rules в data/custom/*.yml)
|
||||
→ backend = http://host.docker.internal:8089/
|
||||
→ host:8089 → IIS site `snolla` (binding *:8089)
|
||||
→ IIS native на хосте
|
||||
→ site `snolla`, .NET Framework 4.8.1, AppPoolIdentity
|
||||
→ C:\sites\snolla\, sitePath patched, conn → localhost
|
||||
→ ASP.NET CMS code (.NET Framework 4.8)
|
||||
→ Connection strings:
|
||||
→ MSSQL: Data Source=localhost,1433 (host:1433 = MSSQL container)
|
||||
→ MinIO/storage: вопрос снят пользователем
|
||||
→ Elasticsearch: не используется CMS
|
||||
→ MSSQL container на host:1433
|
||||
→ 5 production DB (MoreThenCms, Stayer*, stostayer, TireService)
|
||||
← HTTP response back through chain
|
||||
```
|
||||
|
||||
**VM `snolla-recovery`:** running parallel, no traffic (24h+ soak fallback). NAT port forwards `:18080/:18180/:18181/:18189` холостые. Будет savestate'ena после стабильности → потом unregistervm для освобождения ~92 GB.
|
||||
|
||||
## Stayer chain — DISABLED через traefik
|
||||
|
||||
```
|
||||
stostayer.snolla.com / old.stostayer.ru
|
||||
→ DNS → 94.19.247.14
|
||||
→ router → traefik
|
||||
→ match Host → нет routes (stostayer.yml.disabled, oldstostayer.yml.disabled)
|
||||
→ traefik 404 "no route"
|
||||
```
|
||||
|
||||
Host IIS sites `stostayer (:8090)` и `stostayer.old (:8091)` **живут** для прямого/локального доступа. Conn-strings:
|
||||
- `stostayer`: `Data Source=www.stostayer.ru,1433`, user `stayer_site`, password XML-escaped см. [[webconfig-password-xml-escape]]
|
||||
- `stostayer.old`: `Data Source=localhost` (наш MSSQL контейнер)
|
||||
|
||||
## Запущенные docker контейнеры на хосте
|
||||
|
||||
| Container | Image | Port (host) | Volume |
|
||||
|---|---|---|---|
|
||||
| **traefik** | `traefik:v2.6.6` | 4443, 8000, 8080 | named: `traefik_traefik_letsencrypt`; bind: `data/traefik.yml`, `data/custom/` |
|
||||
| **mssql** | `mcr.microsoft.com/mssql/server:2019-latest` | 1433 | named: `mssql_mssql_data` (filled from production tar) |
|
||||
| **minio** | `minio/minio:RELEASE.2020-07-13T18-09-56Z` | 9000 | bind: `./data` |
|
||||
| **imgproxy** | `darthsim/imgproxy:latest` | 8787 | (нет state) |
|
||||
| **imgproxy-nginx** | `nginx:alpine` | 8788 | bind: `./nginx/cache`, `./nginx/nginx.conf` |
|
||||
| **elasticsearch** | `elasticsearch:7.10.1` | 9200 | bind: `./data` |
|
||||
|
||||
Все на docker network `proxy` (external).
|
||||
|
||||
## Traefik routes (после attempt 2)
|
||||
|
||||
11 cms yml репойнтены на host IIS:8089. 2 stayer yml — `.disabled`.
|
||||
|
||||
| File | Hosts | Backend | Статус |
|
||||
|---|---|---|---|
|
||||
| snolla.yml | snolla.com + 10 *.snolla.com subdomains (rule explicit) | host.docker.internal:8089 | active → host IIS |
|
||||
| rimiz.yml | rimiz.ru, www.rimiz.ru | host.docker.internal:8089 | active (но CMS-side 404 — known) |
|
||||
| labtools.yml | labtools.ru, www.labtools.ru | host.docker.internal:8089 | active → host IIS |
|
||||
| labtoolspro.yml | labtools.pro, www.labtools.pro | host.docker.internal:8089 | active → host IIS |
|
||||
| pilorama98.yml | pilorama98.ru, www.pilorama98.ru | host.docker.internal:8089 | active → host IIS |
|
||||
| tandemmebel.yml | tandemmebel.ru, www.tandemmebel.ru | host.docker.internal:8089 | active → host IIS |
|
||||
| emspb.yml | emspb.ru, www.emspb.ru | host.docker.internal:8089 | active → host IIS (canary 1, phone-test ✅) |
|
||||
| kupimknigi.yml | kupimknigi.spb.ru | host.docker.internal:8089 | active → host IIS |
|
||||
| maljarka.yml | maljarka.tandemmebel.ru | host.docker.internal:8089 | active → host IIS |
|
||||
| sestech.yml | sestech.ru, www.sestech.ru | host.docker.internal:8089 | active → host IIS |
|
||||
| isc-artmaterials.yml | ics-artmaterials.com, www.ics-artmaterials.com | host.docker.internal:8089 | active → host IIS (CMS-side 404 — known) |
|
||||
| **stostayer.yml.disabled** | stostayer.snolla.com | (n/a, route disabled) | **DISABLED**, host IIS :8090 локально |
|
||||
| **oldstostayer.yml.disabled** | old.stostayer.ru | (n/a, route disabled) | **DISABLED**, host IIS :8091 локально |
|
||||
|
||||
**Backup-stamp файлы** (накопились за обе попытки):
|
||||
- `*.yml.bak-2026-05-19` — самый ранний backup (до session).
|
||||
- `*.yml.bak-phase3-2026-05-19` — rollback baseline (attempt 1 → revert state, всё на VM `:18080`).
|
||||
- `*.yml.bak-hostip-2026-05-19` — failed attempt 1 (host.docker.internal:80 ⇒ Docker NAT loop).
|
||||
- `*.yml.bak-stayer-switch-2026-05-19` — stayer switch attempt artefact (Phase 5/6 prev session).
|
||||
- **`*.yml.bak-pre-attempt2-2026-05-19`** — текущая live conf attempt 2 (host:8089 backend). Это baseline для **atomic revert** этой попытки.
|
||||
|
||||
Плюс file-provider маршруты для инфраструктурных хостов:
|
||||
|
||||
| File | Host | Backend |
|
||||
|---|---|---|
|
||||
| elasticsearch.yml | elasticold.kzntsv.site | http://elasticsearch:9200 + basicAuth `books:...` |
|
||||
| minio.yml | minio.kzntsv.site | http://minio:9000 |
|
||||
| imgproxy.yml | imgproxy.kzntsv.site | http://imgproxy-nginx:80 |
|
||||
|
||||
И мёртвые (не отключены, но смотрят в никуда):
|
||||
- `disk.yml` → 192.168.1.10:5005 (Synology disk на мёртвой синке)
|
||||
- `dsm.yml` → 192.168.1.10:5000 (DSM мёртвой синки)
|
||||
|
||||
## Host IIS configuration (active prod)
|
||||
|
||||
| Site | Bindings | Physical path | Pool identity | Прим. |
|
||||
|---|---|---|---|---|
|
||||
| **snolla** | `*:80`, `*:8089` | `C:\sites\snolla` | `ApplicationPoolIdentity` (.NET v4.0 Integrated) | **active prod** — catch-all для 11 cms hosts, traefik backend `:8089` |
|
||||
| **stostayer** | `*:8090` | `C:\sites\stostayer` | `ApplicationPoolIdentity` | local-only (traefik route disabled), conn → `www.stostayer.ru,1433` |
|
||||
| **stostayer.old** | `*:8091` | `C:\sites\stostayer.old` | `ApplicationPoolIdentity` | local-only (traefik route disabled), conn → `localhost,1433` |
|
||||
| Default Web Site | (stopped, autoStart=false) | — | — | — |
|
||||
|
||||
ACL: `IIS AppPool\<site>:(OI)(CI)M` рекурсивно на каждом site root.
|
||||
|
||||
## DNS
|
||||
|
||||
Все домены остались указывать на **94.19.247.14** (public IP, статический). REGRU как registrar/DNS.
|
||||
|
||||
## Backup инфраструктура
|
||||
|
||||
Текущая (на момент 2026-05-19, после attempt 2):
|
||||
|
||||
- [[kreknin-synology]] держит Hyper Backup репо мёртвой синки (~430 GB). Новых бэкапов с recovered Windows-PC **НЕТ**.
|
||||
- Локально на Windows-PC: `C:\nas-recovery\vm-sites\` (~11 GB, дамп IIS sites из VM до patches) — резерв если host-IIS сломается катастрофически. После 48h+ uptime можно почистить.
|
||||
- `C:\nas-recovery\backup\snolla\snolla.ova` (42.5 GB) — оригинал OVA. После cleanup VM можно удалить.
|
||||
|
||||
**Дыра:** если Windows-PC сгорит — всё ляжет. Никакой репликации, никакого off-site backup для нового рабочего состояния.
|
||||
|
||||
## SSH ключи и доступ
|
||||
|
||||
- [[windows-recovery-host]] → [[kreknin-synology]]: `id_ed25519_kreknin` (vitya@195.19.90.188)
|
||||
- [[windows-recovery-host]] → [[openwrt-router]]: `id_ed25519_openwrt` (root@192.168.1.1)
|
||||
- [[windows-recovery-host]] → [[snolla-recovery-vm]]: `id_ed25519_snolla_vm` (vitya@127.0.0.1:8022)
|
||||
|
||||
После recovery — отозвать публичные ключи Claude из этих 3 машин (`~/.ssh/authorized_keys` или эквивалент). См. соответствующие entity-страницы.
|
||||
|
||||
## Известные открытые баги
|
||||
|
||||
1. ~~**X-Forwarded headers** не передаются от traefik в IIS → CMS делает redirect с `:4443` в URL.~~ → **RESOLVED 2026-05-19 вечер** через URL Rewrite 2.1 + `<serverVariables>` rule на host IIS. Также закрыл утечку `:8089` в admin SPA после attempt 2 миграции. См. [[cms-server-port-leak-fix]].
|
||||
2. **rimiz.ru → 404**. CMS-side, не инфра.
|
||||
3. **ics-artmaterials.com → 404**. Аналогично — CMS-side (`www.ics-artmaterials.com → 301 → ics-artmaterials.com → 404`).
|
||||
8. ~~**emspb.snolla.com `/admin/assets/<guid>/getList` → 500 NullReferenceException**.~~ → **RESOLVED 2026-05-19 вечер** через DB seed root AssetsFolder rows для 15 sites без них (включая emspb, pilorama98, и др.). Симптом был НЕ site-specific — общий для всех sites которые никогда не использовали admin assets UI. См. [[cms-admin-assets-root-folder-seed]]. Долгосрочный TODO — null-guard в `AssetsJsonViewModelBuilder.Build` (требует recompile DLL, отложено до восстановления build env).
|
||||
4. **acme.json HTTP-01 renewal** будет фейлить для доменов с DNS не на нашем IP → переезд на DNS-01 через REGRU до истечения сертификатов (~3 месяца).
|
||||
5. **C:\inetpub\logs\** растёт — нужна ротация.
|
||||
6. **docker.sock provider в traefik** не работает — но не блокирует (всё через file-provider). См. [[traefik-on-windows-docker-desktop]] Pitfall 3.
|
||||
7. **WinHTTP proxy на хосте strips response headers** для `curl.exe http://localhost:...` — для loop-detect/IIS confirmation использовать `docker exec traefik wget` или `Invoke-WebRequest`. См. [[docker-host-loopback-detect]].
|
||||
|
||||
## Single Points of Failure
|
||||
|
||||
- Один Windows-PC (если сгорит — всё ляжет)
|
||||
- Один публичный IP / провайдер
|
||||
- Один WiFi-канал
|
||||
- Один OpenWRT-роутер
|
||||
- Один **host IIS instance** обслуживает весь cms-трафик (VM остаётся parallel fallback ещё 24-48h)
|
||||
- Один MSSQL контейнер (single primary, нет replica)
|
||||
- Один MinIO (single drive, не distributed)
|
||||
|
||||
Каждый SPOF — кандидат на улучшение в [[future-resilient-architecture-goals]].
|
||||
115
.tmp-concepts/registry-gc-mount-and-modify-flag.md
Normal file
115
.tmp-concepts/registry-gc-mount-and-modify-flag.md
Normal file
@@ -0,0 +1,115 @@
|
||||
---
|
||||
title: Docker Registry garbage-collect mount layout + `-m` flag
|
||||
type: concept
|
||||
tags: [docker-registry, garbage-collect, gotcha, storage]
|
||||
sources: [../sources/vds-kzntsv-bootstrap-2026-05-20.md]
|
||||
updated: 2026-05-20
|
||||
---
|
||||
|
||||
# Registry GC — mount path и `-m` flag
|
||||
|
||||
Распознаваемая пара ошибок при `registry garbage-collect` на offline-restored backup data. Один shoot — `-m` flag для реального удаления, второй — правильный mount path.
|
||||
|
||||
## Симптом 1 — `Path not found: /docker/registry/v2/repositories`
|
||||
|
||||
Запуск:
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v /volume1/docker/infrastucture/registry/docker:/var/lib/registry \
|
||||
registry:2.8.3 garbage-collect /etc/docker/registry/config.yml
|
||||
```
|
||||
|
||||
Ошибка: `failed to garbage collect: failed to mark: filesystem: Path not found: /docker/registry/v2/repositories`.
|
||||
|
||||
### Root cause
|
||||
|
||||
Default config файл `/etc/docker/registry/config.yml` в registry image имеет:
|
||||
```yaml
|
||||
storage:
|
||||
filesystem:
|
||||
rootdirectory: /var/lib/registry
|
||||
```
|
||||
|
||||
Registry expect'ит файлы в `/var/lib/registry/docker/registry/v2/...`. Mounting только `docker/` subdir host'а к `/var/lib/registry/` положит данные в `/var/lib/registry/registry/v2/...` (один уровень потерян).
|
||||
|
||||
### Fix — mount PARENT directory
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v /volume1/docker/infrastucture/registry:/var/lib/registry \ # <-- parent dir, не /docker
|
||||
registry:2.8.3 garbage-collect /etc/docker/registry/config.yml
|
||||
```
|
||||
|
||||
Теперь internal path = `/var/lib/registry/docker/registry/v2/...` ✅.
|
||||
|
||||
(Original kreknin compose использовал `./:/var/lib/registry` — mount whole registry parent dir — что было корректно но для running registry, не для offline GC.)
|
||||
|
||||
## Симптом 2 — GC ничего не удаляет, размер прежний
|
||||
|
||||
После первого GC прохода видим лог:
|
||||
```
|
||||
blob eligible for deletion: sha256:087b41...
|
||||
time="..." level=info msg="Deleting blob: /docker/registry/v2/blobs/sha256/08/087b41..."
|
||||
```
|
||||
|
||||
Лог говорит «Deleting» — но `du -sh` показывает **прежний 99G**. Размер не изменился.
|
||||
|
||||
### Root cause
|
||||
|
||||
`registry garbage-collect` без флагов работает в **dry-run mode** (incident-free). Лог «Deleting blob» — `info` level намерения, не actual unlink.
|
||||
|
||||
Подобный intent vs action разделение типично для batch tools (`apt-get -s`, `git rm --dry-run`, etc.) но в registry CLI нет attention-grabbing `--dry-run` flag — а опция «реально делать» named cryptically.
|
||||
|
||||
### Fix — `-m` (modify) flag
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v /volume1/docker/infrastucture/registry:/var/lib/registry \
|
||||
registry:2.8.3 garbage-collect -m /etc/docker/registry/config.yml
|
||||
```
|
||||
|
||||
С `-m` после прохода размер реально уменьшается. На kreknin backup'е: **99G → 35G** (64G freed), 2-3 минуты обработки.
|
||||
|
||||
## Полный рецепт offline GC restored backup data
|
||||
|
||||
```bash
|
||||
# 1. Pull registry image что соответствует production версии (compatibility)
|
||||
docker pull registry:2.8.3
|
||||
|
||||
# 2. (Optional) dry-run для отчёта — что будет удалено
|
||||
docker run --rm \
|
||||
-v /path/to/restored/registry:/var/lib/registry \
|
||||
registry:2.8.3 garbage-collect /etc/docker/registry/config.yml \
|
||||
> gc-dry-run.log 2>&1
|
||||
|
||||
# 3. Реальный GC
|
||||
docker run --rm \
|
||||
-v /path/to/restored/registry:/var/lib/registry \
|
||||
registry:2.8.3 garbage-collect -m /etc/docker/registry/config.yml
|
||||
|
||||
# 4. Measure delta
|
||||
du -sh /path/to/restored/registry/docker
|
||||
```
|
||||
|
||||
## Online GC (production live registry) — важные дополнения
|
||||
|
||||
Если делаем GC на **running** registry — нужен read-only mode чтобы избежать race condition'ов (новый push во время GC может потерять blobs):
|
||||
|
||||
```yaml
|
||||
# Add to running registry config / env vars
|
||||
storage:
|
||||
maintenance:
|
||||
readonly:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
Затем restart registry, run GC `-m`, отключить read-only, restart again. Окно downtime — продолжительность GC (~10-30 min на средних объёмах).
|
||||
|
||||
## Где применено
|
||||
|
||||
[`vds-kzntsv`](../entities/vds-kzntsv.md) Phase 3.3 — GC kreknin'овского backup'а 99G → 35G. После того как user decided abandon миграцию и fresh install — GC оказался полезной экономией если бы tar/rsync'или (но в финале rsync не запустился).
|
||||
|
||||
## Ссылки
|
||||
|
||||
- Registry docs: [Garbage collection](https://distribution.github.io/distribution/about/garbage-collection/)
|
||||
- Сессия: [`vds-kzntsv-bootstrap-2026-05-20`](../sources/vds-kzntsv-bootstrap-2026-05-20.md) Phase 3.3.
|
||||
92
.tmp-concepts/rusonyx-vps-onboarding-quirks.md
Normal file
92
.tmp-concepts/rusonyx-vps-onboarding-quirks.md
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: Rusonyx VPS onboarding quirks (Astra Облако / myvm.rusonyx.ru)
|
||||
type: concept
|
||||
tags: [rusonyx, vds, bootstrap, onboarding, gotchas, vendor]
|
||||
sources: [../sources/vds-kzntsv-bootstrap-2026-05-20.md]
|
||||
updated: 2026-05-20
|
||||
---
|
||||
|
||||
# Rusonyx VPS onboarding quirks
|
||||
|
||||
Quirks first-boot опыта на Rusonyx VDS (Astra Облако, https://myvm.rusonyx.ru) — для memory будущих сессий. Зафиксировано при активации [`vds-kzntsv`](../entities/vds-kzntsv.md) 2026-05-20.
|
||||
|
||||
## 1. VNC console не открывается с первого раза
|
||||
|
||||
В Управление сервером → Консоль HTML5 noVNC может не загружаться (зависание на индикаторе соединения). Помогает кнопка **«Остановить VNC»** в той же панели — force-disconnect stale attachment'а на hypervisor side. После клика подождать ~5 сек, открыть консоль заново.
|
||||
|
||||
Если не помогает — ticket в support, без VNC реальной возможности зайти в box нет (см. quirk 2).
|
||||
|
||||
## 2. Stale системный образ Ubuntu — обязательный upgrade через VNC
|
||||
|
||||
Поставка содержит сотни pending updates, включая `openssh-server` и kernel. Письмо при активации **прямо рекомендует** последовательность:
|
||||
|
||||
```bash
|
||||
apt update
|
||||
apt upgrade
|
||||
apt --fix-broken install
|
||||
apt upgrade
|
||||
```
|
||||
|
||||
И ключевая часть: **«строго через VNC-консоль»**, потому что `openssh-server` upgrade'у переключают сервис, что разрывает SSH session и бьёт upgrade на половине.
|
||||
|
||||
По дороге будет **5+ dpkg interactive prompts** (conffile conflicts). Ответы:
|
||||
|
||||
| Prompt | Правильный ответ | Reasoning |
|
||||
|---|---|---|
|
||||
| `/etc/ssh/sshd_config` | **2** (keep local) | Rusonyx-modified sshd_config форсит `PermitRootLogin yes` + `PasswordAuthentication yes` для первого захода. Maintainer-версия дефолтит `prohibit-password` — потеряешь SSH-доступ если ключ ещё не залит. |
|
||||
| `/etc/cloud/cloud.cfg` | **N** (keep current) | Rusonyx модифицировал под свой provisioning (network, ssh-key inject). Maintainer-версия может сломать их хуки. |
|
||||
| `grub-pc /dev/vdaX target` | **1** (`/dev/vda` whole-disk MBR) | Опция 2 (на partition) — blocklist mechanism, less reliable. Опция 3 (skip) = box не загрузится. |
|
||||
| `cloud-init local config` (если появится) | keep | По той же логике. |
|
||||
|
||||
После завершения — `reboot` (или ждать пока apt сам запустит).
|
||||
|
||||
## 3. SSH initial password — одноразовый
|
||||
|
||||
Активационное письмо даёт `root` + одноразовый пароль. Первый шаг bootstrap'а (после VNC-upgrade) — push своего SSH key и harden sshd:
|
||||
|
||||
```
|
||||
PermitRootLogin no
|
||||
PasswordAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
```
|
||||
|
||||
Через **drop-in file `/etc/ssh/sshd_config.d/00-hardening.conf`** (префикс `00-` чтобы выиграть first-match precedence над `50-cloud-init.conf` который форсит `PasswordAuthentication yes`).
|
||||
|
||||
## 4. Подключение через SSH с password на Windows
|
||||
|
||||
OpenSSH for Windows **не имеет** `-pw` flag (как `plink`/PuTTY) или `sshpass`. Для одноразового password-bootstrap:
|
||||
|
||||
- **plink.exe** (`C:\Program Files\PuTTY\plink.exe`) — supports `-pw`, доступен если PuTTY установлен.
|
||||
- Pipe `echo y | plink ...` для auto-accept first-time host key (plink кэширует в registry).
|
||||
- После push pubkey → переключаемся на native OpenSSH `ssh -i` (plink больше не нужен).
|
||||
|
||||
## 5. Hypervisor-side VNC ≠ guest-side VNC service
|
||||
|
||||
В гостевой системе **нет** VNC service'а — VNC console работает на qemu/KVM hypervisor side. Поэтому request «зайди и передёрни vnc service из гостя» невозможен. Управляется только через Rusonyx web panel.
|
||||
|
||||
## 6. Default firewall — open?
|
||||
|
||||
Из наблюдений 2026-05-20: после reboot SSH:22 поднимался автоматом, не было видно guest-side ufw default-deny. Но **ICMP ping проходил до VNC-fix**, при том что **все TCP-порты были filtered**. Похоже, Rusonyx имеет perimeter firewall, который автоматически allow'ит TCP только когда VPS становится «active» в их учёте — корреляция с моментом первой VNC-сессии. Не воспроизводимо post-factum; для будущих VDS — стоит сначала открыть VNC, потом ждать что SSH станет доступен.
|
||||
|
||||
## 7. Tariff именования
|
||||
|
||||
«160 SSD» переименован в «160 NVMe» (та же цена, апгрейд по IOPS). Если в старой переписке/доках видите «160 SSD» — это actually 160 NVMe. Аналогично могут быть переименования для 80/220+ тарифов.
|
||||
|
||||
## 8. Welcome-email рекомендации
|
||||
|
||||
Содержит только команды apt upgrade, **не упоминает**:
|
||||
- Что VNC может быть stale.
|
||||
- Какие dpkg prompts и правильные ответы.
|
||||
- Initial password — одноразовый, нужно сменить на ключ.
|
||||
|
||||
См. также: support-ticket draft в [`vds-kzntsv-bootstrap-2026-05-20`](../sources/vds-kzntsv-bootstrap-2026-05-20.md) с конкретными suggestions для Rusonyx.
|
||||
|
||||
## Когда применять
|
||||
|
||||
- Любой новый Rusonyx VDS (Astra Облако).
|
||||
- Возможно частично применимо к другим Russian VDS-providers с custom OS templates (REG.RU, FirstByte, RuVDS), но конкретные dpkg prompt'ы вендор-specific.
|
||||
|
||||
## Ссылки
|
||||
|
||||
- [`vds-kzntsv-bootstrap-2026-05-20`](../sources/vds-kzntsv-bootstrap-2026-05-20.md) — где впервые столкнулись.
|
||||
- [`vds-kzntsv`](../entities/vds-kzntsv.md) — текущий live host.
|
||||
68
.tmp-concepts/traefik-file-watch-wsl2-broken.md
Normal file
68
.tmp-concepts/traefik-file-watch-wsl2-broken.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Traefik file-watch broken under Docker Desktop Windows (WSL2 9p mount)
|
||||
type: concept
|
||||
tags: [traefik, docker-desktop, windows, gotcha, wsl2]
|
||||
sources: []
|
||||
updated: 2026-05-21
|
||||
---
|
||||
|
||||
# Traefik file-watch broken под Docker Desktop Windows
|
||||
|
||||
Traefik file provider's `watch: true` **не работает** для bind mounts из Windows host через Docker Desktop WSL2 9p (виртуальная файловая система). Изменения на disk **не доходят** до traefik. Config остаётся frozen на startup state до явного `docker restart traefik`.
|
||||
|
||||
## Симптомы
|
||||
|
||||
1. Rename `.yml → .yml.disabled` — route ОСТАЁТСЯ active в traefik runtime, продолжает отвечать.
|
||||
2. Edit content of `.yml` — изменения не подхватываются, runtime использует старый snapshot.
|
||||
3. New `.yml` файл в `/custom/` — игнорируется, route не добавляется.
|
||||
4. `touch` обновление mtime — нет reload.
|
||||
5. В logs (`--log.level=DEBUG`) — никаких "Configuration reloaded" сообщений.
|
||||
|
||||
## Root cause
|
||||
|
||||
Docker Desktop на Windows монтирует bind volumes через WSL2 9p protocol (`/run/desktop/mnt/host/c/...`). 9p **не пропагирует inotify events** — fsnotify watchers внутри контейнера не получают уведомлений об изменениях. Traefik file-watcher использует `fsnotify` → молчит.
|
||||
|
||||
Это известная архитектурная проблема Docker Desktop Windows. Linux native Docker, Docker on macOS (через osxfs/virtiofs новый) — работают по-разному.
|
||||
|
||||
## Подтверждение
|
||||
|
||||
```powershell
|
||||
# 1. Mount bind path inside traefik
|
||||
docker inspect traefik --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}'
|
||||
# Покажет: /run/desktop/mnt/host/c/... -> /custom/ <-- WSL2 9p
|
||||
|
||||
# 2. Rename one yml to .disabled, проверить route ещё активный
|
||||
docker exec traefik wget --header='Host: <some-host-in-disabled-yml>' --spider https://traefik/
|
||||
# 200 OK даже после rename (т.к. config не перезагружен)
|
||||
|
||||
# 3. После docker restart traefik — то же запрос вернёт 404
|
||||
docker restart traefik
|
||||
docker exec traefik wget --header='Host: <some-host-in-disabled-yml>' --spider https://traefik/
|
||||
# 404 ✅
|
||||
```
|
||||
|
||||
## Последствия
|
||||
|
||||
- **Любое изменение в `/custom/*.yml` требует `docker restart traefik`.**
|
||||
- Atomic revert: backup .yml → restart → rollback означает restore + restart.
|
||||
- "Hot reload" workflow невозможен под этой config'ом — нужно или native Linux Docker, или migrate на docker provider via labels (отдельные изменения сразу видны через container restart events).
|
||||
|
||||
## Workarounds
|
||||
|
||||
1. **Always restart traefik after config changes** — single source of truth для team is restart, не file-edit. Документировать.
|
||||
2. **Periodic auto-restart** — cron внутри traefik container (`docker exec traefik <restart-mechanism>`), e.g. каждый час. Кustомные disruption для уже работающих routes.
|
||||
3. **Migrate to docker provider** (labels) — labels на сервисах меняются вместе с container restart, traefik догоняет docker events корректно. Big migration работа.
|
||||
4. **Use traefik file provider's `pollInterval`** — НЕ поддерживается в file provider (только в HTTP provider). Не вариант.
|
||||
5. **Move traefik в WSL native** — запускать traefik внутри WSL2 distro (Ubuntu), mount /etc/traefik внутри WSL native fs, traefik видит inotify нормально. Требует переезд compose stack в WSL.
|
||||
|
||||
## Применено
|
||||
|
||||
[`traefik-maljarka-502-bug`](../../.tasks/traefik-maljarka-502-bug.md) — обнаружено при debugging. После рестарта traefik 2026-05-21:
|
||||
- 2 dead routes (sestech, ics-artmaterials) реально 404'нулись (до restart были active despite .disabled rename).
|
||||
- maljarka 502 не ушло — другая root cause (CMS-side HTTPS-mode crash для maljarka.tandemmebel.ru, см. cms-maljarka-https-mode-bug если создана).
|
||||
|
||||
## Ссылки
|
||||
|
||||
- Docker Desktop Windows mount perf: https://docs.docker.com/desktop/windows/wsl/
|
||||
- fsnotify limitations: https://github.com/fsnotify/fsnotify/issues/611 (9p/WSL2)
|
||||
- Setup на этом стэке: [[traefik-on-windows-docker-desktop]]
|
||||
171
.tmp-concepts/traefik-on-windows-docker-desktop.md
Normal file
171
.tmp-concepts/traefik-on-windows-docker-desktop.md
Normal file
@@ -0,0 +1,171 @@
|
||||
---
|
||||
title: Traefik на Windows Docker Desktop — нюансы
|
||||
type: concept
|
||||
tags: [traefik, docker, windows, letsencrypt, reverse-proxy]
|
||||
sources: [../sources/nas-recovery-session-2026-05-18.md]
|
||||
updated: 2026-05-19
|
||||
---
|
||||
|
||||
# Traefik на Windows Docker Desktop
|
||||
|
||||
Запуск traefik 2.6.6 на Windows Docker Desktop (WSL2 backend) с импортированным production-конфигом и сертификатами — пара ловушек.
|
||||
|
||||
## Pitfall 1: traefik.yml не загружается автоматом
|
||||
|
||||
Symptom: traefik стартует, но routers из `data/custom/*.yml` ругаются на "non-existent resolver: letsEncrypt", сертификаты не отдаются.
|
||||
|
||||
Reason: traefik 2.x при отсутствии `--configFile=` ищет в дефолтных путях (`/etc/traefik/`, `./traefik.yml`), но Linux-контейнер с CWD=`/` и bind-mount `./data/traefik.yml:/traefik.yml` — почему-то не подхватывает.
|
||||
|
||||
**Fix:** явно указать в compose `command:`:
|
||||
|
||||
```yaml
|
||||
command:
|
||||
- "--configFile=/traefik.yml"
|
||||
- "--log.level=DEBUG"
|
||||
```
|
||||
|
||||
Подтверждение в логах: `Configuration loaded from file: /traefik.yml`.
|
||||
|
||||
## Pitfall 2: acme.json permissions через bind-mount
|
||||
|
||||
Symptom: traefik ругается `permissions 777 for /letsencrypt/acme.json are too open, please use 600` и **отключает** letsEncrypt resolver (даже если у него есть валидные certs).
|
||||
|
||||
Reason: bind-mount Windows-файла в Linux-контейнере **всегда** показывает permissions `0777`. Изменить через `chmod` нельзя — bind-mount не транслирует POSIX-perms на Windows-side.
|
||||
|
||||
**Fix:** named volume вместо bind для `/letsencrypt`:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- "traefik_letsencrypt:/letsencrypt" # вместо ./letsencrypt:/letsencrypt
|
||||
- "./data/traefik.yml:/traefik.yml:ro"
|
||||
- "./data/custom/:/custom/:ro"
|
||||
|
||||
# и снизу:
|
||||
volumes:
|
||||
traefik_letsencrypt:
|
||||
```
|
||||
|
||||
Population volume единоразово:
|
||||
|
||||
```powershell
|
||||
docker volume create traefik_traefik_letsencrypt
|
||||
docker run --rm \
|
||||
-v traefik_traefik_letsencrypt:/dest \
|
||||
-v <local-path>/traefik/letsencrypt:/src:ro \
|
||||
alpine sh -c "cp /src/acme.json /dest/acme.json && cp /src/acme.old.json /dest/acme.old.json && chmod 600 /dest/*"
|
||||
```
|
||||
|
||||
## Pitfall 3: docker.sock provider не работает
|
||||
|
||||
Symptom:
|
||||
```
|
||||
Failed to retrieve information of the docker client and server host: Error response from daemon:
|
||||
providerName=docker
|
||||
```
|
||||
|
||||
С `-v /var/run/docker.sock:/var/run/docker.sock:ro` сокет монтируется (видно `srw-rw---- 1 root root` внутри контейнера), но соединение с daemon обрывается.
|
||||
|
||||
Reason: Docker Desktop на Windows транслирует docker.sock через WSL2 layer. Иногда permission-моэль клиента (libdocker) не принимает то, что предоставляет Desktop's proxy.
|
||||
|
||||
**Workaround:** **полностью отключить docker-provider, использовать только file-provider** для всех routes.
|
||||
|
||||
Маршруты, которые в производстве были как traefik labels на контейнерах (minio, elasticsearch, imgproxy с `traefik.http.routers...labels`), переписываются в `data/custom/<name>.yml` руками:
|
||||
|
||||
```yaml
|
||||
# data/custom/minio.yml
|
||||
http:
|
||||
routers:
|
||||
minio:
|
||||
entryPoints: [https]
|
||||
rule: Host(`minio.kzntsv.site`)
|
||||
tls:
|
||||
certResolver: letsEncrypt
|
||||
service: minio
|
||||
services:
|
||||
minio:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: http://minio:9000 # docker DNS name (работает потому что все на одной network=proxy)
|
||||
```
|
||||
|
||||
Подобно для elasticsearch (с basicAuth middleware), imgproxy (→ imgproxy-nginx:80), etc.
|
||||
|
||||
## Pitfall 4: file-provider не подхватывает изменения через bind-mount
|
||||
|
||||
`traefik.yml` имеет `providers.file.watch: true`, но на Windows bind-mount inotify не работает через WSL2-слой. Изменения в `data/custom/*.yml` не подхватываются автоматически.
|
||||
|
||||
**Workaround:** `docker compose restart traefik` после изменений в custom/. Несколько секунд downtime, ничего страшного.
|
||||
|
||||
## Production порты vs нашa конфигурация
|
||||
|
||||
Production traefik compose биндил на host:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "8000:80" # router forwards public 80 → host 8000 → container 80
|
||||
- "4443:443"
|
||||
- "8080:8080"
|
||||
```
|
||||
|
||||
То есть **роутер делает port-translation** 80→8000, 443→4443. Не стандартные порты, но работает.
|
||||
|
||||
На Windows-хосте оставили те же порты (8000/4443) — не конфликтуют с локальным IIS на 80 (который не используется, но не выключен). И не требуют admin для bind.
|
||||
|
||||
## host.docker.internal
|
||||
|
||||
Из traefik-контейнера достучаться до VM (которая в VBox NAT, не в docker network) — через **`host.docker.internal`**:
|
||||
|
||||
```yaml
|
||||
servers:
|
||||
- url: http://host.docker.internal:18080/ # 18080 = VBox NAT-forwarded port → VM:80
|
||||
```
|
||||
|
||||
Docker Desktop резолвит `host.docker.internal` в IP хоста (обычно 172.x.x.1 из docker bridge perspective). Дальше Windows-host обрабатывает 18080 → VBox NAT → VM:80 → IIS → CMS.
|
||||
|
||||
## Pitfall 5: X-Forwarded не используется ASP.NET — port utечка в admin URLs (RESOLVED 2026-05-19)
|
||||
|
||||
**Симптом:** CMS-admin генерирует URLs с internal IIS portом (`:8089` после attempt 2; ранее `:4443` от traefik) → browser HSTS auto-upgrade ломает TLS на нестандартном портe → admin SPA broken.
|
||||
|
||||
**Root cause:** Traefik 2.x **шлёт** `X-Forwarded-Proto: https` / `X-Forwarded-Host` / `X-Forwarded-Port` по-default (когда entrypoint https). НО CMS-код (`MoreThenCms.Admin\Mis\Web\Mvc\UrlHelpers\UrlHelpers.cs`) читает **socket-level** `SERVER_PORT` / `SERVER_PORT_SECURE`, **игнорируя** X-Forwarded-* headers. На VM работало случайно потому что IIS binding `:80` → `SERVER_PORT=80` стрипалось whitelist'ом `{80,443}` в коде.
|
||||
|
||||
**Fix:** URL Rewrite 2.1 + `<serverVariables>` rule на host IIS — переписывает `HTTPS`/`SERVER_PORT`/`SERVER_PORT_SECURE` на основе `X-Forwarded-Proto`. Детально в [[cms-server-port-leak-fix]].
|
||||
|
||||
Path B (поменять traefik http entrypoint :80 inside container чтобы IIS-binding :80 не получал loop от Docker NAT) — **не работает** на Windows Docker Desktop из-за WSL2 NAT quirk, см. там же.
|
||||
|
||||
## DNS-01 challenge через REGRU
|
||||
|
||||
`traefik.yml` имеет HTTP-01 challenge:
|
||||
|
||||
```yaml
|
||||
certificatesResolvers:
|
||||
letsEncrypt:
|
||||
acme:
|
||||
email: vitya.kuznetsov@gmail.com
|
||||
storage: /letsencrypt/acme.json
|
||||
httpChallenge:
|
||||
entryPoint: http
|
||||
```
|
||||
|
||||
В compose env уже:
|
||||
```
|
||||
REGRU_USERNAME=OpeItcLoc03
|
||||
REGRU_PASSWORD=ytyYqC%u%QAJ
|
||||
```
|
||||
|
||||
Эти креды для **DNS-01** через REGRU API. Просто закомментировать `httpChallenge` и активировать `dnsChallenge` в traefik.yml:
|
||||
|
||||
```yaml
|
||||
certificatesResolvers:
|
||||
letsEncrypt:
|
||||
acme:
|
||||
email: ...
|
||||
storage: /letsencrypt/acme.json
|
||||
dnsChallenge:
|
||||
provider: regru
|
||||
```
|
||||
|
||||
DNS-01 более надёжный (не требует public:80 reachable для validation) и работает даже если HTTP-01 challenge не пройдёт (например, домен временно на другой хостинге).
|
||||
|
||||
**Текущие 40 LE-сертификатов из acme.json валидны ~3 месяца** (LE default). Когда подойдут к истечению — пора переключать на DNS-01.
|
||||
|
||||
Связано: [[snolla-recovery-vm]], [[windows-recovery-host]], [[recovery-architecture-snapshot]].
|
||||
66
.tmp-concepts/traefik-tcp-passthrough-vs-starttls.md
Normal file
66
.tmp-concepts/traefik-tcp-passthrough-vs-starttls.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: Traefik TCP passthrough vs STARTTLS-protocols
|
||||
type: concept
|
||||
tags: [traefik, tls, tcp, sni, postgres, mariadb, mongo, redis, gotcha]
|
||||
sources: [../sources/vds-kzntsv-bootstrap-2026-05-20.md]
|
||||
updated: 2026-05-20
|
||||
---
|
||||
|
||||
# Traefik TCP passthrough не работает с STARTTLS
|
||||
|
||||
## Симптом
|
||||
|
||||
Traefik TCP router с `HostSNI(<hostname>)` + `tls.passthrough=true`. Mongo / Redis (TLS-from-start) — TLS handshake проходит, виден правильный cert. **Postgres / MariaDB — TLS connection hangs / timeout**, никакого handshake'а не происходит.
|
||||
|
||||
Probe `openssl s_client -connect postgres.vds.kzntsv.site:5432 -servername postgres.vds.kzntsv.site -starttls postgres` → timeout 10s.
|
||||
|
||||
## Root cause
|
||||
|
||||
`tls.passthrough=true` означает: traefik не терминирует TLS, а маршрутизирует TCP-connection raw. Для маршрутизации по `HostSNI` traefik читает SNI **из TLS ClientHello** — первого TLS-сообщения, отправляемого клиентом сразу после TCP handshake.
|
||||
|
||||
**Mongo / Redis** (TLS-from-start) — клиент шлёт TLS ClientHello как первое сообщение. SNI там присутствует. Traefik читает, маршрутизирует, остаток TCP forwarding'ом. Работает.
|
||||
|
||||
**Postgres / MariaDB / MySQL** — используют **STARTTLS pattern**:
|
||||
1. Клиент после TCP-handshake шлёт `SSLRequest` (plaintext, специфичный для протокола).
|
||||
2. Сервер отвечает `'S'` (готов на TLS).
|
||||
3. **Только после этого** клиент начинает TLS ClientHello.
|
||||
|
||||
Первые байты от клиента — **plaintext protocol bytes**, не TLS ClientHello. Traefik ищет SNI в TLS ClientHello, не находит → не может смаршрутизировать → connection hangs (висит на чтении от клиента).
|
||||
|
||||
## Решение — Raw TCP forward + HostSNI(*)
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- "traefik.tcp.routers.postgres.rule=HostSNI(`*`)"
|
||||
- traefik.tcp.routers.postgres.entrypoints=postgres
|
||||
- traefik.tcp.routers.postgres.service=postgres
|
||||
- traefik.tcp.services.postgres.loadbalancer.server.port=5432
|
||||
# NO tls.* — traefik forwards raw TCP без inspect
|
||||
```
|
||||
|
||||
Каждая DB — на **своей dedicated entrypoint port** (`postgres:5432`, `mariadb:3306`, `mongo:27017`, `redis:6379`). Routing — по entrypoint, не по SNI. Traefik просто forwards bytes к backend без чтения TLS ClientHello. STARTTLS protocols договариваются о TLS напрямую с DB-контейнером.
|
||||
|
||||
`HostSNI(\`*\`)` — единственное значение для TCP router'а **без** `tls.*` секции (traefik требует какое-то правило, wildcard catch-all уместен здесь).
|
||||
|
||||
## Trade-off
|
||||
|
||||
- ✔ Работает для всех 4 protocols (STARTTLS + TLS-from-start).
|
||||
- ✔ Uniform config, не нужно ветвить compose под тип protocol'а.
|
||||
- ✗ Каждая DB требует своего entrypoint:port в traefik static config. 4 DB → 4 entrypoints. Если хотим много инстансов одного движка — теряем muxing на один port через SNI.
|
||||
- ✗ Traefik в этом случае не видит контент трафика — только TCP-bytes мимо. Невозможно сделать middleware (rate limit, IP allowlist на уровне traefik) — это нужно делать на DB side.
|
||||
|
||||
## Альтернативный путь (отвергнут)
|
||||
|
||||
Traefik TLS termination (без passthrough) + SNI muxing на 443 entrypoint. Не работает для PG/MariaDB по той же причине — STARTTLS встроен в protocol stack, и traefik терминирующий TLS отдаёт plaintext бэкенду, который ждёт SSLRequest и предлагает свой TLS handshake. Двойная TLS обёртка вокруг STARTTLS — broken.
|
||||
|
||||
LE-cert provisioning при passthrough TCP — на DB side, не traefik. Сейчас self-signed; follow-up — lego sidecar extracting из traefik acme.json.
|
||||
|
||||
## Где применено
|
||||
|
||||
Все 4 DBs на [`vds-kzntsv`](../entities/vds-kzntsv.md): postgres:5432, mariadb:3306, mongo:27017, redis:6379. Traefik v2.11 LTS. См. также [`db-tls-self-signed-via-traefik-raw-tcp`](db-tls-self-signed-via-traefik-raw-tcp.md) — связанный паттерн self-signed cert provisioning'а.
|
||||
|
||||
## Ссылки
|
||||
|
||||
- Traefik docs: [TCP routers](https://doc.traefik.io/traefik/routing/routers/#configuring-tcp-routers)
|
||||
- Сессия где обнаружили: [`vds-kzntsv-bootstrap-2026-05-20`](../sources/vds-kzntsv-bootstrap-2026-05-20.md) Phase 2.
|
||||
140
.tmp-concepts/vbox-windows-stability-tuning.md
Normal file
140
.tmp-concepts/vbox-windows-stability-tuning.md
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
title: VirtualBox + Windows-гость — нюансы стабильности при cross-hypervisor миграции
|
||||
type: concept
|
||||
tags: [virtualbox, windows, kvm, migration, troubleshooting]
|
||||
sources: [../sources/nas-recovery-session-2026-05-18.md]
|
||||
updated: 2026-05-19
|
||||
---
|
||||
|
||||
# VirtualBox + Windows-гость: cross-hypervisor migration
|
||||
|
||||
Импорт OVA с Synology VMM (KVM-based) в VirtualBox прошёл через 3 круга проблем. Записано чтобы в следующий раз не танцевать. Касается [[snolla-recovery-vm]].
|
||||
|
||||
## Симптом исходный
|
||||
|
||||
OVA импортируется через `VBoxManage import`, VM стартует, Windows валится в WinRE ("Восстановление при загрузке не удалось восстановить компьютер"). Startup Repair не помогает.
|
||||
|
||||
## Цепочка фиксов (применять по порядку)
|
||||
|
||||
### 1. Storage controller: SCSI LsiLogic → SATA AHCI
|
||||
|
||||
OVA с KVM-источника обычно имеет SCSI LsiLogic. Windows-гость не имеет встроенного boot-driver для VBox's LsiLogic emulation. SATA AHCI — generic, поддерживается любым Windows из коробки.
|
||||
|
||||
```
|
||||
VBoxManage controlvm "<vm>" poweroff
|
||||
VBoxManage storageattach "<vm>" --storagectl "SCSI" --port 0 --device 0 --medium none
|
||||
VBoxManage storagectl "<vm>" --name "SCSI" --remove
|
||||
VBoxManage storagectl "<vm>" --name "SATA" --add sata --controller IntelAhci --portcount 4
|
||||
VBoxManage storageattach "<vm>" --storagectl "SATA" --port 0 --device 0 --type hdd --medium "<vmdk-path>"
|
||||
```
|
||||
|
||||
После этого Windows бутится дальше WinRE → доходит до login screen.
|
||||
|
||||
### 2. Hyper-V Virtualization Infrastructure Driver — disable в Safe Mode
|
||||
|
||||
После login Windows зависает в **чёрный экран после Welcome**. Виновник — Microsoft Hyper-V virtualization infrastructure driver, который остался от Synology VMM/KVM. Под VBox он не находит свой target hypervisor → виснет.
|
||||
|
||||
Доступ: жмёшь power → Shift+Restart → Recovery → Troubleshoot → Advanced Options → Startup Settings → Restart → **4 (Safe Mode)** или **5 (Safe Mode with Networking)**.
|
||||
|
||||
В Safe Mode:
|
||||
- Device Manager → Системные устройства → **Драйвер инфраструктуры виртуализации Microsoft Hyper-V** → правый клик → **Отключить устройство** (Disable, не удалять — потом можно вернуть).
|
||||
- Параллельно почистить "Другие устройства" с жёлтыми треугольниками (audio-controller, base system device) — uninstall, без удаления драйверов.
|
||||
|
||||
Reboot нормально → Windows загружается до desktop.
|
||||
|
||||
### 3. Paravirt provider: default → kvm
|
||||
|
||||
После пары часов uptime VM начинает виснуть рандомно. Корень — несоответствие paravirt-интерфейса между source-гипервизором (Synology VMM = KVM) и VBox default (`default`/`auto`, который пытается подружиться с гостем но не угадывает).
|
||||
|
||||
```
|
||||
VBoxManage modifyvm "<vm>" --paravirtprovider kvm
|
||||
```
|
||||
|
||||
Windows-гость, который изначально загружал KVM paravirt drivers (virtio?), теперь под VBox видит знакомый интерфейс → стабильнее.
|
||||
|
||||
### 4. OS type: Other_64 → Windows10_64
|
||||
|
||||
VBox по умолчанию ставит OS type = `Other/Unknown (64-bit)` при импорте OVA с неузнанной маркировкой. Это означает дефолтные acceleration settings, которые могут не подходить Windows.
|
||||
|
||||
```
|
||||
VBoxManage modifyvm "<vm>" --ostype Windows10_64
|
||||
```
|
||||
|
||||
Включает VBox-внутренние оптимизации для Windows (HPET off, large pages on, и др.).
|
||||
|
||||
### 5. HPET off, vCPU 2 (а не 4), RAM 4 GB
|
||||
|
||||
- HPET (High Precision Event Timer) — для Windows-гостя на VBox чаще создаёт jitter чем помогает. Off:
|
||||
```
|
||||
VBoxManage modifyvm "<vm>" --hpet off
|
||||
```
|
||||
- vCPU: 4 на 2-ядерном/4-ядерном хосте может создавать contention. Снизить до 2:
|
||||
```
|
||||
VBoxManage modifyvm "<vm>" --cpus 2
|
||||
```
|
||||
- RAM: 4 GB достаточно для IIS + CMS + Windows на легкой нагрузке.
|
||||
|
||||
### 6. Установить **VirtualBox Guest Additions**
|
||||
|
||||
Без GA Windows использует generic Microsoft драйверы для VBox-эмулированного железа. С GA — нативные оптимизированные VBox-драйверы для сети/видео/storage/устройств.
|
||||
|
||||
**Установить через VRDE-консоль** (mstsc к `localhost:13389`), а не через сетевой RDP — потому что сеть может умереть до установки GA.
|
||||
|
||||
Шаги:
|
||||
1. На хосте: `VBoxManage storagectl "<vm>" --name "IDE" --add ide --controller PIIX4` (новый контроллер для DVD)
|
||||
2. `VBoxManage storageattach "<vm>" --storagectl "IDE" --port 0 --device 0 --type dvddrive --medium "C:\Program Files\Oracle\VirtualBox\VBoxGuestAdditions.iso"`
|
||||
3. В VM: Win+E → D: drive → запустить `VBoxWindowsAdditions.exe` → Next/Install/доверять Oracle publisher → Restart.
|
||||
|
||||
После: `GuestAdditionsRunLevel=3` (полностью активны). VM существенно стабильнее.
|
||||
|
||||
## Network nuances
|
||||
|
||||
### Bridged WiFi нестабильно
|
||||
|
||||
Если хост-машина подключена по WiFi и VBox NIC = bridged через WiFi adapter — частые проблемы с promiscuous mode. Симптомы:
|
||||
- VM получает IP по DHCP
|
||||
- Работает 10-60 минут
|
||||
- Network "повисает": TCP-handshake проходит, но трафик не идёт
|
||||
- ARP-table показывает MAC, но State=`Stale`
|
||||
|
||||
Решение: **NAT с port forwarding** вместо bridged.
|
||||
|
||||
```
|
||||
VBoxManage modifyvm "<vm>" --nic1 nat
|
||||
VBoxManage controlvm "<vm>" natpf1 "rdp,tcp,127.0.0.1,23389,,3389"
|
||||
VBoxManage controlvm "<vm>" natpf1 "ssh,tcp,127.0.0.1,8022,,22"
|
||||
VBoxManage controlvm "<vm>" natpf1 "http,tcp,127.0.0.1,18080,,80"
|
||||
# ...и так далее на нужные порты
|
||||
```
|
||||
|
||||
VM получает 10.0.2.15 (default NAT subnet). Host достижим из VM по 10.0.2.2 (NAT gateway).
|
||||
|
||||
### Network recovery inside Windows VM
|
||||
|
||||
Если внутри VM network "повис" (бывает даже с GA + NAT):
|
||||
|
||||
```
|
||||
VBoxManage guestcontrol "<vm>" run --exe "C:\Windows\System32\cmd.exe" \
|
||||
--username vitya --password '<pw>' --wait-stdout --wait-stderr \
|
||||
-- cmd.exe /c "ipconfig /release && ipconfig /renew"
|
||||
```
|
||||
|
||||
Через GA это работает не требуя SSH/RDP связи.
|
||||
|
||||
## VRDE backup-доступ
|
||||
|
||||
Всегда включён в нашей конфигурации как fallback:
|
||||
|
||||
```
|
||||
VBoxManage controlvm "<vm>" vrde on
|
||||
VBoxManage controlvm "<vm>" vrdeport 13389
|
||||
```
|
||||
|
||||
`mstsc → localhost:13389` показывает VM-консоль независимо от состояния сети в VM. Полезно для recovery когда RDP в VM умер.
|
||||
|
||||
## Что не сработало
|
||||
|
||||
- Hyper-V на хосте — рассматривался как cleaner альтернатива для Windows-гостя, но требует доустановки Hyper-V Manager и перезагрузки хоста. Отложено как Plan B, не понадобилось.
|
||||
- VMware Workstation Pro 17 — бесплатен с 2024, но та же migration-головная боль на Windows-госте с другого гипервизора.
|
||||
|
||||
Связано: [[snolla-recovery-vm]], [[windows-recovery-host]].
|
||||
80
.tmp-concepts/verdaccio-prune-semantics.md
Normal file
80
.tmp-concepts/verdaccio-prune-semantics.md
Normal file
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: Verdaccio prune semantics — proxied vs locally-published
|
||||
type: concept
|
||||
tags: [verdaccio, npm, storage, gc, gotcha]
|
||||
sources: []
|
||||
updated: 2026-05-21
|
||||
---
|
||||
|
||||
# Verdaccio prune — proxied vs locally-published
|
||||
|
||||
Прежде чем удалять `.tgz` из verdaccio storage, надо отличать proxied (cached from upstream — безопасно удалить, можно re-fetch) от locally-published (authoritative copy — удаление = permanent loss).
|
||||
|
||||
## Storage layout
|
||||
|
||||
`/storage/data/<package>/`:
|
||||
- `package.json` — packument (metadata, дешёвый)
|
||||
- `<package>-<version>.tgz` — binary tarballs (дорогие)
|
||||
|
||||
Для scoped: `/storage/data/<scope>/<package>/...`. Scope в filename `.tgz` **НЕ** включается — `@snollajs/snolla/snolla-0.2.4.tgz`.
|
||||
|
||||
## Detection rule
|
||||
|
||||
В `package.json` есть три relevant fields:
|
||||
|
||||
```yaml
|
||||
_uplinks: { npmjs: { etag, fetched } } # upstream registries verdaccio queried
|
||||
_distfiles: { "<pkg>-<v>.tgz": { url, sha, registry } } # ключи — ИМЯ tarball, не version
|
||||
_attachments: { "<pkg>-<v>.tgz": { shasum } } # cached + locally-uploaded tarballs
|
||||
```
|
||||
|
||||
**Detection**:
|
||||
- `_distfiles[<tgzName>]` defined → **proxied**, tarball mirrored from upstream URL. Удалять `.tgz` файл безопасно — verdaccio re-fetch'нет при следующем `npm install pkg@v`.
|
||||
- `_distfiles[<tgzName>]` undefined, but `_attachments[<tgzName>]` defined и `.tgz` есть на диске → **locally-published** (`npm publish` в verdaccio через `verdaccio` registry). **НЕ удалять** — это единственная копия.
|
||||
|
||||
## Gotcha: keying
|
||||
|
||||
Ключи `_distfiles` — это **filename** (`lodash-0.1.0.tgz`), **НЕ** version string (`0.1.0`). Lookup `_distfiles["0.1.0"]` всегда возвращает undefined, ложно классифицируя ВСЕ versions как locally-published.
|
||||
|
||||
Easy mistake: первый draft prune скрипта проверял `_distfiles[v]` → dry-run показал 4597/7021 packages "защищены" (на самом деле ВСЕ proxied lodash/react/etc). Fix: `_distfiles[<tgzBase>-<v>.tgz]` где `tgzBase = name.replace(/^@[^/]+\//, '')` (strip scope для filename).
|
||||
|
||||
## Safe prune algorithm
|
||||
|
||||
```
|
||||
для каждого package:
|
||||
keepSet = dist-tagged versions # {latest, next, beta, ...}
|
||||
others = versions − keepSet, отсортированные по time[v] desc
|
||||
keepSet += first (N - |keepSet|) из others # N = 10
|
||||
для v в (versions − keepSet):
|
||||
tgzName = "<scope-stripped-name>-<v>.tgz"
|
||||
if _distfiles[tgzName] undefined:
|
||||
continue # locally-published, skip
|
||||
if .tgz file exists: delete file
|
||||
if _attachments[tgzName] exists: delete entry
|
||||
если что-то изменили в _attachments:
|
||||
bump _rev (`<num+1>-<random_hex>`)
|
||||
atomic rewrite package.json (tmp + rename)
|
||||
```
|
||||
|
||||
## Atomic write + _rev
|
||||
|
||||
`package.json` rewrite через `fs.writeFileSync(tmp); fs.renameSync(tmp, real)` — atomic on POSIX same-fs. Race window между «file gone» и «package.json updated» ничтожен.
|
||||
|
||||
`_rev` field в формате `<num>-<hash>` (CouchDB-style). Verdaccio проверяет его для optimistic concurrency. Bump = increment number + new random hex. Это invalidates npm client packument cache.
|
||||
|
||||
Дополнительная подстраховка от race: **stop verdaccio перед prune, start после** (~1 min downtime weekly). Альтернатива — atomic rewrite без stop — допустима, но cache invalidation менее clean.
|
||||
|
||||
## Что НЕ трогать
|
||||
|
||||
- `versions{}` и `time{}` — оставляем metadata in-place даже после удаления tarballs. Для proxied packages npm re-fetch'нет tgz при request; metadata лёгкая (~KB).
|
||||
- `_uplinks{}` — agent-level state о fetch timestamps; не относится к individual versions.
|
||||
- `readme`, `users`, `_id` — irrelevant для prune.
|
||||
|
||||
## Применено
|
||||
|
||||
[`vds-gc-cron`](../../.tasks/vds-gc-cron.md) — weekly cron Sun 03:30 MSK на VDS. Smoke 2026-05-21: 8.5G→6.8G freed, 4524 tgz deleted, 73 locally-published versions защищены (`@snollajs/*` + ~40 другие internal packages).
|
||||
|
||||
## Ссылки
|
||||
|
||||
- Verdaccio local-storage docs: https://verdaccio.org/docs/configuration#storage
|
||||
- Algorithm в репо: `/opt/stacks/gc/scripts/verdaccio-prune.js` на VDS.
|
||||
76
.tmp-concepts/wd40efax-smr-cascade.md
Normal file
76
.tmp-concepts/wd40efax-smr-cascade.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: WD40EFAX SMR Cascade — root cause NAS failure 2026-05-18
|
||||
type: concept
|
||||
tags: [hardware, raid, smr, failure-mode, wd, lessons]
|
||||
sources: [../sources/nas-recovery-session-2026-05-18.md]
|
||||
updated: 2026-05-19
|
||||
---
|
||||
|
||||
# WD40EFAX SMR Cascade
|
||||
|
||||
Сценарий смерти RAID 5 на WD Red WD40EFAX (SMR-диски). Случился 2026-05-18 на [[dead-synology-diskstation]]. Известная community-проблема, не уникальная.
|
||||
|
||||
## Что такое SMR
|
||||
|
||||
**SMR** (Shingled Magnetic Recording) — способ записи на пластины, где дорожки наезжают друг на друга как черепица. Плюс: больше плотность, дешевле гигабайт. Минус: запись на одну дорожку **физически портит соседние** → нужна re-write whole "zone". Запись стала зональной (вместо random-access).
|
||||
|
||||
**CMR** (Conventional Magnetic Recording) — стандарт, дорожки независимые.
|
||||
|
||||
### Где SMR ломается
|
||||
|
||||
| Сценарий | CMR | SMR |
|
||||
|---|---|---|
|
||||
| Sequential write | ✅ | ✅ |
|
||||
| Чтение | ✅ | ✅ |
|
||||
| Random writes (БД, активная FS) | ✅ | ❌ просаживается |
|
||||
| **RAID rebuild** | ✅ | ❌❌ **смертельно** |
|
||||
|
||||
Во время RAID-rebuild диск получает **долгую sustained-нагрузку**: parity-чтение + запись на новый диск. SMR-firmware пытается жонглировать зонами; внутренний CMR-кэш (есть в начале диска) забивается. Performance падает в 5-10 раз → **command timeouts** → RAID-контроллер выкидывает диск из массива.
|
||||
|
||||
## Скандал WD
|
||||
|
||||
WD в 2018-2020 **тихо** перевёл часть линейки "WD Red" (заточена под NAS) на SMR, не указав в маркировке. Community catch'нуло (Reddit, Servethehome, forum.synology). Class-action в США 2020, WD settled. После — WD переименовал CMR-варианты в "WD Red **Plus**" / "WD Red **Pro**"; "WD Red" без Plus остался SMR.
|
||||
|
||||
**WD40EFAX-68JH4N1, WD40EFAX-68JN4N0** — главные жертвы. Если в RAID — лотерея.
|
||||
|
||||
## Конкретный путь к смерти (наш кейс)
|
||||
|
||||
1. **3-диск RAID 5** на WD40EFAX. Storage pool в DSM на `cachedev_0`, 7 TB usable.
|
||||
2. **Начало мая 2026** — один из 3 дисков вылетел (вероятно тайм-аут под нагрузкой, не физический отказ).
|
||||
3. **Пул degraded.** RAID 5 на 3 дисках теперь толерирует 0 дополнительных отказов.
|
||||
4. **2 недели пользователь не заменил failed диск.** Пул работал в degraded; оставшиеся 2 SMR-диска делают parity-чтение для любого запроса.
|
||||
5. **Под этой нагрузкой второй WD40EFAX накопил тайм-ауты** (известный паттерн для SMR в degraded-RAID).
|
||||
6. **2026-05-18 ~17:00 MSK** — второй вылет. Пул past redundancy, "Сбой сборки" в DSM Storage Manager.
|
||||
7. **Виден только Disk 3 + Disk 8** (третий, который вылетел первым, физически не определяется системой даже).
|
||||
|
||||
## Ключевая ошибка
|
||||
|
||||
**2 недели в degraded не лечатся.** В нормальном RAID 5 на CMR — можно прожить недели без последствий (всё работает). На SMR — каждый день в degraded **повышает шанс второго отказа** из-за SMR-induced таймаутов.
|
||||
|
||||
Правило: при SMR в RAID 5 — **24-48 часов** на замену failed диска. Дольше — лотерея. Поэтому SMR в RAID **запрещён de facto** для серьёзных продакшнов.
|
||||
|
||||
## Что НЕ делать после второго отказа
|
||||
|
||||
- ❌ Repair / Online Assembly в DSM — пул past redundancy, mdadm не соберёт.
|
||||
- ❌ Менять disks в degraded-пуле — может ускорить deterioration оставшихся.
|
||||
- ❌ Запускать `mdadm --assemble` руками с force — без знания внутренней структуры → разрушение partial-data.
|
||||
|
||||
## Что МОЖНО (но дорого)
|
||||
|
||||
- **Pro data recovery** (Storelab/R.LAB/Ace Lab клиенты): $500-3000 typical. Контора берёт диски (все 3, включая failed), делает offline-reconstruct parity, восстанавливает file-tree. Подходит для возврата 9-дневного окна между последним бэкапом (2026-05-09) и инцидентом (2026-05-18).
|
||||
- **Условие:** диски физически живы (головки не упали). У нас 2 видимых "Исправно" + 1 не определяемый — стандартный кейс для recovery service.
|
||||
|
||||
## Lessons для будущего
|
||||
|
||||
Для следующего NAS-пула:
|
||||
|
||||
1. **CMR-only.** Никаких WD40EFAX/EFRX/EFAZ/EFGX (если они SMR). Кандидаты:
|
||||
- WD Red **Plus** (CMR, маркировка "Plus" — важно)
|
||||
- WD Red **Pro** (CMR, enterprise-grade)
|
||||
- Seagate IronWolf 4TB+ (CMR — модели <4TB могут быть SMR, проверять по datasheet)
|
||||
- HGST/WD Ultrastar (enterprise CMR)
|
||||
2. **RAID 6 / SHR-2** при 4+ дисках — толерирует 2 отказа. Один отказ + один SMR-cascade не убивает массив.
|
||||
3. **Дисциплина replace failed disk в 24-48 часов** — в degraded долго не сидеть.
|
||||
4. **Hot spare** если есть place в шасси.
|
||||
5. **Hyper Backup ежедневно** (а не "по триггеру") + retention 30+ дней.
|
||||
6. **Тест восстановления раз в квартал** — мы впервые узнали что наш backup рабочий **только когда случился инцидент**. Это нехорошо.
|
||||
71
.tmp-concepts/webconfig-password-xml-escape.md
Normal file
71
.tmp-concepts/webconfig-password-xml-escape.md
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Web.config — XML escape для спецсимволов в connection-string паролях
|
||||
type: concept
|
||||
tags: [iis, webconfig, encoding, xml, gotcha]
|
||||
sources: [../sources/iis-host-migration-2026-05-19.md]
|
||||
updated: 2026-05-19
|
||||
---
|
||||
|
||||
# Web.config: XML-escape для `&` (и других reserved chars) в пароле
|
||||
|
||||
## Симптом
|
||||
|
||||
После patch Web.config с новым connection-string'ом ASP.NET-сайт отдаёт **HTTP 500** на любой запрос. В Event Viewer / IIS logs — `System.Configuration.ConfigurationErrorsException: Configuration system failed to initialize` или `Unrecognized escape sequence`. На уровне XML парсера — error при чтении Web.config.
|
||||
|
||||
## Корень
|
||||
|
||||
`Web.config` — это **XML-документ**. Атрибуты в нём (`<add connectionString="..." />`) проходят через XML-парсер до того, как .NET runtime увидит сам connection string. Если в пароле есть **XML-зарезервированный символ**, парсер ломается.
|
||||
|
||||
XML reserved chars (в значениях атрибутов):
|
||||
|
||||
| Символ | XML-entity escape | Когда обязателен |
|
||||
|---|---|---|
|
||||
| `&` | `&` | **всегда** (в attribute value и в text content) |
|
||||
| `<` | `<` | **всегда** |
|
||||
| `"` | `"` | если значение в `"..."` (наш кейс — атрибуты обычно в двойных кавычках) |
|
||||
| `'` | `'` | если значение в `'...'` |
|
||||
| `>` | `>` | формально не обязателен, но safer |
|
||||
|
||||
В именно нашем кейсе из [[iis-host-migration-2026-05-19]]: пароль `^I9D)LB)DK)8J#xBDG$t}W&_ioaT!M!LF` содержит `&` — XML-парсер начинал интерпретировать `&_ioaT...` как entity reference (`&_ioaT;` — невалидное имя entity) → exception → IIS 500.
|
||||
|
||||
## Правильный способ
|
||||
|
||||
```xml
|
||||
<!-- WRONG (literal &) -->
|
||||
<add name="MoreThenCmsEntities"
|
||||
connectionString="...Password=^I9D)LB)DK)8J#xBDG$t}W&_ioaT!M!LF;..."
|
||||
providerName="System.Data.SqlClient" />
|
||||
|
||||
<!-- RIGHT (& -> &) -->
|
||||
<add name="MoreThenCmsEntities"
|
||||
connectionString="...Password=^I9D)LB)DK)8J#xBDG$t}W&_ioaT!M!LF;..."
|
||||
providerName="System.Data.SqlClient" />
|
||||
```
|
||||
|
||||
После XML-парсинга .NET runtime получит литеральный `&` в значении атрибута и передаст в SqlConnection — там пароль уже текст, всё ок.
|
||||
|
||||
## Что НЕ требует escape
|
||||
|
||||
- `^`, `$`, `(`, `)`, `{`, `}`, `[`, `]`, `*`, `+`, `?`, `\`, `/`, `.`, `,`, `;`, `:`, `=`, `#`, `@`, `%`, `!`, `~`, `|` — все безопасны в XML attribute value.
|
||||
- Особое: `;` в пароле в ADO.NET conn-string'е требует обёртки пароля в `'...'` или `"..."` внутри connection-string'а — но это отдельная история.
|
||||
|
||||
## PowerShell-подсказка для patch
|
||||
|
||||
При программной замене conn-string'а — **сразу escape `&` в значение**, не оставляй на потом:
|
||||
|
||||
```powershell
|
||||
# 💻 Windows host
|
||||
$plainPassword = '^I9D)LB)DK)8J#xBDG$t}W&_ioaT!M!LF'
|
||||
$xmlSafePassword = $plainPassword -replace '&', '&'
|
||||
# и потом подставлять $xmlSafePassword в Web.config
|
||||
|
||||
# или комбинированный escape всех 5 reserved chars:
|
||||
function Xml-EscapeAttr($s) {
|
||||
$s -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' -replace "'",'''
|
||||
}
|
||||
```
|
||||
|
||||
## Связанные паттерны
|
||||
|
||||
- [[cms-config-rewrite-pattern]] — UTF-8 BOM ловушка при `Set-Content` без `-Encoding utf8` (другая XML/IIS гнойная тема).
|
||||
- При генерации паролей для DB-логинов CMS — лучше **запретить `&`, `<`, `"`** в генераторе, чтобы не словить эту проблему в будущем (особенно при автоматизированных patches Web.config через CI).
|
||||
Reference in New Issue
Block a user