Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Infrastructure/engine.Tests/ConvergeCoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,19 @@ public void Pangolin_WildcardARecords_Empty_WhenPublicIpUnset_OrCloudflaredEdge(
Assert.Empty(PangolinProvisioner.WildcardARecords(cf));
}

[Theory]
[InlineData(null, true)] // unset → gated (the security default; API create leaves it OPEN, #238)
[InlineData(true, true)]
[InlineData(false, false)] // explicit opt-out for native clients (Plex/abs)
[InlineData("true", true)] // YAML scalars can arrive as strings
[InlineData("false", false)]
public void Pangolin_Resource_SsoDefaultsOn_UnlessOptedOut(object? sso, bool expected)
{
var rd = new Dictionary<object, object> { ["subdomain"] = "x", ["zone"] = "lab" };
if (sso is not null) rd["sso"] = sso;
Assert.Equal(expected, PangolinProvisioner.ResourceSsoEnabled(rd));
}

[Fact]
public void Pangolin_ComposeYaml_PinsEeImage_TraefikPublishesPorts_NoGerbilByDefault()
{
Expand Down
15 changes: 14 additions & 1 deletion Infrastructure/engine/Converge/Provisioners.cs
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,7 @@ public async Task<ApplyResult> ApplyAsync(Shape s, ConvergeContext ctx)
// (via a local-type site). A resource may carry a wildcard `zone` (lab|arr) →
// fullDomain = <subdomain>.<zone>.<baseDomain>. ssl follows the edge mode:
// public-wildcard → Traefik terminates TLS (true); cloudflared → CF does (false).
// sso defaults ON (Pangolin auth gates the UI); a resource opts out with sso: false.
// Returns (msg, changed, failedReason).
private static async Task<(string? msg, bool changed, string? failed)> ReconcileResourcesAsync(
Shape s, ConvergeContext ctx, string node, string ctid)
Expand Down Expand Up @@ -769,6 +770,11 @@ public async Task<ApplyResult> ApplyAsync(Shape s, ConvergeContext ctx)
var tip = tgt?["ip"]?.ToString() ?? "localhost";
var tmethod = tgt?["method"]?.ToString() ?? "http";
var tport = int.TryParse(tgt?["port"]?.ToString(), out var pp) ? pp : 80;
// sso gate: default ON — admin UIs must sit behind Pangolin auth (badger). The
// integration-API create defaults sso to null (OPEN), so we MUST set it explicitly
// or the resource is born publicly reachable. A resource may opt out (sso: false)
// for native clients that can't render the SSO interstitial (e.g. Plex, abs).
var sso = ResourceSsoEnabled(rd);

var (rok, rroot) = await pg.CallAsync("PUT", $"/org/{org}/resource",
JsonSerializer.Serialize(new { name, subdomain = pgSub, http = true, protocol = "tcp", domainId }), ct);
Expand All @@ -778,12 +784,19 @@ public async Task<ApplyResult> ApplyAsync(Shape s, ConvergeContext ctx)
await pg.CallAsync("PUT", $"/resource/{resourceId}/target",
JsonSerializer.Serialize(new { siteId, ip = tip, method = tmethod, port = tport, enabled = true }), ct);
// ssl: public-wildcard → Traefik terminates TLS (true); cloudflared → CF does (false).
await pg.CallAsync("POST", $"/resource/{resourceId}", JsonSerializer.Serialize(new { ssl = publicWildcard }), ct);
// sso: gate the resource behind Pangolin auth unless it explicitly opts out.
await pg.CallAsync("POST", $"/resource/{resourceId}", JsonSerializer.Serialize(new { ssl = publicWildcard, sso }), ct);
created++;
}
return ($"{total} resource(s) declared, {created} created", created > 0, null);
}

// Whether a declared resource is gated by Pangolin auth. Default ON: the integration-API
// create leaves sso null (OPEN), so a resource is only gated if we set it — this decision
// is security-relevant (#238). Opt out with sso: false only for native clients (Plex/abs).
internal static bool ResourceSsoEnabled(System.Collections.IDictionary rd) =>
!(rd["sso"] is { } raw && bool.TryParse(raw.ToString(), out var v) && !v);

// response.data is sometimes an array, sometimes { <key>: array } — normalise both.
private static IEnumerable<JsonElement> DataArray(JsonElement root, string key)
{
Expand Down
13 changes: 11 additions & 2 deletions stacks/Core/pangolin.lxc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,23 @@ spec:
# (add-only, idempotent by fullDomain) using PANGOLIN_API_KEY. A `zone` (lab|arr) makes
# fullDomain = <subdomain>.<zone>.chrison.dev, served on :443 with the wildcard cert and
# gated by Pangolin auth (badger). In public-wildcard mode ssl is ON (Traefik terminates).
# A new service is just an entry here + a grey-cloud A record (handled by converge) — no
# per-host cert, no per-host cloudflared ingress.
# sso defaults ON (the auth gate); set `sso: false` only for native clients that can't do
# the SSO interstitial (e.g. Plex, audiobookshelf). A new service is just an entry here +
# a grey-cloud A record (handled by converge) — no per-host cert, no per-host cloudflared ingress.
resources:
# Pangolin's own Traefik dashboard — first migrated UI (kept from #136).
- name: Traefik Dashboard
subdomain: traefik
zone: lab
target: { ip: localhost, port: 8080, method: http }
# PowerOrchestrator dashboard/control API (#191) — systemd service on nuc-01's legacy
# IP; CT 2013 reaches it cross-VLAN via the 10.10.0.1 gateway. sso is REQUIRED here: the
# control API (Wake/Sleep/Arm) has no native auth (#238).
- name: Power Orchestrator
subdomain: power
zone: lab
sso: true
target: { ip: 192.168.179.1, port: 8080, method: http }
# ── arr admin UIs (Phase 5 cutover — uncomment + set the target IP once the media
# CT has a DHCP reservation / static IP; they're DHCP today on VLAN 1010):
# - { name: Prowlarr, subdomain: prowlarr, zone: arr, target: { ip: 10.10.x.x, port: 9696, method: http } }
Expand Down