From 419c2051037cced022177b77c521a376a057b1eb Mon Sep 17 00:00:00 2001 From: mrlee <470349233@qq.com> Date: Fri, 2 Oct 2026 14:59:57 +0800 Subject: [PATCH] feat: add native fnOS gateway socket and base path support --- internal/app/app.go | 49 ++++++++++++++++++++++++++++++++++++- internal/config/config.go | 4 +++ web/src/api/base.ts | 6 +++++ web/src/api/client.ts | 13 +++++----- web/src/router/index.ts | 2 +- web/src/views/ProxyView.vue | 3 ++- web/vite.config.ts | 1 + 7 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 web/src/api/base.ts diff --git a/internal/app/app.go b/internal/app/app.go index 3b78fd5..93d7ca5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,13 +2,16 @@ package app import ( "context" + "errors" "fmt" "io" "io/fs" "log/slog" + "net" "net/http" "os" "path/filepath" + "strings" "time" "github.com/havline/havline/internal/acme" @@ -144,6 +147,9 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) StaticFS: staticFS, StartedAt: startedAt, }) + if prefix := strings.TrimSpace(cfg.GatewayPrefix); prefix != "" { + handler = stripPrefixIfPresent(prefix, handler) + } server := &http.Server{ Addr: cfg.ListenAddr, Handler: handler, @@ -235,7 +241,33 @@ func (a *App) bootstrapNginx(ctx context.Context, proxySvc *service.ProxyService } func (a *App) Run() error { - return a.server.ListenAndServe() + errCh := make(chan error, 2) + go func() { + errCh <- a.server.ListenAndServe() + }() + + if socket := strings.TrimSpace(a.cfg.GatewaySocket); socket != "" { + if err := os.Remove(socket); err != nil && !os.IsNotExist(err) { + return err + } + listener, err := net.Listen("unix", socket) + if err != nil { + return err + } + if err := os.Chmod(socket, 0o660); err != nil { + _ = listener.Close() + return err + } + go func() { + errCh <- a.server.Serve(listener) + }() + } + + err := <-errCh + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err } func (a *App) Shutdown(ctx context.Context) error { @@ -246,6 +278,9 @@ func (a *App) Shutdown(ctx context.Context) error { if err := a.server.Shutdown(ctx); err != nil { return err } + if socket := strings.TrimSpace(a.cfg.GatewaySocket); socket != "" { + _ = os.Remove(socket) + } if err := a.nginx.Stop(ctx); err != nil { a.logger.Error("nginx stop failed", "module", "NGINX", "error", err.Error()) } @@ -260,6 +295,18 @@ func (a *App) Shutdown(ctx context.Context) error { return nil } +func stripPrefixIfPresent(prefix string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, prefix) { + r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix) + if r.URL.Path == "" { + r.URL.Path = "/" + } + } + next.ServeHTTP(w, r) + }) +} + func ResolveMigrationsDir() (string, error) { candidates := []string{ "migrations", diff --git a/internal/config/config.go b/internal/config/config.go index 361df71..951a290 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,8 @@ import ( type Config struct { ListenAddr string + GatewaySocket string + GatewayPrefix string DataDir string SessionSecret string NginxBin string @@ -31,6 +33,8 @@ func Load() Config { } return Config{ ListenAddr: envOr("HAVLINE_LISTEN", ":6893"), + GatewaySocket: envOr("HAVLINE_GATEWAY_SOCKET", ""), + GatewayPrefix: envOr("HAVLINE_GATEWAY_PREFIX", ""), DataDir: dataDir, SessionSecret: envOr("HAVLINE_SESSION_SECRET", ""), NginxBin: envOr("HAVLINE_NGINX_BIN", "nginx"), diff --git a/web/src/api/base.ts b/web/src/api/base.ts new file mode 100644 index 0000000..4cef78a --- /dev/null +++ b/web/src/api/base.ts @@ -0,0 +1,6 @@ +export const APP_BASE = import.meta.env.BASE_URL.replace(/\/$/, '') + +export function withBase(path: string): string { + if (!path.startsWith('/')) return path + return `${APP_BASE}${path}` +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 4bd3200..517b626 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -51,6 +51,7 @@ import type { CfPreflightResult, CfRouteTestResult, } from './types' +import { withBase } from './base' export function asList(value: T[] | null | undefined): T[] { return Array.isArray(value) ? value : [] @@ -79,7 +80,7 @@ async function request(path: string, init?: RequestInit, timeoutMs = REQUEST_ } async function requestWithSignal(path: string, init?: RequestInit, signal?: AbortSignal): Promise { - const response = await fetch(path, { + const response = await fetch(withBase(path), { credentials: 'include', signal, headers: { @@ -179,7 +180,7 @@ export const api = { email: payload.email ?? '', }), }), - certificateApplyStreamURL: (jobId: string) => `/api/certificates/jobs/${encodeURIComponent(jobId)}/stream`, + certificateApplyStreamURL: (jobId: string) => withBase(`/api/certificates/jobs/${encodeURIComponent(jobId)}/stream`), importCertificate: (payload: { certificate?: string private_key?: string @@ -192,7 +193,7 @@ export const api = { }), async downloadCertificate(domain: string, part: 'zip' | 'cert' | 'key' = 'zip') { const response = await fetch( - `/api/certificates/${encodeURIComponent(domain)}/download?part=${part}`, + withBase(`/api/certificates/${encodeURIComponent(domain)}/download?part=${part}`), { credentials: 'include' }, ) if (!response.ok) { @@ -432,7 +433,7 @@ export const api = { }, async exportBackup() { - const response = await fetch('/api/backup/export', { credentials: 'include' }) + const response = await fetch(withBase('/api/backup/export'), { credentials: 'include' }) if (!response.ok) { throw new Error('导出备份失败') } @@ -454,7 +455,7 @@ export const api = { deleteBackupArchive: (name: string) => request<{ ok: boolean }>(`/api/backup/archives/${encodeURIComponent(name)}`, { method: 'DELETE' }), async downloadBackupArchive(name: string) { - const response = await fetch(`/api/backup/archives/${encodeURIComponent(name)}`, { credentials: 'include' }) + const response = await fetch(withBase(`/api/backup/archives/${encodeURIComponent(name)}`), { credentials: 'include' }) if (!response.ok) { throw new Error('下载备份失败') } @@ -470,7 +471,7 @@ export const api = { async restoreBackup(file: File) { const form = new FormData() form.append('file', file) - const response = await fetch('/api/backup/restore', { + const response = await fetch(withBase('/api/backup/restore'), { method: 'POST', credentials: 'include', body: form, diff --git a/web/src/router/index.ts b/web/src/router/index.ts index cc9477c..3466b9c 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -17,7 +17,7 @@ import StatusPageView from '../views/StatusPageView.vue' import AboutView from '../views/AboutView.vue' const router = createRouter({ - history: createWebHistory(), + history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/setup', redirect: '/login' }, { path: '/login', name: 'login', component: LoginView, meta: { public: true } }, diff --git a/web/src/views/ProxyView.vue b/web/src/views/ProxyView.vue index 9ca6443..aaebb42 100644 --- a/web/src/views/ProxyView.vue +++ b/web/src/views/ProxyView.vue @@ -912,6 +912,7 @@ import { WarningOutline, } from '@vicons/ionicons5' import { api, asList } from '../api/client' +import { withBase } from '../api/base' import type { CfTunnel, DiscoveredService, ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types' import { useVisibilityPolling } from '../composables/useVisibilityPolling' import EmptyState from '../components/EmptyState.vue' @@ -1835,7 +1836,7 @@ function stopLogStream() { function startLogStream() { const rule = selectedRule.value if (!rule || logEventSource) return - logEventSource = new EventSource(`/api/proxies/${rule.id}/logs/stream?tail=100`, { + logEventSource = new EventSource(withBase(`/api/proxies/${rule.id}/logs/stream?tail=100`), { withCredentials: true, }) logEventSource.addEventListener('log', (event) => { diff --git a/web/vite.config.ts b/web/vite.config.ts index 6ec660b..1b8c1b5 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -5,6 +5,7 @@ import vue from '@vitejs/plugin-vue' const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as { version: string } export default defineConfig({ + base: process.env.VITE_BASE_PATH || '/', plugins: [vue()], define: { __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || pkg.version),