fix: disable HTTP keep-alive and update nginx proxy config

- Set http1_keep_alive(false) on hyper server to prevent connection
  reuse issues with nginx reverse proxy
- Clean up nginx proxy config: remove upstream block, use direct
  proxy_pass to 127.0.0.1:5678
- Update AppLauncherOverlay and appLauncher store with UI fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-09 09:54:15 +00:00
parent 0cf71c4115
commit 0fb373273a
4 changed files with 123 additions and 35 deletions

View File

@ -163,6 +163,7 @@ impl Server {
});
if let Err(e) = Http::new()
.http1_keep_alive(false)
.serve_connection(stream, service)
.with_upgrades()
.await

View File

@ -11,7 +11,7 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src 'self' http://127.0.0.1:* http://localhost:*" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src *" always;
# AIUI SPA (Chat mode iframe)
location /aiui/ {
@ -28,7 +28,7 @@ server {
proxy_pass http://127.0.0.1:3141/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
# Connection header managed by nginx default
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_cache off;
@ -45,7 +45,7 @@ server {
proxy_pass https://openrouter.ai/api/;
proxy_http_version 1.1;
proxy_set_header Host openrouter.ai;
proxy_set_header Connection "";
# Connection header managed by nginx default
proxy_ssl_server_name on;
proxy_connect_timeout 120s;
proxy_read_timeout 120s;
@ -65,7 +65,7 @@ server {
proxy_cache off;
proxy_connect_timeout 120s;
proxy_read_timeout 300s;
proxy_set_header Connection "";
# Connection header managed by nginx default
}
# AIUI web search proxy — SearXNG on port 8888
@ -108,6 +108,7 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Connection header managed by nginx default
# Increase timeout for long-running operations (e.g., Docker image pulls)
proxy_connect_timeout 600s;
@ -421,7 +422,7 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src 'self' http://127.0.0.1:* http://localhost:*" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src *" always;
# AIUI SPA (Chat mode iframe)
location /aiui/ {
@ -436,7 +437,7 @@ server {
proxy_pass http://127.0.0.1:3141/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
# Connection header managed by nginx default
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_cache off;
@ -456,7 +457,7 @@ server {
proxy_cache off;
proxy_connect_timeout 120s;
proxy_read_timeout 300s;
proxy_set_header Connection "";
# Connection header managed by nginx default
}
location /aiui/api/openrouter/ {
if ($cookie_session = "") {
@ -465,7 +466,7 @@ server {
proxy_pass https://openrouter.ai/api/;
proxy_http_version 1.1;
proxy_set_header Host openrouter.ai;
proxy_set_header Connection "";
# Connection header managed by nginx default
proxy_ssl_server_name on;
proxy_connect_timeout 120s;
proxy_read_timeout 120s;
@ -506,6 +507,7 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Connection header managed by nginx default
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;

View File

@ -77,14 +77,39 @@
</Transition>
<iframe
ref="iframeRef"
v-if="store.url"
v-if="store.url && !iframeBlocked"
:key="iframeRefreshKey"
:src="store.url"
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
title="App content"
@load="onIframeLoad"
@error="onIframeError"
/>
<!-- Iframe blocked fallback -->
<Transition name="content-fade">
<div v-if="iframeBlocked && !iframeLoading" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8V6a2 2 0 012-2h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2v-2m0-8h18M3 8v8m18-8v8" />
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">Can't display in frame</h3>
<p class="text-white/50 text-sm mb-6">This app doesn't support embedded viewing.<br>Please open it in a new tab instead.</p>
<button
@click="openInNewTabAndClose"
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open in new tab
</button>
</div>
</div>
</Transition>
<!-- Payment Confirmation Dialog -->
<Transition name="content-fade">
<div v-if="pendingPayment" class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm">
@ -158,6 +183,16 @@ const iframeRef = ref<HTMLIFrameElement | null>(null)
const iframeRefreshKey = ref(0)
const isRefreshing = ref(false)
const iframeLoading = ref(true)
const iframeBlocked = ref(false)
// Timers for iframe load detection
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
let contentCheckId: ReturnType<typeof setTimeout> | null = null
function clearTimers() {
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
if (contentCheckId) { clearTimeout(contentCheckId); contentCheckId = null }
}
// Wallet connect payment request state
const pendingPayment = ref<PaymentRequest | null>(null)
@ -168,7 +203,15 @@ const paymentOrigin = ref('')
function refreshIframe() {
isRefreshing.value = true
iframeLoading.value = true
iframeBlocked.value = false
clearTimers()
iframeRefreshKey.value++
loadTimeoutId = setTimeout(() => {
if (iframeLoading.value) {
iframeLoading.value = false
iframeBlocked.value = true
}
}, 15000)
}
function openInNewTab() {
@ -177,11 +220,44 @@ function openInNewTab() {
}
}
function openInNewTabAndClose() {
openInNewTab()
store.close()
}
function onIframeLoad() {
injectScrollbarHideIfSameOrigin()
isRefreshing.value = false
iframeLoading.value = false
sendIdentityIfSupported()
// Clear the load timeout
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
// Check iframe content after a brief delay to let the app render
contentCheckId = setTimeout(checkIframeContent, 2000)
}
function onIframeError() {
clearTimers()
iframeLoading.value = false
iframeBlocked.value = true
}
/** Check if the iframe loaded meaningful content (same-origin only) */
function checkIframeContent() {
try {
const iframe = iframeRef.value
if (!iframe) return
const doc = iframe.contentDocument
if (!doc) return // Cross-origin can't check, assume OK
const body = doc.body
if (!body || (body.children.length === 0 && body.innerText.trim() === '')) {
iframeBlocked.value = true
}
} catch {
// Cross-origin: can't access, assume working
}
}
/** Apps that support the Archipelago identity protocol (postMessage) */
@ -379,10 +455,21 @@ watch(
(open) => {
if (open) {
iframeLoading.value = true
iframeBlocked.value = false
clearTimers()
// Set max load timeout if iframe never fires load, show fallback
loadTimeoutId = setTimeout(() => {
if (iframeLoading.value) {
iframeLoading.value = false
iframeBlocked.value = true
}
}, 15000)
closeBtnRef.value?.focus()
} else {
isRefreshing.value = false
iframeLoading.value = true
iframeBlocked.value = false
clearTimers()
// Clear any pending payment when closing
if (pendingPayment.value) {
rejectPayment()
@ -397,6 +484,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
clearTimers()
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('message', onMessage)
})

View File

@ -1,41 +1,34 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
/** Apps that must open in new tab instead of iframe.
* - DENY apps: always new tab (X-Frame-Options: DENY)
* - Redirect apps: new tab on HTTPS (absolute redirects break subpath proxy in iframe)
* On HTTP, these load via direct port URL so iframe works fine.
/** Apps that set X-Frame-Options or CSP frame-ancestors, blocking iframe embedding.
* Verified by checking response headers from each app container.
* These always open in a new tab. Other apps load in the iframe overlay.
*/
function mustOpenInNewTab(url: string): boolean {
try {
const u = new URL(url)
// Always new tab: X-Frame-Options DENY or subpath fundamentally breaks the app
// External sites — third-party cookie/iframe restrictions
if (u.hostname.includes('indeehub')) return true
// Local apps with X-Frame-Options or CSP frame-ancestors blocking iframes
if (
u.port === '23000' || // BTCPay (X-Frame-Options: DENY)
u.port === '8123' || // Home Assistant (subpath breaks routing)
u.port === '8085' || // Nextcloud (subpath breaks CSS/assets)
u.port === '2283' // Immich (subpath breaks SPA)
u.port === '23000' || // BTCPay — X-Frame-Options: DENY
u.port === '3000' || // Grafana — X-Frame-Options: deny
u.port === '8082' || // Vaultwarden — X-Frame-Options: SAMEORIGIN + CSP frame-ancestors
u.port === '2342' || // PhotoPrism — X-Frame-Options: DENY + CSP frame-ancestors: 'none'
u.port === '8085' || // Nextcloud — X-Frame-Options: SAMEORIGIN
u.port === '3001' || // Uptime Kuma — X-Frame-Options: SAMEORIGIN
u.port === '8123' // Home Assistant — X-Frame-Options: SAMEORIGIN
) {
return true
}
// On HTTPS, apps with absolute-path redirects break in iframe via proxy
if (window.location.protocol === 'https:') {
return (
u.port === '8096' || // Jellyfin (redirects to /web/index.html)
u.port === '9000' || // Portainer (redirects to /timeout.html)
u.port === '2342' || // PhotoPrism (redirects to /library/login)
u.port === '9980' || // OnlyOffice (redirects to /welcome/)
u.port === '3001' || // Uptime Kuma (redirects to /dashboard)
u.port === '8175' // Fedimint (redirects to /login)
)
}
return false
} catch {
return false
}
}
/** Port → proxy path for apps (nginx strips X-Frame-Options) */
/** Port → proxy path for apps (nginx strips X-Frame-Options + avoids mixed content) */
const PORT_TO_PROXY: Record<string, string> = {
'81': '/app/nginx-proxy-manager/',
'3000': '/app/grafana/',
@ -65,7 +58,11 @@ const PORT_TO_PROXY: Record<string, string> = {
'18081': '/app/nostr-rs-relay/',
}
/** Rewrite to same-origin proxy so iframe can embed (avoids mixed content on HTTPS) */
/** Rewrite to same-origin proxy ONLY when needed for HTTPS mixed-content.
* On HTTP, direct port URLs are used they avoid subpath routing issues
* (apps' root-relative asset paths like /static/main.js break under /app/xxx/).
* On HTTPS, must proxy to avoid mixed-content blocks; nginx also strips X-Frame-Options.
*/
function toEmbeddableUrl(url: string): string {
try {
const u = new URL(url)
@ -73,8 +70,7 @@ function toEmbeddableUrl(url: string): string {
const proxyPath = PORT_TO_PROXY[u.port]
const sameHost = u.hostname === window.location.hostname
const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:'
// Use proxy when: (a) mixed content, or (b) vaultwarden/penpot always (subpath required)
if (proxyPath && sameHost && (needsProxy || u.port === '8082' || u.port === '9001')) {
if (proxyPath && sameHost && needsProxy) {
return `${origin}${proxyPath}`
}
} catch {
@ -90,11 +86,12 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
let previousActiveElement: HTMLElement | null = null
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
const embeddableUrl = toEmbeddableUrl(payload.url)
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
window.open(embeddableUrl, '_blank', 'noopener,noreferrer')
// New tab: always use direct port URL so app assets load correctly
window.open(payload.url, '_blank', 'noopener,noreferrer')
return
}
const embeddableUrl = toEmbeddableUrl(payload.url)
previousActiveElement = (document.activeElement as HTMLElement) || null
url.value = embeddableUrl
title.value = payload.title