Skip to content

feat: auto-manage external DNS records (Selectel) on proxy-host changes#5702

Open
vasyakrg wants to merge 30 commits into
NginxProxyManager:developfrom
vasyakrg:develop
Open

feat: auto-manage external DNS records (Selectel) on proxy-host changes#5702
vasyakrg wants to merge 30 commits into
NginxProxyManager:developfrom
vasyakrg:develop

Conversation

@vasyakrg

Copy link
Copy Markdown

Base: develop ← Head: feature/dns-provider-integration

Body:

What

Adds a DNS Provider entity so that creating/updating/deleting a proxy host
automatically creates/syncs/deletes the matching DNS A record in an external
provider. First (and currently only) driver: Selectel DNS v2.

Previously you had to point a domain at the server manually before adding a
proxy host. Now NPM can do it for you.

How it works

  1. Admin registers a DNS Provider (credentials + target IP).
  2. On a proxy host you pick that provider.
  3. On save, NPM resolves the zone (longest-suffix match), creates an A record
    domain → default_ip, and records the result in the host's meta.
    DNS failures never block the host operation — they surface as a status.

Provider credentials are admin-only, write-only (never returned by the
API), and omitted from audit logs.

Examples

Register a provider:

POST /api/nginx/dns-providers
{
  "name": "Selectel",
  "type": "selectel",
  "credentials": {
    "account_id": "123456",
    "project_name": "my-project",
    "username": "svc-user",
    "password": "••••••"
  },
  "default_ip": "203.0.113.10"
}

Test the connection:

GET /api/nginx/dns-providers/{id}/test   →   { "ok": true }

Attach to a proxy host (existing endpoint, new optional field):

POST /api/nginx/proxy-hosts
{ "domain_names": ["app.example.com"], ..., "dns_provider_id": 1 }
# → host created, and A record app.example.com → 203.0.113.10 in zone example.com

On failure the host is still created; status is exposed via meta:

"meta": { "dns_synced": false, "dns_err": "No Selectel DNS zone found for domain ..." }

UI: new DNS Providers admin page (list + modal with Test connection), plus
a DNS Provider selector and sync-status badge on the proxy-host form.

Scope

  • Only proxy hosts, only Selectel, only A records to a manually-se
  • Zones must already exist in the Selectel project (NPM does not create zones).
  • Credentials stored as plaintext (same model as existing certbot DNS credenti

Testing

  • Backend: 20 vitest unit tests (Selectel driver auth/zone-resolve/rrset,
    record sync/cleanup diff). vitest added to the backend package.
  • API e2e: Cypress spec test/cypress/e2e/api/DnsProviders.cy.js.
  • Verified end-to-end against a live Selectel account.

Follow-ups (non-blocking)

  • sync() discards partial progress on mid-batch multi-domain failure (possible
    orphaned rrset) — narrow, surfaced via dns_err.
  • proxy-host-object.json / GET expand enum not yet documenting the new fields.
  • enable/disable and updates on a disabled host don't sync DNS (by design).

vasyakrg and others added 30 commits July 11, 2026 09:33
Дизайн авто-управления DNS-записями при create/update/delete proxy-host
через внешнего провайдера. Первый драйвер — Selectel DNS v2 API.
Также добавлен CLAUDE.md (init).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk
Пошаговый TDD-план на 17 задач (9 фаз): backend vitest-инфра, миграция,
модель, permissions, Selectel-драйвер, оркестрация записей, CRUD+API,
хуки в proxy-host, frontend UI, e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk
Mirrors access_list.js: HasOne owner, HasMany proxy_hosts, bool-field
conversion for is_deleted. Wires dns_provider HasOne on ProxyHost and
adds it to defaultAllowGraph.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk
Adds dns-provider-object/list components and per-method path files
for GET/POST /nginx/dns-providers, GET/PUT/DELETE
/nginx/dns-providers/{providerID}, and GET .../test, registered in
swagger.json. Schema-only; Task 12 routes will consume these via
getValidationSchema().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk
Adds /nginx/dns-providers REST endpoints (GET/POST collection,
GET/PUT/DELETE/test item) mirroring access-lists, wired to
internal/dns-provider.js and mounted in main.js.
Wire internalDnsRecord.sync/cleanup into the proxy-host create, update,
and delete flows, and accept dns_provider_id in the POST/PUT schemas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk
Adds the thin API-client wrappers (get/getAll/create/update/delete/test)
and DnsProvider/DnsProviderCredentials types under frontend/src/api/backend,
mirroring the AccessList client pattern. UI lands in a follow-up task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk
Add the admin-only DNS Providers management UI: list page under
/dns-providers (name/type/default IP/status columns, edit/delete),
create/edit modal with Test connection, nav entry, and i18n keys.
Consumes Task 14's api-client as-is; credentials are write-only so
edit submits omit them unless the user actually types new values.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk
Adds a DnsProviderField (mirrors AccessField/SSLCertificateField's
react-select + Field pattern) to ProxyHostModal so a proxy host can be
attached to a DNS Provider entity, plus a small badge showing
meta.dnsSynced/meta.dnsErr sync status. dnsProviderId flows through the
existing unfiltered Formik submit payload to dns_provider_id via humps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk
… status persistence

- selectel.js: Keystone auth host was cloud.api.selectel.ru (nonexistent) →
  cloud.api.selcloud.ru per Selectel IAM docs. This is why sync/testConnection
  failed even with valid credentials (DNS resolution failure, not auth).
- dns-provider.js: test() now persists {last_check_ok,last_check_error} into
  provider.meta so the list Status column reflects the last connection check.
- DnsProviderModal: invalidate the providers list after Test connection so the
  status updates immediately.
- EventFormatter: recognise "dns-provider" object type (name value + IconWorld)
  instead of "UNKNOWN EVENT TYPE".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7Yo1WeiFmKfEFJUmZWzRk
feat: auto-manage external DNS records (Selectel) on proxy-host changes
@nginxproxymanagerci

Copy link
Copy Markdown

CI Error:

/bin/bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8): No such file or directory
certbot-node: Pulling from nginxproxymanager/nginx-full
Digest: sha256:8ef5236a02928b8ba23ed3ddd9c995679723cf4c6a37888de452c6bb203a9e0d
Status: Image is up to date for nginxproxymanager/nginx-full:certbot-node
docker.io/nginxproxymanager/nginx-full:certbot-node
�[1;34m❯ �[1;36mTesting backend ...�[0m
yarn install v1.22.22
[1/4] Resolving packages...
[2/4] Fetching packages...
warning lru.min@1.1.4: The engine "bun" appears to be invalid.
warning lru.min@1.1.4: The engine "deno" appears to be invalid.
warning sql-escaper@1.3.3: The engine "bun" appears to be invalid.
warning sql-escaper@1.3.3: The engine "deno" appears to be invalid.
[3/4] Linking dependencies...
warning " > @apidevtools/json-schema-ref-parser@15.3.5" has unmet peer dependency "@types/json-schema@^7.0.15".
warning " > mysql2@3.22.3" has unmet peer dependency "@types/node@>= 8".
warning " > @apidevtools/swagger-parser@12.1.0" has unmet peer dependency "openapi-types@>=7".
warning " > vitest@4.1.10" has unmet peer dependency "vite@^6.0.0 || ^7.0.0 || ^8.0.0".
[4/4] Building fresh packages...
Done in 10.16s.
yarn run v1.22.22
$ biome lint .
setup.js:9:16 lint/style/useNodejsImportProtocol  FIXABLE  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  i A Node.js builtin module should be imported with the node: protocol.
  
     7 │ import userModel from "./models/user.js";
     8 │ import userPermissionModel from "./models/user_permission.js";
   > 9 │ import fs from "fs/promises";
       │                ^^^^^^^^^^^^^
    10 │ 
    11 │ export const isSetup = async () => {
  
  i Using the node: protocol is more explicit and signals that the imported module belongs to Node.js.
  
  i Unsafe fix: Add the node: protocol.
  
      7   7 │   import userModel from "./models/user.js";
      8   8 │   import userPermissionModel from "./models/user_permission.js";
      9     │ - import·fs·from·"fs/promises";
          9 │ + import·fs·from·"node:fs/promises";
     10  10 │   
     11  11 │   export const isSetup = async () => {
Checked 100 files in 56ms. No fixes applied.
Found 1 info.
  

Done in 0.12s.
�[1;34m❯ �[1;32mTesting Complete�[0m
�[1;34m❯ �[1;36mBuilding ...�[0m
#0 building with "default" instance using docker driver

#1 [internal] load build definition from Dockerfile
#1 transferring dockerfile: 2.35kB done
#1 DONE 0.0s

#2 [internal] load metadata for docker.io/nginxproxymanager/testca:latest
#2 ...

#3 [internal] load metadata for docker.io/nginxproxymanager/nginx-full:certbot-node
#3 DONE 0.6s

#2 [internal] load metadata for docker.io/nginxproxymanager/testca:latest
#2 DONE 0.6s

#4 [internal] load .dockerignore
#4 transferring context: 2B done
#4 DONE 0.0s

#5 [testca 1/1] FROM docker.io/nginxproxymanager/testca:latest@sha256:3e23c78c64789b8e95126589dd502127e57bbad6091a5a88a67ffb87a16f6b6a
#5 resolve docker.io/nginxproxymanager/testca:latest@sha256:3e23c78c64789b8e95126589dd502127e57bbad6091a5a88a67ffb87a16f6b6a done
#5 ...

#6 [internal] load build context
#6 transferring context: 4.00MB 0.0s done
#6 DONE 0.1s

#5 [testca 1/1] FROM docker.io/nginxproxymanager/testca:latest@sha256:3e23c78c64789b8e95126589dd502127e57bbad6091a5a88a67ffb87a16f6b6a
#5 sha256:38343e689e15c8e5e806a81f5d267ebc654a64c4ba8710bb40c6f8dee3251e5a 569B / 569B 0.0s done
#5 sha256:87bb61d514f352ddbfaeda815962d65f539bf694020edbdc3d6999bd44d3e850 2.86kB / 2.86kB 0.0s done
#5 sha256:ae0d12ef9e143f283accd0e8eee98e0829bc912246bf1ca4656335338b55894d 58.59kB / 58.59kB 0.0s done
#5 sha256:987ab97de7c8e28cebb6b45a2546298654c7d25b24720c371d04838152bb3e95 1.36kB / 1.36kB 0.0s done
#5 sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1 0B / 32B 0.0s
#5 sha256:f19dea81e0af6255cb52700a24ca2dec7ff42c12c20486f1bd6e7fac5c735db3 0B / 16.68MB 0.0s
#5 sha256:3882371c5807b6189df26b2c3139baab81e7ceee303ec493ac1c9f8445fb7171 0B / 9.38MB 0.0s
#5 sha256:02f8984a1ccdd4c2c8252b3702bce71f3baaaf94f2663c910316b8a3e17bc16d 2.10MB / 5.79MB 0.1s
#5 ...

#7 [stage-1  2/13] RUN echo "fs.file-max = 65535" > /etc/sysctl.conf 	&& apt-get update 	&& apt-get install -y --no-install-recommends jq logrotate 	&& apt-get clean 	&& rm -rf /var/lib/apt/lists/*
#7 CACHED

#8 [stage-1  4/13] RUN /tmp/install-s6 "linux/amd64" && rm -f /tmp/install-s6
#8 CACHED

#9 [stage-1  7/13] COPY docker/rootfs /
#9 CACHED

#10 [stage-1  5/13] COPY backend       /app
#10 CACHED

#11 [stage-1  6/13] COPY frontend/dist /app/frontend
#11 CACHED

#12 [stage-1  3/13] COPY docker/scripts/install-s6 /tmp/install-s6
#12 CACHED

#13 [stage-1  8/13] COPY --from=testca /home/step/certs/root_ca.crt /etc/ssl/certs/NginxProxyManager.crt
#13 ERROR: short read: expected 32 bytes but got 0: unexpected EOF

#14 [stage-1  1/13] FROM docker.io/nginxproxymanager/nginx-full:certbot-node@sha256:8ef5236a02928b8ba23ed3ddd9c995679723cf4c6a37888de452c6bb203a9e0d
#14 resolve docker.io/nginxproxymanager/nginx-full:certbot-node@sha256:8ef5236a02928b8ba23ed3ddd9c995679723cf4c6a37888de452c6bb203a9e0d 0.0s done
#14 CANCELED
------
 > [stage-1  8/13] COPY --from=testca /home/step/certs/root_ca.crt /etc/ssl/certs/NginxProxyManager.crt:
------
Dockerfile:45
--------------------
  43 |     
  44 |     # Copy test CA cert for use in container and add to trusted certs from a self-signed CA
  45 | >>> COPY --from=testca /home/step/certs/root_ca.crt /etc/ssl/certs/NginxProxyManager.crt
  46 |     WORKDIR /etc/ssl/certs
  47 |     RUN ln -s NginxProxyManager.crt 1d0e3f10.0 && update-ca-certificates
--------------------
ERROR: failed to build: failed to solve: failed to compute cache key: short read: expected 32 bytes but got 0: unexpected EOF

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant