556 lines
15 KiB
TypeScript
556 lines
15 KiB
TypeScript
/**
|
|
* 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
|
|
// One error report per outage streak: a flaky link must not toast every 15s.
|
|
let reported = false
|
|
|
|
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}`
|
|
if (!reported) {
|
|
reported = true
|
|
handlers.onError(message)
|
|
}
|
|
schedule()
|
|
return
|
|
}
|
|
|
|
reported = false
|
|
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'
|
|
if (!reported) {
|
|
reported = true
|
|
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 */
|
|
}
|
|
}
|
|
}
|