This commit is contained in:
+48
-1
@@ -2,13 +2,16 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/havline/havline/internal/acme"
|
"github.com/havline/havline/internal/acme"
|
||||||
@@ -144,6 +147,9 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
|||||||
StaticFS: staticFS,
|
StaticFS: staticFS,
|
||||||
StartedAt: startedAt,
|
StartedAt: startedAt,
|
||||||
})
|
})
|
||||||
|
if prefix := strings.TrimSpace(cfg.GatewayPrefix); prefix != "" {
|
||||||
|
handler = stripPrefixIfPresent(prefix, handler)
|
||||||
|
}
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: cfg.ListenAddr,
|
Addr: cfg.ListenAddr,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
@@ -235,7 +241,33 @@ func (a *App) bootstrapNginx(ctx context.Context, proxySvc *service.ProxyService
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Run() error {
|
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 {
|
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 {
|
if err := a.server.Shutdown(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if socket := strings.TrimSpace(a.cfg.GatewaySocket); socket != "" {
|
||||||
|
_ = os.Remove(socket)
|
||||||
|
}
|
||||||
if err := a.nginx.Stop(ctx); err != nil {
|
if err := a.nginx.Stop(ctx); err != nil {
|
||||||
a.logger.Error("nginx stop failed", "module", "NGINX", "error", err.Error())
|
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
|
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) {
|
func ResolveMigrationsDir() (string, error) {
|
||||||
candidates := []string{
|
candidates := []string{
|
||||||
"migrations",
|
"migrations",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
|
GatewaySocket string
|
||||||
|
GatewayPrefix string
|
||||||
DataDir string
|
DataDir string
|
||||||
SessionSecret string
|
SessionSecret string
|
||||||
NginxBin string
|
NginxBin string
|
||||||
@@ -31,6 +33,8 @@ func Load() Config {
|
|||||||
}
|
}
|
||||||
return Config{
|
return Config{
|
||||||
ListenAddr: envOr("HAVLINE_LISTEN", ":6893"),
|
ListenAddr: envOr("HAVLINE_LISTEN", ":6893"),
|
||||||
|
GatewaySocket: envOr("HAVLINE_GATEWAY_SOCKET", ""),
|
||||||
|
GatewayPrefix: envOr("HAVLINE_GATEWAY_PREFIX", ""),
|
||||||
DataDir: dataDir,
|
DataDir: dataDir,
|
||||||
SessionSecret: envOr("HAVLINE_SESSION_SECRET", ""),
|
SessionSecret: envOr("HAVLINE_SESSION_SECRET", ""),
|
||||||
NginxBin: envOr("HAVLINE_NGINX_BIN", "nginx"),
|
NginxBin: envOr("HAVLINE_NGINX_BIN", "nginx"),
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ import type {
|
|||||||
CfPreflightResult,
|
CfPreflightResult,
|
||||||
CfRouteTestResult,
|
CfRouteTestResult,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
import { withBase } from './base'
|
||||||
|
|
||||||
export function asList<T>(value: T[] | null | undefined): T[] {
|
export function asList<T>(value: T[] | null | undefined): T[] {
|
||||||
return Array.isArray(value) ? value : []
|
return Array.isArray(value) ? value : []
|
||||||
@@ -79,7 +80,7 @@ async function request<T>(path: string, init?: RequestInit, timeoutMs = REQUEST_
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function requestWithSignal<T>(path: string, init?: RequestInit, signal?: AbortSignal): Promise<T> {
|
async function requestWithSignal<T>(path: string, init?: RequestInit, signal?: AbortSignal): Promise<T> {
|
||||||
const response = await fetch(path, {
|
const response = await fetch(withBase(path), {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
signal,
|
signal,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -179,7 +180,7 @@ export const api = {
|
|||||||
email: payload.email ?? '',
|
email: payload.email ?? '',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
certificateApplyStreamURL: (jobId: string) => `/api/certificates/jobs/${encodeURIComponent(jobId)}/stream`,
|
certificateApplyStreamURL: (jobId: string) => withBase(`/api/certificates/jobs/${encodeURIComponent(jobId)}/stream`),
|
||||||
importCertificate: (payload: {
|
importCertificate: (payload: {
|
||||||
certificate?: string
|
certificate?: string
|
||||||
private_key?: string
|
private_key?: string
|
||||||
@@ -192,7 +193,7 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
async downloadCertificate(domain: string, part: 'zip' | 'cert' | 'key' = 'zip') {
|
async downloadCertificate(domain: string, part: 'zip' | 'cert' | 'key' = 'zip') {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/certificates/${encodeURIComponent(domain)}/download?part=${part}`,
|
withBase(`/api/certificates/${encodeURIComponent(domain)}/download?part=${part}`),
|
||||||
{ credentials: 'include' },
|
{ credentials: 'include' },
|
||||||
)
|
)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -432,7 +433,7 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async exportBackup() {
|
async exportBackup() {
|
||||||
const response = await fetch('/api/backup/export', { credentials: 'include' })
|
const response = await fetch(withBase('/api/backup/export'), { credentials: 'include' })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('导出备份失败')
|
throw new Error('导出备份失败')
|
||||||
}
|
}
|
||||||
@@ -454,7 +455,7 @@ export const api = {
|
|||||||
deleteBackupArchive: (name: string) =>
|
deleteBackupArchive: (name: string) =>
|
||||||
request<{ ok: boolean }>(`/api/backup/archives/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
request<{ ok: boolean }>(`/api/backup/archives/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||||
async downloadBackupArchive(name: string) {
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error('下载备份失败')
|
throw new Error('下载备份失败')
|
||||||
}
|
}
|
||||||
@@ -470,7 +471,7 @@ export const api = {
|
|||||||
async restoreBackup(file: File) {
|
async restoreBackup(file: File) {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
const response = await fetch('/api/backup/restore', {
|
const response = await fetch(withBase('/api/backup/restore'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: form,
|
body: form,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import StatusPageView from '../views/StatusPageView.vue'
|
|||||||
import AboutView from '../views/AboutView.vue'
|
import AboutView from '../views/AboutView.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/setup', redirect: '/login' },
|
{ path: '/setup', redirect: '/login' },
|
||||||
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
|
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
|
||||||
|
|||||||
@@ -912,6 +912,7 @@ import {
|
|||||||
WarningOutline,
|
WarningOutline,
|
||||||
} from '@vicons/ionicons5'
|
} from '@vicons/ionicons5'
|
||||||
import { api, asList } from '../api/client'
|
import { api, asList } from '../api/client'
|
||||||
|
import { withBase } from '../api/base'
|
||||||
import type { CfTunnel, DiscoveredService, ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types'
|
import type { CfTunnel, DiscoveredService, ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types'
|
||||||
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
|
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
|
||||||
import EmptyState from '../components/EmptyState.vue'
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
@@ -1835,7 +1836,7 @@ function stopLogStream() {
|
|||||||
function startLogStream() {
|
function startLogStream() {
|
||||||
const rule = selectedRule.value
|
const rule = selectedRule.value
|
||||||
if (!rule || logEventSource) return
|
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,
|
withCredentials: true,
|
||||||
})
|
})
|
||||||
logEventSource.addEventListener('log', (event) => {
|
logEventSource.addEventListener('log', (event) => {
|
||||||
|
|||||||
@@ -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 }
|
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as { version: string }
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: process.env.VITE_BASE_PATH || '/',
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
define: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || pkg.version),
|
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || pkg.version),
|
||||||
|
|||||||
Reference in New Issue
Block a user