initial commit
This commit is contained in:
1 parent
5b935301e5
commit
85453852d5
105 files changed
+24846
No files matched your search
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* Typed client for the fancy-dns admin API.
|
||||
*
|
||||
* Every path is relative (`/api/...`) because the Go binary serves the built
|
||||
* assets and the API from the same origin; in dev Vite proxies `/api` to the
|
||||
* local daemon.
|
||||
*/
|
||||
|
||||
import type {
|
||||
BlocklistsResponse,
|
||||
CacheResponse,
|
||||
CacheTopResponse,
|
||||
ClientsResponse,
|
||||
ClientGroup,
|
||||
Dashboard,
|
||||
DefaultPolicy,
|
||||
DomainAction,
|
||||
DomainCheck,
|
||||
DomainFilters,
|
||||
DomainsResponse,
|
||||
LogFilters,
|
||||
LogItem,
|
||||
LoginResponse,
|
||||
LogsResponse,
|
||||
LiveStats,
|
||||
OkResponse,
|
||||
SaveSettingsResponse,
|
||||
SettingsResponse,
|
||||
UpstreamBootstrap,
|
||||
UpstreamPolicy,
|
||||
UpstreamServer,
|
||||
UpstreamsResponse,
|
||||
ValidateResponse,
|
||||
} from './types.ts'
|
||||
|
||||
const TOKEN_KEY = 'fancydns.token'
|
||||
|
||||
/** An HTTP-level failure. `body` is the raw response text for the logs. */
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
body: string
|
||||
|
||||
constructor(status: number, message: string, body = '') {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? ''
|
||||
} catch {
|
||||
// Private mode / disabled storage: behave as an anonymous session.
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
try {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
} catch {
|
||||
/* non-fatal: the session just does not survive a reload */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Types the API returns for endpoints the shared types.ts does not name. */
|
||||
export interface WhoamiResponse {
|
||||
name: string
|
||||
mode: string
|
||||
read_only: boolean
|
||||
can_write: boolean
|
||||
auth_mode: string
|
||||
base_path: string
|
||||
}
|
||||
|
||||
export interface LoginInput {
|
||||
api_key?: string
|
||||
username?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export interface RefreshBlocklistsResponse {
|
||||
started: boolean
|
||||
names: string[]
|
||||
}
|
||||
|
||||
export interface ToggleBlocklistResponse {
|
||||
ok: boolean
|
||||
name: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AddDomainInput {
|
||||
name: string
|
||||
action: DomainAction | string
|
||||
list?: string
|
||||
source?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
/** A lookup failure comes back 200 with an `error` string, not a 4xx. */
|
||||
export type DomainCheckResponse = DomainCheck & { error?: string }
|
||||
|
||||
export interface ClientsPayload {
|
||||
groups: ClientGroup[]
|
||||
default_policy: DefaultPolicy | string
|
||||
}
|
||||
|
||||
export interface UpstreamsPayload {
|
||||
policy: UpstreamPolicy | string
|
||||
servers: UpstreamServer[]
|
||||
bootstraps: UpstreamBootstrap[]
|
||||
healthcheck_interval_s?: number
|
||||
max_attempts?: number
|
||||
breaker_threshold?: number
|
||||
}
|
||||
|
||||
export interface SaveResponse {
|
||||
ok: boolean
|
||||
version: string
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface FlushCacheResponse {
|
||||
ok: boolean
|
||||
entries: number
|
||||
}
|
||||
|
||||
export interface ReloadSettingsResponse {
|
||||
ok: boolean
|
||||
version: string
|
||||
}
|
||||
|
||||
export type LogStreamHandlers = {
|
||||
onEntry: (entry: LogItem) => void
|
||||
onStats: (stats: LiveStats) => void
|
||||
onReady: (info: { at?: string; version?: string }) => void
|
||||
onError: (message: string) => void
|
||||
onOpen: () => void
|
||||
}
|
||||
|
||||
/** Query values the server should not see at all. */
|
||||
type ParamValue = string | number | boolean | null | undefined
|
||||
|
||||
/** Builds `/api/thing?a=1&b=2`, dropping empty values. */
|
||||
export function apiUrl(path: string, params?: Record<string, ParamValue>): string {
|
||||
if (!params) return path
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === null || value === undefined) continue
|
||||
if (typeof value === 'string' && value === '') continue
|
||||
search.set(key, String(value))
|
||||
}
|
||||
const qs = search.toString()
|
||||
return qs ? `${path}?${qs}` : path
|
||||
}
|
||||
|
||||
/** One redirect per session, however many requests race into a 401. */
|
||||
let redirecting = false
|
||||
|
||||
function sendToLogin(): void {
|
||||
if (redirecting) return
|
||||
redirecting = true
|
||||
const next = typeof location === 'undefined' ? '' : location.pathname
|
||||
location.assign(`/auth/login?next=${encodeURIComponent(next)}`)
|
||||
}
|
||||
|
||||
function isLoginPath(path: string): boolean {
|
||||
return path.includes('/auth/login')
|
||||
}
|
||||
|
||||
async function errorMessage(res: Response): Promise<{ message: string; body: string }> {
|
||||
let body = ''
|
||||
try {
|
||||
body = await res.text()
|
||||
} catch {
|
||||
body = ''
|
||||
}
|
||||
let message = body
|
||||
if (body) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body)
|
||||
if (parsed && typeof parsed === 'object' && 'error' in parsed) {
|
||||
const err = (parsed as { error: unknown }).error
|
||||
if (typeof err === 'string' && err !== '') message = err
|
||||
}
|
||||
} catch {
|
||||
/* not JSON: show the raw text */
|
||||
}
|
||||
}
|
||||
if (message === '') message = res.statusText || `HTTP ${res.status}`
|
||||
return { message, body }
|
||||
}
|
||||
|
||||
/**
|
||||
* `fetch` with JSON conveniences, the API key header and a single 401
|
||||
* redirect. Non-2xx always throws so callers can surface the message.
|
||||
*/
|
||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers)
|
||||
if (!headers.has('Accept')) headers.set('Accept', 'application/json')
|
||||
|
||||
const hasBody = init?.body !== undefined && init?.body !== null
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
const token = getToken()
|
||||
if (token && !headers.has('X-API-Key')) headers.set('X-API-Key', token)
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(path, { ...init, headers, credentials: 'same-origin' })
|
||||
} catch (err) {
|
||||
// Network failure, DNS, offline, or an aborted request from a unmounted
|
||||
// component: never surface AbortError noise to the user.
|
||||
if (err instanceof DOMException && err.name === 'AbortError') throw err
|
||||
const message = err instanceof Error ? err.message : 'network request failed'
|
||||
throw new ApiError(0, `cannot reach the fancy-dns API: ${message}`)
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
// A failed login is not a dead session: do not bounce the login page to
|
||||
// itself.
|
||||
if (!isLoginPath(path)) {
|
||||
clearToken()
|
||||
sendToLogin()
|
||||
}
|
||||
const { message, body } = await errorMessage(res)
|
||||
throw new ApiError(401, message, body)
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const { message, body } = await errorMessage(res)
|
||||
throw new ApiError(res.status, message, body)
|
||||
}
|
||||
|
||||
if (res.status === 204 || res.headers.get('Content-Length') === '0') {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
if (text === '') return undefined as T
|
||||
try {
|
||||
return JSON.parse(text) as T
|
||||
} catch {
|
||||
throw new ApiError(res.status, 'the API returned a malformed response', text)
|
||||
}
|
||||
}
|
||||
|
||||
/** Percent-encodes a single path segment. */
|
||||
function seg(value: string): string {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
|
||||
export async function login(body: LoginInput): Promise<LoginResponse> {
|
||||
const res = await apiFetch<LoginResponse>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (res?.token) setToken(res.token)
|
||||
return res
|
||||
}
|
||||
|
||||
export async function logout(): Promise<OkResponse> {
|
||||
try {
|
||||
return await apiFetch<OkResponse>('/api/auth/logout', { method: 'POST' })
|
||||
} finally {
|
||||
clearToken()
|
||||
}
|
||||
}
|
||||
|
||||
export function whoami(): Promise<WhoamiResponse> {
|
||||
return apiFetch<WhoamiResponse>('/api/whoami')
|
||||
}
|
||||
|
||||
export function getDashboard(range: string): Promise<Dashboard> {
|
||||
return apiFetch<Dashboard>(apiUrl('/api/dashboard', { range }))
|
||||
}
|
||||
|
||||
export function getBlocklists(): Promise<BlocklistsResponse> {
|
||||
return apiFetch<BlocklistsResponse>('/api/blocklists')
|
||||
}
|
||||
|
||||
export async function refreshBlocklists(names?: string[]): Promise<RefreshBlocklistsResponse> {
|
||||
if (names && names.length > 0) {
|
||||
return apiFetch<RefreshBlocklistsResponse>('/api/blocklists/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ names }),
|
||||
})
|
||||
}
|
||||
return apiFetch<RefreshBlocklistsResponse>('/api/blocklists/refresh', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function toggleBlocklist(
|
||||
name: string,
|
||||
enabled: boolean,
|
||||
): Promise<ToggleBlocklistResponse> {
|
||||
return apiFetch<ToggleBlocklistResponse>('/api/blocklists/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, enabled }),
|
||||
})
|
||||
}
|
||||
|
||||
export function getDomains(filters: DomainFilters = {}): Promise<DomainsResponse> {
|
||||
return apiFetch<DomainsResponse>(apiUrl('/api/domains', { ...filters }))
|
||||
}
|
||||
|
||||
export function addDomain(input: AddDomainInput): Promise<OkResponse> {
|
||||
return apiFetch<OkResponse>('/api/domains', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDomain(name: string, action?: string, list?: string): Promise<OkResponse> {
|
||||
return apiFetch<OkResponse>(apiUrl(`/api/domains/${seg(name)}`, { action, list }), {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function checkDomain(name: string): Promise<DomainCheckResponse> {
|
||||
return apiFetch<DomainCheckResponse>(apiUrl('/api/domains/check', { name }))
|
||||
}
|
||||
|
||||
export function getClients(): Promise<ClientsResponse> {
|
||||
return apiFetch<ClientsResponse>('/api/clients')
|
||||
}
|
||||
|
||||
export function putClients(payload: ClientsPayload): Promise<SaveResponse> {
|
||||
return apiFetch<SaveResponse>('/api/clients', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function getUpstreams(): Promise<UpstreamsResponse> {
|
||||
return apiFetch<UpstreamsResponse>('/api/upstreams')
|
||||
}
|
||||
|
||||
export function putUpstreams(payload: UpstreamsPayload): Promise<SaveResponse> {
|
||||
return apiFetch<SaveResponse>('/api/upstreams', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function getLogs(filters: LogFilters = {}): Promise<LogsResponse> {
|
||||
return apiFetch<LogsResponse>(apiUrl('/api/logs', { ...filters }))
|
||||
}
|
||||
|
||||
export function clearLogs(): Promise<OkResponse> {
|
||||
return apiFetch<OkResponse>('/api/logs/clear', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getCache(): Promise<CacheResponse> {
|
||||
return apiFetch<CacheResponse>('/api/cache')
|
||||
}
|
||||
|
||||
export function getCacheTop(limit = 50): Promise<CacheTopResponse> {
|
||||
return apiFetch<CacheTopResponse>(apiUrl('/api/cache/top', { limit }))
|
||||
}
|
||||
|
||||
export function flushCache(): Promise<FlushCacheResponse> {
|
||||
return apiFetch<FlushCacheResponse>('/api/cache/flush', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getSettings(): Promise<SettingsResponse> {
|
||||
return apiFetch<SettingsResponse>('/api/settings')
|
||||
}
|
||||
|
||||
export function putSettings(yaml: string): Promise<SaveSettingsResponse> {
|
||||
return apiFetch<SaveSettingsResponse>('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config_yaml: yaml }),
|
||||
})
|
||||
}
|
||||
|
||||
export function validateSettings(yaml: string): Promise<ValidateResponse> {
|
||||
return apiFetch<ValidateResponse>('/api/settings/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ config_yaml: yaml }),
|
||||
})
|
||||
}
|
||||
|
||||
export function reloadSettings(): Promise<ReloadSettingsResponse> {
|
||||
return apiFetch<ReloadSettingsResponse>('/api/settings/reload', { method: 'POST' })
|
||||
}
|
||||
|
||||
const STREAM_PATH = '/api/logs/stream'
|
||||
const RECONNECT_MIN_MS = 1000
|
||||
const RECONNECT_MAX_MS = 15000
|
||||
|
||||
/**
|
||||
* Subscribes to the live query log.
|
||||
*
|
||||
* Native `EventSource` cannot send the `X-API-Key` header and the daemon
|
||||
* accepts no cookie, so the stream is read with `fetch` plus a minimal SSE
|
||||
* parser. That costs a hand-rolled reconnect, which is what EventSource would
|
||||
* have given us for free; the handler contract is unchanged.
|
||||
*
|
||||
* Returns a teardown function that is safe to call more than once.
|
||||
*/
|
||||
export function streamLogs(handlers: LogStreamHandlers): () => void {
|
||||
const controller = new AbortController()
|
||||
let stopped = false
|
||||
let backoff = RECONNECT_MIN_MS
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let attempt = 0
|
||||
|
||||
const open = (): void => {
|
||||
if (stopped) return
|
||||
void run()
|
||||
}
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
if (stopped) return
|
||||
const token = getToken()
|
||||
const headers = new Headers({ Accept: 'text/event-stream' })
|
||||
if (token) headers.set('X-API-Key', token)
|
||||
|
||||
try {
|
||||
const res = await fetch(STREAM_PATH, {
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
sendToLogin()
|
||||
handlers.onError('unauthorized: the live log stream needs a valid session')
|
||||
return
|
||||
}
|
||||
if (!res.ok || !res.body) {
|
||||
const message = res.ok
|
||||
? 'the live log stream is not supported by this browser'
|
||||
: `the live log stream failed with HTTP ${res.status}`
|
||||
handlers.onError(message)
|
||||
schedule()
|
||||
return
|
||||
}
|
||||
|
||||
attempt = 0
|
||||
backoff = RECONNECT_MIN_MS
|
||||
handlers.onOpen()
|
||||
await readSse(res.body, handlers, controller.signal)
|
||||
if (stopped) return
|
||||
// A clean end of stream still means the connection is gone.
|
||||
schedule()
|
||||
} catch (err) {
|
||||
if (stopped) return
|
||||
if (controller.signal.aborted) return
|
||||
const message = err instanceof Error ? err.message : 'the live log stream dropped'
|
||||
handlers.onError(message)
|
||||
schedule()
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = (): void => {
|
||||
if (stopped) return
|
||||
attempt += 1
|
||||
const delay = Math.min(RECONNECT_MAX_MS, backoff)
|
||||
backoff = Math.min(RECONNECT_MAX_MS, Math.round(backoff * 1.8))
|
||||
timer = setTimeout(open, delay)
|
||||
}
|
||||
|
||||
open()
|
||||
|
||||
return () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
if (timer !== undefined) clearTimeout(timer)
|
||||
controller.abort()
|
||||
}
|
||||
}
|
||||
|
||||
/** Splits an SSE byte stream into `event:`/`data:` frames. */
|
||||
async function readSse(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
handlers: LogStreamHandlers,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
const handle = (frame: string): void => {
|
||||
let event = 'message'
|
||||
const data: string[] = []
|
||||
for (const rawLine of frame.split('\n')) {
|
||||
const line = rawLine.replace(/\r$/, '')
|
||||
if (line === '' || line.startsWith(':')) continue
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim()
|
||||
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''))
|
||||
}
|
||||
if (data.length === 0) return
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = JSON.parse(data.join('\n'))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (event === 'entry') {
|
||||
handlers.onEntry(payload as LogItem)
|
||||
} else if (event === 'stats') {
|
||||
handlers.onStats(payload as LiveStats)
|
||||
} else if (event === 'ready') {
|
||||
handlers.onReady(payload as { at?: string; version?: string })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
if (signal.aborted) return
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let split = buffer.indexOf('\n\n')
|
||||
while (split !== -1) {
|
||||
const frame = buffer.slice(0, split)
|
||||
buffer = buffer.slice(split + 2)
|
||||
handle(frame)
|
||||
split = buffer.indexOf('\n\n')
|
||||
}
|
||||
}
|
||||
if (buffer.trim() !== '') handle(buffer)
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock()
|
||||
} catch {
|
||||
/* already released by the abort */
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user