fix(opnsense): valid HAProxy config + From<&str> codegen cleanup #273

Merged
stremblay merged 7 commits from fix/haproxy-issues into master 2026-04-22 17:01:54 +00:00

7 Commits

Author SHA1 Message Date
ead76e710f fix(opnsense): lowercase match arms in generated From<&str>
All checks were successful
Run Check Script / check (pull_request) Successful in 2m6s
Two regressions from fc16e9f that ./build/check.sh catches:

1. `opnsense-api`'s `test_haproxy_deser` example references
   `resp.haproxy` on the response wrapper. The regen auto-derived the
   field name as `op_nsenseha_proxy` from the struct name. Need to pass
   `--api-key haproxy` to keep the wrapper key stable.

2. For enums whose wire values aren't all-lowercase (e.g. `"SSLv3"`,
   `"CONNECT"`), the emitted `From<&str>` matched `s.to_lowercase()`
   against the original-case wire value, which clippy flags as
   unreachable ("match arm has differing case"). Lowercase the wire
   value in the emitted match arm so case-insensitive matching actually
   works; serialization still emits the original-case wire value
   because the serde module is unaffected.

Regenerated `haproxy.rs` via
`cargo run -p opnsense-codegen -- generate --xml ... --module-name haproxy --api-key haproxy`.

`./build/check.sh` now passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:52:26 -04:00
fc16e9fac9 refactor(opnsense): use From<&str> for wire-value conversions
Some checks failed
Run Check Script / check (pull_request) Failing after 54s
Addresses review feedback on the previous HAProxy field-default fixes:
the eight match blocks in `configure_service` that mapped loose strings
("get", "tcp", "roundrobin", ...) to generated OPNsense enum variants
were poor Rust — they duplicated the wire-value knowledge that the
codegen already has, and any new enum variant in OPNsense meant editing
every call site by hand.

- `opnsense-codegen/src/codegen.rs::generate_enum` now emits
  `impl From<&str>` and `impl From<String>` for every generated enum,
  right after the existing serde module. Lowercase-matches wire values;
  unknown inputs fall through to the `Other(String)` variant the codegen
  already emits for forward-compat round-tripping.
- `opnsense-api/src/generated/haproxy.rs` regenerated — 153 enums, 306
  new impl blocks. No hand edits; re-run via
  `cargo run -p opnsense-codegen -- generate --xml
  opnsense-codegen/vendor/plugins/net/haproxy/src/opnsense/mvc/app/models/OPNsense/HAProxy/HAProxy.xml
  --output-dir opnsense-api/src/generated --module-name haproxy`.
- `opnsense-config/src/modules/load_balancer.rs::configure_service`
  replaces eight string-match blocks with one-liners:
  `HealthcheckType::from(hc.check_type.as_str())` etc.
- Drive-by: fixed a pre-existing typo at
  `harmony/src/infra/opnsense/load_balancer.rs:185` and the matching
  reverse at `:149` — `SSL::SNI` was mapped to `"sslni"`, but the
  OPNsense wire value is `"sslsni"`. Before this refactor the typo
  silently hit `HealthcheckSsl::Other("sslni")`; the cleaner conversion
  made the bug obvious so it's fixed here rather than left for a
  follow-up.

Verification:
- `cargo check -p harmony -p opnsense-config -p opnsense-api` clean
- `cargo test -p harmony --lib okd::load_balancer` 6/6 pass
- `cargo test -p opnsense-codegen` 22/22 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:31:35 -04:00
a196268c1e revert(okd): bind load balancer on 0.0.0.0 again
All checks were successful
Run Check Script / check (pull_request) Successful in 2m17s
Reverting 5e72777. The HAProxy startup failure that motivated the
bind-to-FW-IP change was environment-specific on the sttest basement
firewall: OPNsense's "HTTP → HTTPS redirect" service (lighttpd bound to
`[::]:80`, dual-stack) was holding IPv4 port 80 via v4-mapped addresses
— invisible in `sockstat -l4` but still enough to make `0.0.0.0:80`
return EADDRINUSE to HAProxy.

Disabling the HTTP redirect on that firewall resolves the conflict.
Other OPNsense deployments already ship with the redirect off (or
HAProxy on non-conflicting ports), so `0.0.0.0` remains the correct
default.

This reverts commit 5e72777.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:10:57 -04:00
5a17bc229e fix: formatting
All checks were successful
Run Check Script / check (pull_request) Successful in 2m19s
2026-04-22 11:29:33 -04:00
5e72777c15 fix(okd): bind load balancer services on firewall IP, not 0.0.0.0
Some checks failed
Run Check Script / check (pull_request) Failing after 56s
Binding HAProxy on 0.0.0.0 collided with OPNsense's own listeners
(HTTP→HTTPS redirect on :80, WebUI, etc.), preventing the HAProxy
service from starting once the LoadBalancer score was applied.

Use `topology.load_balancer.get_ip()` to bind each frontend on the
firewall's LAN interface IP instead. The `LoadBalancer` capability was
already in scope, so no new trait imports are needed.

The previous `0.0.0.0` rationale (avoiding CARP VIP rebind races) is
noted in a comment: HA CARP setups still need OPNsense's
`net.inet.ip.nonlocal_bind` or HAProxy `transparent` bind — not
addressed here.

Test module: added an inline `DummyLoadBalancer` stub (mirrors the
existing `DummyRouter` pattern) so `OKDLoadBalancerScore::new` no longer
hits `DummyInfra::get_ip`'s `unimplemented!()` panic. Renamed
`test_all_services_bind_on_unspecified_address` →
`test_all_services_bind_on_firewall_ip`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:07:59 -04:00
21035d2c56 fix(opnsense): set HAProxy healthcheck/server fields explicitly
`configure_service` was relying on `..Default::default()` for most fields
of the generated HAProxy structs. That leaked OPNsense's *model defaults*
into the wire payload for fields Harmony never meant to default:

- `http_host` → `localhost` (sent `Host: localhost` on every check)
- `http_method` → `options` (sent OPTIONS instead of the declared method)
- `http_version` → `http10` (wanted NONE)
- `sslVerify` on real servers → `1` (broke self-signed backends)
- Healthcheck `ssl` was never propagated, so SSL-required checks like
  kube-apiserver `/readyz` on 6443 stayed plain HTTP and never succeeded

Set every field explicitly from `LbHealthCheck`/`LbServer`: map
`http_method` through `HealthcheckHttpMethod`, pass `None` for
`http_version` (serializes as `""` = NONE), clear `http_host` to an empty
string, propagate `hc.ssl` through `HealthcheckSsl`, and pin
`ssl`/`sslVerify` to `false` on the server struct so intent is declared
at the call site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:07:47 -04:00
83d9af211a fix(opnsense): distinguish unreachable API from missing HAProxy plugin
`LoadBalancerConfig::is_installed` previously collapsed every error from
the settings endpoint into `false`, so a timeout, DNS failure, or auth
rejection all looked identical to "os-haproxy not installed" — the
`LoadBalancer` score would then attempt to install the plugin on top of
an unreachable firewall and fail in cascade further down the pipeline.

Return `Result<bool, Error>` and treat only HTTP 404 (controller not
found) as "not installed". Every other error is propagated so
`ensure_initialized` fails the score immediately with a message pointing
at the real problem.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:53:22 -04:00