Skip to content

feat(1panel): serve dalang behind an allow-list, and alias the name - #36

Merged
kholisrag merged 3 commits into
mainfrom
feat/396-dalang-ingress
Aug 9, 2026
Merged

feat(1panel): serve dalang behind an allow-list, and alias the name#36
kholisrag merged 3 commits into
mainfrom
feat/396-dalang-ingress

Conversation

@kholisrag

Copy link
Copy Markdown
Owner

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 hosts for it answers nothing, both measured against the running boxes in August 2026.

What lands

  • live/ansible/playbooks/1panel/vars/main.yaml — a thirteenth entry in onepanel_websites, on 127.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 an allow/deny pair.
  • live/ansible/playbooks/1panel/manage-websites.yaml — one set_fact and one assert, reading the allowed address at run time.
  • live/ansible/playbooks/opnsense/vars/vault.yaml — a thirty-third unbound_host_aliases entry, same shape as the thirty-two above it.
  • requirements.ymlcommunity.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 --resolve and 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] in live/opentofu/.../vms/cloudflared/network-config.enc.yaml, via community.sops. A second copy in a vault is a value that can disagree, and the way that disagreement presents here is a 403 after 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 an allow carrying it would open the whole home LAN and would look perfectly correct in the diff.

$remote_addr is 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 no real_ip_header, set_real_ip_from or real_ip_recursive exists anywhere on this host — so no client-supplied header can forge it. Nothing re-takes that measurement; the tell is written at the allow line 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.

.lycheeignore

The pre-commit link check fails on http://10.10.99.13 and http://10.10.99.10:18789 — pre-existing upstreams in vars/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, editing vars/main.yaml from off-segment is impossible without skipping the hook.

What this does not do

It does not apply. manage-websites.yaml reconciles 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:

  • yamllint over every tracked YAML file — clean.
  • ansible-lint --offline --skip-list internal-error — identical findings to main at 43d0365; this branch adds none.
  • The SOPS read, the /24 strip 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 bare allow <address>;, deny all;, no CIDR anywhere, and the right proxy_pass.
  • gitleaks, typos and lychee via the pre-commit hooks.

Convergence of a re-run cannot be checked without applying, and is not claimed.

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
kholisrag force-pushed the feat/396-dalang-ingress branch from 4424fc9 to 67d95d1 Compare August 9, 2026 15:53
@kholisrag
kholisrag merged commit 03aa17c into main Aug 9, 2026
1 check passed
@kholisrag
kholisrag deleted the feat/396-dalang-ingress branch August 9, 2026 15:54
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