Compare commits

..
3 Commits
Author SHA1 Message Date
archipelago 687196ad3b chore: prepare release v1.8.7-alpha
Demo images / Build & push demo images (push) Failing after 37s
2026-08-31 17:08:37 -04:00
archipelago e2bd6330a1 test(app-catalog): pin the signed-catalog body gate 2026-08-31 17:08:37 -04:00
archipelago 7c0a492c43 fix(ui): launch apps on the page's scheme over HTTPS
New-tab apps and the companion WebView got hardcoded http:// URLs, so a
node reached over HTTPS opened Vaultwarden, BTCPay, Grafana et al in
cleartext. Every app port is gate-owned and serves TLS on the same port
(appgate/tls.rs), so directAppUrl(), the legacy open() path, and
resolveRuntimeLaunchUrl() now follow the page's scheme. HTTP pages (the
kiosk, LAN) are unchanged; netbird keeps its unconditional https.
2026-08-31 17:08:26 -04:00
12 changed files with 194 additions and 52 deletions
+7 -1
View File
@@ -4,7 +4,13 @@
- **What's New really does stop at v1.8.0 now.** The first correction removed old generated release blocks but missed six much older hand-written v1.2 sections at the bottom of the modal. Those sections are gone, and the release check now recognizes and rejects that legacy format too, so the history floor cannot falsely pass again.
- **The installer carries the same corrected release, Companion 0.5.28, and complete crash tooling.** Its artifact gate now checks the companion APK version, the v1.8.0 What's New floor, and every kdump package inside the finished ISO; `makedumpfile` is installed explicitly rather than accidentally relying on a recommended dependency that the minimal image deliberately omits.
- **The installer carries the same corrected release and Companion 0.5.28.** Its artifact gate now checks the companion APK version and the v1.8.0 What's New floor inside the finished ISO, so a stale frontend or phone app cannot be published under the current release label.
- **Crash dumps work on fresh installs as well as upgraded nodes.** The installer gate checks every kdump package inside the finished ISO, and `makedumpfile` is installed explicitly rather than accidentally relying on a recommended dependency that the minimal image deliberately omits.
- **Apps open over HTTPS when your node does.** Connect to your node over HTTPS and every app you open — Vaultwarden in its own tab, BTCPay, Grafana, and the rest, on a remote browser or in the phone's in-app browser — now opens on the same secure connection instead of silently dropping to plain HTTP. The node's app gate already served TLS on every app port; the dashboard was handing out `http://` addresses regardless of how you reached it. Plain-HTTP access (the kiosk, LAN browsing) is unchanged.
- **Newly signed apps appear in the App Store immediately.** The App Store now serves the release-signed catalog the node has already fetched and verified — so publishing a signed app (like Cuprate) makes it appear for every updated node without waiting for a dashboard release. The unsigned community catalog remains only as a fallback for nodes that can't reach the registry. Cuprate was invisible on updated nodes for exactly this reason; it is now in both.
## v1.8.6-alpha (2026-08-31)
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.6-alpha"
version = "1.8.7-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.6-alpha"
version = "1.8.7-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
@@ -661,4 +661,23 @@ mod tests {
]
);
}
// The signed-catalog body served to the browser must be the anchored,
// release-root-verified bytes — and nothing else. Unsigned caches (the
// migration-window form) and self-consistent-but-unanchored signatures
// must both be refused so a tampered mirror can never become an install
// button (same posture as the OTA manifest supply-chain gate).
#[tokio::test]
async fn verified_catalog_body_rejects_unsigned_cache() {
let dir = tempfile::tempdir().unwrap();
write_cache(dir.path(), r#"{"schema":1,"apps":{"demo":{"version":"1"}}}"#).unwrap();
let err = verified_catalog_body(dir.path()).await.unwrap_err();
assert!(err.to_string().contains("unsigned"));
}
#[tokio::test]
async fn verified_catalog_body_rejects_missing_cache() {
let dir = tempfile::tempdir().unwrap();
assert!(verified_catalog_body(dir.path()).await.is_err());
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.8.6-alpha",
"version": "1.8.7-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.8.6-alpha",
"version": "1.8.7-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.8.6-alpha",
"version": "1.8.7-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -256,6 +256,46 @@ describe('useAppLauncherStore', () => {
)
})
// An HTTPS connection must never hand the remote browser (or the phone
// webview) a cleartext app URL: same-host app ports are gate-owned and
// serve TLS on the same port. Plain-http pages keep http exactly as before
// — pinned by every test above this one.
it('upgrades same-host app URLs to https on an https page', () => {
Object.defineProperty(window, 'location', {
value: { origin: 'https://192.0.2.10', protocol: 'https:', hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
const store = useAppLauncherStore()
store.open({ url: 'http://192.0.2.10:8082', title: 'Vaultwarden' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'https://192.0.2.10:8082',
'_blank',
'noopener,noreferrer',
)
})
it('never upgrades a different host on an https page', () => {
Object.defineProperty(window, 'location', {
value: { origin: 'https://192.0.2.10', protocol: 'https:', hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.100:8082', title: 'Vaultwarden' })
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.100:8082',
'_blank',
'noopener,noreferrer',
)
})
it('opens Gitea path URL in new tab', () => {
const store = useAppLauncherStore()
+20 -42
View File
@@ -4,7 +4,7 @@ import { rpcClient } from '@/api/rpc-client'
import { recordAppLaunch } from '@/utils/appUsage'
import { requestExternalOpen } from '@/api/remote-relay'
import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal'
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { directAppUrl, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { useAppStore } from '@/stores/app'
import { resolveAppIcon } from '@/views/apps/appsConfig'
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
@@ -60,9 +60,6 @@ const NEW_TAB_APP_IDS = new Set([
'netbird',
])
// Apps served over HTTPS (self-signed) rather than plain HTTP.
const HTTPS_APP_IDS = new Set(['netbird'])
function mustOpenInNewTab(url: string): boolean {
try {
const u = new URL(url)
@@ -147,33 +144,7 @@ const PORT_TO_APP_ID: Record<string, string> = {
'50002': 'electrumx',
}
const APP_ID_TO_PORT: Record<string, string> = {
'btcpay-server': '23000',
grafana: '3000',
photoprism: '2342',
homeassistant: '8123',
vaultwarden: '8082',
nextcloud: '8085',
portainer: '9000',
tailscale: '8240',
'nginx-proxy-manager': '8081',
'uptime-kuma': '3002',
gitea: '3001',
// Without this, directAppUrl('netbird') returns null and netbird falls
// through to the iframe (and never gets its https URL) — issue #15.
netbird: '8087',
}
function directAppUrl(appId: string): string | null {
const port = APP_ID_TO_PORT[appId]
if (!port || typeof window === 'undefined') return null
const scheme = HTTPS_APP_IDS.has(appId) ? 'https' : 'http'
return `${scheme}://${window.location.hostname}:${port}`
}
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
function getApprovedOrigins(): Set<string> {
try {
const stored = localStorage.getItem(APPROVED_ORIGINS_KEY)
@@ -285,18 +256,25 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
// Apps served over HTTPS (e.g. netbird, which needs a secure context for
// its OIDC dashboard) must be launched over https — a stale http URL hits
// the TLS port and 400s. Upgrade the scheme defensively in every path.
if (resolvedId && HTTPS_APP_IDS.has(resolvedId)) {
try {
const u = new URL(launchUrl, window.location.origin)
if (u.protocol === 'http:') {
u.protocol = 'https:'
launchUrl = u.href
}
} catch { /* leave as-is */ }
}
// Scheme discipline for everything launched on this host. App ports are
// owned by the app gate, which serves TLS on the same port whenever the
// node has a certificate — so on an HTTPS connection every same-host
// app URL must be https: plain http is a silent downgrade at best and
// mixed-content-blocked at worst (remote browsers, the companion
// webview). Apps that are ALWAYS https (netbird's secure-context OIDC
// dashboard) upgrade regardless of the page, and external hosts keep
// their own scheme.
try {
const u = new URL(launchUrl, window.location.origin)
const sameHost = u.hostname === window.location.hostname
const alwaysHttps = !!resolvedId && HTTPS_APP_IDS.has(resolvedId)
const httpsPage = window.location.protocol === 'https:'
if (u.protocol === 'http:' && (alwaysHttps || (httpsPage && sameHost && (resolvedId || mustOpenInNewTab(launchUrl))))) {
// Pure prefix swap — never re-serialize the URL (URL.href would add
// a trailing slash and change the string the caller handed over).
launchUrl = launchUrl.replace(/^http:\/\//i, 'https://')
}
} catch { /* leave as-is */ }
if (!isMobileViewport() && payload.openInNewTab) {
if (resolvedId) recordAppLaunch(resolvedId)
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { NEW_TAB_APPS, resolveAppUrl } from '../appSessionConfig'
import { NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig'
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
describe('appSessionConfig', () => {
@@ -68,4 +68,51 @@ describe('appSessionConfig', () => {
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083')
})
// The direct-port launch path (new-tab apps on desktop, the companion's
// native WebView on phones) used to hardcode http:// — so a node reached
// over HTTPS opened Vaultwarden and friends in cleartext. These pin the
// scheme-following contract on both page schemes.
it('builds direct app URLs on the page scheme — https page, https app', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.0.2.10', protocol: 'https:' },
writable: true,
configurable: true,
})
expect(directAppUrl('vaultwarden')).toBe('https://192.0.2.10:8082')
expect(directAppUrl('gitea')).toBe('https://192.0.2.10:3001')
expect(directAppUrl('btcpay-server')).toBe('https://192.0.2.10:23000')
})
it('keeps plain-http direct app URLs on a plain-http page', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.0.2.10', protocol: 'http:' },
writable: true,
configurable: true,
})
expect(directAppUrl('vaultwarden')).toBe('http://192.0.2.10:8082')
})
it('always launches secure-context apps over https, on either page scheme', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.0.2.10', protocol: 'http:' },
writable: true,
configurable: true,
})
expect(directAppUrl('netbird')).toBe('https://192.0.2.10:8087')
})
it('resolves session app URLs on the page scheme too (https page)', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.0.2.10', protocol: 'https:' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('mempool')).toBe('https://192.0.2.10:4080')
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('https://192.0.2.10:18083')
})
})
@@ -166,6 +166,49 @@ function pageScheme(): string {
return p === 'https:' || p === 'http:' ? p : 'http:'
}
/** Apps served over HTTPS (self-signed) rather than plain HTTP, regardless of
* the page's scheme. */
export const HTTPS_APP_IDS = new Set(['netbird'])
/** App ID -> direct launch port for the paths that bypass the in-app session:
* new-tab apps and the companion's native WebView. Every port here is owned
* by the app gate (manifest `auth: gated`/`open` + `bind: 127.0.0.1`), which
* serves TLS on the same port whenever the node has a certificate. */
export const DIRECT_APP_PORTS: Record<string, string> = {
'btcpay-server': '23000',
grafana: '3000',
photoprism: '2342',
homeassistant: '8123',
vaultwarden: '8082',
nextcloud: '8085',
portainer: '9000',
tailscale: '8240',
'nginx-proxy-manager': '8081',
'uptime-kuma': '3002',
gitea: '3001',
// Without this, directAppUrl('netbird') returns null and netbird falls
// through to the iframe (and never gets its https URL) — issue #15.
netbird: '8087',
}
/** Direct-port launch URL for an app, on the page's scheme.
*
* These are the apps that open OUTSIDE the dashboard's own origin — a new
* browser tab on the desktop, or the companion's in-app WebView on a phone.
* The URL is handed to a context with no dashboard chrome, so it must carry
* the scheme the remote browser actually reached the node on: on an HTTPS
* connection, `http://host:port` is at best a silent downgrade to cleartext
* and at worst blocked outright as mixed content. Every port in
* DIRECT_APP_PORTS is served by the app gate with TLS on the same port
* (see appgate/tls.rs), so following the page scheme is always answerable.
* Plain-HTTP dashboards keep today's behaviour exactly. */
export function directAppUrl(appId: string): string | null {
const port = DIRECT_APP_PORTS[appId]
if (!port || typeof window === 'undefined') return null
const scheme = HTTPS_APP_IDS.has(appId) || pageScheme() === 'https:' ? 'https' : 'http'
return `${scheme}://${window.location.hostname}:${port}`
}
/** Resolve a human-readable title for an app */
export function resolveAppTitle(id: string): string {
return APP_TITLES[id] || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
+8 -2
View File
@@ -3,7 +3,7 @@
import type { Ref } from 'vue'
import { computed } from 'vue'
import { PackageState, type PackageDataEntry } from '@/types/api'
import { resolveAppUrl } from '../appSession/appSessionConfig'
import { matchPageScheme, resolveAppUrl } from '../appSession/appSessionConfig'
import { isAutoTabApp } from '@/utils/autoTabApps'
export type AppsTab = 'apps' | 'websites' | 'services'
@@ -299,7 +299,13 @@ export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null):
export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
const addr = runtimeLanAddress(pkg)
if (!addr || typeof window === 'undefined') return addr
return addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`)
const local = addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`)
// The backend reports runtime URLs as http:// because that is how the app
// binds locally — on an HTTPS connection that is a cleartext downgrade
// (and mixed-content-blocked when opened from the dashboard). The gate
// serves TLS on every app port, so follow the page's scheme, exactly like
// resolveAppUrl() does for the same runtime URLs.
return matchPageScheme(local)
}
export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string {
@@ -370,7 +370,10 @@ init()
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>What's New really does stop at v1.8.0 now.</strong> The first correction removed old generated release blocks but missed six much older hand-written v1.2 sections at the bottom of the modal. Those sections are gone, and the release check now recognizes and rejects that legacy format too, so the history floor cannot falsely pass again.</p>
<p><strong>The installer carries the same corrected release, Companion 0.5.28, and complete crash tooling.</strong> Its artifact gate now checks the companion APK version, the v1.8.0 What's New floor, and every kdump package inside the finished ISO; makedumpfile is installed explicitly rather than accidentally relying on a recommended dependency that the minimal image deliberately omits.</p>
<p><strong>The installer carries the same corrected release and Companion 0.5.28.</strong> Its artifact gate now checks the companion APK version and the v1.8.0 What's New floor inside the finished ISO, so a stale frontend or phone app cannot be published under the current release label.</p>
<p><strong>Crash dumps work on fresh installs as well as upgraded nodes.</strong> The installer gate checks every kdump package inside the finished ISO, and makedumpfile is installed explicitly rather than accidentally relying on a recommended dependency that the minimal image deliberately omits.</p>
<p><strong>Apps open over HTTPS when your node does.</strong> Connect to your node over HTTPS and every app you open — Vaultwarden in its own tab, BTCPay, Grafana, and the rest, on a remote browser or in the phone's in-app browser — now opens on the same secure connection instead of silently dropping to plain HTTP. The node's app gate already served TLS on every app port; the dashboard was handing out http:// addresses regardless of how you reached it. Plain-HTTP access (the kiosk, LAN browsing) is unchanged.</p>
<p><strong>Newly signed apps appear in the App Store immediately.</strong> The App Store now serves the release-signed catalog the node has already fetched and verified — so publishing a signed app (like Cuprate) makes it appear for every updated node without waiting for a dashboard release. The unsigned community catalog remains only as a fallback for nodes that can't reach the registry. Cuprate was invisible on updated nodes for exactly this reason; it is now in both.</p>
</div>
</div>
<!-- v1.8.6-alpha -->