feat(appgate): apps with their own login can skip the node login
Demo images / Build & push demo images (push) Successful in 3m33s
Demo images / Build & push demo images (push) Successful in 3m33s
Some apps carry a complete account system and are broken by an upstream challenge: git clients speak basic-auth (not browser cookies), and a BTCPay checkout link handed to a customer must open for that customer. Both were behind the gate's login page — the "non-browser clients need an access token" gap disclosed in five consecutive releases. - New manifest port policy `auth: open`: the daemon still fronts the port exactly like `gated` (loopback pin, external binds, frame-header fixes, app-down retry page, Tor upstream) but serves it without the login challenge. Requires auth_rationale, same burden of proof as `none`. Gitea 3001 and BTCPay 23000 declare it. - Runtime operator override per app (security.set-app-gate → app-configs/ <id>.json "gateEnabled"), surfaced as Settings → app → Access control. Wins over the manifest in both directions and applies on the next request — no restart, and it works today on catalog-covered apps whose signed manifest still says `gated`. - The gate resolves policy per-request from the live port map, so a toggle takes effect without waiting for the 60s rebind sweep. "Off" never releases the port: gated apps are loopback-pinned, so releasing would strand them, not open them. - security.app-gate-status now reports gate_enabled + any override. - New guard test pins the `auth: open` set (both entries reviewed); the `auth: none` count moves 25 → 26, absorbing pre-existing drift from the phoenixd onboarding (loopback JSON API with its own generated password). - Docs: the manifest spec's ports row documented only host/container/ protocol — bind, auth, auth_rationale and session_passthrough were undocumented. Added a full "Ports & the app gate" section plus a developer-guide entry telling app authors to enforce their own auth regardless, since the operator can flip the gate either way. Verified live on archi-dev-box from an external address: gated → 401 gate page; override off → Gitea 200 own page, BTCPay 302 to its own login, git-over-HTTP info/refs 200; override on → 401 again; clear → default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9b789a64ad
commit
58cdea5e79
@@ -44,6 +44,24 @@ export interface PackageVersionsResponse {
|
||||
versions: CatalogVersionInfo[]
|
||||
}
|
||||
|
||||
export interface AppGatePortStatus {
|
||||
port: number
|
||||
app_id: string
|
||||
app_name: string
|
||||
/** Login challenge active right now (manifest default + operator override, resolved). */
|
||||
gate_enabled: boolean
|
||||
/** Operator override on record; null/undefined = manifest default applies. */
|
||||
override?: boolean | null
|
||||
}
|
||||
|
||||
export interface AppGateStatusResponse {
|
||||
fully_enforced: boolean
|
||||
claimed: [number, string][]
|
||||
unprotected: { port: number; app_id: string; app_name: string; reason: string }[]
|
||||
gated: AppGatePortStatus[]
|
||||
exempt: { port: number; app_id: string; protocol: string; rationale: string }[]
|
||||
}
|
||||
|
||||
export interface SetPackageConfigResponse {
|
||||
status: 'ok' | 'confirm_required'
|
||||
id: string
|
||||
@@ -758,6 +776,29 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
// What the app gate enforces: which app ports are fronted, whether each
|
||||
// one's login challenge is active, exemptions with their rationale.
|
||||
async getAppGateStatus(): Promise<AppGateStatusResponse> {
|
||||
return this.call({
|
||||
method: 'security.app-gate-status',
|
||||
timeout: 15000,
|
||||
})
|
||||
}
|
||||
|
||||
// Per-app gate toggle. enabled=true forces the login challenge, false
|
||||
// serves the app on its own authentication, null clears the override so
|
||||
// the manifest default applies. Live on the next request — no restart.
|
||||
async setAppGate(
|
||||
id: string,
|
||||
enabled: boolean | null,
|
||||
): Promise<{ id: string; override: boolean | null; ports: { port: number; gate_enabled: boolean }[] }> {
|
||||
return this.call({
|
||||
method: 'security.set-app-gate',
|
||||
params: { id, enabled },
|
||||
timeout: 15000,
|
||||
})
|
||||
}
|
||||
|
||||
async checkPackageUpdates(): Promise<{
|
||||
status: string
|
||||
refreshed: boolean
|
||||
|
||||
@@ -575,6 +575,12 @@
|
||||
"installed": "Installed",
|
||||
"noLaunchUrl": "No launch URL available for this app yet",
|
||||
"versionUpdates": "Version & Updates",
|
||||
"appGate": "Access control",
|
||||
"appGateRequireLogin": "Require dashboard login (app gate)",
|
||||
"appGateOnNote": "Every visit to this app must sign in with your node password first. The app's own login (if any) comes after.",
|
||||
"appGateOffNote": "This app is served directly with its own login. The node still fronts the connection (embedding fixes, retry page, Tor), but does not ask for your dashboard password.",
|
||||
"appGateOffWarning": "Anyone who can reach this node — LAN, Tailscale, Tor — reaches this app's own login page. Only turn this off for apps with a real login of their own (Gitea, BTCPay).",
|
||||
"appGateApply": "Apply",
|
||||
"runningVersion": "Running version",
|
||||
"selectVersion": "Version",
|
||||
"alwaysUseLatestVersion": "Always use the latest version",
|
||||
|
||||
@@ -91,6 +91,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App gate card: per-app login-challenge toggle. Shown only for apps
|
||||
the gate actually fronts (has gate-claimed ports). -->
|
||||
<div v-if="gatePorts.length" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.appGate') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center justify-between gap-3 cursor-pointer">
|
||||
<span class="text-white/80 text-sm">{{ t('appDetails.appGateRequireLogin') }}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="gateEnabled"
|
||||
:disabled="gateBusy"
|
||||
class="h-4 w-4 accent-orange-500"
|
||||
/>
|
||||
</label>
|
||||
<p class="text-white/40 text-xs leading-relaxed">
|
||||
{{ gateEnabled ? t('appDetails.appGateOnNote') : t('appDetails.appGateOffNote') }}
|
||||
</p>
|
||||
<div v-if="!gateEnabled" class="rounded-lg border border-orange-400/40 bg-orange-500/10 p-3">
|
||||
<p class="text-orange-200 text-xs leading-relaxed">⚠️ {{ t('appDetails.appGateOffWarning') }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full glass-button glass-button-warning rounded-lg disabled:opacity-50 text-sm font-medium py-2"
|
||||
:disabled="gateBusy || !gateDirty"
|
||||
@click="applyGate"
|
||||
>
|
||||
{{ gateBusy ? t('appDetails.applyingVersion') : t('appDetails.appGateApply') }}
|
||||
</button>
|
||||
<p v-if="gateError" class="text-red-300 text-xs">{{ gateError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fedimint Services Card -->
|
||||
<div v-if="packageKey === 'fedimint'" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.services') }}</h3>
|
||||
@@ -250,7 +282,7 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AppCredentialsResponse } from '@/types/api'
|
||||
import { rpcClient, type PackageVersionsResponse, type CatalogVersionInfo } from '../../api/rpc-client'
|
||||
import { rpcClient, type PackageVersionsResponse, type CatalogVersionInfo, type AppGatePortStatus } from '../../api/rpc-client'
|
||||
import { displayVersion } from '@/utils/version'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -406,10 +438,51 @@ function cancelDowngrade() {
|
||||
if (info) selectedVersion.value = pickSelection(info)
|
||||
}
|
||||
|
||||
// ---- App gate (per-app login-challenge toggle) -----------------------------
|
||||
const gatePorts = ref<AppGatePortStatus[]>([])
|
||||
const gateEnabled = ref(true)
|
||||
const gateBusy = ref(false)
|
||||
const gateError = ref('')
|
||||
|
||||
const gateDirty = computed(() => {
|
||||
const current = gatePorts.value[0]?.gate_enabled
|
||||
return current !== undefined && gateEnabled.value !== current
|
||||
})
|
||||
|
||||
async function loadGate(appId: string) {
|
||||
gatePorts.value = []
|
||||
gateError.value = ''
|
||||
try {
|
||||
const status = await rpcClient.getAppGateStatus()
|
||||
gatePorts.value = status.gated.filter((g) => g.app_id === appId)
|
||||
const first = gatePorts.value[0]
|
||||
if (first) gateEnabled.value = first.gate_enabled
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.warn('[AppSidebar] getAppGateStatus failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function applyGate() {
|
||||
if (!props.packageKey || !gatePorts.value.length) return
|
||||
gateBusy.value = true
|
||||
gateError.value = ''
|
||||
try {
|
||||
await rpcClient.setAppGate(props.packageKey, gateEnabled.value)
|
||||
await loadGate(props.packageKey)
|
||||
} catch (err: unknown) {
|
||||
gateError.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
gateBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.packageKey,
|
||||
(key) => {
|
||||
if (key) void loadVersions(key)
|
||||
if (key) {
|
||||
void loadVersions(key)
|
||||
void loadGate(key)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user