feat(1panel): serve dalang behind an allow-list, and alias the name - #36
Merged
Conversation
The website for that name was never declared here and does not exist on the box - measured against the running host in August 2026, which serves 23 vhosts and none of them this one. So this is a create rather than an import, and the verbatim-transcription rule the July drift scan followed does not apply. It is not an ordinary reverse-proxy entry. The service behind it mounts a catalogue endpoint that takes no credential, so the proxy block allows the tunnel connector VM and denies the rest of the LAN. That vhost is the control and the DNS record is not: nginx picks a site by SNI, so removing the Unbound alias would remove a handle and leave the door. The allowed address is read at run time out of the connector's own cloud-init payload rather than copied into a vault - a second copy is a value that can disagree, and the disagreement here presents as a 403 after a successful sign-in at the edge, which sends the reader to the wrong repository entirely. That read is what brings community.sops into requirements.yml; the sops binary was already on the control node. The blob stores the address as a /24 and the prefix is stripped at the read, because an allow carrying it would open the whole home LAN and would look correct in the diff. The name and the port are both second copies of values the owning repository holds, following vault_defectdojo_host, which is commented there as the one place the no-second-copy rule cannot be followed. The owning repository gains an assertion that this origin answers dalang when reached by this name, which is what makes those copies checked ones. .lycheeignore gains the VLAN upstreams. They are the same class the block above them already describes and were missed only because vars/main.yaml had not been staged since - so the hook failed on lines nobody had touched. Declaration only. The apply is the operator's - manage-websites.yaml reconciles every declared site on every run, and the panel API key is bound to a source-IP allowlist. ADR-0152, ADR-0163, ADR-0169, ADR-0177, ADR-0180.
The thirty-third entry in unbound_host_aliases, in the same shape as the thirty-two above it: everything OpenResty reverse-proxies resolves to the 1Panel host and is picked apart there by SNI, so a name that fronts a vhost gets an alias and not an A record of its own. Not a dnsmasq_hosts entry. That list reserves addresses for DHCP clients keyed on a MAC, and the address this name resolves to is the 1Panel host's, whose reservation is already there. The website it belongs to restricts itself to the tunnel connector, so this record is a convenience rather than a control - removing it would not close anything, because nginx never reads it. ADR-0163.
Review of the commit before this one, and the finding worth the commit is the first. Everything else here is the same class: a guard that reads correct and is weaker than it reads. The allow/deny pair sat inside `location ^~ /`, which covers only the requests that block wins. proxy-prometheus.conf.j2 already records what decides that - an `=` or a longer `^~` is resolved first - so any location 1Panel generates and this file never sees would have stepped around the deny while the header claimed it was the only thing in front of the endpoint. 1Panel includes these files in the site's `server` block, which is how every template here can open with a bare `location`, so the pair now sits at that level and is inherited by every location in the vhost. Nothing needs an exemption: the certificate is pushed in from OPNsense rather than answered for over HTTP. The read loses its `no_log`. It protected an RFC1918 address - the class this file publishes in the clear three entries up - and it censored the only thing that matters when the read fails: a missing age key, an absent sops or a moved blob failed as "output has been hidden", with the assert below never reached. It also loses `always` for its actual consumers. Only the proxy block needs the value, so `--tags manage_https` and the two searches stay runnable on a workstation with neither sops nor the age key. The assert anchors on `\Z` rather than `$`, which matches before a trailing newline and would have accepted a value reaching the template as `allow <addr>\n;`. The lookup rstrips by default and this no longer relies on it. Octets are still not range-checked, and now say why.
kholisrag
force-pushed
the
feat/396-dalang-ingress
branch
from
August 9, 2026 15:53
4424fc9 to
67d95d1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Declares the last hand-made piece of one service's ingress on this estate: a 1Panel reverse-proxy website and the Unbound alias in front of it. Both were missing rather than drifted — the 1Panel host serves 23 vhosts and this name is not one of them, and
getent hostsfor it answers nothing, both measured against the running boxes in August 2026.What lands
live/ansible/playbooks/1panel/vars/main.yaml— a thirteenth entry inonepanel_websites, on127.0.0.1:8185, with its own proxy template.live/ansible/playbooks/1panel/templates/proxy-dalang.conf.j2— new, and the first template in this tree with anallow/denypair.live/ansible/playbooks/1panel/manage-websites.yaml— oneset_factand oneassert, reading the allowed address at run time.live/ansible/playbooks/opnsense/vars/vault.yaml— a thirty-thirdunbound_host_aliasesentry, same shape as the thirty-two above it.requirements.yml—community.sops..lycheeignore— the VLAN proxy upstreams.Why the proxy block is not the stock one
The service behind this vhost mounts a catalogue endpoint that takes no credential. So this website is not one control among several — it is the only thing between that endpoint and every host on the LAN that can reach this box on 443 with the right SNI. The template allows the tunnel connector VM and denies everything else.
Dropping the DNS record would not have been a substitute. nginx picks a site by SNI, so
curl --resolveand a hosts entry both still arrive; the alias is a convenience and the vhost is the control.The allowed address is read, not transcribed. It comes out of the connector's own cloud-init payload at run time —
.ethernets.id0.addresses[0]inlive/opentofu/.../vms/cloudflared/network-config.enc.yaml, viacommunity.sops. A second copy in a vault is a value that can disagree, and the way that disagreement presents here is a403after a successful sign-in at the edge, which reads as an access-control problem and is not one. The blob stores the address as a/24; the prefix is stripped at the read, because anallowcarrying it would open the whole home LAN and would look perfectly correct in the diff.$remote_addris trustworthy here, and that was measured rather than assumed. The connector is on the WAN bridge and this origin on the VLAN, so requests cross the firewall before nginx sees them. Two probes issued from the connector in August 2026 were logged here with the connector's own address, and noreal_ip_header,set_real_ip_fromorreal_ip_recursiveexists anywhere on this host — so no client-supplied header can forge it. Nothing re-takes that measurement; the tell is written at theallowline so the next reader does not go looking in the wrong place.The name and the port are second copies, deliberately
Both are chosen in the repository that owns the service, and this tree cannot read that one. They follow
vault_defectdojo_host, which is already commented here as the one place the no-second-copy rule cannot be followed. The owning repository is gaining an assertion that this origin answers the right service when reached by this name, which is what makes the copies checked ones rather than hopeful ones..lycheeignoreThe
pre-commitlink check fails onhttp://10.10.99.13andhttp://10.10.99.10:18789— pre-existing upstreams invars/main.yaml, unreachable from a workstation by design. They are exactly the class the block above them already describes and were missed only because that file had not been staged since. Without this, editingvars/main.yamlfrom off-segment is impossible without skipping the hook.What this does not do
It does not apply.
manage-websites.yamlreconciles every declared site on every run and pushes the HTTPS configuration to all of them, so adding a thirteenth entry means reconciling twelve on a box that also fronts SSH to four hosts and this estate's vault password. The panel API key is also bound to a source-IP allowlist. The apply and its end-to-end verification are the operator's and are tracked separately.Verification
Everything here is declaration, so what could be checked was:
yamllintover every tracked YAML file — clean.ansible-lint --offline --skip-list internal-error— identical findings tomainat43d0365; this branch adds none./24strip and the template render, run for real against the committed blob: the raw value carries a prefix, the stripped one does not, it matches the assert's pattern, and the rendered block contains a bareallow <address>;,deny all;, no CIDR anywhere, and the rightproxy_pass.gitleaks,typosandlycheevia thepre-commithooks.Convergence of a re-run cannot be checked without applying, and is not claimed.