feature: the package installs the Router Manager, which is no longer a package of its own.
Release / release (push) Failing after 4s

This commit is contained in:
slawek committed 2026-11-14 12:52:44 +01:00
1 parent 6d90441bee
commit 8efe21bfa6
129 files changed
+17381 -104

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
import Apple from '@mui/icons-material/Apple'
import Delete from '@mui/icons-material/Delete'
import Download from '@mui/icons-material/Download'
import Key from '@mui/icons-material/Key'
import Password from '@mui/icons-material/Password'
import {
Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Stack, Table, TableBody, TableCell,
TableHead, TableRow, TextField, Tooltip, Typography,
} from '@mui/material'
import { useEffect, useState, type FormEvent } from 'react'
import OtpDialog from './OtpDialog.tsx'
import PasskeyDialog from './PasskeyDialog.tsx'
import ProfileDialog from './ProfileDialog.tsx'
import { Section } from './RouterPanel.tsx'
import { ACCOUNT_SHAPE, CODE_PATTERN, USER_SHAPE, request, type Account, type User } from './api.ts'
/**
* The account of the signed-in user, from the router's /account, in two columns: the details with Change password,
* which opens the password dialog, and the passkeys in the first row, the enrolment for one-time passwords and the
* OpenVPN and the Apple VPN profile of a user with a certificate in the second. Each change but the removal of a
* passkey takes the current password and, for an enrolled user, a current code. A changed password ends the session,
* through onPasswordChanged.
*/
export default function AccountPanel(props: { onPasswordChanged: () => void }) {
const [account, setAccount] = useState<Account | null>(null)
const [error, setError] = useState<string | null>(null)
const [enrolling, setEnrolling] = useState(false)
const [currentPassword, setCurrentPassword] = useState('')
const [currentCode, setCurrentCode] = useState('')
const [password, setPassword] = useState('')
const [again, setAgain] = useState('')
const [saving, setSaving] = useState(false)
const [passwordError, setPasswordError] = useState<string | null>(null)
const [changingPassword, setChangingPassword] = useState(false)
const [addingPasskey, setAddingPasskey] = useState(false)
const [profile, setProfile] = useState<'openvpn' | 'apple' | null>(null)
const [passkeyError, setPasskeyError] = useState<string | null>(null)
useEffect(() => {
request<Account>('GET', '/account', ACCOUNT_SHAPE)
.then(setAccount)
.catch((caught: Error) => setError(caught.message))
}, [])
if (account === null) {
return error ? <Alert severity="error">{error}</Alert> : <CircularProgress />
}
const againProblem = again !== '' && again !== password ? 'The passwords differ.' : ''
const blocked = currentPassword === '' || (account.otp && !CODE_PATTERN.test(currentCode)) || password === '' || again !== password
async function changePassword(event: FormEvent) {
event.preventDefault()
if (account === null || blocked) {
return
}
setSaving(true)
setPasswordError(null)
try {
const proof = account.otp ? { current_password: currentPassword, current_code: currentCode } : { current_password: currentPassword }
await request('PUT', '/account/password', USER_SHAPE, { ...proof, password })
props.onPasswordChanged()
} catch (caught) {
setPasswordError((caught as Error).message)
setSaving(false)
}
}
/** Closes the password dialog, unless a change is being saved, and empties its fields. */
function closePasswordDialog() {
if (saving) {
return
}
setChangingPassword(false)
setCurrentPassword('')
setCurrentCode('')
setPassword('')
setAgain('')
setPasswordError(null)
}
async function removePasskey(id: string) {
if (account === null) {
return
}
setPasskeyError(null)
try {
setAccount({ ...(await request<User>('DELETE', `/account/passkeys/${encodeURIComponent(id)}`, USER_SHAPE)), admin: account.admin })
} catch (caught) {
setPasskeyError((caught as Error).message)
}
}
const rows: [string, string][] = [
['User name', account.name],
['Kind', account.manager_only ? 'Manager-only account' : 'VPN user'],
...(account.manager_only ? [] : [['First name', account.first_name ?? '-'], ['Last name', account.last_name ?? '-']] as [string, string][]),
['E-mail address', account.email ?? '-'],
['Router administrator', account.admin ? 'yes' : 'no'],
['One-time passwords', account.otp ? 'enrolled' : 'not enrolled'],
]
return (
<Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, alignItems: 'start' }}>
<Section title="Account">
<Box component="dl" sx={{ display: 'grid', gridTemplateColumns: 'max-content 1fr', columnGap: 2, rowGap: 0.5, m: 0 }}>
{rows.map(([label, value]) => (
<Box key={label} sx={{ display: 'contents' }}>
<Typography component="dt" variant="body2" color="text.secondary">{label}</Typography>
<Typography component="dd" variant="body2" sx={{ m: 0 }}>{value}</Typography>
</Box>
))}
</Box>
<Button variant="outlined" startIcon={<Password />} sx={{ mt: 2 }} onClick={() => setChangingPassword(true)}>Change password</Button>
</Section>
<Section title="Passkeys">
<Stack sx={{ gap: 2, alignItems: 'flex-start' }}>
{passkeyError && <Alert severity="error" sx={{ whiteSpace: 'pre-line', alignSelf: 'stretch' }}>{passkeyError}</Alert>}
{account.passkeys.length === 0
? <Typography variant="body2" color="text.secondary">No passkeys.</Typography>
: (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Label</TableCell>
<TableCell>Added</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{account.passkeys.map((passkey) => (
<TableRow key={passkey.id}>
<TableCell>{passkey.label}</TableCell>
<TableCell sx={{ whiteSpace: 'nowrap' }}>{new Date(passkey.created).toLocaleString()}</TableCell>
<TableCell align="right">
<Tooltip title="Remove">
<IconButton size="small" color="error" onClick={() => void removePasskey(passkey.id)}><Delete fontSize="small" /></IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Button variant="outlined" startIcon={<Key />} onClick={() => setAddingPasskey(true)}>Add a passkey</Button>
</Stack>
</Section>
<Section title="One-time passwords">
<Stack sx={{ gap: 2, alignItems: 'flex-start' }}>
<Typography variant="body2" color="text.secondary">
{account.otp
? 'Signing in takes a code of the authenticator app. Enrolling again replaces the authenticator; an administrator removes the enrolment.'
: 'Enrol an authenticator app; signing in then takes a code of it.'}
</Typography>
<Button variant="outlined" onClick={() => setEnrolling(true)}>{account.otp ? 'Replace the authenticator' : 'Enrol'}</Button>
</Stack>
</Section>
{account.certificate && (
<Section title="VPN profiles">
<Stack sx={{ gap: 2, alignItems: 'flex-start' }}>
<Button variant="outlined" startIcon={<Download />} onClick={() => setProfile('openvpn')}>Download the OpenVPN profile</Button>
<Button variant="outlined" startIcon={<Apple />} onClick={() => setProfile('apple')}>Download the Apple VPN profile (IKEv2)</Button>
</Stack>
</Section>
)}
<Dialog open={changingPassword} onClose={closePasswordDialog} fullWidth maxWidth="xs">
<form onSubmit={changePassword}>
<DialogTitle>Change password</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{passwordError && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{passwordError}</Alert>}
<Typography variant="body2" color="text.secondary">Changing the password signs you out.</Typography>
<TextField label="Current password" type="password" value={currentPassword} autoComplete="current-password" autoFocus onChange={(event) => setCurrentPassword(event.target.value)} />
{account.otp && (
<TextField
label="Code"
value={currentCode}
autoComplete="one-time-code"
helperText="A code not used to sign in."
slotProps={{ htmlInput: { inputMode: 'numeric', maxLength: 6 } }}
onChange={(event) => setCurrentCode(event.target.value.trim())}
/>
)}
<TextField label="New password" type="password" value={password} autoComplete="new-password" onChange={(event) => setPassword(event.target.value)} />
<TextField
label="New password again"
type="password"
value={again}
autoComplete="new-password"
error={againProblem !== ''}
helperText={againProblem}
onChange={(event) => setAgain(event.target.value)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={closePasswordDialog}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Change password</Button>
</DialogActions>
</form>
</Dialog>
{profile && (
<ProfileDialog
kind={profile}
name={account.name}
path={profile === 'apple' ? '/account/apple-profile' : '/account/profile'}
own
otp={account.otp}
onClose={() => setProfile(null)}
/>
)}
{addingPasskey && (
<PasskeyDialog
otp={account.otp}
onCancel={() => setAddingPasskey(false)}
onSaved={(user) => {
setAccount({ ...user, admin: account.admin })
setAddingPasskey(false)
}}
/>
)}
{enrolling && (
<OtpDialog
name={account.name}
path="/account/otp"
proof={{ otp: account.otp }}
onCancel={() => setEnrolling(false)}
onSave={async (secret, code, proof) => {
setAccount({ ...(await request<User>('PUT', '/account/otp', USER_SHAPE, { ...proof, secret, code })), admin: account.admin })
setEnrolling(false)
}}
/>
)}
</Box>
)
}
+320
View File
@@ -0,0 +1,320 @@
import AccountCircle from '@mui/icons-material/AccountCircle'
import EditNote from '@mui/icons-material/EditNote'
import HelpOutline from '@mui/icons-material/HelpOutlineOutlined'
import Logout from '@mui/icons-material/Logout'
import Terminal from '@mui/icons-material/Terminal'
import { Box, Button, CircularProgress, Container, IconButton, Link, Stack, Tab, Tabs, Tooltip, Typography } from '@mui/material'
import { useCallback, useEffect, useState } from 'react'
import AccountPanel from './AccountPanel.tsx'
import BackupPanel from './BackupPanel.tsx'
import CertificatesPanel from './CertificatesPanel.tsx'
import GroupsPanel from './GroupsPanel.tsx'
import HelpPanel, { HELP_HOME, HELP_WIDTH } from './HelpPanel.tsx'
import LogsPanel from './LogsPanel.tsx'
import MetricsPanel from './MetricsPanel.tsx'
import DockerPanel from './DockerPanel.tsx'
import DnsPanel from './DnsPanel.tsx'
import PageMenu, { CA_PATH, DOCKER_PATH, REMOTE_ACCESS_PATH, SHELL_PATH, SITES_PATH, SITE_TO_SITE_PATH, USERS_PATH, USER_PATH } from './PageMenu.tsx'
import NetworkPanel from './NetworkPanel.tsx'
import RemoteAccessPanel from './RemoteAccessPanel.tsx'
import RouterPanel from './RouterPanel.tsx'
import ServiceAccountsPanel from './ServiceAccountsPanel.tsx'
import SettingsButton from './SettingsButton.tsx'
import ShellPanel from './ShellPanel.tsx'
import SignIn from './SignIn.tsx'
import SystemPanel from './SystemPanel.tsx'
import SiteFiles from './SiteFiles.tsx'
import SitesPanel from './SitesPanel.tsx'
import SiteToSitePanel from './SiteToSitePanel.tsx'
import SiteToSiteStatusPanel from './SiteToSiteStatusPanel.tsx'
import ThemeMenu from './ThemeMenu.tsx'
import UsersPanel from './UsersPanel.tsx'
import {
ACCOUNT_SHAPE, SESSION_EXPIRED, SESSION_SHAPE, request, type Account, type Info, type RemoteAccessSystem, type Session,
} from './api.ts'
/** The tabs of the Router page, router being the Overview tab, of the Identities page, of the Connectivity page and of the Web Sites page. */
type View = 'router' | 'metrics' | 'logs' | 'backup' | 'system'
type UsersView = 'users' | 'groups' | 'service-accounts'
type SiteToSiteView = 'status' | 'network' | 'connections'
type SitesView = 'sites' | 'files' | 'dns'
/** The help topic of each tab of the Web Sites page. */
const SITES_TOPICS: Record<SitesView, string> = { sites: 'sites.md', files: 'site-files.md', dns: 'dns.md' }
/** The prefix of the sessionStorage keys of the pages the account and the shell page were opened from, by their paths. */
const RETURN_KEY = 'vpn-router-manager.return:'
/** The pages of an administrator besides Router, which every other path shows. */
const PAGE_PATHS = [USER_PATH, USERS_PATH, SITE_TO_SITE_PATH, REMOTE_ACCESS_PATH, CA_PATH, SITES_PATH, DOCKER_PATH, SHELL_PATH]
/** The help topic of each page, a document of /docs; the Router, the Identities and the Web Sites page have one per tab. */
const HELP_TOPICS: Record<string, string> = {
[USER_PATH]: 'account.md',
[SITE_TO_SITE_PATH]: 'site-to-site.md',
[REMOTE_ACCESS_PATH]: 'remote-access.md',
[CA_PATH]: 'certificates.md',
[DOCKER_PATH]: 'docker.md',
[SHELL_PATH]: 'shell.md',
}
/** The localStorage key of whether the help is open. */
const HELP_KEY = 'vpn-router-manager.help'
/**
* The page asks for a sign-in until a session exists. At USER_PATH, and for a user who is not a member of admins at
* every path, it then shows the user's own account; at USERS_PATH the router's users, groups or service accounts; at
* SITE_TO_SITE_PATH its site-to-site connections, their state and its network with its routes; at REMOTE_ACCESS_PATH its IKEv2, OpenVPN and
* WireGuard access; at CA_PATH its certificates; at SITES_PATH its web
* sites, the files of a site, opened from the sites too, and its name server; at DOCKER_PATH the state of Docker; at SHELL_PATH a shell on the router; otherwise the state of the router, with the update information of its packages in
* the Status panel, its metrics, its logs, its backups or the updates of its system's packages. A page shows one tab at a time, each read only while shown. An
* administrator switches pages with the page menu at the right end of the header; the button with the user's name,
* left of Sign out, opens the user's own account, and an administrator's shell button, left of it, the shell; pressed
* on their page, each returns to the page it was opened from. While files are open in the shell's editor, the editor
* button left of the shell button switches the Shell page between the terminal and the editor. The help button, between the theme switcher and the page menu, opens or closes the help on
* the right (HelpPanel) at the topic of the page and tab shown (HELP_TOPICS), the page moving left; the browser keeps
* whether it is open. A call answered 401 ends the session and asks for a sign-in again.
*/
export default function App(props: { info: Info }) {
const [view, setView] = useState<View>('router')
const [usersView, setUsersView] = useState<UsersView>('users')
const [siteToSiteView, setSiteToSiteView] = useState<SiteToSiteView>('status')
const [remoteAccessView, setRemoteAccessView] = useState<RemoteAccessSystem>('p2s')
const [sitesView, setSitesView] = useState<SitesView>('sites')
const [filesSite, setFilesSite] = useState<string | null>(null)
const [session, setSession] = useState<Session | null | undefined>(undefined)
const [role, setRole] = useState<{ name: string; admin: boolean } | null>(null)
const page = PAGE_PATHS.find((path) => path === window.location.pathname) ?? '/'
const userPage = page === USER_PATH
const admin = session && role?.name === session.name ? role.admin : undefined
// The account page is the page of every user who is not an administrator, and of an administrator at USER_PATH.
const accountShown = Boolean(session) && admin !== undefined && (userPage || !admin)
const [helpOpen, setHelpOpen] = useState(() => window.localStorage.getItem(HELP_KEY) === 'open')
// The files open in the Shell page's editor, and whether the editor is shown in place of the terminal.
const [editorFiles, setEditorFiles] = useState(0)
const [editorShown, setEditorShown] = useState(false)
const showFiles = useCallback((count: number) => {
setEditorFiles(count)
if (count === 0) {
setEditorShown(false)
}
}, [])
const showEditor = useCallback(() => setEditorShown(true), [])
const helpShown = Boolean(session) && helpOpen
const topic = !admin ? 'account.md'
: page === '/' ? `${view === 'router' ? 'router' : view}.md`
: page === USERS_PATH ? `${usersView}.md`
: page === SITES_PATH ? SITES_TOPICS[sitesView]
: HELP_TOPICS[page] ?? HELP_HOME
/** Opens or closes the help, kept in localStorage. */
function toggleHelp() {
window.localStorage.setItem(HELP_KEY, helpOpen ? 'closed' : 'open')
setHelpOpen(!helpOpen)
}
useEffect(() => {
request<Session>('GET', '/session', SESSION_SHAPE).then(setSession).catch(() => setSession(null))
const expired = () => setSession(null)
window.addEventListener(SESSION_EXPIRED, expired)
return () => window.removeEventListener(SESSION_EXPIRED, expired)
}, [])
useEffect(() => {
if (!session) {
return
}
request<Account>('GET', '/account', ACCOUNT_SHAPE)
.then((account) => setRole({ name: session.name, admin: account.admin }))
.catch(() => setRole({ name: session.name, admin: false }))
}, [session])
/** Opens the page of the path, keeping the page it was opened from in sessionStorage, or returns to that page from it. */
function toggle(path: string) {
if (page === path) {
window.location.assign(window.sessionStorage.getItem(RETURN_KEY + path) ?? '/')
return
}
window.sessionStorage.setItem(RETURN_KEY + path, window.location.pathname)
window.location.assign(path)
}
async function signOut() {
await request('DELETE', '/session', () => true).catch(() => undefined)
setSession(null)
}
return (
<Box
sx={(theme) => ({
mr: { sm: helpShown ? `${HELP_WIDTH}px` : 0 },
transition: theme.transitions.create('margin', { duration: theme.transitions.duration.enteringScreen }),
})}
>
{session && <HelpPanel open={helpShown} topic={topic} onClose={toggleHelp} />}
<Container maxWidth="xl" sx={{ py: 3 }}>
<Stack direction="row" sx={{ mb: 2, alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 2 }}>
<Box component="img" src="/favicon.svg" alt="" sx={{ width: 56, height: 56 }} />
<Box>
<Stack direction="row" sx={{ alignItems: 'baseline', gap: 1 }}>
<Typography variant="h5" component="h1">
VPN Router Manager
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
{props.info.version ?? 'development'}
</Typography>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Router status page:{' '}
{props.info.status_url
? <Link href={props.info.status_url} target="_blank" rel="noreferrer">{props.info.status_url}</Link>
: <b>not configured</b>}
</Typography>
</Box>
</Stack>
<Stack direction="row" sx={{ alignItems: 'center', gap: 2 }}>
{session && admin && page === SHELL_PATH && editorFiles > 0 && (
<Tooltip title={editorShown ? 'Back to the terminal' : 'Editor'}>
<IconButton
aria-label="editor"
color={editorShown ? 'primary' : 'default'}
onClick={() => setEditorShown(!editorShown)}
sx={editorShown ? { bgcolor: 'action.selected' } : undefined}
>
<EditNote />
</IconButton>
</Tooltip>
)}
{session && admin && (
<Tooltip title={page === SHELL_PATH ? 'Back from the shell' : 'Shell'}>
<IconButton
aria-label="shell"
color={page === SHELL_PATH ? 'primary' : 'default'}
onClick={() => toggle(SHELL_PATH)}
sx={page === SHELL_PATH ? { bgcolor: 'action.selected' } : undefined}
>
<Terminal />
</IconButton>
</Tooltip>
)}
{session && (
<>
<Button variant={userPage ? 'contained' : 'outlined'} startIcon={<AccountCircle />} onClick={() => toggle(USER_PATH)} sx={{ textTransform: 'none' }}>
{session.name}
</Button>
<Button variant="outlined" startIcon={<Logout />} onClick={() => void signOut()}>Sign out</Button>
</>
)}
{session && admin !== undefined && !accountShown && <SettingsButton />}
<ThemeMenu />
{session && (
<Tooltip title={helpShown ? 'Close the help' : 'Help'}>
<IconButton
aria-label="help"
color={helpShown ? 'primary' : 'default'}
onClick={toggleHelp}
sx={helpShown ? { bgcolor: 'action.selected' } : undefined}
>
<HelpOutline />
</IconButton>
</Tooltip>
)}
{session && admin && <PageMenu path={page} />}
</Stack>
</Stack>
{session === undefined && <CircularProgress />}
{session === null && <SignIn onSignedIn={setSession} />}
{session && admin === undefined && <CircularProgress />}
{accountShown && <AccountPanel onPasswordChanged={() => void signOut()} />}
{session && admin && page === CA_PATH && (
<>
<Typography variant="h6" component="h2" sx={{ mb: 2 }}>Certificates</Typography>
<CertificatesPanel />
</>
)}
{session && admin && page === SITES_PATH && (
<>
<Tabs value={sitesView} onChange={(_event, next: SitesView) => setSitesView(next)} sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}>
<Tab value="sites" label="Sites" />
<Tab value="files" label="Files" />
<Tab value="dns" label="DNS" />
</Tabs>
{sitesView === 'sites' && (
<SitesPanel
onOpenFiles={(name) => {
setFilesSite(name)
setSitesView('files')
}}
/>
)}
{sitesView === 'files' && <SiteFiles key={filesSite ?? ''} site={filesSite} onSite={setFilesSite} />}
{sitesView === 'dns' && <DnsPanel />}
</>
)}
{session && admin && page === DOCKER_PATH && (
<>
<Typography variant="h6" component="h2" sx={{ mb: 2 }}>Docker</Typography>
<DockerPanel />
</>
)}
{session && admin && page === SHELL_PATH && (
<>
<Typography variant="h6" component="h2" sx={{ mb: 2 }}>Shell</Typography>
<ShellPanel editorShown={editorShown} onFiles={showFiles} onOpened={showEditor} />
</>
)}
{session && admin && page === REMOTE_ACCESS_PATH && (
<>
<Tabs value={remoteAccessView} onChange={(_event, next: RemoteAccessSystem) => setRemoteAccessView(next)} sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}>
<Tab value="p2s" label="IKEv2" />
<Tab value="openvpn" label="OpenVPN" />
<Tab value="wireguard" label="WireGuard" />
</Tabs>
<RemoteAccessPanel key={remoteAccessView} system={remoteAccessView} />
</>
)}
{session && admin && page === SITE_TO_SITE_PATH && (
<>
<Tabs value={siteToSiteView} onChange={(_event, next: SiteToSiteView) => setSiteToSiteView(next)} sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}>
<Tab value="status" label="Status" />
<Tab value="network" label="Network" />
<Tab value="connections" label="Connections" />
</Tabs>
{siteToSiteView === 'connections' && <SiteToSitePanel />}
{siteToSiteView === 'status' && <SiteToSiteStatusPanel />}
{siteToSiteView === 'network' && <NetworkPanel />}
</>
)}
{session && admin && page === USERS_PATH && (
<>
<Tabs value={usersView} onChange={(_event, next: UsersView) => setUsersView(next)} sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}>
<Tab value="users" label="Users" />
<Tab value="groups" label="Groups" />
<Tab value="service-accounts" label="Service Accounts" />
</Tabs>
{usersView === 'users' && <UsersPanel />}
{usersView === 'groups' && <GroupsPanel />}
{usersView === 'service-accounts' && <ServiceAccountsPanel />}
</>
)}
{session && admin && page === '/' && (
<>
<Tabs value={view} onChange={(_event, next: View) => setView(next)} sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }}>
<Tab value="router" label="Overview" />
<Tab value="metrics" label="Metrics" />
<Tab value="logs" label="Logs" />
<Tab value="backup" label="Backup" />
<Tab value="system" label="System" />
</Tabs>
{view === 'router' && <RouterPanel />}
{view === 'metrics' && <MetricsPanel />}
{view === 'logs' && <LogsPanel />}
{view === 'backup' && <BackupPanel />}
{view === 'system' && <SystemPanel />}
</>
)}
</Container>
</Box>
)
}
+202
View File
@@ -0,0 +1,202 @@
import {
Alert, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Stack, Switch,
TextField, Typography,
} from '@mui/material'
import { useState, type FormEvent } from 'react'
import { TARGET_LABELS } from './BackupSummary.tsx'
import { BACKUP_KEEPS, BACKUP_SCHEDULES, BACKUP_TARGETS, RCLONE_MODES, type Backup, type BackupKeep, type BackupSettings } from './api.ts'
/** The labels of the retention settings. */
const KEEP_LABELS: Record<BackupKeep, string> = {
keep_hourly: 'Hourly',
keep_daily: 'Daily',
keep_weekly: 'Weekly',
keep_monthly: 'Monthly',
keep_yearly: 'Yearly',
}
/** The labels of the rclone modes. */
const MODE_LABELS: Record<string, string> = {
sync: 'Sync: the cloud mirrors the archives, retention applies to both',
copy: 'Copy: nothing is deleted in the cloud',
}
/** A PBS certificate fingerprint: 32 hexadecimal pairs joined by colons. */
const FINGERPRINT = /^([0-9A-Fa-f]{2}:){31}[0-9A-Fa-f]{2}$/
/**
* A dialog that edits the router's backup settings: whether backups run, the schedule and the target; for a local
* folder, its path, the retention settings that apply to the schedule and the cloud copy with a remote of the router's
* rclone.conf; for a Proxmox Backup Server, the repository, the token secret, the fingerprint, the namespace and the
* key file; and whether the metrics database is included. An empty token secret keeps the current one. onSave sends
* the settings and the secret; the dialog closes when it succeeds and shows the router's message when it fails.
*/
export default function BackupDialog(props: {
backup: Backup
onSave: (settings: BackupSettings, password: string) => Promise<void>
onCancel: () => void
}) {
const current = props.backup.settings
const [settings, setSettings] = useState<BackupSettings>(() => {
const { pbs_password_set: _set, ...rest } = current
return rest
})
const [remote, setRemote] = useState(current.rclone_remote.split(':')[0] ?? '')
const [remotePath, setRemotePath] = useState(current.rclone_remote.includes(':') ? current.rclone_remote.slice(current.rclone_remote.indexOf(':') + 1) : '')
const [password, setPassword] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const change = (values: Partial<BackupSettings>) => setSettings({ ...settings, ...values })
const local = settings.target === 'local'
const keeps = BACKUP_KEEPS.slice(BACKUP_SCHEDULES.indexOf(settings.schedule as (typeof BACKUP_SCHEDULES)[number]))
const remotes = [...new Set([...props.backup.rclone.remotes, ...(remote ? [remote] : [])])]
const pathProblem = local && !settings.path.startsWith('/') ? 'An absolute path.' : ''
const keepProblem = local && !keeps.some((keep) => settings[keep] > 0) ? 'At least one of them above 0.' : ''
const repositoryProblem = !local && settings.pbs_repository.trim() === '' ? 'Required.' : ''
const passwordProblem = !local && !current.pbs_password_set && password === '' ? 'Required.' : ''
const fingerprintProblem = settings.pbs_fingerprint !== '' && !FINGERPRINT.test(settings.pbs_fingerprint) ? '32 hexadecimal pairs joined by colons.' : ''
const keyfileProblem = settings.pbs_keyfile !== '' && !settings.pbs_keyfile.startsWith('/') ? 'An absolute path.' : ''
const blocked = [pathProblem, keepProblem, repositoryProblem, passwordProblem, fingerprintProblem, keyfileProblem].some((problem) => problem !== '')
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave({ ...settings, rclone_remote: remote ? `${remote}:${remotePath.trim()}` : '' }, password)
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>Edit backup settings</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<FormControlLabel
control={<Switch checked={settings.enabled} onChange={(event) => change({ enabled: event.target.checked })} />}
label="Scheduled backups"
/>
<Stack direction="row" sx={{ gap: 2 }}>
<TextField select fullWidth label="Schedule" value={settings.schedule} onChange={(event) => change({ schedule: event.target.value })}>
{BACKUP_SCHEDULES.map((item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
</TextField>
<TextField select fullWidth label="Target" value={settings.target} onChange={(event) => change({ target: event.target.value })}>
{BACKUP_TARGETS.map((item) => <MenuItem key={item} value={item}>{TARGET_LABELS[item]}</MenuItem>)}
</TextField>
</Stack>
<FormControlLabel
control={<Checkbox checked={settings.metrics} onChange={(event) => change({ metrics: event.target.checked })} />}
label="Include the metrics database"
/>
{local ? (
<>
<TextField
label="Folder"
value={settings.path}
autoComplete="off"
error={pathProblem !== ''}
helperText={pathProblem || 'The folder of the archives on the router; it may be a mount.'}
onChange={(event) => change({ path: event.target.value })}
/>
<Typography variant="subtitle2">Archives kept</Typography>
<Stack direction="row" sx={{ gap: 2 }}>
{keeps.map((keep) => (
<TextField
key={keep}
label={KEEP_LABELS[keep]}
type="number"
value={settings[keep]}
error={keepProblem !== ''}
slotProps={{ htmlInput: { min: 0 } }}
onChange={(event) => change({ [keep]: Math.max(0, Math.trunc(Number(event.target.value) || 0)) })}
/>
))}
</Stack>
{keepProblem && <Typography variant="caption" color="error">{keepProblem}</Typography>}
<Typography variant="subtitle2">Cloud copy</Typography>
<Stack direction="row" sx={{ gap: 2 }}>
<TextField
select
fullWidth
label="rclone remote"
value={remote}
helperText={props.backup.rclone.installed ? (remotes.length === 0 ? 'No remotes in rclone.conf.' : '') : 'rclone is not installed.'}
onChange={(event) => setRemote(event.target.value)}
>
<MenuItem value="">None</MenuItem>
{remotes.map((item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
</TextField>
<TextField
fullWidth
label="Remote path"
value={remotePath}
disabled={remote === ''}
autoComplete="off"
onChange={(event) => setRemotePath(event.target.value)}
/>
</Stack>
<TextField select label="Mode" value={settings.rclone_mode} disabled={remote === ''} onChange={(event) => change({ rclone_mode: event.target.value })}>
{RCLONE_MODES.map((item) => <MenuItem key={item} value={item}>{MODE_LABELS[item]}</MenuItem>)}
</TextField>
</>
) : (
<>
<TextField
label="Repository"
value={settings.pbs_repository}
autoComplete="off"
error={repositoryProblem !== ''}
helperText={repositoryProblem || 'user@realm!token@host:datastore'}
onChange={(event) => change({ pbs_repository: event.target.value.trim() })}
/>
<TextField
label="Token secret"
type="password"
value={password}
autoComplete="new-password"
error={passwordProblem !== ''}
helperText={passwordProblem || (current.pbs_password_set ? 'Leave empty to keep the current secret.' : '')}
onChange={(event) => setPassword(event.target.value)}
/>
<TextField
label="Fingerprint"
value={settings.pbs_fingerprint}
autoComplete="off"
error={fingerprintProblem !== ''}
helperText={fingerprintProblem || 'Of the server certificate, when the system CAs do not verify it.'}
onChange={(event) => change({ pbs_fingerprint: event.target.value.trim() })}
/>
<TextField
label="Namespace"
value={settings.pbs_namespace}
autoComplete="off"
onChange={(event) => change({ pbs_namespace: event.target.value.trim() })}
/>
<TextField
label="Encryption key file"
value={settings.pbs_keyfile}
autoComplete="off"
error={keyfileProblem !== ''}
helperText={keyfileProblem || 'A key of proxmox-backup-client key create on the router; empty for none.'}
onChange={(event) => change({ pbs_keyfile: event.target.value.trim() })}
/>
</>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Save</Button>
</DialogActions>
</Dialog>
)
}
+204
View File
@@ -0,0 +1,204 @@
import Edit from '@mui/icons-material/Edit'
import PlayArrow from '@mui/icons-material/PlayArrow'
import Refresh from '@mui/icons-material/Refresh'
import { Alert, Box, Button, Table, TableBody, TableCell, TableHead, TablePagination, TableRow } from '@mui/material'
import { useCallback, useEffect, useRef, useState } from 'react'
import BackupDialog from './BackupDialog.tsx'
import { BackupChip, TARGET_LABELS, backupRows, when } from './BackupSummary.tsx'
import { Pairs, Section, bytes } from './RouterPanel.tsx'
import SearchField, { matches } from './SearchField.tsx'
import Toolbar from './Toolbar.tsx'
import { ApiError, BACKUP_KEEPS, BACKUP_SCHEDULES, BACKUP_SETTINGS_SHAPE, BACKUP_SHAPE, STARTED_SHAPE, request, type Backup } from './api.ts'
/** The note shown after a change, which the router applies when vpn-router-setup has run again. */
const APPLIED = 'The router applies the change in the background.'
/** Seconds between reads while a backup runs or is starting. */
const WATCH_SECONDS = 3
/**
* The router's backups: the settings, the last run and the local archives, searched by name and time and shown a page at
* a time; edit the settings and start a backup now.
* While a backup runs, or after Run now until it has ended, the tab reads every WATCH_SECONDS.
*/
export default function BackupPanel() {
const [backup, setBackup] = useState<Backup | null>(null)
const [error, setError] = useState<string | null>(null)
const [unavailable, setUnavailable] = useState(false)
const [note, setNote] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [editing, setEditing] = useState(false)
const [watching, setWatching] = useState(false)
const [search, setSearch] = useState('')
const [page, setPage] = useState(0)
const [rows, setRows] = useState(10)
// The start of the last run when Run now was pressed; watching ends once a later run has ended.
const startedBefore = useRef<string | null>(null)
const refresh = useCallback(async () => {
try {
const found = await request<Backup>('GET', '/backup', BACKUP_SHAPE)
setBackup(found)
setError(null)
setUnavailable(false)
if (found.state && found.state.result !== 'running' && found.state.started !== startedBefore.current) {
setWatching(false)
}
} catch (caught) {
const failure = caught as ApiError
setBackup(null)
setUnavailable(failure.status === 404)
setError(failure.message)
setWatching(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const running = backup?.state?.result === 'running'
useEffect(() => {
if (!watching && !running) {
return
}
const timer = window.setInterval(() => void refresh(), WATCH_SECONDS * 1000)
return () => window.clearInterval(timer)
}, [refresh, watching, running])
async function runNow() {
setBusy(true)
try {
startedBefore.current = backup?.state?.started ?? null
await request('POST', '/backup/run', STARTED_SHAPE)
setWatching(true)
setError(null)
} catch (caught) {
setError((caught as Error).message)
} finally {
setBusy(false)
}
}
const archives = (backup?.archives ?? []).filter((archive) => matches(search, [archive.name, when(archive.time)]))
// The page shown stays within the archives found, which a refresh may make fewer.
const shownPage = Math.min(page, Math.max(0, Math.ceil(archives.length / rows) - 1))
const settings = backup?.settings
const local = settings?.target === 'local'
const keeps = settings ? BACKUP_KEEPS.slice(BACKUP_SCHEDULES.indexOf(settings.schedule as (typeof BACKUP_SCHEDULES)[number])) : []
return (
<Box component="section">
<Toolbar description="Backups of the router's configuration, keys, users and metrics.">
<Button variant="contained" startIcon={<Edit />} disabled={busy || backup === null} onClick={() => setEditing(true)}>
Edit
</Button>
<Button variant="outlined" startIcon={<PlayArrow />} disabled={busy || backup === null || running || watching} onClick={() => void runNow()}>
Run now
</Button>
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>
Refresh
</Button>
</Toolbar>
{error && <Alert severity={unavailable ? 'info' : 'error'} sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
{note && <Alert severity="info" sx={{ mb: 2 }} onClose={() => setNote(null)}>{note}</Alert>}
{backup && settings && !local && !backup.client && (
<Alert severity="warning" sx={{ mb: 2 }}>
proxmox-backup-client is not installed on the router; install proxmox-backup-client-static from Proxmox's client repository.
</Alert>
)}
{backup && settings && local && settings.rclone_remote && !(backup.rclone.installed && backup.rclone.config) && (
<Alert severity="warning" sx={{ mb: 2 }}>
{backup.rclone.installed ? 'The router has no /etc/vpn-router/rclone.conf.' : 'rclone is not installed on the router.'} The cloud copy fails until it has.
</Alert>
)}
{backup && settings && (
<Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' } }}>
<Section title="Settings">
<Pairs
rows={[
['Scheduled backups', settings.enabled ? 'on' : 'off'],
['Schedule', settings.schedule],
['Target', TARGET_LABELS[settings.target] ?? settings.target],
['Metrics database', settings.metrics ? 'included' : 'not included'],
...(local
? [
['Folder', settings.path],
['Kept', keeps.map((keep) => `${settings[keep]} ${keep.slice(5)}`).join(', ')],
['Cloud copy', settings.rclone_remote ? `${settings.rclone_mode} to ${settings.rclone_remote}` : 'none'],
] satisfies [string, string][]
: [
['Repository', settings.pbs_repository || '-'],
['Token secret', settings.pbs_password_set ? 'set' : 'missing'],
['Fingerprint', settings.pbs_fingerprint || '-'],
['Namespace', settings.pbs_namespace || '-'],
['Encryption key file', settings.pbs_keyfile || 'none'],
] satisfies [string, string][]),
]}
/>
</Section>
<Section title="Last run">
<Box sx={{ mb: 1 }}><BackupChip state={backup.state} /></Box>
<Pairs rows={backupRows(backup)} />
</Section>
{local && (
<Section title="Local archives" wide>
<Box sx={{ mb: 1 }}>
<SearchField value={search} onChange={(value) => { setSearch(value); setPage(0) }} />
</Box>
<Box sx={{ overflowX: 'auto' }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Archive</TableCell>
<TableCell>Time</TableCell>
<TableCell align="right">Size</TableCell>
</TableRow>
</TableHead>
<TableBody>
{archives.length === 0 && (
<TableRow>
<TableCell colSpan={3} sx={{ color: 'text.secondary' }}>None.</TableCell>
</TableRow>
)}
{archives.slice(shownPage * rows, (shownPage + 1) * rows).map((archive) => (
<TableRow key={archive.name} hover>
<TableCell sx={{ fontFamily: 'monospace' }}>{archive.name}</TableCell>
<TableCell>{when(archive.time)}</TableCell>
<TableCell align="right">{bytes(archive.size)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
<TablePagination
component="div"
count={archives.length}
page={shownPage}
rowsPerPage={rows}
rowsPerPageOptions={[10, 25, 50, 100]}
onPageChange={(_event, next) => setPage(next)}
onRowsPerPageChange={(event) => { setRows(Number(event.target.value)); setPage(0) }}
/>
</Section>
)}
</Box>
)}
{editing && backup && (
<BackupDialog
backup={backup}
onCancel={() => setEditing(false)}
onSave={async (next, password) => {
await request('PUT', '/backup', BACKUP_SETTINGS_SHAPE, password === '' ? next : { ...next, pbs_password: password })
setEditing(false)
setNote(APPLIED)
await refresh()
}}
/>
)}
</Box>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { Chip } from '@mui/material'
import { bytes } from './RouterPanel.tsx'
import type { Backup, BackupState } from './api.ts'
/** The colour of each result of a backup run. */
const RESULT_COLORS: Record<string, 'success' | 'warning' | 'error' | 'info'> = {
ok: 'success',
warning: 'warning',
failed: 'error',
interrupted: 'error',
running: 'info',
}
/** The labels of the backup targets. */
export const TARGET_LABELS: Record<string, string> = { local: 'Local folder', pbs: 'Proxmox Backup Server' }
/** A time of the router in the browser's format, or a dash. */
export function when(time: string | null | undefined) {
return time ? new Date(time).toLocaleString() : '-'
}
/** The result of the last backup run as a chip, or "never" before the first. */
export function BackupChip(props: { state: BackupState | null }) {
return props.state
? <Chip size="small" label={props.state.result} color={RESULT_COLORS[props.state.result] ?? 'default'} />
: <Chip size="small" label="never" variant="outlined" />
}
/** The schedule, the next run (as systemd shows it) and the last run of the router's backups, as rows of Pairs. */
export function backupRows(backup: Backup): [string, string][] {
const { settings, state } = backup
const rows: [string, string][] = [
['Backups', settings.enabled ? `${settings.schedule}, to ${TARGET_LABELS[settings.target] ?? settings.target}` : 'off'],
['Next run', backup.next ?? '-'],
]
if (!state) {
return [...rows, ['Last run', 'none']]
}
rows.push(
['Last run', `${when(state.started)}${state.finished ? ` to ${new Date(state.finished).toLocaleTimeString()}` : ''}`],
['Last result', state.result],
)
if (state.archive ?? state.snapshot) {
rows.push(['Last backup', `${state.archive ?? state.snapshot}${state.size === null ? '' : `, ${bytes(state.size)}`}`])
}
if (state.cloud) {
rows.push(['Cloud copy', `${state.cloud.mode} to ${state.cloud.remote}: ${state.cloud.error ?? state.cloud.result}`])
}
if (state.error) {
rows.push(['Error', state.error])
}
return rows
}
+17
View File
@@ -0,0 +1,17 @@
import { Box, Paper, Typography } from '@mui/material'
/** A centered panel with a title and a message, for states before the application can render. */
export default function Card(props: { title: string; message: string }) {
return (
<Box sx={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', p: 3 }}>
<Paper sx={{ maxWidth: 480, p: 3 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
{props.title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'pre-line' }}>
{props.message}
</Typography>
</Paper>
</Box>
)
}
+162
View File
@@ -0,0 +1,162 @@
import UploadFile from '@mui/icons-material/UploadFile'
import {
Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Radio, RadioGroup, Stack, TextField,
} from '@mui/material'
import { useState, type FormEvent } from 'react'
import { ROUTER_TEMPLATES, type Pki } from './api.ts'
/** A request of POST /ca/certificates: a CSR to sign, or a subject and names for a key the router makes into a PFX file. */
export type IssueRequest =
| { ca: string; template: string; csr: string }
| { ca: string; template: string; subject: string; sans: string[]; password: string }
/** The labels of the kinds of common name of a template. */
const SUBJECT_LABELS: Record<string, string> = { dns: 'a DNS name', name: 'a name', email: 'an e-mail address' }
/** A list typed as text separated by commas. */
function listOf(text: string) {
return text.split(',').map((item) => item.trim()).filter((item) => item !== '')
}
/**
* A dialog that issues a certificate from an active issuing CA with one of the templates it issues but those only the
* router issues: from a CSR, pasted or read from a file, or with a key the router makes for a subject and subject
* alternative names, returned once in a PFX file protected by a password. onSave sends the request and saves what the
* router answers; the dialog closes when it succeeds and shows the router's message when it fails.
*/
export default function CertificateDialog(props: { pki: Pki; onSave: (request: IssueRequest) => Promise<void>; onCancel: () => void }) {
const authorities = props.pki.status.authorities
.map((authority) => ({ ...authority, templates: authority.templates.filter((name) => !(ROUTER_TEMPLATES as readonly string[]).includes(name)) }))
.filter((authority) => authority.state === 'active' && authority.templates.length > 0)
const [ca, setCa] = useState(authorities[0]?.name ?? '')
const [template, setTemplate] = useState(authorities[0]?.templates[0] ?? '')
const [mode, setMode] = useState<'csr' | 'key'>('csr')
const [csr, setCsr] = useState('')
const [subject, setSubject] = useState('')
const [sans, setSans] = useState('')
const [password, setPassword] = useState('')
const [repeated, setRepeated] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const templates = authorities.find((authority) => authority.name === ca)?.templates ?? []
const values = props.pki.templates[template]
const csrProblem = mode === 'csr' && csr.trim() !== '' && !csr.includes('-----BEGIN CERTIFICATE REQUEST-----') ? 'A PEM certificate signing request.' : ''
const repeatProblem = mode === 'key' && repeated !== '' && repeated !== password ? 'The passwords differ.' : ''
const blocked = ca === '' || template === '' || (mode === 'csr'
? csr.trim() === '' || csrProblem !== ''
: subject.trim() === '' || password === '' || password !== repeated)
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave(mode === 'csr'
? { ca, template, csr: csr.trim() }
: { ca, template, subject: subject.trim(), sans: listOf(sans), password })
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>Issue a certificate</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
{authorities.length === 0 && <Alert severity="info">No active issuing CA issues certificates for other systems; add one in Settings.</Alert>}
<Stack direction="row" sx={{ gap: 2 }}>
<TextField
select
fullWidth
label="Issuing CA"
value={ca}
onChange={(event) => {
setCa(event.target.value)
setTemplate(authorities.find((authority) => authority.name === event.target.value)?.templates[0] ?? '')
}}
>
{authorities.map((authority) => <MenuItem key={authority.name} value={authority.name}>{`${authority.name}: ${authority.common_name}`}</MenuItem>)}
</TextField>
<TextField select fullWidth label="Template" value={template} onChange={(event) => setTemplate(event.target.value)}>
{templates.map((name) => <MenuItem key={name} value={name}>{name}</MenuItem>)}
</TextField>
</Stack>
<RadioGroup row value={mode} onChange={(event) => setMode(event.target.value as 'csr' | 'key')}>
<FormControlLabel value="csr" control={<Radio />} label="Sign a CSR" />
<FormControlLabel value="key" control={<Radio />} label="Make a key and a PFX file" />
</RadioGroup>
{mode === 'csr' ? (
<>
<TextField
label="CSR"
value={csr}
multiline
minRows={6}
maxRows={16}
autoComplete="off"
error={csrProblem !== ''}
helperText={csrProblem || "The certificate takes the CSR's key, common name and subject alternative names, checked against the template."}
slotProps={{ htmlInput: { sx: { fontFamily: 'monospace', fontSize: 12 } } }}
onChange={(event) => setCsr(event.target.value)}
/>
<Button component="label" variant="outlined" startIcon={<UploadFile />} sx={{ alignSelf: 'flex-start' }}>
Read a file
<input hidden type="file" accept=".csr,.pem,.req,.txt" onChange={(event) => void event.target.files?.[0]?.text().then(setCsr)} />
</Button>
</>
) : (
<>
<TextField
label="Subject"
value={subject}
autoComplete="off"
helperText={values ? `The common name: ${SUBJECT_LABELS[values.subject] ?? values.subject}.` : ''}
onChange={(event) => setSubject(event.target.value)}
/>
<TextField
label="Subject alternative names"
value={sans}
autoComplete="off"
disabled={values !== undefined && values.sans.length === 0}
helperText={values ? `Separated by commas; the template allows ${values.sans.join(', ') || 'none'}.` : ''}
onChange={(event) => setSans(event.target.value)}
/>
<Stack direction="row" sx={{ gap: 2 }}>
<TextField
fullWidth
label="PFX password"
type="password"
value={password}
autoComplete="new-password"
helperText="Protects the key in the PFX file; the router keeps no copy of the key."
onChange={(event) => setPassword(event.target.value)}
/>
<TextField
fullWidth
label="Repeat the password"
type="password"
value={repeated}
autoComplete="new-password"
error={repeatProblem !== ''}
helperText={repeatProblem}
onChange={(event) => setRepeated(event.target.value)}
/>
</Stack>
</>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Issue</Button>
</DialogActions>
</Dialog>
)
}
+754
View File
@@ -0,0 +1,754 @@
import Add from '@mui/icons-material/Add'
import Block from '@mui/icons-material/Block'
import Delete from '@mui/icons-material/Delete'
import Download from '@mui/icons-material/Download'
import Edit from '@mui/icons-material/Edit'
import Refresh from '@mui/icons-material/Refresh'
import Settings from '@mui/icons-material/Settings'
import UploadFile from '@mui/icons-material/UploadFile'
import WarningAmber from '@mui/icons-material/WarningAmber'
import {
Alert, Box, Button, Chip, IconButton, MenuItem, Stack, Tab, Table, TableBody, TableCell, TableHead, TableRow, Tabs, TextField,
Tooltip, Typography,
} from '@mui/material'
import { useCallback, useEffect, useState } from 'react'
import { when } from './BackupSummary.tsx'
import CertificateDialog from './CertificateDialog.tsx'
import ConfirmDialog, { type ConfirmDialogProps } from './ConfirmDialog.tsx'
import ImportDialog from './ImportDialog.tsx'
import IssuingCaDialog from './IssuingCaDialog.tsx'
import TemplateDialog from './TemplateDialog.tsx'
import Toolbar from './Toolbar.tsx'
import EkuDialog from './EkuDialog.tsx'
import PkiSettingsDialog, { PUBLISH_LABELS } from './PkiSettingsDialog.tsx'
import { Pairs, Section } from './RouterPanel.tsx'
import {
ApiError, BUNDLE_SHAPE, CA_IMPORTED_SHAPE, CRL_IMPORTED_SHAPE, CSR_SHAPE, ISSUED_SHAPE, PFX_SHAPE, PKI_SETTINGS_SHAPE, PKI_SHAPE, REVOKED_SHAPE,
BUILTIN_TEMPLATES, REVOKE_REASONS, request, type Pki, type PkiCertificate, type PkiIssuingCa, type PkiTemplate,
} from './api.ts'
import { saveFile } from './saveFile.ts'
/** The tabs of the page: the root and the general information, the templates, the extended key usages, the two
* built-in issuing CAs, the general-purpose ones. */
type View = 'root' | 'templates' | 'usages' | 'p2s' | 's2s' | 'issuing'
/** The built-in CAs, whose certificates the Issuing CAs tab leaves out. */
const BUILTIN_CAS = ['root', 'p2s', 's2s']
/** The note shown after a change, which the router applies when vpn-router-setup has run again. */
const APPLIED = 'The router applies the change in the background.'
/** The colour of each state of an issuing CA and of a certificate. */
const STATE_COLORS: Record<string, 'success' | 'warning' | 'error' | 'default'> = {
active: 'success',
valid: 'success',
pending: 'warning',
missing: 'error',
expired: 'default',
revoked: 'error',
}
/** The labels of the revocation reasons. */
const REASON_LABELS: Record<string, string> = {
unspecified: 'Unspecified',
key_compromise: 'Key compromise',
affiliation_changed: 'Affiliation changed',
superseded: 'Superseded',
cessation_of_operation: 'Cessation of operation',
privilege_withdrawn: 'Privilege withdrawn',
}
/**
* The router's PKI in tabs: Root, the root CA with the general settings and the certificates of the issuing CAs;
* Templates, the certificate templates, which are added, changed, removed and reset there; Extended key usages, the
* built-in ones and the custom ones, which are added, changed and removed there; P2S Issuing CA and S2S
* Issuing CA, each built-in CA with the certificates it issued; Issuing CAs, the general-purpose CAs, which are added,
* changed and removed there, with their certificates, filtered by CA. The warnings, Issue and Settings
* are above the tabs. Settings edits the pki section, Issue issues a certificate from a CSR or into a PFX file, a
* certificate of another system or of the router is revoked, an issuing CA waiting for the offline root gives its CSR
* and takes its signed certificate, and the root takes a CRL signed offline. The root key is managed on the router
* with vpn-router-ca only.
*/
export default function CertificatesPanel() {
const [pki, setPki] = useState<Pki | null>(null)
const [error, setError] = useState<string | null>(null)
const [unavailable, setUnavailable] = useState(false)
const [note, setNote] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [editing, setEditing] = useState(false)
const [authority, setAuthority] = useState<{ name?: string } | null>(null)
const [template, setTemplate] = useState<{ name?: string } | null>(null)
const [usage, setUsage] = useState<{ name?: string } | null>(null)
const [issuing, setIssuing] = useState(false)
const [importing, setImporting] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [confirm, setConfirm] = useState<ConfirmDialogProps | null>(null)
const [filter, setFilter] = useState('')
const [view, setView] = useState<View>('root')
const refresh = useCallback(async () => {
setBusy(true)
try {
setPki(await request<Pki>('GET', '/ca', PKI_SHAPE))
setError(null)
setUnavailable(false)
} catch (caught) {
const failure = caught as ApiError
setPki(null)
setUnavailable(failure.status === 404)
setError(failure.message)
} finally {
setBusy(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
/** Saves the certificates a client trusts the CA with: the root alone, or an issuing CA followed by the root. */
async function downloadBundle(name: string) {
try {
const { pem } = await request<{ name: string; pem: string }>('GET', `/ca/bundle/${name}`, BUNDLE_SHAPE)
saveFile(name === 'root' ? 'root-ca.pem' : `${name}-ca-bundle.pem`, pem, 'application/x-pem-file')
setError(null)
} catch (caught) {
setError((caught as Error).message)
}
}
async function downloadCsr(name: string) {
try {
const { csr, extensions } = await request<{ csr: string; extensions: string }>('GET', `/ca/issuing/${name}/csr`, CSR_SHAPE)
saveFile(`${name}.csr`, csr, 'application/pkcs10')
saveFile(`${name}-extensions.cnf`, extensions, 'text/plain')
setError(null)
} catch (caught) {
setError((caught as Error).message)
}
}
/** Writes the general-purpose issuing CAs: the current ones with name set to values, or without it when values is null. */
async function saveAuthority(name: string, values: PkiIssuingCa | null) {
const { [name]: _current, ...others } = pki?.settings.issuing_cas ?? {}
await request('PUT', '/ca', PKI_SETTINGS_SHAPE, { issuing_cas: values === null ? others : { ...others, [name]: values } })
setNote(APPLIED)
await refresh()
}
/** Writes the templates of pki.templates: the current ones with name set to values, or without it when values is
* null, which removes a template of the router's own or brings a built-in one back to its defaults. */
async function saveTemplate(name: string, values: PkiTemplate | null) {
const { [name]: _current, ...others } = pki?.settings.templates ?? {}
await request('PUT', '/ca', PKI_SETTINGS_SHAPE, { templates: values === null ? others : { ...others, [name]: values } })
setNote(APPLIED)
await refresh()
}
/** Writes the custom extended key usages: the current ones with name set to oid, or without it when oid is null. */
async function saveUsage(name: string, oid: string | null) {
const { [name]: _current, ...others } = pki?.settings.extended_key_usages ?? {}
await request('PUT', '/ca', PKI_SETTINGS_SHAPE, { extended_key_usages: oid === null ? others : { ...others, [name]: oid } })
setNote(APPLIED)
await refresh()
}
function removeUsage(name: string) {
setConfirm({
title: 'Remove extended key usage',
message: `Remove extended key usage ${name}? The router refuses while a template gives it; certificates issued with it keep it.`,
confirmLabel: 'Remove',
destructive: true,
onCancel: () => setConfirm(null),
onConfirm: () => {
setConfirm(null)
setBusy(true)
saveUsage(name, null)
.then(() => setError(null))
.catch((caught: Error) => setError(caught.message))
.finally(() => setBusy(false))
},
})
}
function removeTemplate(name: string, builtin: boolean) {
setConfirm({
title: builtin ? 'Reset template' : 'Remove template',
message: builtin
? `Bring template ${name} back to its built-in options? The certificates issued afterwards follow them.`
: `Remove template ${name}? The router refuses while an issuing CA issues it; certificates issued with it stay valid.`,
confirmLabel: builtin ? 'Reset' : 'Remove',
destructive: !builtin,
onCancel: () => setConfirm(null),
onConfirm: () => {
setConfirm(null)
setBusy(true)
saveTemplate(name, null)
.then(() => setError(null))
.catch((caught: Error) => setError(caught.message))
.finally(() => setBusy(false))
},
})
}
function removeAuthority(name: string) {
setConfirm({
title: 'Remove issuing CA',
message: `Remove issuing CA ${name}? The router retires it and revokes its certificate on the root CRL; with the root offline, the root CRL is signed offline. Its certificates stay valid until they expire or are revoked.`,
confirmLabel: 'Remove',
destructive: true,
onCancel: () => setConfirm(null),
onConfirm: () => {
setConfirm(null)
setBusy(true)
saveAuthority(name, null)
.then(() => setError(null))
.catch((caught: Error) => setError(caught.message))
.finally(() => setBusy(false))
},
})
}
function revoke(certificate: PkiCertificate) {
let reason: string = REVOKE_REASONS[0]
setConfirm({
title: 'Revoke certificate',
message: `Revoke certificate ${certificate.serial} of ${certificate.subject}? The CRL of ${certificate.ca} lists it at once.${certificate.kind === 'router' ? ' The router issues itself a new one.' : ''}`,
confirmLabel: 'Revoke',
destructive: true,
children: (
<TextField
select
fullWidth
label="Reason"
defaultValue={reason}
sx={{ mt: 2 }}
onChange={(event) => {
reason = event.target.value
}}
>
{REVOKE_REASONS.map((item) => <MenuItem key={item} value={item}>{REASON_LABELS[item]}</MenuItem>)}
</TextField>
),
onCancel: () => setConfirm(null),
onConfirm: () => {
setConfirm(null)
setBusy(true)
request<{ reissue: boolean }>('POST', `/ca/certificates/${certificate.serial}/revoke`, REVOKED_SHAPE, { reason })
.then((revoked) => {
setError(null)
setNote(revoked.reissue ? APPLIED : null)
})
.catch((caught: Error) => setError(caught.message))
.finally(() => void refresh())
},
})
}
const root = pki?.status.root
const general = pki?.status.authorities.filter((authority) => !authority.builtin) ?? []
const generalNames = [...new Set([...general.map((authority) => authority.name),
...(pki?.certificates ?? []).map((certificate) => certificate.ca).filter((name) => !BUILTIN_CAS.includes(name))])]
const generalCertificates = (pki?.certificates ?? [])
.filter((certificate) => !BUILTIN_CAS.includes(certificate.ca) && (filter === '' || certificate.ca === filter))
/** The actions of an issuing CA waiting for the offline root: download its CSR and import its signed certificate. */
const pendingActions = (name: string) => (
<>
<Tooltip title="Download the CSR and the openssl extensions to sign it with">
<span>
<IconButton size="small" disabled={busy} onClick={() => void downloadCsr(name)}><Download fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title="Import the certificate signed by the root">
<span>
<IconButton size="small" disabled={busy} onClick={() => setImporting(name)}><UploadFile fontSize="small" /></IconButton>
</span>
</Tooltip>
</>
)
/** The active issuing CA a certificate the root issued belongs to, matched by its subject and expiry. */
const authorityOf = (certificate: PkiCertificate) => certificate.kind === 'ca' && certificate.state === 'valid'
? pki?.status.authorities.find((item) => item.state === 'active' && item.subject === certificate.subject && item.expires === certificate.expires)
: undefined
/** A table of certificates with their state and, for one of another system or of the router, Revoke; for an active
* issuing CA, the download of its CA bundle. */
const certificateTable = (list: PkiCertificate[]) => (
<Box sx={{ overflowX: 'auto' }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Serial</TableCell>
<TableCell>CA</TableCell>
<TableCell>Kind</TableCell>
<TableCell>Template</TableCell>
<TableCell>Subject</TableCell>
<TableCell>Alternative names</TableCell>
<TableCell>Expires</TableCell>
<TableCell>State</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{list.length === 0 && (
<TableRow>
<TableCell colSpan={9} sx={{ color: 'text.secondary' }}>None.</TableCell>
</TableRow>
)}
{list.map((certificate) => (
<TableRow key={certificate.serial} hover>
<TableCell sx={{ fontFamily: 'monospace', whiteSpace: 'nowrap' }}>{certificate.serial}</TableCell>
<TableCell sx={{ whiteSpace: 'nowrap' }}>{certificate.ca}</TableCell>
<TableCell sx={{ whiteSpace: 'nowrap' }}>{certificate.kind}</TableCell>
<TableCell sx={{ whiteSpace: 'nowrap' }}>{certificate.template ?? '-'}</TableCell>
<TableCell>{certificate.subject}</TableCell>
<TableCell>{certificate.sans.join(', ') || '-'}</TableCell>
<TableCell sx={{ whiteSpace: 'nowrap' }}>{when(certificate.expires)}</TableCell>
<TableCell>
<Tooltip title={certificate.revoked ? `${when(certificate.revoked)}, ${REASON_LABELS[certificate.reason ?? ''] ?? certificate.reason ?? ''}` : ''}>
<Chip size="small" label={certificate.state} color={STATE_COLORS[certificate.state] ?? 'default'} />
</Tooltip>
</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
{authorityOf(certificate) && (
<Tooltip title="Download the CA bundle: the issuing CA and the root">
<span>
<IconButton size="small" disabled={busy} onClick={() => void downloadBundle(authorityOf(certificate)!.name)}>
<Download fontSize="small" />
</IconButton>
</span>
</Tooltip>
)}
{(certificate.kind === 'external' || certificate.kind === 'router') && certificate.state === 'valid' && (
<Tooltip title="Revoke">
<span>
<IconButton size="small" color="error" disabled={busy} onClick={() => revoke(certificate)}><Block fontSize="small" /></IconButton>
</span>
</Tooltip>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
)
/** A tab of a built-in issuing CA: its details, the actions while it waits for the offline root, its certificates. */
const builtinTab = (name: 'p2s' | 's2s', title: string) => {
const authority = pki?.status.authorities.find((item) => item.name === name)
return (
<Box sx={{ display: 'grid', gap: 2 }}>
<Section title={title}>
{authority ? (
<>
<Pairs
rows={[
['Common name', authority.subject ?? authority.common_name],
['State', <Chip size="small" label={authority.state} color={STATE_COLORS[authority.state] ?? 'default'} />],
['Expires', when(authority.expires)],
['CRL valid until', when(authority.crl_next_update)],
['Templates', authority.templates.join(', ')],
]}
/>
{authority.state === 'pending' && (
<Stack direction="row" sx={{ mt: 1, alignItems: 'center', justifyContent: 'flex-end', gap: 1 }}>
<Typography variant="body2" color="text.secondary">Waits for its certificate signed by the root:</Typography>
{pendingActions(name)}
</Stack>
)}
{authority.state === 'active' && (
<Stack direction="row" sx={{ mt: 1, justifyContent: 'flex-end' }}>
<Button size="small" variant="outlined" startIcon={<Download />} disabled={busy} onClick={() => void downloadBundle(name)}>
Download CA bundle
</Button>
</Stack>
)}
</>
) : <Typography variant="body2" color="text.secondary">None yet; the router creates it once a platform is chosen.</Typography>}
</Section>
<Section title="Certificates issued">{certificateTable((pki?.certificates ?? []).filter((certificate) => certificate.ca === name))}</Section>
</Box>
)
}
return (
<Box component="section">
<Toolbar description="The root CA and the issuing CAs for P2S, site-to-site and general use.">
<Button variant="contained" startIcon={<Add />} disabled={busy || pki === null} onClick={() => setIssuing(true)}>
Issue
</Button>
<Button variant="outlined" startIcon={<Settings />} disabled={busy || pki === null} onClick={() => setEditing(true)}>
Settings
</Button>
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>
Refresh
</Button>
</Toolbar>
{error && <Alert severity={unavailable ? 'info' : 'error'} sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
{note && <Alert severity="info" sx={{ mb: 2 }} onClose={() => setNote(null)}>{note}</Alert>}
{pki?.status.warnings.map((warning) => <Alert key={warning} severity="warning" sx={{ mb: 2 }}>{warning}</Alert>)}
{pki && (
<Tabs value={view} onChange={(_event, next: View) => setView(next)} sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}>
<Tab value="root" label="Root" />
<Tab value="templates" label="Templates" />
<Tab value="usages" label="Extended key usages" />
<Tab value="p2s" label="P2S Issuing CA" />
<Tab value="s2s" label="S2S Issuing CA" />
<Tab value="issuing" label="Issuing CAs" />
</Tabs>
)}
{pki && view === 'root' && (
<Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' } }}>
<Section title="Root CA">
{root ? (
<>
<Pairs
rows={[
['Subject', root.subject],
['Expires', when(root.expires)],
['Key', root.key ? (
<Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
on the router
<Tooltip title="Keeping the root key off the router is recommended: vpn-router-ca root export-key, then vpn-router-ca root remove-key.">
<WarningAmber fontSize="small" color="warning" aria-label="The root key is on the router" />
</Tooltip>
</Box>
) : 'offline'],
['CRL valid until', when(root.crl_next_update)],
['CRL', root.crl_source === 'uploaded' ? 'uploaded' : root.crl_source === 'signed' ? 'signed by the router' : '-'],
]}
/>
<Stack direction="row" sx={{ mt: 1, justifyContent: 'flex-end', gap: 1 }}>
<Button size="small" variant="outlined" startIcon={<Download />} disabled={busy} onClick={() => void downloadBundle('root')}>
Download root CA
</Button>
<Button size="small" variant="outlined" startIcon={<UploadFile />} disabled={busy} onClick={() => setUploading(true)}>
Upload CRL
</Button>
</Stack>
</>
) : <Typography variant="body2" color="text.secondary">None yet; the router creates it once a platform is chosen.</Typography>}
</Section>
<Section title="General">
<Pairs
rows={[
['Published at', pki.settings.publish.map((item) => PUBLISH_LABELS[item] ?? item).join('; ') || 'nowhere'],
['CA key', pki.settings.key],
['Lifetimes', `root ${pki.settings.root_days} days, issuing CAs ${pki.settings.ca_days} days`],
['CRLs', `issuing CAs ${pki.settings.crl_days} days, root ${pki.settings.root_crl_days} days`],
]}
/>
</Section>
<Section title="Issuing CA certificates" wide>
{certificateTable(pki.certificates.filter((certificate) => certificate.ca === 'root'))}
{pki.status.retired.length > 0 && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{`Retired: ${pki.status.retired.map((retired) => `${retired.name} (${when(retired.retired)})`).join(', ')}`}
</Typography>
)}
</Section>
</Box>
)}
{pki && view === 'templates' && (
<Box sx={{ display: 'grid', gap: 2 }}>
<Section title="Certificate templates">
<Stack direction="row" sx={{ mb: 1, justifyContent: 'flex-end' }}>
<Button size="small" variant="outlined" startIcon={<Add />} disabled={busy} onClick={() => setTemplate({})}>
Add template
</Button>
</Stack>
<Box sx={{ overflowX: 'auto' }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Key</TableCell>
<TableCell>Days</TableCell>
<TableCell>Subject</TableCell>
<TableCell>Key usage</TableCell>
<TableCell>Extended key usage</TableCell>
<TableCell>Alternative names</TableCell>
<TableCell>Policies</TableCell>
<TableCell>Issued by</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(pki.templates).map(([name, values]) => {
const builtin = (BUILTIN_TEMPLATES as readonly string[]).includes(name)
const changed = pki.settings.templates[name] !== undefined
return (
<TableRow key={name} hover>
<TableCell>
{name}
{builtin && <Chip size="small" variant="outlined" label={changed ? 'built in, changed' : 'built in'} sx={{ ml: 1 }} />}
</TableCell>
<TableCell>{values.key}</TableCell>
<TableCell>{values.days}</TableCell>
<TableCell>{values.subject}</TableCell>
<TableCell>{values.key_usage.join(', ')}</TableCell>
<TableCell>{values.extended_key_usage.join(', ') || '-'}</TableCell>
<TableCell>{values.sans.join(', ') || '-'}</TableCell>
<TableCell>{values.policies.join(', ') || '-'}</TableCell>
<TableCell>
{pki.status.authorities.filter((item) => item.templates.includes(name)).map((item) => item.name).join(', ') || '-'}
</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
<Tooltip title="Edit">
<span>
<IconButton size="small" disabled={busy} onClick={() => setTemplate({ name })}><Edit fontSize="small" /></IconButton>
</span>
</Tooltip>
{(!builtin || changed) && (
<Tooltip title={builtin ? 'Reset to the built-in options' : 'Remove'}>
<span>
<IconButton size="small" color={builtin ? 'default' : 'error'} disabled={busy} onClick={() => removeTemplate(name, builtin)}>
{builtin ? <Refresh fontSize="small" /> : <Delete fontSize="small" />}
</IconButton>
</span>
</Tooltip>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Box>
</Section>
</Box>
)}
{pki && view === 'usages' && (
<Box sx={{ display: 'grid', gap: 2 }}>
<Section title="Extended key usages">
<Stack direction="row" sx={{ mb: 1, justifyContent: 'flex-end' }}>
<Button size="small" variant="outlined" startIcon={<Add />} disabled={busy} onClick={() => setUsage({})}>
Add extended key usage
</Button>
</Stack>
<Box sx={{ overflowX: 'auto' }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>OID</TableCell>
<TableCell>Given by</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(pki.extended_key_usages).map(([name, usage]) => (
<TableRow key={name} hover>
<TableCell>
{name}
{usage.builtin && <Chip size="small" variant="outlined" label="built in" sx={{ ml: 1 }} />}
</TableCell>
<TableCell sx={{ fontFamily: 'monospace' }}>{usage.oid}</TableCell>
<TableCell>
{Object.entries(pki.templates).filter(([, values]) => values.extended_key_usage.includes(name)).map(([template]) => template).join(', ') || '-'}
</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
{!usage.builtin && (
<>
<Tooltip title="Edit">
<span>
<IconButton size="small" disabled={busy} onClick={() => setUsage({ name })}><Edit fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title="Remove">
<span>
<IconButton size="small" color="error" disabled={busy} onClick={() => removeUsage(name)}><Delete fontSize="small" /></IconButton>
</span>
</Tooltip>
</>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
</Section>
</Box>
)}
{pki && view === 'p2s' && builtinTab('p2s', 'P2S Issuing CA: the VPN server certificate and the users\' certificates')}
{pki && view === 's2s' && builtinTab('s2s', 'S2S Issuing CA: the site-to-site certificates of the router and its peers')}
{pki && view === 'issuing' && (
<Box sx={{ display: 'grid', gap: 2 }}>
<Section title="General-purpose issuing CAs">
<Stack direction="row" sx={{ mb: 1, justifyContent: 'flex-end' }}>
<Button size="small" variant="outlined" startIcon={<Add />} disabled={busy} onClick={() => setAuthority({})}>
Add issuing CA
</Button>
</Stack>
<Box sx={{ overflowX: 'auto' }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Common name</TableCell>
<TableCell>State</TableCell>
<TableCell>Expires</TableCell>
<TableCell>CRL valid until</TableCell>
<TableCell>Templates</TableCell>
<TableCell>ACME</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{general.length === 0 && (
<TableRow>
<TableCell colSpan={8} sx={{ color: 'text.secondary' }}>None.</TableCell>
</TableRow>
)}
{general.map((authority) => (
<TableRow key={authority.name} hover>
<TableCell>{authority.name}</TableCell>
<TableCell>{authority.subject ?? authority.common_name}</TableCell>
<TableCell><Chip size="small" label={authority.state} color={STATE_COLORS[authority.state] ?? 'default'} /></TableCell>
<TableCell>{when(authority.expires)}</TableCell>
<TableCell>{when(authority.crl_next_update)}</TableCell>
<TableCell>{authority.templates.join(', ')}</TableCell>
<TableCell sx={{ wordBreak: 'break-all' }}>{authority.acme_url ?? (authority.acme ? 'waits for the certificate' : '-')}</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
{authority.state === 'pending' && pendingActions(authority.name)}
{authority.state === 'active' && (
<Tooltip title="Download the CA bundle: the issuing CA and the root">
<span>
<IconButton size="small" disabled={busy} onClick={() => void downloadBundle(authority.name)}><Download fontSize="small" /></IconButton>
</span>
</Tooltip>
)}
<Tooltip title="Edit">
<span>
<IconButton size="small" disabled={busy} onClick={() => setAuthority({ name: authority.name })}><Edit fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title="Remove">
<span>
<IconButton size="small" color="error" disabled={busy} onClick={() => removeAuthority(authority.name)}><Delete fontSize="small" /></IconButton>
</span>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
</Section>
<Section title="Certificates issued">
<TextField select size="small" label="Issuing CA" value={filter} sx={{ mb: 1, minWidth: 200 }} onChange={(event) => setFilter(event.target.value)}>
<MenuItem value="">All</MenuItem>
{generalNames.map((name) => <MenuItem key={name} value={name}>{name}</MenuItem>)}
</TextField>
{certificateTable(generalCertificates)}
</Section>
</Box>
)}
{editing && pki && (
<PkiSettingsDialog
pki={pki}
onCancel={() => setEditing(false)}
onSave={async (settings) => {
await request('PUT', '/ca', PKI_SETTINGS_SHAPE, settings)
setEditing(false)
setNote(APPLIED)
await refresh()
}}
/>
)}
{usage && pki && (
<EkuDialog
pki={pki}
name={usage.name}
onCancel={() => setUsage(null)}
onSave={async (name, oid) => {
await saveUsage(name, oid)
setUsage(null)
}}
/>
)}
{template && pki && (
<TemplateDialog
pki={pki}
name={template.name}
onCancel={() => setTemplate(null)}
onSave={async (name, values) => {
await saveTemplate(name, values)
setTemplate(null)
}}
/>
)}
{authority && pki && (
<IssuingCaDialog
pki={pki}
name={authority.name}
onCancel={() => setAuthority(null)}
onSave={async (name, values) => {
await saveAuthority(name, values)
setAuthority(null)
}}
/>
)}
{issuing && pki && (
<CertificateDialog
pki={pki}
onCancel={() => setIssuing(false)}
onSave={async (issue) => {
if ('csr' in issue) {
const issued = await request<{ serial: string; certificate: string; chain: string }>('POST', '/ca/certificates', ISSUED_SHAPE, issue)
saveFile(`${issued.serial}.pem`, issued.certificate + issued.chain, 'application/x-pem-file')
} else {
const issued = await request<{ serial: string; pfx: string }>('POST', '/ca/certificates', PFX_SHAPE, issue)
saveFile(`${issue.subject}.pfx`, Uint8Array.from(atob(issued.pfx), (character) => character.charCodeAt(0)), 'application/x-pkcs12')
}
setIssuing(false)
await refresh()
}}
/>
)}
{importing && (
<ImportDialog
title={`Import the certificate of ${importing}`}
message="The certificate of the issuing CA, signed by the root CA for the key of its CSR."
label="Certificate"
begin="-----BEGIN CERTIFICATE-----"
onCancel={() => setImporting(null)}
onSave={async (certificate) => {
await request('PUT', `/ca/issuing/${importing}/certificate`, CA_IMPORTED_SHAPE, { certificate })
setImporting(null)
setNote(APPLIED)
await refresh()
}}
/>
)}
{uploading && (
<ImportDialog
title="Upload the root CRL"
message="A CRL of the root CA signed offline, not older than the current one."
label="CRL"
begin="-----BEGIN X509 CRL-----"
onCancel={() => setUploading(false)}
onSave={async (crl) => {
await request('PUT', '/ca/root/crl', CRL_IMPORTED_SHAPE, { crl })
setUploading(false)
await refresh()
}}
/>
)}
{confirm && <ConfirmDialog {...confirm} />}
</Box>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material'
import type { ReactNode } from 'react'
export type ConfirmDialogProps = {
title: string
message: string
confirmLabel: string
destructive: boolean
onConfirm: () => void
onCancel: () => void
/** Fields shown below the message, such as a choice the confirmation sends. */
children?: ReactNode
}
/** A question that must be answered before the page goes on. Cancel has the initial focus. */
export default function ConfirmDialog(props: ConfirmDialogProps) {
return (
<Dialog open onClose={props.onCancel} maxWidth="xs" fullWidth>
<DialogTitle>{props.title}</DialogTitle>
<DialogContent>
<DialogContentText sx={{ whiteSpace: 'pre-line' }}>{props.message}</DialogContentText>
{props.children}
</DialogContent>
<DialogActions>
<Button autoFocus onClick={props.onCancel}>
Cancel
</Button>
<Button variant="contained" color={props.destructive ? 'error' : 'primary'} onClick={props.onConfirm}>
{props.confirmLabel}
</Button>
</DialogActions>
</Dialog>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField } from '@mui/material'
import { useState, type FormEvent } from 'react'
import { ZONE_PATTERN, type DnsDomain } from './api.ts'
/**
* A dialog that adds a domain of the name server, or edits one given as current: its name, fixed for an existing one,
* and an optional TTL. onSave sends it; the dialog closes when it succeeds and shows the router's message when it fails.
*/
export default function DnsDomainDialog(props: {
current?: DnsDomain
onSave: (domain: { name: string; ttl: number | null }) => Promise<void>
onCancel: () => void
}) {
const [name, setName] = useState(props.current?.name ?? '')
const [ttl, setTtl] = useState(props.current?.ttl ? String(props.current.ttl) : '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const nameProblem = name === '' || ZONE_PATTERN.test(name) ? '' : 'A DNS name in lower case.'
const ttlProblem = ttl === '' || (/^\d{2,5}$/.test(ttl) && Number(ttl) >= 60 && Number(ttl) <= 86400) ? '' : '60 to 86400 seconds.'
const blocked = name === '' || nameProblem !== '' || ttlProblem !== ''
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave({ name, ttl: ttl ? Number(ttl) : null })
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>{props.current ? `Domain ${props.current.name}` : 'Add domain'}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<TextField
label="Domain"
value={name}
autoFocus={!props.current}
autoComplete="off"
disabled={Boolean(props.current)}
error={nameProblem !== ''}
helperText={nameProblem || 'The domain the router is the primary name server of.'}
onChange={(event) => setName(event.target.value.trim().toLowerCase())}
/>
<TextField
label="TTL (seconds)"
value={ttl}
autoComplete="off"
error={ttlProblem !== ''}
helperText={ttlProblem || 'Empty for the name server\'s TTL.'}
slotProps={{ htmlInput: { inputMode: 'numeric' } }}
onChange={(event) => setTtl(event.target.value.trim())}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel} disabled={saving}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>{props.current ? 'Save' : 'Add'}</Button>
</DialogActions>
</Dialog>
)
}
+263
View File
@@ -0,0 +1,263 @@
import Add from '@mui/icons-material/Add'
import Delete from '@mui/icons-material/Delete'
import Edit from '@mui/icons-material/Edit'
import Refresh from '@mui/icons-material/Refresh'
import Settings from '@mui/icons-material/Settings'
import { Alert, Box, Button, Chip, IconButton, Table, TableBody, TableCell, TableHead, TableRow, Tooltip } from '@mui/material'
import { useCallback, useEffect, useState } from 'react'
import ConfirmDialog, { type ConfirmDialogProps } from './ConfirmDialog.tsx'
import DnsDomainDialog from './DnsDomainDialog.tsx'
import DnsRecordDialog from './DnsRecordDialog.tsx'
import DnsSettingsDialog from './DnsSettingsDialog.tsx'
import { Pairs, Section } from './RouterPanel.tsx'
import Toolbar from './Toolbar.tsx'
import {
ApiError, DNS_DOMAIN_REMOVED_SHAPE, DNS_DOMAIN_SHAPE, DNS_RECORD_SHAPE, DNS_REMOVED_SHAPE, DNS_SETTINGS_SHAPE, DNS_SHAPE,
request, type Dns, type DnsDomain, type DnsRecord,
} from './api.ts'
/** A table of records: name, type, content and TTL, with a remove action when onRemove is given. */
function Records(props: { records: DnsRecord[]; busy: boolean; onRemove?: (record: DnsRecord) => void }) {
return (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Type</TableCell>
<TableCell>Content</TableCell>
<TableCell align="right">TTL</TableCell>
{props.onRemove && <TableCell align="right">Actions</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{props.records.length === 0 && (
<TableRow>
<TableCell colSpan={props.onRemove ? 5 : 4} sx={{ color: 'text.secondary' }}>None.</TableCell>
</TableRow>
)}
{props.records.map((record) => (
<TableRow key={`${record.name} ${record.type} ${record.content}`} hover>
<TableCell>{record.name}</TableCell>
<TableCell>{record.type}</TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all' }}>{record.content}</TableCell>
<TableCell align="right">{record.ttl}</TableCell>
{props.onRemove && (
<TableCell align="right">
<Tooltip title="Remove">
<span>
<IconButton size="small" color="error" disabled={props.busy} onClick={() => props.onRemove?.(record)}><Delete fontSize="small" /></IconButton>
</span>
</Tooltip>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)
}
/**
* The router's name server, from its /dns: the settings of the dns section with whether PowerDNS is installed and runs,
* its primary name server, the router's FQDN, and the router's public IP, edited with Settings; the domains it is the
* primary of: add, edit and remove; and for the selected domain its manual records: add and remove, and the records it
* serves. The records of the web sites follow the DNS setting of each site on the Sites page.
*/
export default function DnsPanel() {
const [dns, setDns] = useState<Dns | null>(null)
const [error, setError] = useState<string | null>(null)
const [unavailable, setUnavailable] = useState(false)
const [busy, setBusy] = useState(false)
const [settings, setSettings] = useState(false)
const [selected, setSelected] = useState<string | null>(null)
const [editing, setEditing] = useState<DnsDomain | 'new' | null>(null)
const [addingRecord, setAddingRecord] = useState(false)
const [confirm, setConfirm] = useState<ConfirmDialogProps | null>(null)
const refresh = useCallback(async () => {
setBusy(true)
try {
setDns(await request<Dns>('GET', '/dns', DNS_SHAPE))
setError(null)
setUnavailable(false)
} catch (caught) {
const failure = caught as ApiError
setDns(null)
setUnavailable(failure.status === 404)
setError(failure.message)
} finally {
setBusy(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
/** Ask for a confirmation, then send the removal and refresh. */
function confirmRemove(title: string, message: string, send: () => Promise<unknown>) {
setConfirm({
title,
message,
confirmLabel: 'Remove',
destructive: true,
onCancel: () => setConfirm(null),
onConfirm: async () => {
setConfirm(null)
setBusy(true)
try {
await send()
setError(null)
} catch (caught) {
setError((caught as Error).message)
}
await refresh()
},
})
}
const section = dns?.settings
const domain = dns?.domains.find((item) => item.name === selected) ?? null
const path = domain ? `/dns/domains/${encodeURIComponent(domain.name)}` : ''
function removeDomain(item: DnsDomain) {
confirmRemove('Remove domain', `Remove the domain ${item.name} with its manual records?`,
() => request('DELETE', `/dns/domains/${encodeURIComponent(item.name)}`, DNS_DOMAIN_REMOVED_SHAPE))
}
function removeRecord(record: DnsRecord) {
const query = new URLSearchParams({ name: `${record.name}.`, type: record.type, content: record.content })
confirmRemove('Remove record', `Remove the ${record.type} record of ${record.name}?`,
() => request('DELETE', `${path}/records?${query.toString()}`, DNS_REMOVED_SHAPE))
}
return (
<Box component="section">
<Toolbar description="The primary name server of the router's domains.">
<Button variant="outlined" startIcon={<Settings />} disabled={busy || !section} onClick={() => setSettings(true)}>
Settings
</Button>
<Button variant="contained" startIcon={<Add />} disabled={busy || !section} onClick={() => setEditing('new')}>
Add domain
</Button>
<Button variant="outlined" startIcon={<Add />} disabled={busy || !domain} onClick={() => setAddingRecord(true)}>
Add record
</Button>
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>
Refresh
</Button>
</Toolbar>
{error && <Alert severity={unavailable ? 'info' : 'error'} sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
{dns?.problem && section?.enabled && <Alert severity="warning" sx={{ mb: 2, whiteSpace: 'pre-line' }}>{dns.problem}</Alert>}
{dns && section && (
<Box sx={{ display: 'grid', gap: 2 }}>
<Section title="Settings">
<Pairs
rows={[
['State', <Chip key="state" size="small" label={!section.enabled ? 'disabled' : dns.running ? 'running' : 'not running'} color={!section.enabled ? 'default' : dns.running ? 'success' : 'warning'} />],
['PowerDNS', dns.installed ? 'installed' : 'not installed'],
['Primary name server', dns.nameserver || '-'],
['Secondary name server', section.secondary || (section.enabled ? 'not set' : '-')],
['Public IP address', dns.public_address || '-'],
['Listen', section.listen],
['TTL', `${section.ttl} seconds`],
]}
/>
</Section>
<Section title="Domains">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Domain</TableCell>
<TableCell align="right">Manual records</TableCell>
<TableCell align="right">TTL</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{dns.domains.length === 0 && (
<TableRow>
<TableCell colSpan={4} sx={{ color: 'text.secondary' }}>None.</TableCell>
</TableRow>
)}
{dns.domains.map((item) => (
<TableRow key={item.name} hover selected={item.name === selected} sx={{ cursor: 'pointer' }} onClick={() => setSelected(item.name)}>
<TableCell>{item.name}</TableCell>
<TableCell align="right">{item.manual.length}</TableCell>
<TableCell align="right">{item.ttl ?? section.ttl}</TableCell>
<TableCell align="right" onClick={(event) => event.stopPropagation()}>
<Tooltip title="Edit">
<span>
<IconButton size="small" disabled={busy} onClick={() => setEditing(item)}><Edit fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title="Remove">
<span>
<IconButton size="small" color="error" disabled={busy} onClick={() => removeDomain(item)}><Delete fontSize="small" /></IconButton>
</span>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
{domain && (
<>
<Section title={`Records served of ${domain.name}`}>
<Records records={domain.records} busy={busy} />
</Section>
<Section title={`Manual records of ${domain.name}`}>
<Records records={domain.manual} busy={busy} onRemove={removeRecord} />
</Section>
</>
)}
</Box>
)}
{settings && section && (
<DnsSettingsDialog
current={section}
nameserver={dns.nameserver}
publicAddress={dns.public_address}
onCancel={() => setSettings(false)}
onSave={async (values, publicAddress) => {
await request('PUT', '/dns', DNS_SETTINGS_SHAPE, { ...values, public_address: publicAddress })
setSettings(false)
await refresh()
}}
/>
)}
{editing && (
<DnsDomainDialog
current={editing === 'new' ? undefined : editing}
onCancel={() => setEditing(null)}
onSave={async (values) => {
if (editing === 'new') {
await request('POST', '/dns/domains', DNS_DOMAIN_SHAPE, values)
} else {
await request('PUT', `/dns/domains/${encodeURIComponent(editing.name)}`, DNS_DOMAIN_SHAPE, { ttl: values.ttl })
}
setEditing(null)
setSelected(values.name)
await refresh()
}}
/>
)}
{addingRecord && domain && (
<DnsRecordDialog
domain={domain.name}
onCancel={() => setAddingRecord(false)}
onSave={async (record) => {
await request('POST', `${path}/records`, DNS_RECORD_SHAPE, record)
setAddingRecord(false)
await refresh()
}}
/>
)}
{confirm && <ConfirmDialog {...confirm} />}
</Box>
)
}
+86
View File
@@ -0,0 +1,86 @@
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField } from '@mui/material'
import { useState, type FormEvent } from 'react'
import { DNS_TYPES } from './api.ts'
/** The content each type of record takes. */
const CONTENT_HELP: Record<string, string> = {
A: 'An IPv4 address.',
AAAA: 'An IPv6 address.',
CNAME: 'The host name the name points to.',
TXT: 'The text.',
MX: '<preference> <host>',
SRV: '<priority> <weight> <port> <host>',
CAA: '<flags> <tag> "<value>"',
}
/**
* A dialog that adds a record to a domain: its name, relative to the domain, @ for the domain itself, its type, its
* content and an optional TTL. onSave sends it; the dialog closes when it succeeds and shows the router's message when
* it fails.
*/
export default function DnsRecordDialog(props: {
domain: string
onSave: (record: { name: string; type: string; content: string; ttl?: number }) => Promise<void>
onCancel: () => void
}) {
const [name, setName] = useState('')
const [type, setType] = useState('A')
const [content, setContent] = useState('')
const [ttl, setTtl] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const ttlProblem = ttl === '' || (/^\d{2,5}$/.test(ttl) && Number(ttl) >= 60 && Number(ttl) <= 86400) ? '' : '60 to 86400 seconds.'
const blocked = content.trim() === '' || ttlProblem !== ''
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave({ name: name.trim() || '@', type, content: content.trim(), ...(ttl ? { ttl: Number(ttl) } : {}) })
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>Add record</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<TextField
label="Name"
value={name}
autoFocus
autoComplete="off"
helperText={`The name in ${props.domain}; @ or empty for ${props.domain} itself.`}
onChange={(event) => setName(event.target.value)}
/>
<TextField select label="Type" value={type} onChange={(event) => setType(event.target.value)}>
{DNS_TYPES.map((item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
</TextField>
<TextField label="Content" value={content} autoComplete="off" helperText={CONTENT_HELP[type]} onChange={(event) => setContent(event.target.value)} />
<TextField
label="TTL (seconds)"
value={ttl}
autoComplete="off"
error={ttlProblem !== ''}
helperText={ttlProblem || 'Empty for the domain\'s TTL.'}
slotProps={{ htmlInput: { inputMode: 'numeric' } }}
onChange={(event) => setTtl(event.target.value.trim())}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel} disabled={saving}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Add</Button>
</DialogActions>
</Dialog>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Stack, Switch, TextField } from '@mui/material'
import { useState, type FormEvent } from 'react'
import { ADDRESS_PATTERN, LISTEN_CHOICES, ZONE_PATTERN, type DnsSettings, type Listen } from './api.ts'
/**
* A dialog that edits the router's dns section: whether the name server runs, the secondary name server of every
* domain, the interfaces it answers on and the TTL, and wan.public_address, the router's public IP the records of the
* sites with Public IP take. The primary name server is the router's FQDN, nameserver, shown and not edited. An enabled
* name server needs a secondary other than the router's FQDN. onSave sends the section and the public address; the
* dialog closes when it succeeds and shows the router's message when it fails.
*/
export default function DnsSettingsDialog(props: {
current: DnsSettings
nameserver: string
publicAddress: string
onSave: (settings: DnsSettings, publicAddress: string) => Promise<void>
onCancel: () => void
}) {
const [enabled, setEnabled] = useState(props.current.enabled)
const [secondary, setSecondary] = useState(props.current.secondary)
const [publicAddress, setPublicAddress] = useState(props.publicAddress)
const [listen, setListen] = useState<Listen>(props.current.listen)
const [ttl, setTtl] = useState(String(props.current.ttl))
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const secondaryProblem = secondary === '' ? (enabled ? 'Required while enabled.' : '')
: !ZONE_PATTERN.test(secondary) ? 'A DNS name in lower case.'
: secondary === props.nameserver ? 'The secondary is not the router\'s FQDN, the primary.' : ''
const addressProblem = publicAddress === '' || ADDRESS_PATTERN.test(publicAddress) ? '' : 'Not an IP address.'
const ttlProblem = /^\d{2,5}$/.test(ttl) && Number(ttl) >= 60 && Number(ttl) <= 86400 ? '' : '60 to 86400 seconds.'
const blocked = secondaryProblem !== '' || addressProblem !== '' || ttlProblem !== ''
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave({ enabled, secondary, listen, ttl: Number(ttl) }, publicAddress)
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>Name server settings</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<FormControlLabel control={<Switch checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />} label="Enabled" />
<TextField
label="Primary name server"
value={props.nameserver || '(the router has no FQDN)'}
disabled
helperText="The router's FQDN: the SOA and the first NS of every domain."
/>
<TextField
label="Secondary name server"
value={secondary}
autoFocus
autoComplete="off"
error={secondaryProblem !== ''}
helperText={secondaryProblem || 'The second NS of every domain; the addresses it resolves to may transfer the domains.'}
onChange={(event) => setSecondary(event.target.value.trim().toLowerCase())}
/>
<TextField
label="Public IP address"
value={publicAddress}
autoComplete="off"
error={addressProblem !== ''}
helperText={addressProblem || 'The router\'s public IP, for the records of the sites with Public IP. Empty: none.'}
onChange={(event) => setPublicAddress(event.target.value.trim().toLowerCase())}
/>
<Stack direction="row" sx={{ gap: 2 }}>
<TextField select label="Listen" value={listen} helperText="The interfaces port 53 is open on." onChange={(event) => setListen(event.target.value as Listen)} sx={{ flex: 1 }}>
{LISTEN_CHOICES.map((item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
</TextField>
<TextField
label="TTL (seconds)"
value={ttl}
autoComplete="off"
error={ttlProblem !== ''}
helperText={ttlProblem || 'The TTL of the domains without their own.'}
slotProps={{ htmlInput: { inputMode: 'numeric' } }}
onChange={(event) => setTtl(event.target.value.trim())}
sx={{ flex: 1 }}
/>
</Stack>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel} disabled={saving}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Save</Button>
</DialogActions>
</Dialog>
)
}
+58
View File
@@ -0,0 +1,58 @@
import Refresh from '@mui/icons-material/Refresh'
import { Alert, Box, Button, Typography } from '@mui/material'
import { Pairs, Section, text } from './RouterPanel.tsx'
import Toolbar from './Toolbar.tsx'
import { DETAILS_SHAPE, STATUS_SHAPE, request, type RouterDetails, type RouterStatus } from './api.ts'
import { useRead } from './useRead.ts'
/** Reads the router's /status/full and /config. */
const readDocker = async () => {
const [status, details] = await Promise.all([
request<RouterStatus>('GET', '/status/full', STATUS_SHAPE),
request<RouterDetails>('GET', '/config', DETAILS_SHAPE),
])
return { status, details }
}
/**
* The state of Docker on the router: whether it is installed, the state of docker.service and of the router's Docker
* rules, from /status/full, and the ports exposed on the external interface and the rules of the VPN-ROUTER-DOCKER
* chain, from /config.
*/
export default function DockerPanel() {
const { data, error, busy, refresh } = useRead(readDocker)
const status = data?.status
const details = data?.details
const docker = status?.docker
return (
<Box component="section">
<Toolbar description="Docker and its firewall rules.">
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>Refresh</Button>
</Toolbar>
{error && <Alert severity="error" sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
{status && details && (
<Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' } }}>
<Section title="Status">
<Pairs
rows={[
['Docker', docker === undefined ? 'unknown' : docker.installed ? 'installed' : 'not installed'],
['docker.service', text(docker?.service)],
['Docker rules', text(docker?.rules)],
['Exposed ports', text(details.docker.exposed_ports)],
]}
/>
</Section>
<Section title="Rules of VPN-ROUTER-DOCKER">
{details.docker.rules === null
? <Typography variant="body2" color="text.secondary">Not available.</Typography>
: (
<Box component="pre" sx={{ m: 0, overflow: 'auto', fontFamily: 'monospace', fontSize: 12 }}>
{details.docker.rules.length === 0 ? '(none)' : details.docker.rules.join('\n')}
</Box>
)}
</Section>
</Box>
)}
</Box>
)
}
+274
View File
@@ -0,0 +1,274 @@
import Close from '@mui/icons-material/Close'
import MapIcon from '@mui/icons-material/Map'
import MapOutlined from '@mui/icons-material/MapOutlined'
import Save from '@mui/icons-material/Save'
import { Alert, Box, Button, CircularProgress, IconButton, Stack, Tab, Tabs, Tooltip, Typography } from '@mui/material'
import type * as Monaco from 'monaco-editor/editor'
import { useCallback, useEffect, useRef, useState } from 'react'
import ConfirmDialog from './ConfirmDialog.tsx'
import { ApiError, EDITOR_CONTENT_SHAPE, EDITOR_SAVED_SHAPE, request, type EditorContent, type EditorFile } from './api.ts'
/** The fonts of VS Code's editor on macOS, Windows and Linux. */
const FONT = "Menlo, Monaco, Consolas, 'Droid Sans Mono', 'DejaVu Sans Mono', 'Courier New', monospace"
/** The localStorage key of whether the editor's minimap is hidden. */
const MINIMAP_KEY = 'vpn-router-manager.editor-minimap'
/** What the page knows of an open file: its version on the router, whether it has unsaved changes, and its trouble. */
type FileState = { version: string | null; dirty: boolean; saving: boolean; conflict: boolean; error: string | null }
/** The router's path of a file of the editor. */
function pathOf(file: EditorFile) {
return `/shell/editor/${encodeURIComponent(file.session)}/${encodeURIComponent(file.id)}`
}
/**
* The editor of the Shell page: a tab for each file the shell opened with vre, its name on the tab, its path as the
* tab's tooltip, a dot while it has unsaved changes and a close icon, above a Monaco editor of the file chosen, with
* the syntax colours of the language of its extension and the light or dark theme of the page. Monaco is loaded when
* the panel first shows. The minimap button shows or hides the editor's minimap, kept in localStorage.
*
* Each file is read from the router when it opens. Save, or Ctrl+S, writes the editor's text with the version it
* replaces; when the file changed on the router meanwhile, Reload takes the router's text and Overwrite writes over
* it. Closing a tab with unsaved changes asks first; a closed tab is closed on the router too, and onClosed tells the
* shell. Files no longer in files, as when the shell ended, are dropped.
*/
export default function EditorPanel(props: { files: EditorFile[]; scheme: 'light' | 'dark'; onClosed: (id: string) => void }) {
const { files, scheme, onClosed } = props
const [monaco, setMonaco] = useState<typeof Monaco | null>(null)
const [failed, setFailed] = useState<string | null>(null)
const [states, setStates] = useState<Record<string, FileState>>({})
const [active, setActive] = useState<string | null>(null)
const [closing, setClosing] = useState<EditorFile | null>(null)
const [minimap, setMinimap] = useState(() => window.localStorage.getItem(MINIMAP_KEY) !== 'hidden')
const container = useRef<HTMLDivElement>(null)
const editor = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null)
const models = useRef(new Map<string, { model: Monaco.editor.ITextModel; saved: number }>())
const activeRef = useRef(active)
const saveRef = useRef<(id: string) => void>(() => undefined)
const change = useCallback((id: string, next: Partial<FileState>) => {
setStates((current) => (current[id] ? { ...current, [id]: { ...current[id], ...next } } : current))
}, [])
useEffect(() => {
import('./monaco.ts').then((loaded) => setMonaco(loaded.default), (caught: Error) => setFailed(caught.message))
}, [])
// One editor shows the model of the file chosen; Ctrl+S saves it.
useEffect(() => {
if (!monaco || !container.current) {
return
}
const created = monaco.editor.create(container.current, { automaticLayout: true, fontFamily: FONT, fontSize: 14, model: null })
created.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
if (activeRef.current) {
saveRef.current(activeRef.current)
}
})
editor.current = created
const owned = models.current
return () => {
created.dispose()
editor.current = null
owned.forEach(({ model }) => model.dispose())
owned.clear()
}
}, [monaco])
useEffect(() => {
monaco?.editor.setTheme(scheme === 'dark' ? 'vs-dark' : 'vs')
}, [monaco, scheme])
useEffect(() => {
editor.current?.updateOptions({ minimap: { enabled: minimap } })
}, [monaco, minimap])
/** Shows or hides the minimap, kept in localStorage. */
function toggleMinimap() {
window.localStorage.setItem(MINIMAP_KEY, minimap ? 'hidden' : 'shown')
setMinimap(!minimap)
}
// A new file is read and chosen; a file no longer open is dropped.
useEffect(() => {
if (!monaco) {
return
}
const open = new Set(files.map((file) => file.id))
for (const [id, { model }] of models.current) {
if (!open.has(id)) {
model.dispose()
models.current.delete(id)
}
}
for (const file of files) {
if (models.current.has(file.id)) {
continue
}
const model = monaco.editor.createModel('', undefined, monaco.Uri.file(file.path).with({ query: file.id }))
const entry = { model, saved: model.getAlternativeVersionId() }
models.current.set(file.id, entry)
model.onDidChangeContent(() => change(file.id, { dirty: model.getAlternativeVersionId() !== entry.saved }))
setStates((current) => ({ ...current, [file.id]: { version: null, dirty: false, saving: false, conflict: false, error: null } }))
setActive(file.id)
request<EditorContent>('GET', pathOf(file), EDITOR_CONTENT_SHAPE).then((answer) => {
model.setValue(answer.content)
entry.saved = model.getAlternativeVersionId()
change(file.id, { version: answer.version, dirty: false })
}, (caught: Error) => change(file.id, { error: caught.message }))
}
}, [monaco, files, change])
useEffect(() => {
activeRef.current = active
editor.current?.setModel(active ? models.current.get(active)?.model ?? null : null)
editor.current?.focus()
}, [active, monaco])
/** Writes the editor's text of the file; with overwrite, over whatever the router has now. */
const save = useCallback(async (id: string, overwrite = false) => {
const file = files.find((item) => item.id === id)
const entry = models.current.get(id)
const state = states[id]
if (!file || !entry || !state || state.saving || (state.version === null && !overwrite)) {
return
}
change(id, { saving: true, error: null })
try {
const version = overwrite ? (await request<EditorContent>('GET', pathOf(file), EDITOR_CONTENT_SHAPE)).version : state.version
const alternative = entry.model.getAlternativeVersionId()
const answer = await request<{ version: string }>('PUT', pathOf(file), EDITOR_SAVED_SHAPE, { content: entry.model.getValue(), version })
entry.saved = alternative
change(id, { version: answer.version, saving: false, conflict: false, dirty: entry.model.getAlternativeVersionId() !== alternative })
} catch (caught) {
const conflict = caught instanceof ApiError && caught.status === 409
change(id, { saving: false, conflict, error: conflict ? null : (caught as Error).message })
}
}, [files, states, change])
useEffect(() => {
saveRef.current = (id) => void save(id)
}, [save])
/** Replaces the editor's text of the file with the router's. */
async function reload(file: EditorFile) {
const entry = models.current.get(file.id)
try {
const answer = await request<EditorContent>('GET', pathOf(file), EDITOR_CONTENT_SHAPE)
if (entry) {
entry.model.setValue(answer.content)
entry.saved = entry.model.getAlternativeVersionId()
}
change(file.id, { version: answer.version, dirty: false, conflict: false, error: null })
} catch (caught) {
change(file.id, { error: (caught as Error).message })
}
}
/** Closes the file here and on the router, choosing the tab beside it. */
function close(file: EditorFile) {
setClosing(null)
void request('DELETE', pathOf(file), () => true).catch(() => undefined)
if (active === file.id) {
const others = files.filter((item) => item.id !== file.id)
const index = files.findIndex((item) => item.id === file.id)
setActive(others[Math.min(index, others.length - 1)]?.id ?? null)
}
onClosed(file.id)
}
const current = files.find((file) => file.id === active) ?? null
const state = current ? states[current.id] : undefined
return (
<Box>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, borderBottom: 1, borderColor: 'divider', mb: 1 }}>
<Tabs value={current ? current.id : false} onChange={(_event, id: string) => setActive(id)} variant="scrollable" sx={{ flex: '1 1 0', minWidth: 0 }}>
{files.map((file) => (
<Tab
key={file.id}
value={file.id}
sx={{ textTransform: 'none', minHeight: 40, py: 0 }}
label={
<Tooltip title={file.path}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.5 }}>
<span>{file.path.split('/').pop() || file.path}</span>
{states[file.id]?.dirty && <Box component="span" aria-label="unsaved changes" sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'text.primary' }} />}
<Box
component="span"
role="button"
aria-label={`close ${file.path}`}
onClick={(event) => {
event.stopPropagation()
if (states[file.id]?.dirty) {
setClosing(file)
} else {
close(file)
}
}}
sx={{ display: 'inline-flex', borderRadius: 1, '&:hover': { bgcolor: 'action.hover' } }}
>
<Close sx={{ fontSize: 16 }} />
</Box>
</Stack>
</Tooltip>
}
/>
))}
</Tabs>
<Tooltip title={minimap ? 'Hide the minimap' : 'Show the minimap'}>
<IconButton size="small" aria-label="minimap" aria-pressed={minimap} onClick={toggleMinimap} sx={{ flexShrink: 0 }}>
{minimap ? <MapIcon /> : <MapOutlined />}
</IconButton>
</Tooltip>
<Button
size="small"
variant="contained"
startIcon={<Save />}
disabled={!current || !state?.dirty || state.saving || state.version === null}
onClick={() => current && void save(current.id)}
sx={{ flexShrink: 0 }}
>
Save
</Button>
</Stack>
{failed && <Alert severity="error" sx={{ mb: 1 }}>The editor cannot be loaded: {failed}</Alert>}
{state?.error && <Alert severity="error" sx={{ mb: 1, whiteSpace: 'pre-line' }}>{state.error}</Alert>}
{current && state?.conflict && (
<Alert
severity="warning"
sx={{ mb: 1 }}
action={
<>
<Button color="inherit" size="small" onClick={() => void reload(current)}>Reload</Button>
<Button color="inherit" size="small" onClick={() => void save(current.id, true)}>Overwrite</Button>
</>
}
>
{`${current.path} changed on the router since it was read.`}
</Alert>
)}
<Box sx={{ position: 'relative', height: 'calc(100vh - 280px)', minHeight: 280, border: 1, borderColor: 'divider', borderRadius: 1, overflow: 'hidden' }}>
{!monaco && !failed && (
<Stack sx={{ position: 'absolute', inset: 0, alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Stack>
)}
{current && state?.version === null && !state.error && (
<Typography variant="body2" color="text.secondary" sx={{ position: 'absolute', top: 8, right: 16, zIndex: 1 }}>Reading...</Typography>
)}
<Box ref={container} sx={{ width: '100%', height: '100%' }} />
</Box>
{closing && (
<ConfirmDialog
title="Close without saving"
message={`${closing.path} has changes that are not saved. Closing it drops them.`}
confirmLabel="Close"
destructive
onConfirm={() => close(closing)}
onCancel={() => setClosing(null)}
/>
)}
</Box>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography } from '@mui/material'
import { useState, type FormEvent } from 'react'
import { EKU_NAME_PATTERN, OID_PATTERN, type Pki } from './api.ts'
/**
* A dialog that adds a custom extended key usage, or with name changes the OID of one: its name, fixed once it exists,
* by which templates give it, and its OID in dotted form. onSave gets the name and the OID; the dialog closes when it
* succeeds and shows the router's message when it fails.
*/
export default function EkuDialog(props: { pki: Pki; name?: string; onSave: (name: string, oid: string) => Promise<void>; onCancel: () => void }) {
const [name, setName] = useState(props.name ?? '')
const [oid, setOid] = useState(props.name !== undefined ? props.pki.extended_key_usages[props.name].oid : '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const nameError = props.name !== undefined ? ''
: !EKU_NAME_PATTERN.test(name.trim()) ? 'Up to 31 lower case letters, digits, dashes and underscores, starting with a letter.'
: props.pki.extended_key_usages[name.trim()] !== undefined ? 'An extended key usage has this name.' : ''
const oidError = OID_PATTERN.test(oid.trim()) ? '' : 'An OID in dotted form, such as 1.3.6.1.4.1.311.20.2.2.'
const blocked = nameError !== '' || oidError !== ''
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave(name.trim(), oid.trim())
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>{props.name === undefined ? 'Add extended key usage' : `Edit extended key usage ${props.name}`}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<Typography variant="body2" color="text.secondary">
Templates give an extended key usage by its name; the certificates carry its OID.
</Typography>
<TextField
label="Name"
value={name}
disabled={props.name !== undefined}
autoComplete="off"
error={nameError !== ''}
helperText={nameError}
onChange={(event) => setName(event.target.value)}
/>
<TextField
label="OID"
value={oid}
autoComplete="off"
error={oid !== '' && oidError !== ''}
helperText={oidError}
onChange={(event) => setOid(event.target.value)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Save</Button>
</DialogActions>
</Dialog>
)
}
+89
View File
@@ -0,0 +1,89 @@
import { Alert, Autocomplete, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField } from '@mui/material'
import { useState, type FormEvent } from 'react'
import type { Group } from './api.ts'
/** The group names vpn-router-group accepts, the same as user names. */
const NAME_PATTERN = /^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$/
/** The longest description vpn-router-group accepts. */
const DESCRIPTION_MAX = 256
/**
* A dialog that adds a group, or, given a group, edits it: its name, description and members, at least one, chosen
* from users. onSave sends them; the dialog closes when it succeeds and shows the router's message when it fails.
*/
export default function GroupDialog(props: {
group?: Group
users: string[]
onSave: (name: string, description: string, members: string[]) => Promise<void>
onCancel: () => void
}) {
const adding = props.group === undefined
const [name, setName] = useState(props.group?.name ?? '')
const [description, setDescription] = useState(props.group?.description ?? '')
const [members, setMembers] = useState<string[]>(props.group?.members ?? [])
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const nameProblem = name !== '' && !NAME_PATTERN.test(name)
? 'Up to 32 lower case letters, digits and dashes, starting with a letter and not ending with a dash.'
: ''
const descriptionProblem = description.trim().length > DESCRIPTION_MAX ? `At most ${DESCRIPTION_MAX} characters.` : ''
const blocked = name === '' || nameProblem !== '' || descriptionProblem !== '' || members.length === 0
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave(name, description.trim(), members)
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="xs" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>{adding ? 'Add group' : `Edit group ${props.group?.name}`}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<TextField
label="Name"
value={name}
autoFocus
autoComplete="off"
error={nameProblem !== ''}
helperText={nameProblem}
onChange={(event) => setName(event.target.value)}
/>
<TextField
label="Description"
value={description}
autoComplete="off"
error={descriptionProblem !== ''}
helperText={descriptionProblem}
onChange={(event) => setDescription(event.target.value)}
/>
<Autocomplete
multiple
options={props.users}
value={members}
onChange={(_event, next) => setMembers(next)}
renderInput={(params) => <TextField {...params} label="Members" helperText={members.length === 0 ? 'Select at least one member.' : undefined} />}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>
{adding ? 'Add' : 'Save'}
</Button>
</DialogActions>
</Dialog>
)
}
+192
View File
@@ -0,0 +1,192 @@
import Add from '@mui/icons-material/Add'
import Delete from '@mui/icons-material/Delete'
import Edit from '@mui/icons-material/Edit'
import Refresh from '@mui/icons-material/Refresh'
import {
Alert, Box, Button, Chip, IconButton, Stack, Table, TableBody, TableCell, TableHead, TableRow, Tooltip,
} from '@mui/material'
import { useCallback, useEffect, useState } from 'react'
import ConfirmDialog, { type ConfirmDialogProps } from './ConfirmDialog.tsx'
import GroupDialog from './GroupDialog.tsx'
import { Section } from './RouterPanel.tsx'
import SearchField, { matches } from './SearchField.tsx'
import Toolbar from './Toolbar.tsx'
import { ApiError, GROUPS_SHAPE, GROUP_SHAPE, REMOVED_SHAPE, SERVICE_ACCOUNTS_SHAPE, USERS_SHAPE, request, type Group, type User } from './api.ts'
/**
* The groups of the router's directory: list with their descriptions and members, add, edit the name, the
* description and the members, and remove. A group always keeps at least one member; the members offered are the users
* and the service accounts but the internal ones.
*/
export default function GroupsPanel() {
const [groups, setGroups] = useState<Group[] | null>(null)
const [users, setUsers] = useState<string[]>([])
const [error, setError] = useState<string | null>(null)
const [unavailable, setUnavailable] = useState(false)
const [busy, setBusy] = useState(false)
const [adding, setAdding] = useState(false)
const [editing, setEditing] = useState<Group | null>(null)
const [confirm, setConfirm] = useState<ConfirmDialogProps | null>(null)
const [search, setSearch] = useState('')
const refresh = useCallback(async () => {
setBusy(true)
try {
const [found, people, services] = await Promise.all([
request<{ groups: Group[] }>('GET', '/groups', GROUPS_SHAPE),
request<{ users: User[] }>('GET', '/users', USERS_SHAPE),
request<{ service_accounts: User[] }>('GET', '/service-accounts', SERVICE_ACCOUNTS_SHAPE),
])
setGroups(found.groups)
setUsers([...people.users, ...services.service_accounts.filter((account) => !account.internal)].map((user) => user.name).sort())
setError(null)
setUnavailable(false)
} catch (caught) {
const failure = caught as ApiError
setGroups(null)
setUnavailable(failure.status === 404)
setError(failure.message)
} finally {
setBusy(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
/** Runs a change of a group, then reads the list again; a failure is shown above the list. */
async function change(work: () => Promise<unknown>) {
setBusy(true)
try {
await work()
setError(null)
} catch (caught) {
setError((caught as Error).message)
}
await refresh()
}
function remove(group: Group) {
setConfirm({
title: 'Remove group',
message: `Remove ${group.name}? Its members are kept.`,
confirmLabel: 'Remove',
destructive: true,
onCancel: () => setConfirm(null),
onConfirm: () => {
setConfirm(null)
void change(() => request('DELETE', `/groups/${group.name}`, REMOVED_SHAPE))
},
})
}
const shown = (groups ?? []).filter((group) => matches(search, [group.name, group.description, ...group.members]))
return (
<Box component="section">
<Toolbar description="User groups.">
<SearchField value={search} onChange={setSearch} />
<Button variant="contained" startIcon={<Add />} disabled={busy || groups === null || users.length === 0} onClick={() => setAdding(true)}>
Add
</Button>
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>
Refresh
</Button>
</Toolbar>
{error && <Alert severity={unavailable ? 'info' : 'error'} sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
{groups && (
<Section title="Groups">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Group</TableCell>
<TableCell>Description</TableCell>
<TableCell>Members</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{shown.length === 0 && (
<TableRow>
<TableCell colSpan={4} sx={{ color: 'text.secondary' }}>None.</TableCell>
</TableRow>
)}
{shown.map((group) => (
<TableRow key={group.name} hover>
<TableCell>{group.name}</TableCell>
<TableCell>{group.description || '-'}</TableCell>
<TableCell>
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
{group.members.map((member) => <Chip key={member} size="small" label={member} />)}
</Stack>
</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
<Tooltip title="Edit">
<span>
<IconButton size="small" disabled={busy} onClick={() => setEditing(group)}><Edit fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title="Remove">
<span>
<IconButton size="small" color="error" disabled={busy} onClick={() => remove(group)}><Delete fontSize="small" /></IconButton>
</span>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
)}
{adding && (
<GroupDialog
users={users}
onCancel={() => setAdding(false)}
onSave={async (name, description, members) => {
await request('POST', '/groups', GROUP_SHAPE, { name, description, members })
setAdding(false)
await refresh()
}}
/>
)}
{editing && (
<GroupDialog
group={editing}
users={users}
onCancel={() => setEditing(null)}
onSave={async (name, description, members) => {
// Only what changed is sent: the changed properties, then each member added and each member taken out,
// the additions first so that the group never loses its last member; nothing when nothing changed.
const properties: { name?: string; description?: string } = {}
if (name !== editing.name) {
properties.name = name
}
if (description !== editing.description) {
properties.description = description
}
const added = members.filter((member) => !editing.members.includes(member))
const removed = editing.members.filter((member) => !members.includes(member))
if (Object.keys(properties).length > 0) {
await request('PUT', `/groups/${editing.name}`, GROUP_SHAPE, properties)
}
for (const user of added) {
await request('POST', `/groups/${name}/members`, GROUP_SHAPE, { user })
}
for (const user of removed) {
await request('DELETE', `/groups/${name}/members/${user}`, GROUP_SHAPE)
}
setEditing(null)
if (Object.keys(properties).length > 0 || added.length > 0 || removed.length > 0) {
await refresh()
}
}}
/>
)}
{confirm && <ConfirmDialog {...confirm} />}
</Box>
)
}
+178
View File
@@ -0,0 +1,178 @@
import ArrowBack from '@mui/icons-material/ArrowBack'
import Close from '@mui/icons-material/Close'
import Home from '@mui/icons-material/Home'
import { Alert, Box, Drawer, IconButton, Stack, Tooltip, Typography, useColorScheme } from '@mui/material'
import DOMPurify from 'dompurify'
import darkCss from 'github-markdown-css/github-markdown-dark.css?inline'
import lightCss from 'github-markdown-css/github-markdown-light.css?inline'
import { marked } from 'marked'
import { useEffect, useRef, useState, type MouseEvent } from 'react'
import { SESSION_EXPIRED } from './api.ts'
/** The width of the panel, in pixels, by which the page's right edge moves left while it is open. */
export const HELP_WIDTH = 420
/** The first document of the help. */
export const HELP_HOME = 'index.md'
/** The anchor name of a heading's text, unique within the document, as serve-markdown makes it. */
function headingSlug(text: string, taken: Set<string>) {
const base = text.toLowerCase().trim().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '') || 'section'
let slug = base
for (let suffix = 2; taken.has(slug); suffix += 1) {
slug = `${base}-${suffix}`
}
taken.add(slug)
return slug
}
/**
* Names the headings of the rendered document and resolves its links and pictures against the document's folder in /docs,
* as serve-markdown's resolveLinks does: a link to another document of the help gets data-doc, the document's path in /docs
* with its anchor, which the panel opens; an anchor gets data-anchor; a link to another host opens in a new tab; a picture
* of /docs keeps its path.
*/
function resolve(content: HTMLElement, path: string) {
const taken = new Set<string>()
content.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6').forEach((heading) => {
heading.id = headingSlug(heading.textContent ?? '', taken)
})
const base = new URL(`/docs/${path}`, window.location.origin)
content.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((link) => {
const href = link.getAttribute('href') ?? ''
if (href.startsWith('#')) {
link.dataset.anchor = href.slice(1)
return
}
const target = new URL(href, base)
if (target.origin !== window.location.origin) {
link.target = '_blank'
link.rel = 'noopener noreferrer'
} else if (target.pathname.startsWith('/docs/') && target.pathname.endsWith('.md')) {
link.dataset.doc = decodeURIComponent(target.pathname.slice('/docs/'.length)) + target.hash
}
})
content.querySelectorAll<HTMLImageElement>('img[src]').forEach((picture) => {
const target = new URL(picture.getAttribute('src') ?? '', base)
if (target.origin === window.location.origin) {
picture.src = target.pathname
}
})
}
/**
* The help: a panel on the right, under which the page moves left, that renders the Markdown documents of /docs in the
* browser with marked and DOMPurify, styled with github-markdown-css in the Manager's light or dark scheme. It opens at
* topic, and goes to topic again when topic changes; a link to another document opens it in the panel, Back returns to
* the one before and Home opens HELP_HOME. Documents link to each other and to their SVG and PNG pictures by paths
* relative to their folder.
*/
export default function HelpPanel(props: { open: boolean; topic: string; onClose: () => void }) {
const { mode, systemMode } = useColorScheme()
const scheme = (mode === 'system' ? systemMode : mode) === 'dark' ? 'dark' : 'light'
const [history, setHistory] = useState<string[]>([props.topic])
const [title, setTitle] = useState('Help')
const [error, setError] = useState<string | null>(null)
const content = useRef<HTMLDivElement>(null)
const current = history[history.length - 1]
const [path, anchor] = current.split('#')
useEffect(() => {
setHistory([props.topic])
}, [props.topic])
useEffect(() => {
if (!props.open) {
return
}
let stale = false
fetch(`/docs/${path}`)
.then(async (response) => {
if (response.status === 401) {
window.dispatchEvent(new Event(SESSION_EXPIRED))
}
if (!response.ok) {
throw new Error(response.status === 404 ? `${path} is not in the help.` : `/docs/${path} failed (${response.status}).`)
}
return response.text()
})
.then((text) => {
if (stale || !content.current) {
return
}
content.current.innerHTML = DOMPurify.sanitize(marked.parse(text, { gfm: true, async: false }))
resolve(content.current, path)
setTitle(content.current.querySelector('h1')?.textContent || 'Help')
setError(null)
const target = anchor ? content.current.querySelector(`#${CSS.escape(anchor)}`) : null
if (target) {
target.scrollIntoView()
} else {
content.current.parentElement?.scrollTo(0, 0)
}
})
.catch((caught: Error) => {
if (!stale) {
setError(caught.message)
}
})
return () => {
stale = true
}
}, [props.open, path, anchor])
/** Opens a link to another document in the panel and scrolls to an anchor; other links go their own way. */
function follow(event: MouseEvent<HTMLDivElement>) {
const link = (event.target as HTMLElement).closest('a')
if (!link) {
return
}
if (link.dataset.doc) {
event.preventDefault()
setHistory((previous) => [...previous, link.dataset.doc as string])
} else if (link.dataset.anchor) {
event.preventDefault()
content.current?.querySelector(`#${CSS.escape(link.dataset.anchor)}`)?.scrollIntoView({ behavior: 'smooth' })
}
}
return (
<Drawer
variant="persistent"
anchor="right"
open={props.open}
slotProps={{ paper: { sx: { width: { xs: '100vw', sm: HELP_WIDTH }, boxSizing: 'border-box' } } }}
>
<style>{scheme === 'dark' ? darkCss : lightCss}</style>
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.5, px: 1, py: 1, borderBottom: 1, borderColor: 'divider' }}>
<Tooltip title="Back">
<span>
<IconButton size="small" aria-label="back" disabled={history.length < 2} onClick={() => setHistory((previous) => previous.slice(0, -1))}>
<ArrowBack fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Contents">
<IconButton size="small" aria-label="contents" onClick={() => setHistory((previous) => [...previous, HELP_HOME])}>
<Home fontSize="small" />
</IconButton>
</Tooltip>
<Typography variant="subtitle1" noWrap sx={{ flex: 1, fontWeight: 700, ml: 1 }}>{title}</Typography>
<Tooltip title="Close the help">
<IconButton size="small" aria-label="close the help" onClick={props.onClose}>
<Close fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<Box sx={{ flex: 1, overflowY: 'auto', p: 2 }}>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Box
ref={content}
className="markdown-body"
onClick={follow}
sx={{ bgcolor: 'transparent !important', fontSize: 14, '& img': { maxWidth: '100%' } }}
/>
</Box>
</Drawer>
)
}
+54
View File
@@ -0,0 +1,54 @@
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, Stack, Switch, Typography } from '@mui/material'
import { useState, type FormEvent } from 'react'
import type { HttpsSettings } from './api.ts'
/**
* A dialog that switches the CA of the certificates Caddy obtains on the router: Let's Encrypt's staging environment,
* https.acme_staging, or Caddy's default CAs. onSave sends acme_staging; the dialog closes when it succeeds and shows
* the router's message when it fails.
*/
export default function HttpsSettingsDialog(props: {
current: HttpsSettings
onSave: (settings: { acme_staging: boolean }) => Promise<void>
onCancel: () => void
}) {
const [staging, setStaging] = useState(props.current.acme_staging)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
async function submit(event: FormEvent) {
event.preventDefault()
setSaving(true)
setError(null)
try {
await props.onSave({ acme_staging: staging })
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>HTTPS settings</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<FormControlLabel control={<Switch checked={staging} onChange={(event) => setStaging(event.target.checked)} />} label="Let's Encrypt staging CA" />
<Typography variant="body2" color="text.secondary">
{staging
? 'Caddy takes every certificate from Let\'s Encrypt\'s staging environment, the web sites\' included. Its rate limits are far higher, but no browser trusts its certificates: for routers rebuilt often, for tests only.'
: 'Caddy takes its certificates from Let\'s Encrypt, and from ZeroSSL when Let\'s Encrypt refuses, such as for its limit of 5 certificates a week for the same name.'}
</Typography>
{staging !== props.current.acme_staging && (
<Alert severity="info">The router runs its setup again, which applies the change.</Alert>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel} disabled={saving}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || staging === props.current.acme_staging}>Save</Button>
</DialogActions>
</Dialog>
)
}
+72
View File
@@ -0,0 +1,72 @@
import UploadFile from '@mui/icons-material/UploadFile'
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Stack, TextField } from '@mui/material'
import { useState, type FormEvent } from 'react'
/**
* A dialog that takes a PEM text, pasted or read from a file, such as the certificate of an issuing CA signed by the
* offline root or a root CRL. begin is the PEM header the text must have. onSave sends the text; the dialog closes when
* it succeeds and shows the router's message when it fails.
*/
export default function ImportDialog(props: {
title: string
message: string
label: string
begin: string
onSave: (text: string) => Promise<void>
onCancel: () => void
}) {
const [text, setText] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const problem = text.trim() !== '' && !text.includes(props.begin) ? `A PEM text with ${props.begin}.` : ''
const blocked = text.trim() === '' || problem !== ''
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave(text.trim())
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>{props.title}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<DialogContentText>{props.message}</DialogContentText>
<TextField
label={props.label}
value={text}
multiline
minRows={6}
maxRows={16}
autoFocus
autoComplete="off"
error={problem !== ''}
helperText={problem || 'Paste the PEM text, or read it from a file.'}
slotProps={{ htmlInput: { sx: { fontFamily: 'monospace', fontSize: 12 } } }}
onChange={(event) => setText(event.target.value)}
/>
<Button component="label" variant="outlined" startIcon={<UploadFile />} sx={{ alignSelf: 'flex-start' }}>
Read a file
<input hidden type="file" accept=".pem,.crt,.crl,.cer,.txt" onChange={(event) => void event.target.files?.[0]?.text().then(setText)} />
</Button>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Import</Button>
</DialogActions>
</Dialog>
)
}
+100
View File
@@ -0,0 +1,100 @@
import {
Alert, Autocomplete, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, Stack, Switch, TextField, Typography,
} from '@mui/material'
import { useState, type FormEvent } from 'react'
import { NAME_PATTERN, RESERVED, nameProblem } from './PkiSettingsDialog.tsx'
import { ROUTER_TEMPLATES, type Pki, type PkiIssuingCa } from './api.ts'
/**
* A dialog that adds a general-purpose issuing CA, or with name changes one: its name, fixed once it exists, its common
* name, the templates it issues and whether Caddy runs an ACME server for it. onSave gets the name and the options; the
* dialog closes when it succeeds and shows the router's message when it fails.
*/
export default function IssuingCaDialog(props: {
pki: Pki
name?: string
onSave: (name: string, values: PkiIssuingCa) => Promise<void>
onCancel: () => void
}) {
const existing = props.pki.status.authorities.find((authority) => authority.name === props.name)
const issuable = Object.keys(props.pki.templates).filter((name) => !(ROUTER_TEMPLATES as readonly string[]).includes(name))
const [name, setName] = useState(props.name ?? '')
const [commonName, setCommonName] = useState(existing?.common_name ?? '')
const [templates, setTemplates] = useState<string[]>(existing?.templates ?? (issuable.includes('server') ? ['server'] : []))
const [acme, setAcme] = useState(existing?.acme ?? false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const taken = props.pki.status.authorities.some((authority) => authority.name === name.trim())
const nameError = props.name !== undefined ? ''
: !NAME_PATTERN.test(name.trim()) || RESERVED.includes(name.trim()) ? 'Up to 31 lower case letters, digits and dashes, starting with a letter; not root, p2s or s2s.'
: taken ? 'An issuing CA has this name.' : ''
const commonNameError = nameProblem(commonName)
const templatesError = templates.length === 0 ? 'At least one template.' : ''
const blocked = nameError !== '' || commonNameError !== '' || templatesError !== ''
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave(name.trim(), { name: commonName.trim(), templates, acme })
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>{props.name === undefined ? 'Add issuing CA' : `Edit issuing CA ${props.name}`}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<Typography variant="body2" color="text.secondary">
{props.name === undefined
? 'The root signs the new CA at once while its key is on the router; otherwise the CA waits with a CSR for the offline root.'
: 'The common name applies when the CA is created; the templates and the ACME server apply at once.'}
</Typography>
<TextField
label="Name"
value={name}
disabled={props.name !== undefined}
autoComplete="off"
error={nameError !== ''}
helperText={nameError || 'Names its directory and its published files.'}
onChange={(event) => setName(event.target.value)}
/>
<TextField
label="Common name"
value={commonName}
autoComplete="off"
error={commonNameError !== ''}
helperText={commonNameError}
onChange={(event) => setCommonName(event.target.value)}
/>
<Autocomplete
multiple
options={issuable}
value={templates}
onChange={(_event, next) => setTemplates(next)}
renderInput={(params) => (
<TextField {...params} label="Templates" error={templatesError !== ''} helperText={templatesError || 'The templates the CA issues.'} />
)}
/>
<FormControlLabel
control={<Switch checked={acme} onChange={(event) => setAcme(event.target.checked)} />}
label="ACME server (Caddy, needs HTTPS)"
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Save</Button>
</DialogActions>
</Dialog>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { Box } from '@mui/material'
import { useEffect, useRef } from 'react'
/** Lines of a log in a scrolling monospace box, or empty when there are none; with follow, scrolled to the last line whenever the lines change. */
export default function LogView(props: { lines: string[] | null; empty: string; maxHeight?: number; follow?: boolean }) {
const box = useRef<HTMLPreElement>(null)
useEffect(() => {
if (props.follow && box.current) {
box.current.scrollTop = box.current.scrollHeight
}
}, [props.lines, props.follow])
return (
<Box ref={box} component="pre" sx={{ m: 0, p: 1, maxHeight: props.maxHeight ?? 400, overflow: 'auto', fontFamily: 'monospace', fontSize: 12, bgcolor: 'action.hover' }}>
{props.lines && props.lines.length > 0 ? props.lines.join('\n') : props.empty}
</Box>
)
}
+116
View File
@@ -0,0 +1,116 @@
import Refresh from '@mui/icons-material/Refresh'
import VerticalAlignBottom from '@mui/icons-material/VerticalAlignBottom'
import { Alert, Box, Button, MenuItem, Tab, Tabs, TextField, ToggleButton } from '@mui/material'
import { useCallback, useEffect, useState } from 'react'
import LogView from './LogView.tsx'
import Toolbar from './Toolbar.tsx'
import { LOGS_SHAPE, LOG_LINES_SHAPE, request, type Logs } from './api.ts'
/** The numbers of lines offered, up to the router's most. */
const LINE_CHOICES = [100, 200, 500, 1000]
/** Seconds between the reads of a followed log. */
const FOLLOW_SECONDS = 2
/**
* The router's logs, from its /logs: the Journal sub-tab shows the last lines of the journal of a unit the router names, or of
* the whole journal, and the Files sub-tab the last lines of a log file the router's web.log_files lists. The number of lines
* is chosen up to the router's most; a log is read when its unit, file or number of lines is chosen and with Refresh, and
* while Follow is on every FOLLOW_SECONDS, scrolled to its last line, as tail -f shows it.
*/
export default function LogsPanel() {
const [sources, setSources] = useState<Logs | null>(null)
const [kind, setKind] = useState<'journal' | 'file'>('journal')
const [unit, setUnit] = useState('')
const [file, setFile] = useState('')
const [count, setCount] = useState(200)
const [lines, setLines] = useState<string[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [follow, setFollow] = useState(false)
useEffect(() => {
request<Logs>('GET', '/logs', LOGS_SHAPE)
.then((found) => {
setSources(found)
setFile((current) => current || found.files[0] || '')
})
.catch((caught) => setError((caught as Error).message))
}, [])
const read = useCallback(async () => {
if (kind === 'file' && !file) {
setLines(null)
return
}
setBusy(true)
const query = kind === 'journal'
? `/logs/journal?lines=${count}${unit ? `&unit=${encodeURIComponent(unit)}` : ''}`
: `/logs/file?lines=${count}&path=${encodeURIComponent(file)}`
try {
setLines((await request<{ lines: string[] }>('GET', query, LOG_LINES_SHAPE)).lines)
setError(null)
} catch (caught) {
setLines(null)
setError((caught as Error).message)
} finally {
setBusy(false)
}
}, [kind, unit, file, count])
useEffect(() => {
if (sources) {
void read()
}
}, [sources, read])
useEffect(() => {
if (!follow || !sources) {
return
}
const timer = window.setInterval(() => void read(), FOLLOW_SECONDS * 1000)
return () => window.clearInterval(timer)
}, [follow, sources, read])
return (
<Box component="section">
<Tabs value={kind} onChange={(_event, next: 'journal' | 'file') => setKind(next)} sx={{ mb: 2 }}>
<Tab value="journal" label="Journal" />
<Tab value="file" label="Files" />
</Tabs>
<Toolbar description={kind === 'journal' ? 'The last lines of the systemd journal.' : 'The last lines of a log file.'}>
{kind === 'journal' ? (
<TextField select size="small" label="Unit" value={unit} onChange={(event) => setUnit(event.target.value)} sx={{ minWidth: 240 }}>
<MenuItem value="">All units</MenuItem>
{(sources?.units ?? []).map((name) => <MenuItem key={name} value={name}>{name}</MenuItem>)}
</TextField>
) : (
<TextField
select
size="small"
label="File"
value={file}
disabled={!sources?.files.length}
onChange={(event) => setFile(event.target.value)}
sx={{ minWidth: 240 }}
>
{(sources?.files ?? []).map((name) => <MenuItem key={name} value={name}>{name}</MenuItem>)}
</TextField>
)}
<TextField select size="small" label="Lines" value={count} onChange={(event) => setCount(Number(event.target.value))} sx={{ minWidth: 100 }}>
{LINE_CHOICES.filter((choice) => choice <= (sources?.max_lines ?? 0)).map((choice) => <MenuItem key={choice} value={choice}>{choice}</MenuItem>)}
</TextField>
<ToggleButton value="follow" size="small" color="primary" selected={follow} disabled={!sources} onChange={() => setFollow(!follow)}>
<VerticalAlignBottom fontSize="small" sx={{ mr: 0.5 }} />
Follow
</ToggleButton>
<Button variant="outlined" startIcon={<Refresh />} disabled={busy || follow || !sources} onClick={() => void read()}>
Refresh
</Button>
</Toolbar>
{error && <Alert severity="error" sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
{kind === 'file' && sources && sources.files.length === 0 && <Alert severity="info" sx={{ mb: 2 }}>No log files are configured.</Alert>}
<LogView lines={lines} empty={busy ? '(reading)' : '(no lines)'} maxHeight={600} follow={follow} />
</Box>
)
}
+264
View File
@@ -0,0 +1,264 @@
import Refresh from '@mui/icons-material/Refresh'
import Settings from '@mui/icons-material/Settings'
import { Alert, Box, Button, Stack, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { LineChart } from '@mui/x-charts/LineChart'
import { useCallback, useEffect, useState } from 'react'
import MetricsSettingsDialog from './MetricsSettingsDialog.tsx'
import { Board, BoardButtons, useBoard, type BoardPanel } from './PanelBoard.tsx'
import { bytes } from './RouterPanel.tsx'
import {
METRICS_RANGES, METRICS_SETTINGS_SHAPE, METRICS_SHAPE, request, type Aggregate, type MetricsHistory, type MetricsRange,
type MetricsSettings,
} from './api.ts'
import { useBrowserSettings } from './settings.ts'
/** The localStorage key of the tab's layout. */
const LAYOUT_KEY = 'vpn-router-manager.metrics-layout'
/** The localStorage key of the lines hidden by their legend item: chart id -> line labels. */
const HIDDEN_KEY = 'vpn-router-manager.metrics-hidden'
/** The hidden lines kept in localStorage, or none when there are none or they cannot be read. */
function readHidden(): Record<string, string[]> {
try {
const value: unknown = JSON.parse(window.localStorage.getItem(HIDDEN_KEY) ?? 'null')
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
return Object.fromEntries(Object.entries(value).map(([chart, labels]) => [
chart, Array.isArray(labels) ? labels.filter((label): label is string => typeof label === 'string') : []]))
}
} catch {
// Hidden lines that cannot be read are shown.
}
return {}
}
/** The label of each view, how a time on its axis is written, and the start of the span it shows, which ends at end, as the router's span. */
const RANGES: Record<MetricsRange, { label: string; format: Intl.DateTimeFormatOptions; start: (end: Date) => Date }> = {
hour: { label: 'Hour', format: { hour: '2-digit', minute: '2-digit' }, start: (end) => new Date(end.getTime() - 3600_000) },
day: { label: 'Day', format: { hour: '2-digit', minute: '2-digit' }, start: (end) => new Date(end.getTime() - 86400_000) },
month: { label: 'Month', format: { month: 'short', day: 'numeric' }, start: (end) => new Date(new Date(end).setMonth(end.getMonth() - 1)) },
year: { label: 'Year', format: { year: 'numeric', month: 'short' }, start: (end) => new Date(new Date(end).setFullYear(end.getFullYear() - 1)) },
}
/** The labels of the kinds of VPN clients the router counts. */
const CLIENT_KINDS: Record<string, string> = {
p2s: 'IKEv2',
openvpn: 'OpenVPN',
wireguard: 'WireGuard',
}
/** Which value of a bucket the charts show: its average or its maximum. */
type Consolidation = 'avg' | 'max'
/** A line of a chart: its label and its values, null where a bucket has no data. */
type Line = { label: string; data: (number | null)[] }
/**
* The router's metrics as charts, for the last hour, day, month or year, as the router's /metrics/history answers them:
* CPU, memory, free disk space in bytes and in percent, the bytes per second of each interface, the VPN clients, the
* load averages, and, per site-to-site connection, the part of the time it was up and its bytes and packets per
* second; each bucket as its average or its maximum. The time axis spans the whole view, ending at the last read,
* so a part without data stays empty. Read at once, on every change of the view, then as the browser's settings say (every minute by default, or only on Refresh).
*
* The charts are panels of a PanelBoard, as on the Overview tab, with their layout kept in the browser: each folds by its
* title bar and hides by its [X]; Panels shows them again, and Edit layout moves them by dragging and sets their width.
* A click on a legend item hides or shows its line; the hidden lines of each chart are kept in the browser too.
* Settings reads the router's metrics section from /metrics/settings and edits it in MetricsSettingsDialog.
*/
export default function MetricsPanel() {
const board = useBoard(LAYOUT_KEY)
const [range, setRange] = useState<MetricsRange>('hour')
const [consolidation, setConsolidation] = useState<Consolidation>('avg')
const [history, setHistory] = useState<MetricsHistory | null>(null)
const [error, setError] = useState<string | null>(null)
const [reading, setReading] = useState(true)
const [at, setAt] = useState<Date | null>(null)
const [hidden, setHidden] = useState<Record<string, string[]>>(readHidden)
const [settings, setSettings] = useState<(MetricsSettings & { otlp_password_set: boolean }) | null>(null)
const [opening, setOpening] = useState(false)
/** Reads the metrics section and opens the settings dialog with it. */
async function openSettings() {
setOpening(true)
try {
setSettings((await request<{ settings: MetricsSettings & { otlp_password_set: boolean } }>('GET', '/metrics/settings', METRICS_SETTINGS_SHAPE)).settings)
setError(null)
} catch (caught) {
setError(`The router did not give its metrics settings: ${(caught as Error).message}`)
}
setOpening(false)
}
/** Keeps the lines of a chart that are hidden, in the state and in localStorage. */
function hide(chart: string, labels: string[]) {
setHidden((current) => {
const next = { ...current, [chart]: labels }
window.localStorage.setItem(HIDDEN_KEY, JSON.stringify(next))
return next
})
}
const refresh = useCallback(async () => {
setReading(true)
try {
setHistory(await request<MetricsHistory>('GET', `/metrics/history?range=${range}`, METRICS_SHAPE))
setError(null)
} catch (caught) {
setError(`The router did not give its metrics: ${(caught as Error).message}`)
}
setAt(new Date())
setReading(false)
}, [range])
// A read at once and on every change of the view, then every metricsRefresh seconds of the browser's settings, unless
// it is manual (0).
const { metricsRefresh } = useBrowserSettings()
useEffect(() => {
void refresh()
}, [refresh])
useEffect(() => {
if (metricsRefresh === 0) {
return
}
const timer = window.setInterval(() => void refresh(), metricsRefresh * 1000)
return () => window.clearInterval(timer)
}, [refresh, metricsRefresh])
const points = history?.range === range ? history.points : []
const times = points.map((point) => new Date(point.time * 1000))
const pick = (aggregate: Aggregate | undefined) => aggregate?.[consolidation] ?? null
const interfaces = [...new Set(points.flatMap((point) => Object.keys(point.network)))].sort()
const kinds = [...new Set(points.flatMap((point) => Object.keys(point.clients)))].sort()
const tunnels = [...new Set(points.flatMap((point) => Object.keys(point.site_to_site)))].sort()
const format = new Intl.DateTimeFormat(undefined, RANGES[range].format)
// The axis spans the whole view, whatever part of it has data.
const end = at ?? new Date()
const start = RANGES[range].start(end)
const percent = (value: number) => `${value.toFixed(1)} %`
const rate = (value: number) => `${bytes(value)}/s`
/** A chart panel of lines over x, the times of the view's buckets unless given. */
function chart(id: string, title: string, lines: Line[], unit: (value: number) => string, max?: number, x: Date[] = times): BoardPanel {
return {
id,
title,
content: x.length === 0 ? (
<Typography variant="body2" color="text.secondary">No data for this view yet.</Typography>
) : (
<LineChart
height={240}
skipAnimation
xAxis={[{ data: x, scaleType: 'time', min: start, max: end, valueFormatter: (value: Date) => format.format(value) }]}
yAxis={[{ min: 0, max, width: 72, valueFormatter: (value: number) => unit(value) }]}
slotProps={{ legend: { toggleVisibilityOnClick: true } }}
hiddenItems={(hidden[id] ?? []).map((label) => ({ type: 'line' as const, seriesId: label }))}
onHiddenItemsChange={(items) => hide(id, items.map((item) => String(item.seriesId)))}
series={lines.map((line) => ({
id: line.label,
label: line.label,
data: line.data,
showMark: false,
valueFormatter: (value: number | null) => (value === null ? 'no data' : unit(value)),
}))}
/>
),
}
}
const panels = [
chart('cpu', 'CPU', [{ label: 'CPU', data: points.map((point) => pick(point.cpu)) }], percent, 100),
chart('memory', 'Memory', [
{ label: 'Used', data: points.map((point) => pick(point.memory_used)) },
{ label: 'Total', data: points.map((point) => pick(point.memory_total)) },
], bytes),
chart('disk', 'Disk free', [
{ label: 'Free', data: points.map((point) => pick(point.disk_free)) },
{ label: 'Total', data: points.map((point) => pick(point.disk_total)) },
], bytes),
chart('disk-percent', 'Disk free (%)', [{
label: 'Free',
data: points.map((point) => {
const free = pick(point.disk_free)
const total = pick(point.disk_total)
return free === null || !total ? null : (free / total) * 100
}),
}], percent, 100),
chart('network', 'Network', interfaces.flatMap((name) => [
{ label: `${name} in`, data: points.map((point) => pick(point.network[name]?.rx)) },
{ label: `${name} out`, data: points.map((point) => pick(point.network[name]?.tx)) },
]), rate),
chart('vpn-clients', 'VPN clients', kinds.map((kind) => ({
label: CLIENT_KINDS[kind] ?? kind,
data: points.map((point) => pick(point.clients[kind])),
})), (value) => value.toFixed(value % 1 === 0 ? 0 : 1)),
chart('load', 'Load average', [
{ label: '1 min', data: points.map((point) => pick(point.load1)) },
{ label: '5 min', data: points.map((point) => pick(point.load5)) },
{ label: '15 min', data: points.map((point) => pick(point.load15)) },
], (value) => value.toFixed(2)),
]
// The site-to-site charts, while the view has data of a connection: the percentage of each slot of the view it was up,
// which the router computes in slots of its own whatever Average or Maximum shows, its traffic and its packets.
if (tunnels.length > 0) {
const availability = history?.range === range ? history.site_to_site_up : { slot: '', points: [] }
panels.push(
chart(
'site-to-site-up',
`Site-to-site up, % of each ${availability.slot}`,
tunnels.map((name) => ({ label: name, data: availability.points.map((slot) => slot.connections[name] ?? null) })),
percent,
100,
availability.points.map((slot) => new Date(slot.time * 1000)),
),
chart('site-to-site-traffic', 'Site-to-site traffic', tunnels.flatMap((name) => [
{ label: `${name} in`, data: points.map((point) => pick(point.site_to_site[name]?.rx)) },
{ label: `${name} out`, data: points.map((point) => pick(point.site_to_site[name]?.tx)) },
]), rate),
chart('site-to-site-packets', 'Site-to-site packets', tunnels.flatMap((name) => [
{ label: `${name} in`, data: points.map((point) => pick(point.site_to_site[name]?.packets_rx)) },
{ label: `${name} out`, data: points.map((point) => pick(point.site_to_site[name]?.packets_tx)) },
]), (value) => `${value.toFixed(value < 10 ? 1 : 0)}/s`),
)
}
return (
<Box component="section">
<Stack direction="row" sx={{ mb: 2, alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<ToggleButtonGroup size="small" exclusive value={range} onChange={(_event, next: MetricsRange | null) => next && setRange(next)}>
{METRICS_RANGES.map((name) => <ToggleButton key={name} value={name}>{RANGES[name].label}</ToggleButton>)}
</ToggleButtonGroup>
<ToggleButtonGroup size="small" exclusive value={consolidation} onChange={(_event, next: Consolidation | null) => next && setConsolidation(next)}>
<ToggleButton value="avg">Average</ToggleButton>
<ToggleButton value="max">Maximum</ToggleButton>
</ToggleButtonGroup>
<Typography variant="body2" color="text.secondary" sx={{ flexGrow: 1 }}>
{at ? `${history ? `Buckets of ${history.bucket}, ` : ''}read at ${at.toLocaleTimeString()}` : 'Reading the metrics...'}
</Typography>
<BoardButtons board={board} />
<Button variant="outlined" startIcon={<Settings />} disabled={opening} onClick={() => void openSettings()}>
Settings
</Button>
<Button variant="outlined" startIcon={<Refresh />} disabled={reading} onClick={() => void refresh()}>
Refresh
</Button>
</Stack>
{error && <Alert severity="warning" sx={{ mb: 2 }}>{error}</Alert>}
<Board board={board} panels={panels} />
{settings && (
<MetricsSettingsDialog
current={settings}
onCancel={() => setSettings(null)}
onSave={async (values) => {
await request('PUT', '/metrics/settings', METRICS_SETTINGS_SHAPE, values)
setSettings(null)
}}
/>
)}
</Box>
)
}
+154
View File
@@ -0,0 +1,154 @@
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from '@mui/material'
import { useState, type FormEvent } from 'react'
import { LISTEN_CHOICES, METRICS_KEEPS, type MetricsKeep, type MetricsSettings } from './api.ts'
/** The labels of the retention settings. */
const KEEP_LABELS: Record<MetricsKeep, string> = {
keep_details: 'Per-minute samples',
keep_hourly: 'Hourly summaries',
keep_daily: 'Daily summaries',
keep_monthly: 'Monthly summaries',
keep_yearly: 'Yearly summaries',
}
/** A retention period as the router takes it: a count and a unit. */
const KEEP_PATTERN = /^[1-9]\d{0,4} (hour|day|month|year)s?$/
/**
* A dialog that edits the router's metrics section: whether the metrics service runs, the interfaces and the ports of
* /metrics, its service account, the OTLP endpoint the samples are pushed to with its user name and password, and how
* long each level of the history is kept. An empty password keeps the current one, and an empty user name removes it.
* onSave sends the section, with otlp_password only when one is entered; the dialog closes when it succeeds and shows
* the router's message when it fails.
*/
export default function MetricsSettingsDialog(props: {
current: MetricsSettings & { otlp_password_set: boolean }
onSave: (settings: MetricsSettings & { otlp_password?: string }) => Promise<void>
onCancel: () => void
}) {
const { otlp_password_set: passwordSet, ...initial } = props.current
const [settings, setSettings] = useState<MetricsSettings>(initial)
const [port, setPort] = useState(String(initial.port))
const [httpsPort, setHttpsPort] = useState(String(initial.https_port))
const [password, setPassword] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const change = (values: Partial<MetricsSettings>) => setSettings((current) => ({ ...current, ...values }))
const portProblem = (value: string) => /^\d{1,5}$/.test(value) && Number(value) >= 1 && Number(value) <= 65535 ? '' : '1 to 65535.'
const endpointProblem = settings.otlp_endpoint === '' || /^https?:\/\/\S+$/.test(settings.otlp_endpoint) ? '' : 'An http or https URL.'
const passwordProblem = settings.otlp_username !== '' && !passwordSet && password === '' ? 'Required with a user name.' : ''
const usernameProblem = settings.otlp_username === '' && password !== '' ? 'Required with a password.' : ''
const keepProblems = Object.fromEntries(METRICS_KEEPS.map((keep) => [keep, KEEP_PATTERN.test(settings[keep]) ? '' : 'A count and a unit, such as 24 hours.']))
const blocked = portProblem(port) !== '' || portProblem(httpsPort) !== '' || settings.service_account === '' ||
endpointProblem !== '' || passwordProblem !== '' || usernameProblem !== '' || Object.values(keepProblems).some((problem) => problem !== '')
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave({ ...settings, port: Number(port), https_port: Number(httpsPort), ...(password ? { otlp_password: password } : {}) })
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>Metrics settings</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<FormControlLabel control={<Switch checked={settings.enabled} onChange={(event) => change({ enabled: event.target.checked })} />} label="Enabled" />
<Stack direction="row" sx={{ gap: 2 }}>
<TextField select label="Listen" value={settings.listen} helperText="The interfaces /metrics is served on." onChange={(event) => change({ listen: event.target.value as MetricsSettings['listen'] })} sx={{ flex: 1 }}>
{LISTEN_CHOICES.map((item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
</TextField>
<TextField
label="Port"
value={port}
autoComplete="off"
error={portProblem(port) !== ''}
helperText={portProblem(port) || 'Plain HTTP.'}
slotProps={{ htmlInput: { inputMode: 'numeric' } }}
onChange={(event) => setPort(event.target.value.trim())}
sx={{ flex: 1 }}
/>
<TextField
label="HTTPS port"
value={httpsPort}
autoComplete="off"
error={portProblem(httpsPort) !== ''}
helperText={portProblem(httpsPort) || 'Served by Caddy.'}
slotProps={{ htmlInput: { inputMode: 'numeric' } }}
onChange={(event) => setHttpsPort(event.target.value.trim())}
sx={{ flex: 1 }}
/>
</Stack>
<TextField
label="Service account"
value={settings.service_account}
autoComplete="off"
error={settings.service_account === ''}
helperText={settings.service_account === '' ? 'Required.' : 'The account a collector signs in to /metrics with.'}
onChange={(event) => change({ service_account: event.target.value.trim() })}
/>
<TextField
label="OTLP endpoint"
value={settings.otlp_endpoint}
autoComplete="off"
error={endpointProblem !== ''}
helperText={endpointProblem || 'An OTLP/HTTP URL every sample is pushed to; empty for none.'}
onChange={(event) => change({ otlp_endpoint: event.target.value.trim() })}
/>
<Stack direction="row" sx={{ gap: 2 }}>
<TextField
label="OTLP user name"
value={settings.otlp_username}
autoComplete="off"
error={usernameProblem !== ''}
helperText={usernameProblem || 'Empty for no authentication.'}
onChange={(event) => change({ otlp_username: event.target.value.trim() })}
sx={{ flex: 1 }}
/>
<TextField
label="OTLP password"
type="password"
value={password}
autoComplete="new-password"
disabled={settings.otlp_username === '' && password === ''}
error={passwordProblem !== ''}
helperText={passwordProblem || (passwordSet && settings.otlp_username !== '' ? 'Leave empty to keep the current password.' : '')}
onChange={(event) => setPassword(event.target.value)}
sx={{ flex: 1 }}
/>
</Stack>
<Typography variant="subtitle2">History kept</Typography>
<Stack direction="row" sx={{ gap: 2, flexWrap: 'wrap' }}>
{METRICS_KEEPS.map((keep) => (
<TextField
key={keep}
label={KEEP_LABELS[keep]}
value={settings[keep]}
autoComplete="off"
error={keepProblems[keep] !== ''}
helperText={keepProblems[keep]}
onChange={(event) => change({ [keep]: event.target.value })}
sx={{ flex: '1 1 30%' }}
/>
))}
</Stack>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel} disabled={saving}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Save</Button>
</DialogActions>
</Dialog>
)
}
+68
View File
@@ -0,0 +1,68 @@
import Refresh from '@mui/icons-material/Refresh'
import { Alert, Box, Button } from '@mui/material'
import { Board, BoardButtons, useBoard, type BoardPanel } from './PanelBoard.tsx'
import { Grid, Pairs, text } from './RouterPanel.tsx'
import Toolbar from './Toolbar.tsx'
import { DETAILS_SHAPE, request, type RouterDetails } from './api.ts'
import { useRead } from './useRead.ts'
/** The localStorage key of the tab's layout. */
const LAYOUT_KEY = 'vpn-router-manager.network-layout'
/** Reads the router's /config. */
const readDetails = () => request<RouterDetails>('GET', '/config', DETAILS_SHAPE)
/**
* The router's network, from its /config: the external and the internal interface with their addresses, the internal
* gateway and the local CIDRs, and the IPv4 routes. The panels are on a board whose layout is kept in the browser.
*/
export default function NetworkPanel() {
const { data: details, error, busy, refresh } = useRead(readDetails)
const board = useBoard(LAYOUT_KEY)
const network = details?.network
const panels: BoardPanel[] = network ? [
{
id: 'network',
title: 'Network',
content: (
<Pairs
rows={[
['External interface', `${network.external_interface ?? '-'}: ${text(network.external_addresses)}`],
['Internal interface', `${network.internal_interface ?? '-'}: ${text(network.internal_address)}`],
['Internal gateway', text(network.internal_gateway)],
['Local CIDRs', text(network.local_cidrs)],
]}
/>
),
},
{
id: 'routes',
title: 'Routes',
content: (
<Grid
head={['Destination', 'Gateway', 'Device', 'Source', 'Protocol', 'Metric']}
rows={network.routes?.map((route) => [
route.dst,
text(route.gateway),
route.flags.length > 0 ? `${route.dev} (${route.flags.join(', ')})` : route.dev,
text(route.prefsrc),
text(route.protocol),
text(route.metric),
])}
/>
),
},
] : []
return (
<Box component="section">
<Toolbar description="Interfaces, addresses and routes.">
<BoardButtons board={board} />
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>Refresh</Button>
</Toolbar>
{error && <Alert severity="error" sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
<Board board={board} panels={panels} />
</Box>
)
}
+112
View File
@@ -0,0 +1,112 @@
import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography } from '@mui/material'
import { QRCodeSVG } from 'qrcode.react'
import { useEffect, useState, type FormEvent } from 'react'
import { CODE_PATTERN, OTP_SECRET_SHAPE, request, type OtpSecret, type Proof } from './api.ts'
/**
* A dialog that enrols a user for TOTP: it asks the router for a new secret at path, shows it as a QR code and as text
* for an authenticator app, and asks for a code the app shows. With proof it also asks for the user's current password
* and, when proof.otp says the user is enrolled, a code of the current authenticator. onSave sends the secret with the
* code and the proof; the dialog closes when it succeeds and shows the router's message when it fails. Nothing is
* stored before the code is verified.
*/
export default function OtpDialog(props: {
name: string
path: string
proof?: { otp: boolean }
onSave: (secret: string, code: string, proof: Proof | null) => Promise<void>
onCancel: () => void
}) {
const [otp, setOtp] = useState<OtpSecret | null>(null)
const [code, setCode] = useState('')
const [currentPassword, setCurrentPassword] = useState('')
const [currentCode, setCurrentCode] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
request<OtpSecret>('POST', props.path, OTP_SECRET_SHAPE)
.then(setOtp)
.catch((caught: Error) => setError(caught.message))
}, [props.path])
const proofBlocked = props.proof !== undefined && (currentPassword === '' || (props.proof.otp && !CODE_PATTERN.test(currentCode)))
const blocked = otp === null || !CODE_PATTERN.test(code) || proofBlocked
async function submit(event: FormEvent) {
event.preventDefault()
if (otp === null || blocked) {
return
}
setSaving(true)
setError(null)
const proof = props.proof === undefined ? null : props.proof.otp ? { current_password: currentPassword, current_code: currentCode } : { current_password: currentPassword }
try {
await props.onSave(otp.secret, code, proof)
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="xs" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>{`Enrol ${props.name} for one-time passwords`}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
{otp === null && !error && <CircularProgress sx={{ alignSelf: 'center' }} />}
{otp && (
<>
{props.proof && (
<>
<TextField
label="Current password"
type="password"
value={currentPassword}
autoFocus
autoComplete="current-password"
onChange={(event) => setCurrentPassword(event.target.value)}
/>
{props.proof.otp && (
<TextField
label="Code of the current authenticator"
value={currentCode}
autoComplete="one-time-code"
helperText="A code not used to sign in."
slotProps={{ htmlInput: { inputMode: 'numeric', maxLength: 6 } }}
onChange={(event) => setCurrentCode(event.target.value.trim())}
/>
)}
</>
)}
<Typography variant="body2" color="text.secondary">
Scan the code with an authenticator app, or enter the key, then enter the code the app shows.
</Typography>
<Box sx={{ alignSelf: 'center', p: 1, bgcolor: '#fff', lineHeight: 0 }}>
<QRCodeSVG value={otp.uri} size={200} title={`TOTP secret of ${props.name}`} />
</Box>
<Typography variant="body2" sx={{ fontFamily: 'monospace', textAlign: 'center', wordBreak: 'break-all' }}>
{otp.secret}
</Typography>
<TextField
label="Code"
value={code}
autoFocus={!props.proof}
autoComplete="one-time-code"
slotProps={{ htmlInput: { inputMode: 'numeric', maxLength: 6 } }}
onChange={(event) => setCode(event.target.value.trim())}
/>
</>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>
Enrol
</Button>
</DialogActions>
</Dialog>
)
}
+75
View File
@@ -0,0 +1,75 @@
import ArrowDropDown from '@mui/icons-material/ArrowDropDown'
import Hub from '@mui/icons-material/Hub'
import MenuIcon from '@mui/icons-material/Menu'
import People from '@mui/icons-material/People'
import Public from '@mui/icons-material/Public'
import ViewInAr from '@mui/icons-material/ViewInAr'
import Router from '@mui/icons-material/Router'
import VerifiedUser from '@mui/icons-material/VerifiedUser'
import VpnKey from '@mui/icons-material/VpnKey'
import { Button, ListItemIcon, ListItemText, Menu, MenuItem } from '@mui/material'
import { useState, type ReactNode } from 'react'
/** The path of the signed-in user's own account page. */
export const USER_PATH = '/user'
/** The path of the Remote Access page: IKEv2, OpenVPN and WireGuard. */
export const REMOTE_ACCESS_PATH = '/remote-access-manager'
/** The path of the Certificates page. */
export const CA_PATH = '/ca-manager'
/** The path of the Identities page: the users, the groups and the service accounts. */
export const USERS_PATH = '/user-manager'
/** The path of the Connectivity page: the site-to-site connections and their state. */
export const SITE_TO_SITE_PATH = '/site-to-site-manager'
/** The path of the Docker page: the state of Docker and of its rules. */
export const DOCKER_PATH = '/docker-manager'
/** The path of the Web Sites page: the sites Caddy serves, their files and the name server. */
export const SITES_PATH = '/sites-manager'
/** The path of the shell page: a login shell on the router, opened with the shell button of the header, not the menu. */
export const SHELL_PATH = '/shell'
/** The pages of an administrator in the menu: path, label and icon. */
const PAGES: { path: string; label: string; icon: ReactNode }[] = [
{ path: '/', label: 'Router', icon: <Router fontSize="small" /> },
{ path: USERS_PATH, label: 'Identities', icon: <People fontSize="small" /> },
{ path: SITE_TO_SITE_PATH, label: 'Connectivity', icon: <Hub fontSize="small" /> },
{ path: REMOTE_ACCESS_PATH, label: 'Remote Access', icon: <VpnKey fontSize="small" /> },
{ path: CA_PATH, label: 'Certificates', icon: <VerifiedUser fontSize="small" /> },
{ path: SITES_PATH, label: 'Web Sites', icon: <Public fontSize="small" /> },
{ path: DOCKER_PATH, label: 'Docker', icon: <ViewInAr fontSize="small" /> },
]
/** A button naming the page shown, or Pages on the account and the shell page, whose menu opens the pages of an administrator. */
export default function PageMenu(props: { path: string }) {
const [anchor, setAnchor] = useState<HTMLElement | null>(null)
const current = PAGES.find((page) => page.path === props.path) ?? { path: props.path, label: 'Pages', icon: <MenuIcon fontSize="small" /> }
return (
<>
<Button
variant="text"
color="inherit"
startIcon={current.icon}
endIcon={<ArrowDropDown />}
aria-haspopup="menu"
aria-expanded={anchor !== null}
onClick={(event) => setAnchor(event.currentTarget)}
>
{current.label}
</Button>
<Menu anchorEl={anchor} open={anchor !== null} onClose={() => setAnchor(null)}>
{PAGES.map((page) => (
<MenuItem key={page.path} component="a" href={page.path} selected={page.path === current.path}>
<ListItemIcon>{page.icon}</ListItemIcon>
<ListItemText>{page.label}</ListItemText>
</MenuItem>
))}
</Menu>
</>
)
}
+265
View File
@@ -0,0 +1,265 @@
import Check from '@mui/icons-material/Check'
import Close from '@mui/icons-material/Close'
import DragIndicator from '@mui/icons-material/DragIndicator'
import Edit from '@mui/icons-material/Edit'
import ExpandLess from '@mui/icons-material/ExpandLess'
import ExpandMore from '@mui/icons-material/ExpandMore'
import Settings from '@mui/icons-material/Settings'
import WidthFull from '@mui/icons-material/WidthFull'
import WidthNormal from '@mui/icons-material/WidthNormal'
import {
Box, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, Paper, Stack, Tooltip, Typography,
} from '@mui/material'
import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'
import { SortableContext, arrayMove, rectSortingStrategy, sortableKeyboardCoordinates, useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useState, type ReactNode } from 'react'
/** Colours of the panel titles: brown in the light scheme, gold in the dark one. */
const TITLE_LIGHT = '#8b4513'
const TITLE_DARK = '#d4af37'
/**
* A panel of a board: its id, which the layout keeps, its title, actions such as buttons or a state on the title bar,
* and its content; wide spans both columns by default, and a fixed panel cannot be hidden.
*/
export type BoardPanel = { id: string; title: string; actions?: ReactNode; content: ReactNode; wide?: boolean; fixed?: boolean }
/** The layout of a board: the order of the panel ids, the width chosen for a panel, and the hidden and the folded panels. */
export type Layout = { order: string[]; widths: Record<string, 'wide' | 'single'>; hidden: string[]; folded: string[] }
const EMPTY: Layout = { order: [], widths: {}, hidden: [], folded: [] }
/** The layout kept in localStorage under key, or an empty one when there is none or it cannot be read. */
function readLayout(key: string): Layout {
try {
const value: unknown = JSON.parse(window.localStorage.getItem(key) ?? 'null')
if (value !== null && typeof value === 'object') {
const stored = value as Partial<Layout>
const strings = (items: unknown) => (Array.isArray(items) ? items.filter((item): item is string => typeof item === 'string') : [])
return {
order: strings(stored.order),
widths: stored.widths !== null && typeof stored.widths === 'object' ? stored.widths : {},
hidden: strings(stored.hidden),
folded: strings(stored.folded),
}
}
} catch {
// A layout that cannot be read is replaced by the default.
}
return EMPTY
}
/** The layout of a board kept in the browser's localStorage under key, and a function that changes and keeps it. */
export function useLayout(key: string): [Layout, (change: (current: Layout) => Layout) => void] {
const [layout, setLayout] = useState<Layout>(() => readLayout(key))
function update(change: (current: Layout) => Layout) {
setLayout((current) => {
const next = change(current)
window.localStorage.setItem(key, JSON.stringify(next))
return next
})
}
return [layout, update]
}
/** The panels in the order of the layout; a panel the layout does not know yet comes after the known ones, in the given order. */
function ordered(panels: BoardPanel[], layout: Layout) {
const rank = (panel: BoardPanel) => {
const index = layout.order.indexOf(panel.id)
return index === -1 ? layout.order.length + panels.indexOf(panel) : index
}
return [...panels].sort((a, b) => rank(a) - rank(b))
}
/**
* Panels in a grid of two columns on wide screens, in the order of the layout, panels side by side of the same height;
* hidden panels are left out. A panel's
* title bar folds and unfolds it, and the [X] of a panel that is not fixed hides it. While editing, a panel is moved by
* dragging its handle, and the width button switches it between one column and both.
*/
export function PanelBoard(props: { panels: BoardPanel[]; layout: Layout; onLayout: (change: (current: Layout) => Layout) => void; editing: boolean }) {
const { layout, onLayout, editing } = props
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }))
const shown = ordered(props.panels, layout).filter((panel) => panel.fixed || !layout.hidden.includes(panel.id))
function dragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) {
return
}
onLayout((current) => {
const ids = ordered(props.panels, current).map((panel) => panel.id)
const known = [...ids, ...current.order.filter((id) => !ids.includes(id))]
return { ...current, order: arrayMove(known, known.indexOf(String(active.id)), known.indexOf(String(over.id))) }
})
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={dragEnd}>
<SortableContext items={shown.map((panel) => panel.id)} strategy={rectSortingStrategy}>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' }, gap: 2 }}>
{shown.map((panel) => (
<FoldablePanel
key={panel.id}
panel={panel}
wide={(layout.widths[panel.id] ?? (panel.wide ? 'wide' : 'single')) === 'wide'}
folded={layout.folded.includes(panel.id)}
editing={editing}
onFold={(folded) => onLayout((current) => ({
...current,
folded: folded ? [...current.folded, panel.id] : current.folded.filter((id) => id !== panel.id),
}))}
onWide={(wide) => onLayout((current) => ({ ...current, widths: { ...current.widths, [panel.id]: wide ? 'wide' : 'single' } }))}
onHide={() => onLayout((current) => ({ ...current, hidden: [...current.hidden, panel.id] }))}
/>
))}
</Box>
</SortableContext>
</DndContext>
)
}
/** A panel with a title bar of a slightly different colour, whose title folds it, with the panel's actions and the buttons of the board. */
function FoldablePanel(props: {
panel: BoardPanel
wide: boolean
folded: boolean
editing: boolean
onFold: (folded: boolean) => void
onWide: (wide: boolean) => void
onHide: () => void
}) {
const { panel, wide, folded, editing } = props
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id: panel.id, disabled: !editing })
return (
<Paper
ref={setNodeRef}
variant="outlined"
sx={{
minWidth: 0,
overflow: 'hidden',
gridColumn: wide ? '1 / -1' : undefined,
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
zIndex: isDragging ? 1 : undefined,
position: 'relative',
}}
>
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.5, px: 1, py: 0.5, bgcolor: 'action.hover', borderBottom: folded ? 0 : 1, borderColor: 'divider' }}>
{editing && (
<IconButton size="small" ref={setActivatorNodeRef} {...attributes} {...listeners} aria-label={`Move ${panel.title}`} sx={{ cursor: 'grab' }}>
<DragIndicator fontSize="small" />
</IconButton>
)}
<Stack
direction="row"
role="button"
tabIndex={0}
aria-expanded={!folded}
onClick={() => props.onFold(!folded)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
props.onFold(!folded)
}
}}
sx={{ flexGrow: 1, alignItems: 'center', gap: 0.5, cursor: 'pointer', minWidth: 0 }}
>
{folded ? <ExpandMore fontSize="small" /> : <ExpandLess fontSize="small" />}
<Typography
variant="subtitle1"
noWrap
sx={[{ fontWeight: 700, color: TITLE_LIGHT }, (theme) => theme.applyStyles('dark', { color: TITLE_DARK })]}
>
{panel.title}
</Typography>
</Stack>
{panel.actions && <Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>{panel.actions}</Stack>}
{editing && (
<Tooltip title={wide ? 'One column' : 'Both columns'}>
<IconButton size="small" onClick={() => props.onWide(!wide)} aria-label={wide ? 'One column' : 'Both columns'}>
{wide ? <WidthNormal fontSize="small" /> : <WidthFull fontSize="small" />}
</IconButton>
</Tooltip>
)}
{!panel.fixed && (
<Tooltip title="Hide">
<IconButton size="small" onClick={props.onHide} aria-label={`Hide ${panel.title}`}>
<Close fontSize="small" />
</IconButton>
</Tooltip>
)}
</Stack>
{!folded && <Box sx={{ p: 2 }}>{panel.content}</Box>}
</Paper>
)
}
/** A dialog that shows or hides each panel that is not fixed; a fixed panel is listed, always shown. */
export function PanelsDialog(props: { open: boolean; panels: BoardPanel[]; layout: Layout; onLayout: (change: (current: Layout) => Layout) => void; onClose: () => void }) {
const { layout, onLayout } = props
return (
<Dialog open={props.open} onClose={props.onClose} fullWidth maxWidth="xs">
<DialogTitle>Panels</DialogTitle>
<DialogContent>
<Stack>
{ordered(props.panels, layout).map((panel) => (
<FormControlLabel
key={panel.id}
label={panel.title}
control={
<Checkbox
checked={panel.fixed || !layout.hidden.includes(panel.id)}
disabled={panel.fixed}
onChange={(event) => onLayout((current) => ({
...current,
hidden: event.target.checked ? current.hidden.filter((id) => id !== panel.id) : [...current.hidden, panel.id],
}))}
/>
}
/>
))}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose}>Close</Button>
</DialogActions>
</Dialog>
)
}
/** The state of a page's board: its layout kept in the browser under key, whether it is edited, and the Panels dialog. */
export type BoardState = ReturnType<typeof useBoard>
export function useBoard(key: string) {
const [layout, setLayout] = useLayout(key)
const [editing, setEditing] = useState(false)
const [choosing, setChoosing] = useState(false)
return { layout, setLayout, editing, setEditing, choosing, setChoosing }
}
/** The buttons of a board: Edit layout, Done while editing, and Panels, which opens the Panels dialog. */
export function BoardButtons(props: { board: BoardState }) {
const { editing, setEditing, setChoosing } = props.board
return (
<>
<Button variant={editing ? 'contained' : 'outlined'} startIcon={editing ? <Check /> : <Edit />} onClick={() => setEditing(!editing)}>
{editing ? 'Done' : 'Edit layout'}
</Button>
<Button variant="outlined" startIcon={<Settings />} onClick={() => setChoosing(true)}>Panels</Button>
</>
)
}
/** A board of panels with its Panels dialog, in the state of useBoard. */
export function Board(props: { board: BoardState; panels: BoardPanel[] }) {
const { layout, setLayout, editing, choosing, setChoosing } = props.board
return (
<>
<PanelBoard panels={props.panels} layout={layout} onLayout={setLayout} editing={editing} />
<PanelsDialog open={choosing} panels={props.panels} layout={layout} onLayout={setLayout} onClose={() => setChoosing(false)} />
</>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField } from '@mui/material'
import { useState, type FormEvent } from 'react'
import { CODE_PATTERN, PASSKEY_BEGUN_SHAPE, USER_SHAPE, request, type PasskeyBegun, type User } from './api.ts'
/**
* A dialog that registers a passkey of the signed-in user: a label, the current password and, for a user enrolled for
* one-time passwords, a code. The router begins the registration with them, the browser creates the passkey, and the
* router stores it; onSaved gets the user with the new passkey. A failure shows its message.
*/
export default function PasskeyDialog(props: { otp: boolean; onSaved: (user: User) => void; onCancel: () => void }) {
const [label, setLabel] = useState('')
const [currentPassword, setCurrentPassword] = useState('')
const [currentCode, setCurrentCode] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const blocked = currentPassword === '' || (props.otp && !CODE_PATTERN.test(currentCode))
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
const proof = props.otp ? { current_password: currentPassword, current_code: currentCode } : { current_password: currentPassword }
const begun = await request<PasskeyBegun<PublicKeyCredentialCreationOptionsJSON>>('POST', '/account/passkeys', PASSKEY_BEGUN_SHAPE, proof)
const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(begun.options.publicKey) })
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('The browser created no passkey.')
}
props.onSaved(await request<User>('PUT', '/account/passkeys', USER_SHAPE, { id: begun.id, credential: credential.toJSON(), label }))
} catch (caught) {
setError(caught instanceof DOMException && caught.name === 'NotAllowedError' ? 'The passkey was not created.' : (caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={props.onCancel} fullWidth maxWidth="xs">
<form onSubmit={submit}>
<DialogTitle>Add a passkey</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<TextField label="Label" value={label} placeholder="Passkey" autoFocus slotProps={{ htmlInput: { maxLength: 64 } }} onChange={(event) => setLabel(event.target.value)} />
<TextField label="Current password" type="password" value={currentPassword} autoComplete="current-password" onChange={(event) => setCurrentPassword(event.target.value)} />
{props.otp && (
<TextField
label="Code"
value={currentCode}
autoComplete="one-time-code"
slotProps={{ htmlInput: { inputMode: 'numeric', maxLength: 6 } }}
onChange={(event) => setCurrentCode(event.target.value.trim())}
/>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Add</Button>
</DialogActions>
</form>
</Dialog>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField } from '@mui/material'
import { useState, type FormEvent } from 'react'
import type { WireGuardPeer } from './api.ts'
import { ipsOf, ipsProblem, keyProblem } from './wireguard.ts'
/**
* A dialog that adds a WireGuard peer to one of the VPN users: the user, the device's public key, which no peer may have
* already (keys), and its IPs, which start as proposed, the next free address of the WireGuard network. onSave sends
* the user's name and the peer; the dialog closes when it succeeds and shows the router's message when it fails.
*/
export default function PeerDialog(props: {
users: string[]
keys: string[]
proposed: string
onSave: (user: string, peer: WireGuardPeer) => Promise<void>
onCancel: () => void
}) {
const [user, setUser] = useState(props.users.length === 1 ? props.users[0] : '')
const [key, setKey] = useState('')
const [ips, setIps] = useState(props.proposed)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const keyError = key === '' ? '' : keyProblem(key) || (props.keys.includes(key) ? 'This key is already a peer.' : '')
const ipsError = ipsProblem(ips)
const blocked = user === '' || key === '' || keyError !== '' || ipsError !== ''
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave(user, { public_key: key, ips: ipsOf(ips) })
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>Add a WireGuard peer</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<TextField select label="User" value={user} autoFocus={props.users.length !== 1} helperText="The VPN user whose device the peer is." onChange={(event) => setUser(event.target.value)}>
{props.users.map((name) => <MenuItem key={name} value={name}>{name}</MenuItem>)}
</TextField>
<TextField
label="Public key"
value={key}
autoFocus={props.users.length === 1}
autoComplete="off"
error={keyError !== ''}
helperText={keyError || "The device's public key, from wg pubkey or its WireGuard app."}
slotProps={{ htmlInput: { style: { fontFamily: 'monospace' } } }}
onChange={(event) => setKey(event.target.value.trim())}
/>
<TextField
label="IPs"
value={ips}
autoComplete="off"
error={ipsError !== ''}
helperText={ipsError || 'The addresses of the device in the tunnel, separated by commas; the next free one is proposed.'}
onChange={(event) => setIps(event.target.value)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel} disabled={saving}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Add</Button>
</DialogActions>
</Dialog>
)
}
+54
View File
@@ -0,0 +1,54 @@
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack } from '@mui/material'
import { useState, type FormEvent } from 'react'
import PersonFields from './PersonFields.tsx'
import type { Person, User } from './api.ts'
import { personComplete, personProblems, trimmed } from './person.ts'
/**
* A dialog that edits the first name, last name and e-mail address of a VPN user, or the optional e-mail address of a
* Manager-only account, where an empty one removes it; onSave sends them.
*/
export default function PersonDialog(props: { user: User; onSave: (person: Partial<Person>) => Promise<void>; onCancel: () => void }) {
const [person, setPerson] = useState<Person>({
first_name: props.user.first_name ?? '',
last_name: props.user.last_name ?? '',
email: props.user.email ?? '',
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const managerOnly = props.user.manager_only
const blocked = managerOnly ? personProblems(person).email !== '' : !personComplete(person)
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave(managerOnly ? { email: person.email.trim() } : trimmed(person))
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="xs" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>Details of {props.user.name}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<PersonFields person={person} onChange={setPerson} autoFocus emailOnly={managerOnly} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>
Save
</Button>
</DialogActions>
</Dialog>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { TextField } from '@mui/material'
import type { Person } from './api.ts'
import { personProblems } from './person.ts'
/** The first name, last name and e-mail fields; with emailOnly, of a Manager-only account, only the e-mail field, optional. */
export default function PersonFields(props: { person: Person; onChange: (person: Person) => void; autoFocus?: boolean; emailOnly?: boolean }) {
const problems = personProblems(props.person)
const field = (key: keyof Person, label: string, type = 'text', help = '') => (
<TextField
label={label}
type={type}
value={props.person[key]}
autoFocus={props.autoFocus && key === (props.emailOnly ? 'email' : 'first_name')}
autoComplete="off"
error={problems[key] !== ''}
helperText={problems[key] || help}
onChange={(event) => props.onChange({ ...props.person, [key]: event.target.value })}
/>
)
if (props.emailOnly) {
return field('email', 'E-mail', 'email', 'Optional, for notifications.')
}
return (
<>
{field('first_name', 'First name')}
{field('last_name', 'Last name')}
{field('email', 'E-mail', 'email')}
</>
)
}
+177
View File
@@ -0,0 +1,177 @@
import {
Alert, Box, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Stack, TextField,
Typography,
} from '@mui/material'
import { useState, type FormEvent } from 'react'
import { PKI_KEYS, PKI_PUBLISH, type Pki, type PkiSettings, type PkiTemplate } from './api.ts'
/** The labels of the publishing endpoints. */
export const PUBLISH_LABELS: Record<string, string> = {
http: 'HTTP on the status port, served by the web service',
'http-80': 'HTTP on port 80, served by Caddy',
https: 'HTTPS on the HTTPS status port, served by Caddy',
'https-default': 'HTTPS on port 443, served by Caddy',
}
/** The name of an issuing CA or a template, as the router accepts it. */
export const NAME_PATTERN = /^[a-z][a-z0-9-]{0,30}$/
/** The names the issuing CAs of pki.issuing_cas cannot have. */
export const RESERVED = ['root', 'p2s', 's2s']
/** The longest common name of a CA, in characters. */
const CA_NAME_MAX = 64
/** The longest lifetime the router accepts, in days. */
const DAYS_MAX = 36500
/** A new template's options. */
export const NEW_TEMPLATE: PkiTemplate = {
key: 'rsa-3072', days: 365, key_usage: ['digital_signature'], extended_key_usage: [], subject: 'name', sans: [], policies: [],
}
/** A common name's problem, or ''. */
export function nameProblem(value: string) {
return value.trim() === '' || value.trim().length > CA_NAME_MAX ? `1 to ${CA_NAME_MAX} characters.` : ''
}
/** A lifetime's problem, or ''. */
export function daysProblem(value: number) {
return !Number.isInteger(value) || value < 1 || value > DAYS_MAX ? `1 to ${DAYS_MAX} days.` : ''
}
/**
* A dialog that edits the router's pki section but the general-purpose issuing CAs, the templates and the custom
* extended key usages, which IssuingCaDialog, TemplateDialog and EkuDialog edit: the names, lifetimes and key type of
* the CAs, the validity of the CRLs and
* where certificates and CRLs are published. The router applies the names, lifetimes and key type when it creates a
* CA. onSave sends the settings; the dialog closes when it succeeds and shows the router's message when it fails.
*/
export default function PkiSettingsDialog(props: {
pki: Pki
onSave: (settings: Omit<PkiSettings, 'issuing_cas' | 'templates' | 'extended_key_usages'>) => Promise<void>
onCancel: () => void
}) {
const [settings, setSettings] = useState<PkiSettings>(props.pki.settings)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const change = (values: Partial<PkiSettings>) => setSettings({ ...settings, ...values })
const blocked = [
nameProblem(settings.root_name), nameProblem(settings.p2s_name), nameProblem(settings.s2s_name),
daysProblem(settings.root_days), daysProblem(settings.ca_days), daysProblem(settings.crl_days), daysProblem(settings.root_crl_days),
].some((problem) => problem !== '')
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
const { issuing_cas: _issuingCas, templates: _templates, extended_key_usages: _usages, ...rest } = settings
try {
await props.onSave(rest)
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
const number = (value: string) => Math.trunc(Number(value) || 0)
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="md" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>Edit PKI settings</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<Typography variant="body2" color="text.secondary">
Names, lifetimes and the key type apply when the router creates a CA; URLs apply to the certificates signed afterwards.
</Typography>
<Typography variant="subtitle2">CAs</Typography>
{(['root', 'p2s', 's2s'] as const).map((name) => {
const option = `${name}_name` as 'root_name' | 'p2s_name' | 's2s_name'
return (
<TextField
key={name}
label={{ root: 'Root CA name', p2s: 'P2S CA name', s2s: 'Site-to-site CA name' }[name]}
value={settings[option]}
autoComplete="off"
error={nameProblem(settings[option]) !== ''}
helperText={nameProblem(settings[option])}
onChange={(event) => change({ [option]: event.target.value })}
/>
)
})}
<Stack direction="row" sx={{ gap: 2 }}>
<TextField
fullWidth
label="Root days"
type="number"
value={settings.root_days}
error={daysProblem(settings.root_days) !== ''}
helperText={daysProblem(settings.root_days)}
onChange={(event) => change({ root_days: number(event.target.value) })}
/>
<TextField
fullWidth
label="Issuing CA days"
type="number"
value={settings.ca_days}
error={daysProblem(settings.ca_days) !== ''}
helperText={daysProblem(settings.ca_days)}
onChange={(event) => change({ ca_days: number(event.target.value) })}
/>
<TextField select fullWidth label="CA key" value={settings.key} onChange={(event) => change({ key: event.target.value })}>
{PKI_KEYS.map((item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
</TextField>
</Stack>
<Typography variant="subtitle2">CRLs</Typography>
<Stack direction="row" sx={{ gap: 2 }}>
<TextField
fullWidth
label="Issuing CA CRL days"
type="number"
value={settings.crl_days}
error={daysProblem(settings.crl_days) !== ''}
helperText={daysProblem(settings.crl_days) || 'Signed again at every revocation and daily after half of it.'}
onChange={(event) => change({ crl_days: number(event.target.value) })}
/>
<TextField
fullWidth
label="Root CRL days"
type="number"
value={settings.root_crl_days}
error={daysProblem(settings.root_crl_days) !== ''}
helperText={daysProblem(settings.root_crl_days) || 'When the router signs it, with the root key on the router.'}
onChange={(event) => change({ root_crl_days: number(event.target.value) })}
/>
</Stack>
<Typography variant="subtitle2">Published certificates and CRLs</Typography>
<Box>
{PKI_PUBLISH.map((item) => (
<FormControlLabel
key={item}
sx={{ display: 'flex' }}
control={
<Checkbox
checked={settings.publish.includes(item)}
onChange={(event) => change({
publish: PKI_PUBLISH.filter((name) => (name === item ? event.target.checked : settings.publish.includes(name))),
})}
/>
}
label={PUBLISH_LABELS[item]}
/>
))}
</Box>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Save</Button>
</DialogActions>
</Dialog>
)
}
+115
View File
@@ -0,0 +1,115 @@
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField } from '@mui/material'
import { useState, type FormEvent } from 'react'
import { CODE_PATTERN, PROFILE_SHAPE, request } from './api.ts'
import { saveFile } from './saveFile.ts'
/** The length of a password the router accepts for the key in an Apple profile. */
const PASSWORD_MIN = 8
const PASSWORD_MAX = 128
/** The profiles: their title, file extension and content type. */
const KINDS = {
openvpn: { title: 'OpenVPN profile', extension: 'ovpn', type: 'application/x-openvpn-profile' },
apple: { title: 'Apple VPN profile', extension: 'mobileconfig', type: 'application/x-apple-aspen-config' },
}
/**
* A dialog that downloads a VPN profile of a user, saved as <name>.ovpn or <name>.mobileconfig: for the signed-in
* user's own profile (own) the current password and, when enrolled (otp), a code, and for an Apple profile the
* password that protects the user's key in it, typed twice. path is the router's call, such as
* /users/<name>/apple-profile or /account/profile. The dialog closes when the file is saved and shows the router's
* message when it fails.
*/
export default function ProfileDialog(props: {
kind: keyof typeof KINDS
name: string
path: string
own: boolean
otp: boolean
onClose: () => void
}) {
const kind = KINDS[props.kind]
const apple = props.kind === 'apple'
const [currentPassword, setCurrentPassword] = useState('')
const [currentCode, setCurrentCode] = useState('')
const [password, setPassword] = useState('')
const [again, setAgain] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const passwordProblem = password !== '' && (password.length < PASSWORD_MIN || password.length > PASSWORD_MAX)
? `${PASSWORD_MIN} to ${PASSWORD_MAX} characters.` : ''
const againProblem = again !== '' && again !== password ? 'The passwords differ.' : ''
const blocked = (apple && (password === '' || passwordProblem !== '' || again !== password))
|| (props.own && (currentPassword === '' || (props.otp && !CODE_PATTERN.test(currentCode))))
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
const proof = props.own ? { current_password: currentPassword, ...(props.otp ? { current_code: currentCode } : {}) } : {}
const { profile } = await request<{ profile: string }>('POST', props.path, PROFILE_SHAPE, { ...proof, ...(apple ? { password } : {}) })
saveFile(`${props.name}.${kind.extension}`, profile, kind.type)
props.onClose()
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onClose()} maxWidth="xs" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>{`${kind.title} of ${props.name}`}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
{props.own && (
<>
<TextField label="Current password" type="password" value={currentPassword} autoFocus autoComplete="current-password" onChange={(event) => setCurrentPassword(event.target.value)} />
{props.otp && (
<TextField
label="Code"
value={currentCode}
autoComplete="one-time-code"
slotProps={{ htmlInput: { inputMode: 'numeric', maxLength: 6 } }}
onChange={(event) => setCurrentCode(event.target.value.trim())}
/>
)}
</>
)}
{apple && (
<>
<TextField
label="Profile password"
type="password"
value={password}
autoFocus={!props.own}
autoComplete="new-password"
error={passwordProblem !== ''}
helperText={passwordProblem || 'The password asked when the profile is installed.'}
onChange={(event) => setPassword(event.target.value)}
/>
<TextField
label="Profile password again"
type="password"
value={again}
autoComplete="new-password"
error={againProblem !== ''}
helperText={againProblem}
onChange={(event) => setAgain(event.target.value)}
/>
</>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} disabled={saving}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Download</Button>
</DialogActions>
</Dialog>
)
}
+127
View File
@@ -0,0 +1,127 @@
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Stack, Switch, TextField } from '@mui/material'
import { useState, type FormEvent } from 'react'
import { OPENVPN_PROTOCOLS, REMOTE_ACCESS_LABELS, type RemoteAccess, type RemoteAccessSystem } from './api.ts'
import { routerAddressProblem } from './wireguard.ts'
/** An IPv4 network with its prefix length, as the router takes an address pool. */
const CIDR_PATTERN = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/
/** A domain of IKEv2's DNS domains, as the router takes it, and the most domains. */
const DOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/
const DOMAINS_MAX = 16
/** A port as text: 1 to 65535. */
function portProblem(text: string) {
return /^\d{1,5}$/.test(text) && Number(text) >= 1 && Number(text) <= 65535 ? '' : 'A port from 1 to 65535.'
}
/**
* A dialog that edits the settings of one remote access system: whether it is enabled and its address pool, for
* WireGuard wg0's address, for IKEv2 the DNS domains its clients resolve with the router's DNS, one per line, and the
* port, with OpenVPN's protocol. An enabled system needs its address. onSave sends
* the section; the dialog closes when it succeeds and shows the router's message when it fails.
*/
export default function RemoteAccessDialog(props: {
system: RemoteAccessSystem
current: RemoteAccess
onSave: (settings: Record<string, unknown>) => Promise<void>
onCancel: () => void
}) {
const { p2s, openvpn, wireguard } = props.current
const section = props.current[props.system]
const [enabled, setEnabled] = useState(section.enabled)
const [address, setAddress] = useState(props.system === 'wireguard' ? wireguard.address : props.system === 'p2s' ? p2s.address_pool : openvpn.address_pool)
const [port, setPort] = useState(String(props.system === 'wireguard' ? wireguard.listen_port : openvpn.port))
const [protocol, setProtocol] = useState(openvpn.protocol)
const [domains, setDomains] = useState(p2s.dns_domains.join('\n'))
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const addressLabel = props.system === 'wireguard' ? 'Address of wg0' : 'Address pool'
const addressProblem = address.trim() === ''
? (enabled ? 'Required while enabled.' : '')
: props.system === 'wireguard' ? routerAddressProblem(address)
: CIDR_PATTERN.test(address.trim()) ? '' : 'An IPv4 address with a prefix length, such as 10.10.0.0/24.'
const portError = props.system === 'p2s' ? '' : portProblem(port)
const domainList = domains.split('\n').map((line) => line.trim().toLowerCase()).filter((line) => line !== '')
const badDomain = domainList.find((domain) => !DOMAIN_PATTERN.test(domain))
const domainsProblem = props.system !== 'p2s' ? ''
: badDomain ? `${badDomain} is not a DNS name.`
: domainList.length > DOMAINS_MAX || new Set(domainList).size !== domainList.length ? `At most ${DOMAINS_MAX} different domains.` : ''
const blocked = addressProblem !== '' || portError !== '' || domainsProblem !== ''
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
const settings: Record<string, unknown> = props.system === 'wireguard'
? { enabled, address: address.trim(), listen_port: Number(port) }
: props.system === 'openvpn'
? { enabled, address_pool: address.trim(), port: Number(port), protocol }
: { enabled, address_pool: address.trim(), dns_domains: domainList }
try {
await props.onSave(settings)
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>{`${REMOTE_ACCESS_LABELS[props.system]} settings`}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<FormControlLabel control={<Switch checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />} label="Enabled" />
<TextField
label={addressLabel}
value={address}
autoFocus
autoComplete="off"
error={addressProblem !== ''}
helperText={addressProblem || (props.system === 'wireguard'
? 'The router\'s address and prefix on the WireGuard network, such as 192.168.200.1/24.'
: 'The IPv4 network the clients get their addresses from.')}
onChange={(event) => setAddress(event.target.value)}
/>
{props.system === 'p2s' && (
<TextField
label="DNS domains"
value={domains}
autoComplete="off"
multiline
minRows={2}
error={domainsProblem !== ''}
helperText={domainsProblem || 'The domains the clients resolve with the router\'s DNS, one per line.'}
onChange={(event) => setDomains(event.target.value)}
/>
)}
{props.system !== 'p2s' && (
<TextField
label={props.system === 'wireguard' ? 'UDP port' : 'Port'}
value={port}
autoComplete="off"
error={portError !== ''}
helperText={portError || 'A cloud firewall in front of the router must allow this port.'}
slotProps={{ htmlInput: { inputMode: 'numeric' } }}
onChange={(event) => setPort(event.target.value.trim())}
/>
)}
{props.system === 'openvpn' && (
<TextField select label="Protocol" value={protocol} onChange={(event) => setProtocol(event.target.value)}>
{OPENVPN_PROTOCOLS.map((item) => <MenuItem key={item} value={item}>{item.toUpperCase()}</MenuItem>)}
</TextField>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel} disabled={saving}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Save</Button>
</DialogActions>
</Dialog>
)
}
+178
View File
@@ -0,0 +1,178 @@
import Description from '@mui/icons-material/Description'
import Edit from '@mui/icons-material/Edit'
import Refresh from '@mui/icons-material/Refresh'
import { Alert, Box, Button, Chip, Stack } from '@mui/material'
import { useCallback, useEffect, useState } from 'react'
import RemoteAccessDialog from './RemoteAccessDialog.tsx'
import Toolbar from './Toolbar.tsx'
import WireGuardConfigDialog from './WireGuardConfigDialog.tsx'
import WireGuardPeers from './WireGuardPeers.tsx'
import { Copyable, Grid, IPSEC_HEADS, Pairs, Section, bytes, ipsecRows, text } from './RouterPanel.tsx'
import {
DETAILS_SHAPE, REMOTE_ACCESS_LABELS, REMOTE_ACCESS_SECTION_SHAPES, REMOTE_ACCESS_SHAPE, STATUS_SHAPE, USERS_SHAPE, request,
type RemoteAccess, type RemoteAccessSystem, type RouterDetails, type RouterStatus, type Users,
} from './api.ts'
/** What each system describes, above its panels. */
const DESCRIPTIONS: Record<RemoteAccessSystem, string> = {
p2s: 'IKEv2 remote access.',
openvpn: 'OpenVPN remote access.',
wireguard: 'WireGuard remote access.',
}
/**
* The settings and the state of one remote access system of the router, from its /remote-access and /status/full: the
* settings with Edit, whether setup runs the system, why an enabled one does not run, and its connected clients; for
* IKEv2, from /config too, the IPsec security associations, child SAs, connections and connection children of all but
* the site-to-site connections, that is of IKEv2 access; for WireGuard, from /users and /config too, the peers of the users, added and removed here (WireGuardPeers), and each
* connection with its user, found by the peer's key, its IPs, endpoint, handshake and traffic. The router applies a
* change in the background; Refresh reads the state again.
*/
export default function RemoteAccessPanel(props: { system: RemoteAccessSystem }) {
const [access, setAccess] = useState<RemoteAccess | null>(null)
const [status, setStatus] = useState<RouterStatus | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [editing, setEditing] = useState(false)
const [showConfig, setShowConfig] = useState(false)
// WireGuard's: the users with their peers, and the router's details with each peer's key, endpoint and traffic.
const [users, setUsers] = useState<Users | null>(null)
const [details, setDetails] = useState<RouterDetails | null>(null)
const wireguard = props.system === 'wireguard'
// IKEv2's: the router's details with its IPsec SAs and connections, those of IKEv2 access being all but site to site.
const ikev2 = props.system === 'p2s'
const refresh = useCallback(async () => {
setBusy(true)
try {
const [nextAccess, nextStatus, nextUsers, nextDetails] = await Promise.all([
request<RemoteAccess>('GET', '/remote-access', REMOTE_ACCESS_SHAPE),
request<RouterStatus>('GET', '/status/full', STATUS_SHAPE),
wireguard ? request<Users>('GET', '/users', USERS_SHAPE) : null,
wireguard || ikev2 ? request<RouterDetails>('GET', '/config', DETAILS_SHAPE) : null,
])
setAccess(nextAccess)
setStatus(nextStatus)
setUsers(nextUsers)
setDetails(nextDetails)
setError(null)
} catch (caught) {
setError((caught as Error).message)
} finally {
setBusy(false)
}
}, [wireguard, ikev2])
useEffect(() => {
void refresh()
}, [refresh])
async function save(settings: Record<string, unknown>) {
await request('PUT', `/remote-access/${props.system}`, REMOTE_ACCESS_SECTION_SHAPES[props.system], settings)
setEditing(false)
await refresh()
}
const system = props.system
const settings = access?.[system]
const running = access?.running[system] ?? false
const rows: [string, string][] = !access ? [] : system === 'p2s'
? [['Enabled', access.p2s.enabled ? 'yes' : 'no'], ['Address pool', text(access.p2s.address_pool)],
['DNS domains', access.p2s.dns_domains.join(', ') || '-']]
: system === 'openvpn'
? [['Enabled', access.openvpn.enabled ? 'yes' : 'no'], ['Address pool', text(access.openvpn.address_pool)],
['Port', `${access.openvpn.port}/${access.openvpn.protocol}`]]
: [['Enabled', access.wireguard.enabled ? 'yes' : 'no'], ['Address of wg0', text(access.wireguard.address)],
['UDP port', String(access.wireguard.listen_port)]]
const clients = system === 'p2s' ? status?.p2s?.clients : system === 'openvpn' ? status?.openvpn?.clients : undefined
const peers = status?.wireguard?.peers
const siteToSite = new Set((details?.network.site_to_site ?? []).map((connection) => connection.name))
const ipsec = ipsecRows(ikev2 ? details : null, (name) => !siteToSite.has(name))
// An IPsec table is shown with rows, or with the note that the router could not read them.
const shown = (rows: string[][] | undefined) => ikev2 && details !== null && (rows === undefined || rows.length > 0)
return (
<Box component="section">
<Toolbar description={DESCRIPTIONS[system]}>
<Button variant="outlined" startIcon={<Edit />} disabled={!access || busy} onClick={() => setEditing(true)}>Edit</Button>
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>Refresh</Button>
</Toolbar>
{error && <Alert severity="error" sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
{access && settings && settings.enabled && !running && (
<Alert severity="warning" sx={{ mb: 2 }}>
{system === 'wireguard'
? 'WireGuard is enabled but does not run yet; the router applies a change within a moment.'
: `${REMOTE_ACCESS_LABELS[system]} is enabled but does not run yet. It needs wan.local_fqdn${access.local_fqdn ? '' : ', which is not set,'} and the router's VPN server certificate of the P2S issuing CA; the router applies a change within a moment.`}
</Alert>
)}
{access && (
<Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' } }}>
<Section title="Settings"><Pairs rows={rows} /></Section>
<Section title="State">
<Pairs
rows={[
['Running', <Chip key="running" size="small" label={running ? 'running' : 'not running'} color={running ? 'success' : 'default'} />],
...(system === 'wireguard'
? [['Peers', peers === undefined || peers === null ? '-' : String(peers.length)] as [string, string]]
: [['Connected clients', clients === undefined || clients === null ? '-' : String(clients)] as [string, string]]),
]}
/>
</Section>
{shown(ipsec.sas) && <Section title="IPsec security associations" wide><Grid head={IPSEC_HEADS.sas} rows={ipsec.sas} /></Section>}
{shown(ipsec.childSas) && <Section title="IPsec child SAs" wide><Grid head={IPSEC_HEADS.childSas} rows={ipsec.childSas} /></Section>}
{shown(ipsec.connections) && <Section title="IPsec connections" wide><Grid head={IPSEC_HEADS.connections} rows={ipsec.connections} /></Section>}
{shown(ipsec.children) && <Section title="IPsec connection children" wide><Grid head={IPSEC_HEADS.children} rows={ipsec.children} /></Section>}
{system === 'wireguard' && (
<Section title="Server" wide>
<Pairs
rows={[
['Public key', access.wireguard_server.public_key === null ? 'not made yet; setup makes it once WireGuard is enabled'
: <Copyable key="key" value={access.wireguard_server.public_key} label="public key" />],
['Endpoint', access.wireguard_server.endpoint ?? 'unknown: wan.local_fqdn is not set'],
['Allowed IPs', text(access.wireguard_server.allowed_ips)],
]}
/>
<Stack direction="row" sx={{ mt: 1, justifyContent: 'flex-end' }}>
<Button
size="small"
variant="outlined"
startIcon={<Description />}
disabled={!access.wireguard_server.public_key || !access.wireguard_server.endpoint}
onClick={() => setShowConfig(true)}
>
Show config
</Button>
</Stack>
</Section>
)}
{system === 'wireguard' && <WireGuardPeers server={access.wireguard_server} users={users} onChanged={() => void refresh()} />}
{system === 'wireguard' && running && (
<Section title="Connections" wide>
<Grid
head={['User', 'IPs', 'Endpoint', 'Handshake', 'Received', 'Sent']}
rows={details?.wireguard?.peers.map((peer) => {
// A peer is a user's by its key; one added to wg0.conf by hand has no user.
const owner = users?.users.find((user) => user.wireguard_peers.some((own) => own.public_key === peer.public_key))
return [
owner?.name ?? `- (${peer.public_key.slice(0, 8)}...)`,
peer.allowed_ips.join(', '),
peer.endpoint ?? '-',
peer.handshake_seconds_ago === null ? 'none' : `${peer.handshake_seconds_ago} s ago`,
bytes(peer.received_bytes),
bytes(peer.sent_bytes),
]
}) ?? undefined}
/>
</Section>
)}
</Box>
)}
{showConfig && access && (
<WireGuardConfigDialog title="WireGuard configuration of a device" server={access.wireguard_server} ips={null} onClose={() => setShowConfig(false)} />
)}
{editing && access && (
<RemoteAccessDialog system={system} current={access} onSave={save} onCancel={() => setEditing(false)} />
)}
</Box>
)
}
+659
View File
@@ -0,0 +1,659 @@
import ContentCopy from '@mui/icons-material/ContentCopy'
import PlayArrow from '@mui/icons-material/PlayArrow'
import Refresh from '@mui/icons-material/Refresh'
import Settings from '@mui/icons-material/Settings'
import { Alert, Box, Button, Chip, IconButton, Link, Paper, Stack, Table, TableBody, TableCell, TableHead, TableRow, Tooltip, Typography } from '@mui/material'
import { Fragment, useCallback, useEffect, useState, type ReactNode } from 'react'
import { Board, BoardButtons, useBoard, type BoardPanel } from './PanelBoard.tsx'
import HttpsSettingsDialog from './HttpsSettingsDialog.tsx'
import UpdatesNotice from './UpdatesNotice.tsx'
import { BackupChip, backupRows } from './BackupSummary.tsx'
import {
ApiError, BACKUP_SHAPE, DETAILS_SHAPE, HTTPS_SETTINGS_SHAPE, POSTGRESQL_SHAPE, STARTED_SHAPE, STATUS_SHAPE, request, type Backup,
type HttpsSettings, type PostgresqlDatabases, type RouterDetails, type RouterStatus,
} from './api.ts'
import { useBrowserSettings } from './settings.ts'
/** The localStorage key of the tab's layout. */
const LAYOUT_KEY = 'vpn-router-manager.router-layout'
/**
* The router's state: its status, ok or degraded on the title bar of the Status panel, with the update information of
* its packages (UpdatesNotice) at the top of that panel, its remote access clients, and from /config the status web
* service, HTTPS, with a chip while Caddy takes Let's Encrypt's staging CA and a button that opens HttpsSettingsDialog
* with /https/settings, and Caddy (version, source and the state of caddy.service) in it; the state of each of its systemd
* services and of Caddy in the Services panel; the Addresses panel's links to the status page, over HTTPS for
* wan.local_fqdn and over plain HTTP on the addresses web.listen names, and to the Router Manager over HTTPS, and the
* OpenID Provider's issuer and discovery URL, each with a copy icon, or off while it is disabled; its
* backups with Run now, and its non-public details as tables and command output, with a panel per PostgreSQL
* cluster: its settings from /config and its databases with their sizes from /postgresql. Read at once, then as the
* browser's settings say (every 10 seconds by default, or only on Refresh). Nothing is read, and Refresh is off, while
* a check for updates, an upgrade or a reboot runs; the tab reads again when it ends.
*
* The panels are on a PanelBoard whose layout is kept in the browser: each folds by its title bar and, but for Status,
* hides by its [X]; the Panels dialog shows them again, and Edit layout moves them by dragging and sets their width.
*/
export default function RouterPanel() {
const [status, setStatus] = useState<RouterStatus | null>(null)
const [details, setDetails] = useState<RouterDetails | null>(null)
const [databases, setDatabases] = useState<PostgresqlDatabases | null>(null)
const [backup, setBackup] = useState<Backup | null>(null)
const [problems, setProblems] = useState<{ status?: string; details?: string; databases?: string; backup?: string }>({})
const [starting, setStarting] = useState(false)
const [reading, setReading] = useState(true)
const board = useBoard(LAYOUT_KEY)
const [busy, setBusy] = useState(false)
const [httpsSettings, setHttpsSettings] = useState<HttpsSettings | null>(null)
const [httpsError, setHttpsError] = useState<string | null>(null)
/** Reads the router's https section from /https/settings and opens HttpsSettingsDialog with it. */
async function openHttps() {
setHttpsError(null)
try {
setHttpsSettings((await request<{ settings: HttpsSettings }>('GET', '/https/settings', HTTPS_SETTINGS_SHAPE)).settings)
} catch (caught) {
setHttpsError(`The router did not give its HTTPS settings: ${(caught as Error).message}`)
}
}
const refresh = useCallback(async () => {
setReading(true)
const found: { status?: string; details?: string; databases?: string; backup?: string } = {}
const [nextStatus, nextDetails, nextDatabases, nextBackup] = await Promise.all([
request<RouterStatus>('GET', '/status/full', STATUS_SHAPE).catch((error: Error) => {
found.status = `The router did not give its status: ${error.message}`
return null
}),
request<RouterDetails>('GET', '/config', DETAILS_SHAPE).catch((error: Error) => {
found.details = `The router did not give its details: ${error.message}`
return null
}),
request<PostgresqlDatabases>('GET', '/postgresql', POSTGRESQL_SHAPE).catch((error: Error) => {
found.databases = `The router did not give its PostgreSQL databases: ${error.message}`
return null
}),
request<Backup>('GET', '/backup', BACKUP_SHAPE).catch((error: ApiError) => {
if (error.status !== 404) {
found.backup = `The router did not give its backups: ${error.message}`
}
return null
}),
])
setStatus(nextStatus)
setDetails(nextDetails)
setDatabases(nextDatabases)
setBackup(nextBackup)
setProblems(found)
setReading(false)
}, [])
// A read at once and when a check for updates, an upgrade or a reboot ends, then every routerRefresh seconds of the
// browser's settings, unless it is manual (0); nothing is read while one of them runs (busy).
const { routerRefresh } = useBrowserSettings()
useEffect(() => {
if (!busy) {
void refresh()
}
}, [refresh, busy])
useEffect(() => {
if (routerRefresh === 0 || busy) {
return
}
const timer = window.setInterval(() => void refresh(), routerRefresh * 1000)
return () => window.clearInterval(timer)
}, [refresh, routerRefresh, busy])
const network = details?.network
const web = details?.configuration.web
const https = details?.configuration.https
const fqdn = String(details?.configuration.wan?.local_fqdn ?? '')
const httpsOn = https?.enabled === true
// The addresses the status web service listens on with plain HTTP, as web.listen names them.
const listen = String(web?.listen ?? 'both')
const plain = web?.enabled === false ? [] : [
...(listen !== 'external' && network?.internal_address ? [network.internal_address] : []),
...(listen !== 'internal' ? network?.external_addresses ?? [] : []),
].map((address) => `http://${String(address).split('/')[0]}:${text(web?.port)}`)
// The panels in their default order; a panel without data is left out, and keeps its place in the layout.
const panels: BoardPanel[] = []
panels.push({
id: 'status',
title: 'Status',
actions: status && <Chip size="small" label={status.status} color={status.status === 'ok' ? 'success' : 'warning'} />,
wide: true,
fixed: true,
content: (
<>
<UpdatesNotice onBusy={setBusy} />
{status ? (
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ width: 260 }}>Part</TableCell>
<TableCell>State</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!status.p2s && !status.openvpn && !status.wireguard && (
<TableRow>
<TableCell>Remote access</TableCell>
<TableCell sx={{ color: 'text.secondary' }}>none running</TableCell>
</TableRow>
)}
{status.p2s && (
<TableRow>
<TableCell>IKEv2</TableCell>
<TableCell>{status.p2s.clients === null ? 'unknown' : `${status.p2s.clients} connected`}</TableCell>
</TableRow>
)}
{status.openvpn && (
<TableRow>
<TableCell>OpenVPN</TableCell>
<TableCell>{status.openvpn.clients === null ? 'unknown' : `${status.openvpn.clients} connected`}</TableCell>
</TableRow>
)}
{status.wireguard && (
<TableRow>
<TableCell>WireGuard</TableCell>
<TableCell>
{status.wireguard.peers === null
? 'down'
: `${status.wireguard.peers.length} peers: ${status.wireguard.peers.map((peer) => (peer.handshake_seconds_ago === null ? 'no handshake' : `${peer.handshake_seconds_ago} s ago`)).join(', ')}`}
</TableCell>
</TableRow>
)}
{details && (
<>
<TableRow>
<TableCell>Status web service</TableCell>
<TableCell>{web?.enabled === false ? 'off' : `on, port ${text(web?.port)}`}</TableCell>
</TableRow>
<TableRow>
<TableCell>HTTPS</TableCell>
<TableCell>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<span>{httpsOn ? (fqdn ? `on, for ${fqdn}` : 'on, but wan.local_fqdn is not set') : 'off'}</span>
{httpsOn && https?.acme_staging === true && <Chip size="small" label="Let's Encrypt staging" color="warning" />}
<Tooltip title="HTTPS settings">
<span>
<IconButton size="small" aria-label="HTTPS settings" disabled={busy} onClick={() => void openHttps()}>
<Settings fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
</TableCell>
</TableRow>
<TableRow>
<TableCell>Caddy</TableCell>
<TableCell>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<span>{details.caddy ? `${details.caddy.version}, ${sourceText(details.caddy.source, String(https?.caddy_source ?? 'ubuntu'))}` : `not installed; source ${sourceText(undefined, String(https?.caddy_source ?? 'ubuntu'))}`}</span>
{details.caddy?.service && (
<Chip size="small" label={details.caddy.service} color={details.caddy.service === 'active' ? 'success' : 'warning'} />
)}
</Stack>
</TableCell>
</TableRow>
</>
)}
</TableBody>
</Table>
) : <Typography variant="body2" color="text.secondary">The router's status is not read yet.</Typography>}
</>
),
})
if (status && (status.services || status.units)) {
const units = Object.entries(status.units ?? {})
const failed = status.services?.failed ?? units.filter(([, state]) => state !== 'active').length
panels.push({
id: 'services',
title: 'Services',
actions: <Chip size="small" label={failed === 0 ? 'all active' : `${failed} not active`} color={failed === 0 ? 'success' : 'warning'} />,
content: (
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ width: 260 }}>Service</TableCell>
<TableCell>State</TableCell>
</TableRow>
</TableHead>
<TableBody>
{status.services && units.length === 0 && (
<TableRow>
<TableCell>Services</TableCell>
<TableCell sx={{ color: status.services.failed === 0 ? 'success.main' : 'warning.main' }}>
{`${status.services.active} active, ${status.services.failed} failed`}
</TableCell>
</TableRow>
)}
{units.map(([unit, state]) => (
<TableRow key={unit}>
<TableCell>{UNIT_LABELS[unit] ?? unit}</TableCell>
<TableCell sx={{ color: state === 'active' ? 'success.main' : 'warning.main' }}>{state}</TableCell>
</TableRow>
))}
{details?.caddy?.service && (
<TableRow>
<TableCell>Caddy</TableCell>
<TableCell sx={{ color: details.caddy.service === 'active' ? 'success.main' : 'warning.main' }}>{details.caddy.service}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
),
})
}
if (backup) {
panels.push({
id: 'backup',
title: 'Backup',
actions: <BackupChip state={backup.state} />,
content: (
<>
<Pairs rows={backupRows(backup)} />
<Stack direction="row" sx={{ mt: 1, justifyContent: 'flex-end' }}>
<Button
size="small"
variant="outlined"
startIcon={<PlayArrow />}
disabled={starting || busy || backup.state?.result === 'running'}
onClick={() => {
setStarting(true)
request('POST', '/backup/run', STARTED_SHAPE)
.catch((error: Error) => setProblems((current) => ({ ...current, backup: `The backup did not start: ${error.message}` })))
.finally(() => {
setStarting(false)
void refresh()
})
}}
>
Run now
</Button>
</Stack>
</>
),
})
}
if (details) {
panels.push({
id: 'addresses',
title: 'Addresses',
content: (
<Pairs
rows={[
['Status page', <Links key="status" urls={[...(httpsOn && fqdn ? [`https://${fqdn}:${text(https?.web_port)}`] : []), ...plain]} />],
['Router Manager', <Links key="manager" urls={httpsOn && fqdn ? [`https://${fqdn}:${text(https?.manager_port)}`] : []} />],
...(details.oidc?.issuer
? [
['OpenID Provider', <Copyable key="issuer" value={details.oidc.issuer} label="issuer" />],
['Discovery document', <Copyable key="discovery" value={`${details.oidc.issuer}/.well-known/openid-configuration`} label="discovery URL" />],
] as [string, ReactNode][]
: [['OpenID Provider', 'off'] as [string, string]]),
]}
/>
),
})
}
// The IPsec connections are on the Connectivity page, site to site, and on the Remote Access page, IKEv2, with WireGuard.
if (details && network) {
if (details.postgresql === null) {
panels.push({ id: 'postgresql', title: 'PostgreSQL', content: <Typography variant="body2" color="text.secondary">Not available.</Typography> })
}
for (const cluster of details.postgresql?.clusters ?? []) {
const id = `${cluster.version}/${cluster.name}`
const held = databases?.clusters?.find((item) => item.version === cluster.version && item.name === cluster.name)
panels.push({
id: `postgresql:${id}`,
title: `PostgreSQL ${id}`,
content: (
<>
<Pairs
rows={[
['Status', cluster.status],
['Port', text(cluster.port)],
['Data directory', cluster.data_directory],
...Object.entries(cluster.settings ?? {}).map(([name, value]): [string, string] => [name, text(value)]),
]}
/>
<Typography variant="subtitle2" sx={{ mt: 2 }}>
{held?.size === null || held?.size === undefined ? 'Databases' : `Databases, ${bytes(held.size)} in total`}
</Typography>
<Grid
head={['Database', 'Owner', 'Encoding', 'Size']}
rows={held?.databases?.map((database) => [database.name, database.owner, database.encoding, bytes(database.size)]) ?? undefined}
/>
</>
),
})
}
// site_to_site holds objects, the connections, which the Connectivity page shows; backup is shown by the Backup panel
// and the Backup tab, and pki by the Certificates page.
const sections = Object.entries(details.configuration)
.filter(([name, values]) => !['site_to_site', 'backup', 'pki'].includes(name) && sectionConfigured(values))
if (sections.length > 0) {
panels.push({
id: 'configuration',
title: 'Configuration',
wide: true,
content: (
<Table size="small">
<TableBody>
{sections.map(([name, values]) => (
<Fragment key={name}>
<TableRow>
<TableCell colSpan={2} sx={{ pt: 2, fontWeight: 700, borderBottom: 2, borderColor: 'divider' }}>
{SECTION_LABELS[name] ?? name}
</TableCell>
</TableRow>
{Object.entries(values).map(([key, value]) => (
<TableRow key={`${name}.${key}`}>
<TableCell sx={{ width: 200, whiteSpace: 'nowrap', color: 'text.secondary', pl: 4 }}>{key}</TableCell>
<TableCell sx={{ wordBreak: 'break-word' }}><Addressed text={text(value)} /></TableCell>
</TableRow>
))}
</Fragment>
))}
</TableBody>
</Table>
),
})
}
}
return (
<Box component="section">
<Stack direction="row" sx={{ mb: 2, alignItems: 'center', justifyContent: 'flex-end', gap: 2, flexWrap: 'wrap' }}>
<BoardButtons board={board} />
<Button variant="outlined" startIcon={<Refresh />} disabled={reading || busy} onClick={() => void refresh()}>
Refresh
</Button>
</Stack>
{problems.status && <Alert severity="warning" sx={{ mb: 2 }}>{problems.status}</Alert>}
{problems.details && <Alert severity="warning" sx={{ mb: 2 }}>{problems.details}</Alert>}
{problems.databases && <Alert severity="warning" sx={{ mb: 2 }}>{problems.databases}</Alert>}
{problems.backup && <Alert severity="warning" sx={{ mb: 2 }}>{problems.backup}</Alert>}
{httpsError && <Alert severity="warning" sx={{ mb: 2 }} onClose={() => setHttpsError(null)}>{httpsError}</Alert>}
<Board board={board} panels={panels} />
{httpsSettings && (
<HttpsSettingsDialog
current={httpsSettings}
onCancel={() => setHttpsSettings(null)}
onSave={async (values) => {
await request('PUT', '/https/settings', HTTPS_SETTINGS_SHAPE, values)
setHttpsSettings(null)
void refresh()
}}
/>
)}
</Box>
)
}
/** A value as text: lists joined with commas, an empty value as a dash. */
export function text(value: unknown): string {
if (Array.isArray(value)) {
return value.length === 0 ? '-' : value.join(', ')
}
return value === undefined || value === null || value === '' ? '-' : String(value)
}
/** The names shown for the router's systemd units; a unit not listed is shown by its unit name. */
const UNIT_LABELS: Record<string, string> = {
'vpn-router-setup': 'VPN Router Setup',
strongswan: 'StrongSwan',
'wg-quick@wg0': 'WireGuard interface',
'openvpn-server@vpn-router': 'OpenVPN server',
slapd: 'OpenLDAP',
}
/** Colours of the panel titles: brown in the light scheme, gold in the dark one. */
const TITLE_LIGHT = '#8b4513'
const TITLE_DARK = '#d4af37'
/** A titled panel of a grid; a wide one spans both columns. */
export function Section(props: { title: string; wide?: boolean; children: ReactNode }) {
return (
<Paper variant="outlined" sx={{ p: 2, minWidth: 0, gridColumn: props.wide ? '1 / -1' : undefined }}>
<Typography
variant="subtitle1"
sx={[{ mb: 1, fontWeight: 700, color: TITLE_LIGHT }, (theme) => theme.applyStyles('dark', { color: TITLE_DARK })]}
>
{props.title}
</Typography>
{props.children}
</Paper>
)
}
export function Pairs(props: { rows: [string, string | ReactNode][] }) {
return (
<Table size="small">
<TableBody>
{props.rows.map(([label, value]) => (
<TableRow key={label}>
<TableCell sx={{ width: 200, whiteSpace: 'nowrap', color: 'text.secondary' }}>{label}</TableCell>
<TableCell sx={{ wordBreak: 'break-word' }}>{typeof value === 'string' ? <Addressed text={value} /> : value}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
/** A value in monospace for Pairs with an action icon that puts it on the clipboard; label names it in the tooltip. */
export function Copyable(props: { value: string; label: string }) {
return (
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<Box component="span" sx={{ fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all' }}>{props.value}</Box>
<Tooltip title={`Copy the ${props.label}`}>
<IconButton size="small" aria-label={`copy the ${props.label}`} onClick={() => void navigator.clipboard.writeText(props.value)}>
<ContentCopy fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
)
}
/** A table with a header row, or a note when the router could not read it. */
export function Grid(props: { head: string[]; rows: string[][] | undefined }) {
if (props.rows === undefined) {
return <Typography variant="body2" color="text.secondary">Not available.</Typography>
}
return (
<Box sx={{ overflowX: 'auto', mb: 1 }}>
<Table size="small">
<TableHead>
<TableRow>
{props.head.map((title) => <TableCell key={title}>{title}</TableCell>)}
</TableRow>
</TableHead>
<TableBody>
{props.rows.length === 0 && (
<TableRow>
<TableCell colSpan={props.head.length} sx={{ color: 'text.secondary' }}>None.</TableCell>
</TableRow>
)}
{props.rows.map((row, index) => (
<TableRow key={index}>
{row.map((cell, column) => <TableCell key={column}><Addressed text={cell} /></TableCell>)}
</TableRow>
))}
</TableBody>
</Table>
</Box>
)
}
/** Whether a word is an IPv4 address, with or without a prefix length. */
function isAddress(word: string) {
const [address, prefix, ...rest] = word.split('/')
const octets = address.split('.')
const number = (part: string, max: number) => part !== '' && String(Number(part)) === part && Number(part) <= max
return rest.length === 0 && octets.length === 4 && octets.every((octet) => number(octet, 255)) && (prefix === undefined || number(prefix, 32))
}
/** Text with its IPv4 addresses and CIDRs in a monospaced font; punctuation around a word stays as it is. */
function Addressed(props: { text: string }) {
return props.text.split(' ').map((word, index) => {
let start = 0
let end = word.length
while (start < end && '(['.includes(word[start])) start += 1
while (end > start && ',;:)]'.includes(word[end - 1])) end -= 1
const core = word.slice(start, end)
return (
<Fragment key={index}>
{index > 0 && ' '}
{isAddress(core) ? (
<>
{word.slice(0, start)}
<Box component="span" sx={{ fontFamily: 'monospace', fontSize: '80%' }}>{core}</Box>
{word.slice(end)}
</>
) : word}
</Fragment>
)
})
}
/** Whether a configuration section is in use: not switched off, and with at least one value set. */
/** The headings of the sections of the configuration; a section not listed is headed by its name. */
const SECTION_LABELS: Record<string, string> = {
general: 'General',
interfaces: 'Interfaces',
wan: 'WAN',
local: 'Local networks',
p2s: 'IKEv2',
openvpn: 'OpenVPN',
wireguard: 'WireGuard',
docker: 'Docker',
metrics: 'Metrics',
ssh: 'SSH',
web: 'Status web service',
https: 'HTTPS',
}
/** The names shown for where the router's Caddy comes from. */
const CADDY_SOURCES: Record<string, string> = { ubuntu: 'Ubuntu', upstream: 'Upstream' }
/**
* Where Caddy comes from: the source in effect (effective, of the installed Caddy) and https.caddy_source when it
* differs, which happens when ubuntu is configured after upstream's repository was, or before setup installed Caddy.
*/
function sourceText(effective: string | undefined, configured: string): string {
const name = (source: string) => CADDY_SOURCES[source] ?? source
if (effective === undefined) {
return `${name(configured)} (configured)`
}
if (effective === configured) {
return name(effective)
}
return effective === 'upstream'
? `${name(effective)} (configured: ${name(configured)}, which upstream's repository overrides)`
: `${name(effective)} (configured: ${name(configured)}; setup switches to it)`
}
/** Addresses as links that open in a new tab, one under the other, or a dash without any. */
function Links(props: { urls: string[] }) {
if (props.urls.length === 0) {
return '-'
}
return (
<Stack sx={{ gap: 0.5 }}>
{props.urls.map((url) => <Link key={url} href={url} target="_blank" rel="noreferrer">{url}</Link>)}
</Stack>
)
}
function sectionConfigured(values: Record<string, unknown>) {
return values.enabled !== false && Object.values(values).some((value) => text(value) !== '-')
}
/** The column titles of the IPsec tables of ipsecRows. */
export const IPSEC_HEADS = {
sas: ['Connection', 'State', 'Local', 'Remote', 'Algorithms', 'Up'],
childSas: ['Child SA', 'State', 'Traffic selectors', 'In', 'Out', 'Up'],
connections: ['Connection', 'Version', 'Local address', 'Local identity', 'Remote address', 'Remote identity', 'Authentication'],
children: ['Child', 'Mode', 'Local traffic selectors', 'Remote traffic selectors'],
}
/**
* The rows of the IPsec tables of the router's /config for the connections whose names keep accepts: the IKE SAs, their
* child SAs, the connections and their children; undefined where the router could not list them.
*/
export function ipsecRows(details: RouterDetails | null, keep: (name: string) => boolean) {
const sas = details?.ipsec.sas?.filter((sa) => keep(sa.name))
const connections = details?.ipsec.connections?.filter((connection) => keep(connection.name))
return {
sas: sas?.map((sa) => [
sa.name,
sa.state,
`${sa['local-id']} @ ${sa['local-host']}`,
`${sa['remote-id']} @ ${sa['remote-host']}`,
[`${text(sa['encr-alg'])}-${text(sa['encr-keysize'])}`, sa['integ-alg'], sa['dh-group']].filter(Boolean).join(', '),
sa.established === undefined ? '-' : duration(Number(sa.established)),
]),
childSas: sas?.flatMap((sa) =>
Object.values(sa['child-sas'] ?? {}).map((child) => [
`${sa.name}/${child.name}`,
`${child.state}, ${child.mode}`,
`${child['local-ts'].join(', ')} <-> ${child['remote-ts'].join(', ')}`,
`${bytes(Number(child['bytes-in']))}, ${child['packets-in']} packets`,
`${bytes(Number(child['bytes-out']))}, ${child['packets-out']} packets`,
child['install-time'] === undefined ? '-' : duration(Number(child['install-time'])),
]),
),
connections: connections?.map((connection) => {
const local = authOf(connection, 'local')
const remote = authOf(connection, 'remote')
return [
connection.name,
connection.version,
text(connection.local_addrs),
text(local.id),
text(connection.remote_addrs),
text(remote.id),
local.class === remote.class ? text(local.class) : `${text(local.class)} / ${text(remote.class)}`,
]
}),
children: connections?.flatMap((connection) =>
Object.entries(connection.children ?? {}).map(([name, child]) => [
`${connection.name}/${name}`,
child.mode,
child['local-ts'].join(', '),
child['remote-ts'].join(', '),
]),
),
}
}
/** The authentication round of one side of a connection: swanctl names them local-1, remote-1 and so on. */
function authOf(connection: Record<string, unknown>, side: 'local' | 'remote'): { class?: string; id?: string } {
const round = Object.entries(connection).find(([key, value]) => key.startsWith(`${side}-`) && typeof value === 'object' && value !== null)
return (round?.[1] ?? {}) as { class?: string; id?: string }
}
/** Seconds as "1h 2m 3s". */
export function duration(seconds: number) {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return [h && `${h}h`, (h || m) && `${m}m`, `${seconds % 60}s`].filter(Boolean).join(' ')
}
/** Bytes in B, KiB, MiB or GiB. */
export function bytes(count: number) {
const units = ['B', 'KiB', 'MiB', 'GiB']
let value = count
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`
}
+22
View File
@@ -0,0 +1,22 @@
import Search from '@mui/icons-material/Search'
import { InputAdornment, TextField } from '@mui/material'
/** A small search box for a toolbar; onChange gets the text as typed. */
export default function SearchField(props: { value: string; onChange: (value: string) => void }) {
return (
<TextField
size="small"
placeholder="Search"
value={props.value}
onChange={(event) => props.onChange(event.target.value)}
sx={{ width: 220, flexShrink: 0 }}
slotProps={{ input: { startAdornment: <InputAdornment position="start"><Search fontSize="small" /></InputAdornment> } }}
/>
)
}
/** Whether any of the texts contains the search, ignoring case; an empty search matches everything. */
export function matches(search: string, texts: (string | null | undefined)[]) {
const wanted = search.trim().toLowerCase()
return wanted === '' || texts.some((text) => text?.toLowerCase().includes(wanted))
}
+421
View File
@@ -0,0 +1,421 @@
import Add from '@mui/icons-material/Add'
import Delete from '@mui/icons-material/Delete'
import {
Alert, Autocomplete, Box, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, FormGroup, IconButton, Stack, Switch,
TextField, Tooltip, Typography,
} from '@mui/material'
import { useState, type FormEvent } from 'react'
import type { Role, User } from './api.ts'
/** The names vpn-router-user accepts for a service account: a user name or a GUID in lowercase. */
const NAME_PATTERN = /^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
/** A random GUID (version 4) in lowercase, from crypto.getRandomValues, which pages served over plain HTTP have too. */
function newGuid() {
const bytes = crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
/** The e-mail addresses vpn-router-user accepts: one @, no spaces, and a dot in the domain. */
const EMAIL_PATTERN = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
/** The longest display name and description the router accepts. */
const DISPLAY_NAME_MAX = 64
const DESCRIPTION_MAX = 256
/** The most URIs of each kind and allowed groups of an OpenID Provider client, and the longest URI, the router accepts. */
const CLIENT_VALUES_MAX = 16
const URI_MAX = 1024
/** The hosts a client's URI may name over plain HTTP. */
const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']
/** The names of a role and of a scope of a resource the router accepts. */
const ROLE_NAME = /^[A-Za-z][A-Za-z0-9._-]{0,63}$/
const SCOPE_NAME = /^[a-z][a-z0-9._-]{0,63}$/
/** The grants a client may have besides the authorization code, with their labels. */
const GRANTS = [['client_credentials', 'Client credentials'], ['device_code', 'Device authorization']] as const
/** The settings of a service account that /service-accounts takes; an empty one removes it when editing. */
export type ServiceAccountSettings = {
display_name: string
description: string
email: string
redirect_uris: string[]
post_logout_redirect_uris: string[]
allowed_groups: string[]
grants: string[]
public_client: boolean
client_scopes: string[]
resource_scopes: string[]
roles: Role[]
}
/** Why the roles are refused by the router, or ''. */
function rolesProblem(roles: Role[]) {
for (const role of roles) {
if (!ROLE_NAME.test(role.name.trim())) {
return 'A role name is up to 64 letters, digits, dots, underscores and dashes, starting with a letter.'
}
if (role.description.trim().length > DESCRIPTION_MAX) {
return `A role description has at most ${DESCRIPTION_MAX} characters.`
}
}
const names = roles.map((role) => role.name.trim().toLowerCase())
return new Set(names).size === names.length ? '' : 'Each role name may appear once.'
}
/** The non-empty lines of a text field, trimmed. */
function lines(text: string) {
return text.split('\n').map((line) => line.trim()).filter((line) => line !== '')
}
/** Why the URIs of a text field are refused by the router, or ''. */
function urisProblem(text: string) {
const uris = lines(text)
if (uris.length > CLIENT_VALUES_MAX) {
return `At most ${CLIENT_VALUES_MAX} URIs.`
}
for (const uri of uris) {
let parsed: URL
try {
parsed = new URL(uri)
} catch {
return `${uri} is not a URI.`
}
if (uri.length > URI_MAX || uri.includes('#')
|| !(parsed.protocol === 'https:' || (parsed.protocol === 'http:' && LOOPBACK_HOSTS.includes(parsed.hostname)))) {
return `${uri}: an https URI, or an http URI of localhost, without a fragment.`
}
}
return ''
}
/**
* A dialog that adds a service account, with its name, a new GUID unless replaced by a user name, display name, description, optional e-mail address and a
* password typed twice, or, given an account, edits its display name, description and e-mail address. Both also set the OpenID Provider settings. As a
* client: the redirect URIs and the post-logout redirect URIs, one per line, the allowed groups, chosen from groups, the grants, whether it is public, with
* no secret, which client credentials need, and the client scopes, chosen from scopes, those of every resource; an account with redirect URIs or grants is
* a client, its name the client ID and its password the secret. As a resource: its scopes, typed, and its roles, each with a name, a description and the
* groups and accounts, chosen from accounts, it is assigned to. onSave sends them, trimmed, with the password when adding; the dialog closes when it
* succeeds and shows the router's message when it fails.
*/
export default function ServiceAccountDialog(props: {
account?: User
groups: string[]
accounts: string[]
scopes: string[]
onSave: (name: string, settings: ServiceAccountSettings, password: string) => Promise<void>
onCancel: () => void
}) {
const adding = props.account === undefined
const [name, setName] = useState(() => props.account?.name ?? newGuid())
const [displayName, setDisplayName] = useState(props.account?.display_name ?? '')
const [description, setDescription] = useState(props.account?.description ?? '')
const [email, setEmail] = useState(props.account?.email ?? '')
const [redirectUris, setRedirectUris] = useState((props.account?.redirect_uris ?? []).join('\n'))
const [logoutUris, setLogoutUris] = useState((props.account?.post_logout_redirect_uris ?? []).join('\n'))
const [allowedGroups, setAllowedGroups] = useState(props.account?.allowed_groups ?? [])
const [grants, setGrants] = useState(props.account?.grants ?? [])
const [publicClient, setPublicClient] = useState(props.account?.public_client ?? false)
const [clientScopes, setClientScopes] = useState(props.account?.client_scopes ?? [])
const [resourceScopes, setResourceScopes] = useState(props.account?.resource_scopes ?? [])
const [roles, setRoles] = useState<Role[]>(props.account?.roles ?? [])
const [password, setPassword] = useState('')
const [again, setAgain] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const nameProblem = adding && name !== '' && !NAME_PATTERN.test(name)
? 'A GUID, or up to 32 lower case letters, digits and dashes, starting with a letter and not ending with a dash.'
: ''
const displayNameProblem = displayName.trim().length > DISPLAY_NAME_MAX ? `At most ${DISPLAY_NAME_MAX} characters.` : ''
const descriptionProblem = description.trim().length > DESCRIPTION_MAX ? `At most ${DESCRIPTION_MAX} characters.` : ''
const emailProblem = email.trim() !== '' && !EMAIL_PATTERN.test(email.trim()) ? 'Not an e-mail address.' : ''
const againProblem = again !== '' && again !== password ? 'The passwords differ.' : ''
const redirectProblem = urisProblem(redirectUris)
const logoutProblem = urisProblem(logoutUris)
const groupsProblem = allowedGroups.length > CLIENT_VALUES_MAX ? `At most ${CLIENT_VALUES_MAX} groups.` : ''
const clientScopesProblem = clientScopes.length > CLIENT_VALUES_MAX ? `At most ${CLIENT_VALUES_MAX} scopes.` : ''
const resourceScopesProblem = resourceScopes.length > CLIENT_VALUES_MAX
? `At most ${CLIENT_VALUES_MAX} scopes.`
: resourceScopes.every((scope) => SCOPE_NAME.test(scope))
? ''
: 'A scope is up to 64 lower case letters, digits, dots, underscores and dashes, starting with a letter.'
const roleProblem = rolesProblem(roles)
const blocked = name === '' || nameProblem !== '' || displayNameProblem !== '' || descriptionProblem !== '' || emailProblem !== ''
|| redirectProblem !== '' || logoutProblem !== '' || groupsProblem !== '' || clientScopesProblem !== '' || resourceScopesProblem !== ''
|| roleProblem !== ''
|| (adding && (password === '' || again !== password))
/** Changes one role of the list. */
function setRole(index: number, change: Partial<Role>) {
setRoles((current) => current.map((role, at) => (at === index ? { ...role, ...change } : role)))
}
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave(name, {
display_name: displayName.trim(),
description: description.trim(),
email: email.trim(),
redirect_uris: lines(redirectUris),
post_logout_redirect_uris: lines(logoutUris),
allowed_groups: allowedGroups,
grants: GRANTS.map(([grant]) => grant).filter((grant) => grants.includes(grant)),
public_client: publicClient,
client_scopes: [...clientScopes].sort(),
resource_scopes: [...resourceScopes].sort(),
roles: roles.map((role) => ({
name: role.name.trim(),
description: role.description.trim(),
groups: [...role.groups].sort(),
accounts: [...role.accounts].sort(),
})).sort((one, other) => one.name.localeCompare(other.name)),
}, password)
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>{adding ? 'Add service account' : `Edit service account ${props.account?.name}`}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<TextField
label="Name"
value={name}
disabled={!adding}
autoFocus={adding}
autoComplete="off"
error={nameProblem !== ''}
helperText={nameProblem || (adding ? 'The name the other system signs in with: a new GUID, or a user name.' : 'The name the other system signs in with.')}
onChange={(event) => setName(event.target.value.trim().toLowerCase())}
/>
<TextField
label="Display name"
value={displayName}
autoFocus={!adding}
autoComplete="off"
error={displayNameProblem !== ''}
helperText={displayNameProblem || 'Optional.'}
onChange={(event) => setDisplayName(event.target.value)}
/>
<TextField
label="Description"
value={description}
autoComplete="off"
multiline
minRows={2}
error={descriptionProblem !== ''}
helperText={descriptionProblem || 'Optional: what the account is for.'}
onChange={(event) => setDescription(event.target.value)}
/>
<TextField
label="E-mail"
type="email"
value={email}
autoComplete="off"
error={emailProblem !== ''}
helperText={emailProblem || 'Optional, for notifications.'}
onChange={(event) => setEmail(event.target.value)}
/>
<Typography variant="subtitle2" sx={{ mt: 1 }}>As an OpenID Connect client</Typography>
<TextField
label="Redirect URIs"
value={redirectUris}
autoComplete="off"
multiline
minRows={2}
error={redirectProblem !== ''}
helperText={redirectProblem || 'Optional: the OpenID Connect redirect URIs of the client, one per line.'}
onChange={(event) => setRedirectUris(event.target.value)}
/>
<TextField
label="Post-logout redirect URIs"
value={logoutUris}
autoComplete="off"
multiline
minRows={1}
error={logoutProblem !== ''}
helperText={logoutProblem || 'Optional: the post-logout redirect URIs of the client, one per line.'}
onChange={(event) => setLogoutUris(event.target.value)}
/>
<Autocomplete
multiple
options={props.groups}
value={allowedGroups}
onChange={(_event, next) => setAllowedGroups(next)}
renderInput={(params) => (
<TextField
{...params}
label="Allowed groups"
error={groupsProblem !== ''}
helperText={groupsProblem || 'Optional: the groups whose members may sign in to the client.'}
/>
)}
/>
<Box>
<Typography variant="body2" color="text.secondary">Grants besides the authorization code, which the redirect URIs give</Typography>
<FormGroup row>
{GRANTS.map(([grant, label]) => (
<FormControlLabel
key={grant}
label={label}
control={(
<Checkbox
checked={grants.includes(grant)}
disabled={grant === 'client_credentials' && publicClient}
onChange={(event) => setGrants((current) => (event.target.checked
? [...current, grant]
: current.filter((value) => value !== grant)))}
/>
)}
/>
))}
</FormGroup>
<FormControlLabel
label="Public client (no secret)"
control={(
<Switch
checked={publicClient}
onChange={(event) => {
setPublicClient(event.target.checked)
if (event.target.checked) {
setGrants((current) => current.filter((value) => value !== 'client_credentials'))
}
}}
/>
)}
/>
</Box>
<Autocomplete
multiple
options={props.scopes}
value={clientScopes}
onChange={(_event, next) => setClientScopes(next)}
renderInput={(params) => (
<TextField
{...params}
label="Client scopes"
error={clientScopesProblem !== ''}
helperText={clientScopesProblem || 'Optional: the scopes of resources the client may ask for.'}
/>
)}
/>
<Typography variant="subtitle2" sx={{ mt: 1 }}>As a resource</Typography>
<Autocomplete
multiple
freeSolo
options={[]}
value={resourceScopes}
onChange={(_event, next) => setResourceScopes(next.map((scope) => scope.trim()).filter((scope) => scope !== ''))}
renderInput={(params) => (
<TextField
{...params}
label="Scopes"
error={resourceScopesProblem !== ''}
helperText={resourceScopesProblem || `Optional: the scopes clients ask for as ${name || '<name>'}:<scope>; type one and press Enter.`}
/>
)}
/>
<Stack sx={{ gap: 1.5 }}>
<Typography variant="body2" color="text.secondary">
Roles, which the tokens for this resource carry for the groups and accounts they are assigned to
</Typography>
{roles.map((role, index) => (
<Stack key={index} direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<Stack sx={{ gap: 1, flex: 1 }}>
<Stack direction="row" sx={{ gap: 1 }}>
<TextField
label="Role"
size="small"
value={role.name}
autoComplete="off"
error={!ROLE_NAME.test(role.name.trim())}
onChange={(event) => setRole(index, { name: event.target.value })}
/>
<TextField
label="Description"
size="small"
value={role.description}
autoComplete="off"
sx={{ flex: 1 }}
onChange={(event) => setRole(index, { description: event.target.value })}
/>
</Stack>
<Autocomplete
multiple
size="small"
options={props.groups}
value={role.groups}
onChange={(_event, next) => setRole(index, { groups: next })}
renderInput={(params) => <TextField {...params} label="Groups" />}
/>
<Autocomplete
multiple
size="small"
options={props.accounts}
value={role.accounts}
onChange={(_event, next) => setRole(index, { accounts: next })}
renderInput={(params) => <TextField {...params} label="Users and service accounts" />}
/>
</Stack>
<Tooltip title="Remove the role">
<IconButton onClick={() => setRoles((current) => current.filter((_role, at) => at !== index))}>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
))}
{roleProblem && <Typography variant="caption" color="error">{roleProblem}</Typography>}
<Box>
<Button size="small" startIcon={<Add />} onClick={() => setRoles((current) => [...current, { name: '', description: '', groups: [], accounts: [] }])}>
Add role
</Button>
</Box>
</Stack>
{adding && (
<>
<TextField
label="Password"
type="password"
value={password}
autoComplete="new-password"
onChange={(event) => setPassword(event.target.value)}
/>
<TextField
label="Password again"
type="password"
value={again}
autoComplete="new-password"
error={againProblem !== ''}
helperText={againProblem}
onChange={(event) => setAgain(event.target.value)}
/>
</>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>
{adding ? 'Add' : 'Save'}
</Button>
</DialogActions>
</Dialog>
)
}
+278
View File
@@ -0,0 +1,278 @@
import Add from '@mui/icons-material/Add'
import Block from '@mui/icons-material/Block'
import CheckCircle from '@mui/icons-material/CheckCircle'
import Delete from '@mui/icons-material/Delete'
import Edit from '@mui/icons-material/Edit'
import Key from '@mui/icons-material/Key'
import LockOpen from '@mui/icons-material/LockOpen'
import Refresh from '@mui/icons-material/Refresh'
import { Alert, Box, Button, IconButton, Table, TableBody, TableCell, TableHead, TableRow, Tooltip } from '@mui/material'
import { useCallback, useEffect, useState } from 'react'
import ConfirmDialog, { type ConfirmDialogProps } from './ConfirmDialog.tsx'
import { Section } from './RouterPanel.tsx'
import SearchField, { matches } from './SearchField.tsx'
import ServiceAccountDialog from './ServiceAccountDialog.tsx'
import Toolbar from './Toolbar.tsx'
import UserDialog from './UserDialog.tsx'
import {
ApiError, GROUPS_SHAPE, REMOVED_SHAPE, SERVICE_ACCOUNTS_SHAPE, USER_SHAPE, USERS_SHAPE, request, type Group, type ServiceAccounts, type User,
} from './api.ts'
/** The OpenID Provider settings of a service account as a cell: its client settings, the scopes it defines and its roles, or '-'. */
function oidcText(account: User) {
const parts: string[] = []
if (account.redirect_uris?.length) {
parts.push(`${account.redirect_uris.length} redirect URI${account.redirect_uris.length === 1 ? '' : 's'}, `
+ (account.allowed_groups?.length ? account.allowed_groups.join(', ') : 'all users'))
}
parts.push(...(account.grants ?? []).map((grant) => (grant === 'device_code' ? 'device authorization' : 'client credentials')))
if (account.public_client) {
parts.push('public')
}
if (account.client_scopes?.length) {
parts.push(`asks ${account.client_scopes.join(', ')}`)
}
if (account.resource_scopes?.length) {
parts.push(`scopes ${account.resource_scopes.join(', ')}`)
}
if (account.roles?.length) {
parts.push(`roles ${account.roles.map((role) => role.name).join(', ')}`)
}
return parts.length ? parts.join('; ') : '-'
}
/**
* The service accounts of the router's directory, from its /service-accounts: the accounts other systems sign in with
* to use the router's services. List with their display names, descriptions, e-mail addresses, OpenID Provider
* settings and state, add, edit the display name, the description, the e-mail address and the OpenID Provider settings,
* as a client and as a resource with its scopes and roles, set the password, disable, enable, unlock after failed
* logins and remove. While the OpenID Provider is enabled, the list's title has the number of its clients, the service
* accounts with redirect URIs or grants; its issuer is on the Overview's Addresses panel. The internal ones, which the router creates and manages for its own services with all their properties, are
* listed only: the router refuses every change of them through the API.
*/
export default function ServiceAccountsPanel() {
const [accounts, setAccounts] = useState<User[] | null>(null)
const [oidc, setOidc] = useState<ServiceAccounts['oidc'] | null>(null)
const [groups, setGroups] = useState<string[]>([])
const [users, setUsers] = useState<string[]>([])
const [error, setError] = useState<string | null>(null)
const [unavailable, setUnavailable] = useState(false)
const [busy, setBusy] = useState(false)
const [adding, setAdding] = useState(false)
const [editing, setEditing] = useState<User | null>(null)
const [password, setPassword] = useState<string | null>(null)
const [confirm, setConfirm] = useState<ConfirmDialogProps | null>(null)
const [search, setSearch] = useState('')
const refresh = useCallback(async () => {
setBusy(true)
try {
const [found, groupList, userList] = await Promise.all([
request<ServiceAccounts>('GET', '/service-accounts', SERVICE_ACCOUNTS_SHAPE),
request<{ groups: Group[] }>('GET', '/groups', GROUPS_SHAPE),
request<{ users: User[] }>('GET', '/users', USERS_SHAPE),
])
setAccounts(found.service_accounts)
setOidc(found.oidc)
setGroups(groupList.groups.map((group) => group.name))
setUsers(userList.users.map((user) => user.name))
setError(null)
setUnavailable(false)
} catch (caught) {
const failure = caught as ApiError
setAccounts(null)
setUnavailable(failure.status === 404)
setError(failure.message)
} finally {
setBusy(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
/** Runs a change of an account, then reads the list again; a failure is shown above the list. */
async function change(work: () => Promise<unknown>) {
setBusy(true)
try {
await work()
setError(null)
} catch (caught) {
setError((caught as Error).message)
}
await refresh()
}
function remove(account: User) {
setConfirm({
title: 'Remove service account',
message: `Remove ${account.name}? A service that signs in with it is refused.`,
confirmLabel: 'Remove',
destructive: true,
onCancel: () => setConfirm(null),
onConfirm: () => {
setConfirm(null)
void change(() => request('DELETE', `/service-accounts/${account.name}`, REMOVED_SHAPE))
},
})
}
const clients = (accounts ?? []).filter((account) => account.redirect_uris?.length || account.grants?.length).length
// The users and the service accounts a role may be assigned to, and the scopes of every resource.
const members = [...users, ...(accounts ?? []).filter((account) => !account.internal).map((account) => account.name)].sort()
const scopes = (accounts ?? []).filter((account) => !account.internal)
.flatMap((account) => (account.resource_scopes ?? []).map((scope) => `${account.name}:${scope}`)).sort()
const shown = (accounts ?? []).filter((account) => matches(search, [account.name, account.display_name, account.description, account.email]))
return (
<Box component="section">
<Toolbar description="Accounts of other systems that use the router's services.">
<SearchField value={search} onChange={setSearch} />
<Button variant="contained" startIcon={<Add />} disabled={busy || accounts === null} onClick={() => setAdding(true)}>
Add
</Button>
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>
Refresh
</Button>
</Toolbar>
{error && <Alert severity={unavailable ? 'info' : 'error'} sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
{accounts && (
<Section title={oidc?.enabled ? `Service accounts, ${clients} client${clients === 1 ? '' : 's'} of the OpenID Provider` : 'Service accounts'}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Display name</TableCell>
<TableCell>Description</TableCell>
<TableCell>Kind</TableCell>
<TableCell>E-mail</TableCell>
<TableCell>OpenID Provider</TableCell>
<TableCell>State</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{shown.length === 0 && (
<TableRow>
<TableCell colSpan={8} sx={{ color: 'text.secondary' }}>None.</TableCell>
</TableRow>
)}
{shown.map((account) => (
<TableRow key={account.name} hover>
<TableCell>{account.name}</TableCell>
<TableCell>{account.display_name ?? '-'}</TableCell>
<TableCell sx={{ minWidth: 200 }}>{account.description ?? '-'}</TableCell>
<TableCell sx={account.internal ? { color: 'text.secondary', fontStyle: 'italic' } : undefined}>
{account.internal ? 'Internal' : 'Service account'}
</TableCell>
<TableCell>{account.email ?? '-'}</TableCell>
<TableCell>{oidcText(account)}</TableCell>
<TableCell sx={{ color: account.enabled && !account.locked ? 'success.main' : 'warning.main' }}>
{!account.enabled ? 'disabled' : account.locked ? 'locked' : 'enabled'}
</TableCell>
{account.internal ? (
<TableCell align="right" sx={{ color: 'text.disabled', whiteSpace: 'nowrap' }}>Managed by the system</TableCell>
) : (
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
<Tooltip title="Edit the display name, the description, the e-mail address and the OpenID Provider settings">
<span>
<IconButton size="small" disabled={busy} onClick={() => setEditing(account)}><Edit fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title="Set the password">
<span>
<IconButton size="small" disabled={busy} onClick={() => setPassword(account.name)}><Key fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title={account.enabled ? 'Disable' : 'Enable'}>
<span>
<IconButton
size="small"
disabled={busy}
onClick={() => void change(() => request('POST', `/service-accounts/${account.name}/${account.enabled ? 'disable' : 'enable'}`, USER_SHAPE))}
>
{account.enabled ? <Block fontSize="small" /> : <CheckCircle fontSize="small" />}
</IconButton>
</span>
</Tooltip>
<Tooltip title="Unlock after failed logins">
<span>
<IconButton
size="small"
disabled={busy || !account.locked}
onClick={() => void change(() => request('POST', `/service-accounts/${account.name}/unlock`, USER_SHAPE))}
>
<LockOpen fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Remove">
<span>
<IconButton size="small" color="error" disabled={busy} onClick={() => remove(account)}><Delete fontSize="small" /></IconButton>
</span>
</Tooltip>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</Section>
)}
{adding && (
<ServiceAccountDialog
groups={groups}
accounts={members}
scopes={scopes}
onCancel={() => setAdding(false)}
onSave={async (name, settings, secret) => {
// An empty or false setting is left out, so the account is added without it.
const given = Object.fromEntries(Object.entries(settings).filter(([, value]) => (typeof value === 'boolean' ? value : value.length > 0)))
await request('POST', '/service-accounts', USER_SHAPE, { name, password: secret, ...given })
setAdding(false)
await refresh()
}}
/>
)}
{editing && (
<ServiceAccountDialog
account={editing}
groups={groups}
accounts={members}
scopes={scopes}
onCancel={() => setEditing(null)}
onSave={async (name, settings) => {
// Only the settings that changed are sent, '', [] or false removing one, and nothing when none did.
const changed = Object.fromEntries(Object.entries(settings).filter(([key, value]) =>
JSON.stringify(value) !== JSON.stringify(editing[key as keyof typeof settings]
?? (Array.isArray(value) ? [] : typeof value === 'boolean' ? false : ''))))
if (Object.keys(changed).length > 0) {
await request('PUT', `/service-accounts/${name}`, USER_SHAPE, changed)
}
setEditing(null)
if (Object.keys(changed).length > 0) {
await refresh()
}
}}
/>
)}
{password && (
<UserDialog
title={`Set the password of ${password}`}
name={password}
saveLabel="Set"
onCancel={() => setPassword(null)}
onSave={async (name, secret) => {
await request('PUT', `/service-accounts/${name}/password`, USER_SHAPE, { password: secret })
setPassword(null)
}}
/>
)}
{confirm && <ConfirmDialog {...confirm} />}
</Box>
)
}
+60
View File
@@ -0,0 +1,60 @@
import SettingsIcon from '@mui/icons-material/Settings'
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, IconButton, MenuItem, Stack, TextField, Tooltip } from '@mui/material'
import { useState } from 'react'
import { DEFAULT_SETTINGS, REFRESH_CHOICES, refreshLabel, saveBrowserSettings, useBrowserSettings } from './settings.ts'
/**
* An icon button that opens the settings of this browser: how often the Overview and the Metrics tab read the router on
* their own, or manual, when they read only when opened and on Refresh. A change applies at once and is kept in the
* browser; Defaults sets every setting back.
*/
export default function SettingsButton() {
const [open, setOpen] = useState(false)
const settings = useBrowserSettings()
return (
<>
<Tooltip title="Settings of this browser">
<IconButton aria-label="settings" onClick={() => setOpen(true)}>
<SettingsIcon />
</IconButton>
</Tooltip>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Settings</DialogTitle>
<DialogContent>
<DialogContentText variant="body2" sx={{ mb: 2 }}>
Kept in this browser; other browsers keep their own.
</DialogContentText>
<Stack sx={{ gap: 2 }}>
<TextField
select
label="Overview tab refresh"
value={settings.routerRefresh}
helperText="Manual: read when the tab opens and on Refresh."
onChange={(event) => saveBrowserSettings({ ...settings, routerRefresh: Number(event.target.value) })}
>
{REFRESH_CHOICES.map((seconds) => <MenuItem key={seconds} value={seconds}>{refreshLabel(seconds)}</MenuItem>)}
</TextField>
<TextField
select
label="Metrics tab refresh"
value={settings.metricsRefresh}
helperText="The router takes a sample every minute."
onChange={(event) => saveBrowserSettings({ ...settings, metricsRefresh: Number(event.target.value) })}
>
{REFRESH_CHOICES.map((seconds) => <MenuItem key={seconds} value={seconds}>{refreshLabel(seconds)}</MenuItem>)}
</TextField>
</Stack>
</DialogContent>
<DialogActions>
<Button
onClick={() => saveBrowserSettings({ ...settings, routerRefresh: DEFAULT_SETTINGS.routerRefresh, metricsRefresh: DEFAULT_SETTINGS.metricsRefresh })}
>
Defaults
</Button>
<Button variant="contained" onClick={() => setOpen(false)}>Close</Button>
</DialogActions>
</Dialog>
</>
)
}
+284
View File
@@ -0,0 +1,284 @@
import Key from '@mui/icons-material/Key'
import Terminal from '@mui/icons-material/Terminal'
import { Alert, Box, Button, Paper, Stack, TextField, Typography, useColorScheme } from '@mui/material'
import { FitAddon } from '@xterm/addon-fit'
import { Terminal as XTerm, type ITheme } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
import EditorPanel from './EditorPanel.tsx'
import { ACCOUNT_SHAPE, CODE_PATTERN, PASSKEY_BEGUN_SHAPE, SHELL_TICKET_SHAPE, request, type Account, type EditorFile, type PasskeyBegun, type ShellTicket } from './api.ts'
/** The colours of the terminal in the light and the dark mode, those of VS Code's terminal. */
const THEMES: Record<'light' | 'dark', ITheme> = {
light: { background: '#ffffff', foreground: '#333333', cursor: '#333333', selectionBackground: '#add6ff' },
dark: { background: '#1e1e1e', foreground: '#cccccc', cursor: '#cccccc', selectionBackground: '#264f78' },
}
/** The fonts of VS Code's terminal on macOS, Windows and Linux. */
const FONT = "Menlo, Monaco, Consolas, 'Droid Sans Mono', 'DejaVu Sans Mono', 'Courier New', monospace"
/**
* A login shell on the router in an xterm.js terminal, as the signed-in user's console account; a user whose console
* login is not on is told how root switches it on. The router gives a ticket without a sign-in while the user's last
* use of a shell is within its web.shell_auth_timeout; otherwise the user signs in again with the password and the
* code, or with one of its passkeys, which the browser unlocks with the device's PIN or biometrics. With the ticket, a
* WebSocket to /api/shell carries the terminal: keystrokes and output as binary
* messages, the window size as a text message {cols, rows}. When the router or the connection ends the shell, the
* terminal stays readable with the reason above it, and New shell asks for a new ticket.
*
* A text message {editor: "open", session, id, path} of the router, sent when vre in the shell names a file, opens the
* file in the editor (EditorPanel) and calls onOpened. editorShown shows the editor in place of the terminal, which
* keeps running; onFiles is called with the number of open files, 0 once the last is closed or the shell ends.
*/
export default function ShellPanel(props: { editorShown: boolean; onFiles: (count: number) => void; onOpened: () => void }) {
const { onFiles } = props
const [files, setFiles] = useState<EditorFile[]>([])
const opened = useRef(props.onOpened)
const { mode, systemMode } = useColorScheme()
const scheme = (mode === 'system' ? systemMode : mode) === 'dark' ? 'dark' : 'light'
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [ticket, setTicket] = useState<string | null>(null)
const [reason, setReason] = useState<string | null>(null)
const [account, setAccount] = useState<Account | null>(null)
const [signIn, setSignIn] = useState(false)
const container = useRef<HTMLDivElement>(null)
const terminal = useRef<XTerm | null>(null)
const theme = useRef(THEMES[scheme])
const codeProblem = !CODE_PATTERN.test(code) ? 'Six digits.' : ''
const login = account?.console?.enabled && account.otp ? account.console : null
/** Asks the router for a ticket without signing in; the sign-in form shows when the router wants one. */
const reopen = useCallback(async () => {
setBusy(true)
setError(null)
setReason(null)
setTicket(null)
try {
const answer = await request<ShellTicket>('POST', '/shell', SHELL_TICKET_SHAPE, {})
setTicket(answer.ticket)
setSignIn(answer.ticket === null)
} catch (caught) {
setError((caught as Error).message)
setSignIn(true)
} finally {
setBusy(false)
}
}, [])
useEffect(() => {
request<Account>('GET', '/account', ACCOUNT_SHAPE).then(setAccount).catch((caught: Error) => setError(caught.message))
}, [])
const enabled = login !== null
useEffect(() => {
if (enabled) {
void reopen()
}
}, [enabled, reopen])
useEffect(() => {
opened.current = props.onOpened
}, [props.onOpened])
useEffect(() => {
onFiles(files.length)
}, [onFiles, files.length])
const terminalShown = !props.editorShown || files.length === 0
useEffect(() => {
if (terminalShown) {
terminal.current?.focus()
}
}, [terminalShown])
useEffect(() => {
theme.current = THEMES[scheme]
if (terminal.current) {
terminal.current.options.theme = THEMES[scheme]
}
}, [scheme])
// The terminal and its WebSocket live as long as the ticket that opened them.
useEffect(() => {
if (ticket === null || !container.current) {
return
}
const term = new XTerm({ cursorBlink: true, fontFamily: FONT, fontSize: 14, theme: theme.current })
const fit = new FitAddon()
term.loadAddon(fit)
term.open(container.current)
fit.fit()
terminal.current = term
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const socket = new WebSocket(`${protocol}://${window.location.host}/api/shell?ticket=${encodeURIComponent(ticket)}`)
socket.binaryType = 'arraybuffer'
const encoder = new TextEncoder()
const send = (data: Uint8Array<ArrayBuffer> | string) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data)
}
}
socket.onopen = () => {
send(JSON.stringify({ cols: term.cols, rows: term.rows }))
term.focus()
}
socket.onmessage = (message) => {
if (message.data instanceof ArrayBuffer) {
term.write(new Uint8Array(message.data))
return
}
try {
const event: unknown = JSON.parse(String(message.data))
const { editor, session, id, path } = (event ?? {}) as Record<string, unknown>
if (editor === 'open' && typeof session === 'string' && typeof id === 'string' && typeof path === 'string') {
setFiles((current) => [...current, { session, id, path }])
opened.current()
}
} catch {
// A text message that is not an editor event is ignored.
}
}
socket.onclose = (closed) => {
term.options.disableStdin = true
term.options.cursorBlink = false
setFiles([])
setReason(closed.reason || 'The connection to the shell closed.')
}
term.onData((data) => send(encoder.encode(data)))
term.onBinary((data) => send(Uint8Array.from(data, (character) => character.charCodeAt(0))))
term.onResize(({ cols, rows }) => send(JSON.stringify({ cols, rows })))
// A terminal hidden behind the editor keeps its size.
const observer = new ResizeObserver(([entry]) => {
if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
fit.fit()
}
})
observer.observe(container.current)
return () => {
observer.disconnect()
socket.onclose = null
socket.close()
term.dispose()
terminal.current = null
setFiles([])
}
}, [ticket])
async function open(event: FormEvent) {
event.preventDefault()
if (password === '' || codeProblem !== '') {
return
}
setBusy(true)
setError(null)
try {
const answer = await request<ShellTicket>('POST', '/shell', SHELL_TICKET_SHAPE, { password, code })
setPassword('')
setCode('')
setReason(null)
setTicket(answer.ticket)
setSignIn(answer.ticket === null)
} catch (caught) {
setError((caught as Error).message)
} finally {
setBusy(false)
}
}
/** Opens a shell with one of the user's passkeys, which the router asks the browser for. */
async function openWithPasskey() {
setBusy(true)
setError(null)
try {
const begun = await request<PasskeyBegun<PublicKeyCredentialRequestOptionsJSON>>('POST', '/shell/passkey', PASSKEY_BEGUN_SHAPE, {})
const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(begun.options.publicKey) })
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('The browser gave no passkey.')
}
const answer = await request<ShellTicket>('POST', '/shell', SHELL_TICKET_SHAPE, { passkey: { id: begun.id, credential: credential.toJSON() } })
setPassword('')
setCode('')
setReason(null)
setTicket(answer.ticket)
setSignIn(answer.ticket === null)
} catch (caught) {
setError(caught instanceof DOMException && caught.name === 'NotAllowedError' ? 'No passkey was chosen.' : (caught as Error).message)
} finally {
setBusy(false)
}
}
return (
<Box component="section">
{ticket === null && account && !login && (
<Alert severity="info">
{account.console?.enabled
? 'The shell needs one-time passwords. Enrol an authenticator app on the Account page.'
: `Console login is not on for ${account.name}. Root on the router switches it on: vpn-router-user console ${account.name} --posix, or --account LOCAL to use a local account.`}
</Alert>
)}
{ticket === null && !account && error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
{ticket === null && login && signIn && (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 4 }}>
<Paper component="form" onSubmit={open} variant="outlined" sx={{ p: 3, width: '100%', maxWidth: 380 }}>
<Stack sx={{ gap: 2 }}>
<Typography variant="h6" component="h3">Open a shell</Typography>
<Typography variant="body2" color="text.secondary">
{`The shell runs on the router as ${login.local ? 'the local account' : 'your POSIX account'} ${login.account}. Sign in again to open it.`}
</Typography>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<TextField label="Password" type="password" value={password} autoFocus autoComplete="current-password" onChange={(event) => setPassword(event.target.value)} />
<TextField
label="Code"
value={code}
autoComplete="one-time-code"
error={code !== '' && codeProblem !== ''}
helperText={code !== '' ? codeProblem : 'The code of your authenticator app.'}
slotProps={{ htmlInput: { inputMode: 'numeric', maxLength: 6 } }}
onChange={(event) => setCode(event.target.value.trim())}
/>
<Button type="submit" variant="contained" startIcon={<Terminal />} disabled={busy || password === '' || codeProblem !== ''}>
Open shell
</Button>
{account && account.passkeys.length > 0 && (
<Button variant="outlined" startIcon={<Key />} disabled={busy} onClick={() => void openWithPasskey()}>
Open with a passkey
</Button>
)}
</Stack>
</Paper>
</Box>
)}
{reason !== null && (
<Alert severity="info" sx={{ mb: 2 }} action={<Button color="inherit" size="small" disabled={busy} onClick={() => void reopen()}>New shell</Button>}>
{reason}
</Alert>
)}
{files.length > 0 && (
<Box sx={{ display: props.editorShown ? 'block' : 'none' }}>
<EditorPanel files={files} scheme={scheme} onClosed={(id) => setFiles((current) => current.filter((file) => file.id !== id))} />
</Box>
)}
{ticket !== null && (
<Box
sx={{
display: terminalShown ? 'block' : 'none',
height: 'calc(100vh - 220px)',
minHeight: 320,
p: 1,
borderRadius: 1,
border: 1,
borderColor: 'divider',
bgcolor: THEMES[scheme].background,
}}
>
<Box ref={container} sx={{ width: '100%', height: '100%' }} />
</Box>
)}
</Box>
)
}
+78
View File
@@ -0,0 +1,78 @@
import Key from '@mui/icons-material/Key'
import { Alert, Box, Button, Paper, Stack, TextField, Typography } from '@mui/material'
import { useState, type FormEvent } from 'react'
import { CODE_PATTERN, PASSKEY_BEGUN_SHAPE, SESSION_SHAPE, request, type PasskeyBegun, type Session } from './api.ts'
/**
* The sign-in form: the user name, the password and, for a user enrolled for one-time passwords, the code, or a
* passkey in a browser with WebAuthn. The router checks them; onSignedIn gets the session, and a failure shows the
* router's message.
*/
export default function SignIn(props: { onSignedIn: (session: Session) => void }) {
const [name, setName] = useState('')
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const codeProblem = code !== '' && !CODE_PATTERN.test(code) ? 'Six digits.' : ''
const blocked = name === '' || password === '' || codeProblem !== ''
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setBusy(true)
setError(null)
try {
props.onSignedIn(await request<Session>('POST', '/session', SESSION_SHAPE, code === '' ? { name, password } : { name, password, code }))
} catch (caught) {
setError((caught as Error).message)
setBusy(false)
}
}
/** Signs in with a passkey the browser offers for the router's name. */
async function signInWithPasskey() {
setBusy(true)
setError(null)
try {
const begun = await request<PasskeyBegun<PublicKeyCredentialRequestOptionsJSON>>('POST', '/session/passkey', PASSKEY_BEGUN_SHAPE, {})
const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(begun.options.publicKey) })
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('The browser gave no passkey.')
}
props.onSignedIn(await request<Session>('POST', '/session/passkey/complete', SESSION_SHAPE, { id: begun.id, credential: credential.toJSON() }))
} catch (caught) {
setError(caught instanceof DOMException && caught.name === 'NotAllowedError' ? 'No passkey was chosen.' : (caught as Error).message)
setBusy(false)
}
}
return (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>
<Paper component="form" onSubmit={submit} variant="outlined" sx={{ p: 3, width: '100%', maxWidth: 380 }}>
<Stack sx={{ gap: 2 }}>
<Typography variant="h6" component="h2">Sign in</Typography>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<TextField label="User name" value={name} autoFocus autoComplete="username" onChange={(event) => setName(event.target.value.trim())} />
<TextField label="Password" type="password" value={password} autoComplete="current-password" onChange={(event) => setPassword(event.target.value)} />
<TextField
label="Code"
value={code}
autoComplete="one-time-code"
error={codeProblem !== ''}
helperText={codeProblem || 'Only for users enrolled for one-time passwords.'}
slotProps={{ htmlInput: { inputMode: 'numeric', maxLength: 6 } }}
onChange={(event) => setCode(event.target.value.trim())}
/>
<Button type="submit" variant="contained" disabled={busy || blocked}>Sign in</Button>
{window.PublicKeyCredential && (
<Button variant="outlined" startIcon={<Key />} disabled={busy} onClick={() => void signInWithPasskey()}>Sign in with a passkey</Button>
)}
</Stack>
</Paper>
</Box>
)
}
+274
View File
@@ -0,0 +1,274 @@
import { Alert, Autocomplete, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Stack, Switch, TextField } from '@mui/material'
import { useState, type FormEvent } from 'react'
import {
LISTEN_CHOICES, SITE_DNS_ADDRESS_LABELS, SITE_DNS_LABELS, SITE_TLS, SITE_TLS_LABELS, type Listen, type Site, type SiteDns,
type SiteDnsAddress, type SiteTls,
} from './api.ts'
/** The names, host names and index files the router accepts for a site. */
const NAME_PATTERN = /^[a-z][a-z0-9-]{0,30}$/
const HOST_PATTERN = /^(\*\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/
const ADDRESS_PATTERN = /^(\d{1,3}\.){3}\d{1,3}$|^[0-9a-f:]+:[0-9a-f:]*$/
const INDEX_PATTERN = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/
/** The most host names of one site. */
const HOSTS_MAX = 16
/** The settings of a site that /sites takes. */
export type SiteSettings = {
enabled: boolean
hosts: string[]
port: number
listen: Listen
tls: SiteTls
ca: string
certificate: string
private_key: string
index_file: string
spa: boolean
editors: string
dns: SiteDns
dns_address: SiteDnsAddress
}
/** The non-empty lines of a text field, trimmed and in lowercase. */
function hostLines(text: string) {
return text.split('\n').map((line) => line.trim().toLowerCase()).filter((line) => line !== '')
}
/**
* A dialog that adds a site, with its name, or, given a site, edits it: whether it is served, its host names, one per
* line, its port, the interfaces whose firewall opens it, its TLS with the issuing CA of cas or a certificate chain and
* key in PEM, read from files or pasted, its index file, the single-page fallback, the editors group, chosen from
* groups, and its DNS: User managed, its records kept by a DNS provider outside the router, or Router managed, the
* router's name server giving its host names records of the router's Public, Internal or External interface IP. A host
* name may be the router's FQDN, on a port of the site's own, but not with Router managed: the record of the router's
* FQDN stays where its domain is. onSave sends them; an empty private key keeps the current one. The dialog closes when
* it succeeds and shows the router's message when it fails.
*/
export default function SiteDialog(props: {
site?: Site
cas: string[]
groups: string[]
onSave: (name: string, settings: SiteSettings) => Promise<void>
onCancel: () => void
}) {
const adding = props.site === undefined
const [name, setName] = useState(props.site?.name ?? '')
const [enabled, setEnabled] = useState(props.site?.enabled ?? true)
const [hosts, setHosts] = useState((props.site?.hosts ?? []).join('\n'))
const [port, setPort] = useState(String(props.site?.port ?? 443))
const [listen, setListen] = useState<Listen>(props.site?.listen ?? 'external')
const [tls, setTls] = useState<SiteTls>(props.site?.tls ?? 'acme')
const [ca, setCa] = useState(props.site?.ca || props.cas[0] || '')
const [certificate, setCertificate] = useState(props.site?.certificate ?? '')
const [privateKey, setPrivateKey] = useState('')
const [indexFile, setIndexFile] = useState(props.site?.index_file ?? 'index.html')
const [spa, setSpa] = useState(props.site?.spa ?? false)
const [editors, setEditors] = useState<string | null>(props.site?.editors || null)
const [dns, setDns] = useState<SiteDns>(props.site?.dns ?? 'user')
const [dnsAddress, setDnsAddress] = useState<SiteDnsAddress>(props.site?.dns_address ?? 'public')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const hostList = hostLines(hosts)
const nameProblem = adding && name !== '' && !NAME_PATTERN.test(name)
? 'Up to 31 lower case letters, digits and dashes, starting with a letter.'
: ''
const badHost = hostList.find((host) => !HOST_PATTERN.test(host) && !ADDRESS_PATTERN.test(host))
const hostsProblem = hostList.length > HOSTS_MAX ? `At most ${HOSTS_MAX} host names.`
: badHost ? `${badHost} is not a host name or an IP address.`
: tls !== 'none' && hostList.length === 0 ? 'Required with TLS.'
: tls === 'acme' && hostList.some((host) => host.startsWith('*.') || ADDRESS_PATTERN.test(host))
? "Let's Encrypt takes host names without wildcards." : ''
const portProblem = /^\d{1,5}$/.test(port) && Number(port) >= 1 && Number(port) <= 65535 ? '' : 'A port from 1 to 65535.'
const caProblem = tls === 'ca' && ca === '' ? 'Required.' : ''
const certificateProblem = tls === 'file' && !certificate.includes('-----BEGIN CERTIFICATE-----') ? 'A PEM certificate chain.' : ''
const keyProblem = tls === 'file' && privateKey === '' && !(props.site?.private_key_set && props.site.tls === 'file')
? 'Required.'
: tls === 'file' && privateKey !== '' && !privateKey.includes('PRIVATE KEY-----') ? 'A PEM private key.' : ''
const indexProblem = INDEX_PATTERN.test(indexFile) ? '' : 'A file name.'
const dnsProblem = dns === 'router' && hostList.some((host) => host.startsWith('*.') || ADDRESS_PATTERN.test(host))
? 'Router managed takes host names without wildcards.' : ''
const blocked = name === '' || nameProblem !== '' || hostsProblem !== '' || portProblem !== '' || caProblem !== ''
|| certificateProblem !== '' || keyProblem !== '' || indexProblem !== '' || dnsProblem !== ''
/** Reads a PEM file the user picks into a field. */
async function pick(files: FileList | null, set: (text: string) => void) {
const file = files?.[0]
if (file) {
set(await file.text())
}
}
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave(name, {
enabled,
hosts: hostList,
port: Number(port),
listen,
tls,
ca: tls === 'ca' ? ca : '',
certificate: tls === 'file' ? certificate.trim() : '',
private_key: tls === 'file' ? privateKey.trim() : '',
index_file: indexFile,
spa,
editors: editors ?? '',
dns,
dns_address: dnsAddress,
})
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>{adding ? 'Add site' : `Edit site ${props.site?.name}`}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<TextField
label="Name"
value={name}
disabled={!adding}
autoFocus={adding}
autoComplete="off"
error={nameProblem !== ''}
helperText={nameProblem || 'The name of the site and of its folder.'}
onChange={(event) => setName(event.target.value.trim().toLowerCase())}
/>
<FormControlLabel control={<Switch checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />} label="Enabled" />
<TextField
label="Host names"
value={hosts}
autoFocus={!adding}
autoComplete="off"
multiline
minRows={2}
error={hostsProblem !== ''}
helperText={hostsProblem || 'The names the site answers on, one per line, also the router\'s FQDN; none answers any name.'}
onChange={(event) => setHosts(event.target.value)}
/>
<Stack direction="row" sx={{ gap: 2 }}>
<TextField
select
label="DNS"
value={dns}
error={dnsProblem !== ''}
helperText={dnsProblem || (dns === 'router'
? 'The router\'s name server gives each host name a record in its domain on the DNS page.'
: 'The records are kept by a DNS provider outside the router.')}
onChange={(event) => setDns(event.target.value as SiteDns)}
sx={{ flex: 1 }}
>
{(['user', 'router'] as SiteDns[]).map((item) => <MenuItem key={item} value={item}>{SITE_DNS_LABELS[item]}</MenuItem>)}
</TextField>
<TextField
select
label="Address"
value={dnsAddress}
disabled={dns !== 'router'}
helperText="The router's address in the records."
onChange={(event) => setDnsAddress(event.target.value as SiteDnsAddress)}
sx={{ flex: 1 }}
>
{(['public', 'internal', 'external'] as SiteDnsAddress[]).map((item) => (
<MenuItem key={item} value={item}>{SITE_DNS_ADDRESS_LABELS[item]}</MenuItem>
))}
</TextField>
</Stack>
<Stack direction="row" sx={{ gap: 2 }}>
<TextField
label="Port"
value={port}
autoComplete="off"
error={portProblem !== ''}
helperText={portProblem || 'The TCP port.'}
slotProps={{ htmlInput: { inputMode: 'numeric' } }}
onChange={(event) => setPort(event.target.value.trim())}
sx={{ flex: 1 }}
/>
<TextField select label="Listen" value={listen} helperText="The interfaces the port is open on." onChange={(event) => setListen(event.target.value as Listen)} sx={{ flex: 1 }}>
{LISTEN_CHOICES.map((item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
</TextField>
</Stack>
<TextField select label="TLS" value={tls} helperText="Where the certificate comes from." onChange={(event) => setTls(event.target.value as SiteTls)}>
{SITE_TLS.map((item) => <MenuItem key={item} value={item}>{SITE_TLS_LABELS[item]}</MenuItem>)}
</TextField>
{tls === 'ca' && (
<TextField select label="Issuing CA" value={ca} error={caProblem !== ''} helperText={caProblem || 'The CA that issues the certificates.'} onChange={(event) => setCa(event.target.value)}>
{props.cas.map((item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
</TextField>
)}
{tls === 'file' && (
<>
<TextField
label="Certificate"
value={certificate}
autoComplete="off"
multiline
minRows={3}
maxRows={8}
error={certificateProblem !== ''}
helperText={certificateProblem || 'The PEM certificate of the site, then its chain.'}
onChange={(event) => setCertificate(event.target.value)}
slotProps={{ htmlInput: { sx: { fontFamily: 'monospace', fontSize: 12 } } }}
/>
<Button component="label" variant="outlined" sx={{ alignSelf: 'flex-start' }}>
Read the certificate from a file
<input hidden type="file" accept=".pem,.crt,.cer" onChange={(event) => void pick(event.target.files, setCertificate)} />
</Button>
<TextField
label="Private key"
value={privateKey}
autoComplete="off"
multiline
minRows={3}
maxRows={8}
error={keyProblem !== ''}
helperText={keyProblem || (props.site?.private_key_set && props.site.tls === 'file' ? 'The unencrypted PEM key; empty keeps the current key.' : 'The unencrypted PEM key.')}
onChange={(event) => setPrivateKey(event.target.value)}
slotProps={{ htmlInput: { sx: { fontFamily: 'monospace', fontSize: 12 } } }}
/>
<Button component="label" variant="outlined" sx={{ alignSelf: 'flex-start' }}>
Read the key from a file
<input hidden type="file" accept=".pem,.key" onChange={(event) => void pick(event.target.files, setPrivateKey)} />
</Button>
</>
)}
<TextField
label="Index file"
value={indexFile}
autoComplete="off"
error={indexProblem !== ''}
helperText={indexProblem || 'The file served for a folder.'}
onChange={(event) => setIndexFile(event.target.value.trim())}
/>
<FormControlLabel
control={<Switch checked={spa} onChange={(event) => setSpa(event.target.checked)} />}
label="Single-page application: a path without a file is served the index file of the site"
/>
<Autocomplete
options={props.groups}
value={editors}
onChange={(_event, next) => setEditors(next)}
renderInput={(params) => <TextField {...params} label="Editors" helperText="The group whose members change the files." />}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel} disabled={saving}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>{adding ? 'Add' : 'Save'}</Button>
</DialogActions>
</Dialog>
)
}
+343
View File
@@ -0,0 +1,343 @@
import ArrowUpward from '@mui/icons-material/ArrowUpward'
import CreateNewFolder from '@mui/icons-material/CreateNewFolder'
import Delete from '@mui/icons-material/Delete'
import Download from '@mui/icons-material/Download'
import DriveFileRenameOutline from '@mui/icons-material/DriveFileRenameOutline'
import Folder from '@mui/icons-material/Folder'
import InsertDriveFileOutlined from '@mui/icons-material/InsertDriveFileOutlined'
import Refresh from '@mui/icons-material/Refresh'
import UploadFile from '@mui/icons-material/UploadFile'
import {
Alert, Box, Breadcrumbs, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, LinearProgress,
Link, MenuItem, Stack, Switch, Table, TableBody, TableCell, TableHead, TableRow, TextField, Tooltip, Typography,
} from '@mui/material'
import { useCallback, useEffect, useState, type DragEvent, type FormEvent } from 'react'
import ConfirmDialog, { type ConfirmDialogProps } from './ConfirmDialog.tsx'
import { Section, bytes } from './RouterPanel.tsx'
import Toolbar from './Toolbar.tsx'
import {
ApiError, SITES_SHAPE, SITE_FILES_SHAPE, SITE_FOLDER_SHAPE, SITE_PATH_SHAPE, download, encodedPath, request, upload,
type SiteFolder, type Sites,
} from './api.ts'
import { saveFile } from './saveFile.ts'
/** A name of a file or folder: no slash, not . or .. */
function nameProblem(name: string) {
return name === '' || name.includes('/') || name === '.' || name === '..' ? 'A name without a slash.' : ''
}
/** A dialog that asks for a name: of a new folder, or the new name of a file or folder; onSave closes it when it succeeds. */
function NameDialog(props: { title: string; label: string; initial: string; onSave: (name: string) => Promise<void>; onCancel: () => void }) {
const [name, setName] = useState(props.initial)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const problem = name === props.initial ? '' : nameProblem(name.trim())
async function submit(event: FormEvent) {
event.preventDefault()
if (problem !== '' || name.trim() === '' || name === props.initial) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave(name.trim())
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="xs" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>{props.title}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<TextField label={props.label} value={name} autoFocus autoComplete="off" error={problem !== ''} helperText={problem} onChange={(event) => setName(event.target.value)} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel} disabled={saving}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || problem !== '' || name.trim() === '' || name === props.initial}>Save</Button>
</DialogActions>
</Dialog>
)
}
/**
* The files of a web site, from the router's /sites/<name>/files/: the site chosen from the sites, starting with site,
* a folder at a time with its path as links up to the site's folder. List the files and folders with their sizes and
* times, open a folder, download a file, upload files, chosen or dropped on the list, into the folder shown, with zip
* archives extracted there while Extract zip archives is on, create a folder, rename and remove. An upload shows its
* progress; a file larger than the router's sites.upload_max_mb is not sent.
*/
export default function SiteFiles(props: { site: string | null; onSite: (name: string) => void }) {
const [sites, setSites] = useState<Sites | null>(null)
const [path, setPath] = useState('')
const [folder, setFolder] = useState<SiteFolder | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [progress, setProgress] = useState<{ name: string; share: number } | null>(null)
const [extract, setExtract] = useState(true)
const [creating, setCreating] = useState(false)
const [renaming, setRenaming] = useState<string | null>(null)
const [confirm, setConfirm] = useState<ConfirmDialogProps | null>(null)
const [dropping, setDropping] = useState(false)
const site = props.site ?? sites?.sites[0]?.name ?? null
const base = site ? `/sites/${site}/files` : ''
useEffect(() => {
request<Sites>('GET', '/sites', SITES_SHAPE).then(setSites).catch((caught) => setError((caught as ApiError).message))
}, [])
const refresh = useCallback(async () => {
if (!site) {
return
}
setBusy(true)
try {
setFolder(await request<SiteFolder>('GET', `${base}/${encodedPath(path)}`, SITE_FOLDER_SHAPE))
setError(null)
} catch (caught) {
setFolder(null)
setError((caught as Error).message)
} finally {
setBusy(false)
}
}, [site, base, path])
useEffect(() => {
void refresh()
}, [refresh])
/** Runs a change of the files, then reads the folder again; a failure is shown above the list. */
async function change(work: () => Promise<unknown>) {
setBusy(true)
try {
await work()
setError(null)
} catch (caught) {
setError((caught as Error).message)
}
await refresh()
}
/** Uploads the files one after the other into the folder shown, a zip archive extracted there while extract is on. */
async function send(files: File[]) {
const limit = (sites?.settings.upload_max_mb ?? 0) * 1024 * 1024
const failures: string[] = []
setBusy(true)
for (const file of files) {
if (limit && file.size > limit) {
failures.push(`${file.name}: larger than ${sites?.settings.upload_max_mb} MiB.`)
continue
}
const unzip = extract && file.name.toLowerCase().endsWith('.zip')
const target = unzip ? `${base}/${encodedPath(path)}?unzip=true` : `${base}/${encodedPath(`${path}/${file.name}`)}`
setProgress({ name: file.name, share: 0 })
try {
await upload(target, file, SITE_FILES_SHAPE, (share) => setProgress({ name: file.name, share }))
} catch (caught) {
failures.push(`${file.name}: ${(caught as Error).message}`)
}
}
setProgress(null)
setBusy(false)
await refresh()
setError(failures.length ? failures.join('\n') : null)
}
async function get(name: string) {
try {
saveFile(name, await download(`${base}/${encodedPath(`${path}/${name}`)}`), 'application/octet-stream')
} catch (caught) {
setError((caught as Error).message)
}
}
function remove(name: string, isFolder: boolean) {
setConfirm({
title: isFolder ? 'Remove folder' : 'Remove file',
message: isFolder ? `Remove the folder ${name} and everything in it?` : `Remove ${name}?`,
confirmLabel: 'Remove',
destructive: true,
onCancel: () => setConfirm(null),
onConfirm: () => {
setConfirm(null)
void change(() => request('DELETE', `${base}/${encodedPath(`${path}/${name}`)}`, () => true))
},
})
}
function drop(event: DragEvent) {
event.preventDefault()
setDropping(false)
if (!busy && event.dataTransfer.files.length) {
void send(Array.from(event.dataTransfer.files))
}
}
const parts = path.split('/').filter((part) => part !== '')
return (
<Box component="section">
<Toolbar description="The files of a web site.">
<TextField
select
size="small"
label="Site"
value={site ?? ''}
sx={{ minWidth: 200 }}
onChange={(event) => {
setPath('')
props.onSite(event.target.value)
}}
>
{(sites?.sites ?? []).map((item) => <MenuItem key={item.name} value={item.name}>{item.name}</MenuItem>)}
</TextField>
<FormControlLabel control={<Switch checked={extract} onChange={(event) => setExtract(event.target.checked)} />} label="Extract zip archives" />
<Button component="label" variant="contained" startIcon={<UploadFile />} disabled={busy || !site}>
Upload
<input
hidden
multiple
type="file"
onChange={(event) => {
const files = Array.from(event.target.files ?? [])
event.target.value = ''
void send(files)
}}
/>
</Button>
<Button variant="outlined" startIcon={<CreateNewFolder />} disabled={busy || !site} onClick={() => setCreating(true)}>
New folder
</Button>
<Button variant="outlined" startIcon={<Refresh />} disabled={busy || !site} onClick={() => void refresh()}>
Refresh
</Button>
</Toolbar>
{error && <Alert severity="error" sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
{sites && sites.sites.length === 0 && <Alert severity="info" sx={{ mb: 2 }}>No sites.</Alert>}
{progress && (
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="text.secondary">{`Uploading ${progress.name}: ${Math.round(progress.share * 100)} %`}</Typography>
<LinearProgress variant="determinate" value={progress.share * 100} />
</Box>
)}
{site && folder && (
<Box
onDragOver={(event) => {
event.preventDefault()
setDropping(true)
}}
onDragLeave={() => setDropping(false)}
onDrop={drop}
sx={{ outline: dropping ? 2 : 0, outlineStyle: 'dashed', outlineColor: 'primary.main', borderRadius: 1 }}
>
<Section title="Files">
<Breadcrumbs sx={{ mb: 1 }}>
<Link component="button" underline="hover" onClick={() => setPath('')}>{site}</Link>
{parts.map((part, index) => (
<Link key={index} component="button" underline="hover" onClick={() => setPath(parts.slice(0, index + 1).join('/'))}>{part}</Link>
))}
</Breadcrumbs>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell align="right">Size</TableCell>
<TableCell>Modified</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{parts.length > 0 && (
<TableRow hover>
<TableCell colSpan={4}>
<Link component="button" underline="hover" onClick={() => setPath(parts.slice(0, -1).join('/'))}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}><ArrowUpward fontSize="small" />..</Stack>
</Link>
</TableCell>
</TableRow>
)}
{folder.entries.length === 0 && (
<TableRow>
<TableCell colSpan={4} sx={{ color: 'text.secondary' }}>Empty.</TableCell>
</TableRow>
)}
{folder.entries.map((entry) => (
<TableRow key={entry.name} hover>
<TableCell>
<Link
component="button"
underline="hover"
onClick={() => (entry.folder ? setPath(parts.concat(entry.name).join('/')) : void get(entry.name))}
>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
{entry.folder ? <Folder fontSize="small" color="primary" /> : <InsertDriveFileOutlined fontSize="small" />}
{entry.name}
</Stack>
</Link>
</TableCell>
<TableCell align="right">{entry.folder ? '' : bytes(entry.size)}</TableCell>
<TableCell>{new Date(entry.modified).toLocaleString()}</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
{!entry.folder && (
<Tooltip title="Download">
<span>
<IconButton size="small" onClick={() => void get(entry.name)}><Download fontSize="small" /></IconButton>
</span>
</Tooltip>
)}
<Tooltip title="Rename">
<span>
<IconButton size="small" disabled={busy} onClick={() => setRenaming(entry.name)}><DriveFileRenameOutline fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title="Remove">
<span>
<IconButton size="small" color="error" disabled={busy} onClick={() => remove(entry.name, entry.folder)}><Delete fontSize="small" /></IconButton>
</span>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
</Box>
)}
{creating && (
<NameDialog
title="New folder"
label="Name"
initial=""
onCancel={() => setCreating(false)}
onSave={async (name) => {
await request('POST', `${base}/${encodedPath(`${path}/${name}`)}`, SITE_PATH_SHAPE, { folder: true })
setCreating(false)
await refresh()
}}
/>
)}
{renaming && (
<NameDialog
title={`Rename ${renaming}`}
label="New name"
initial={renaming}
onCancel={() => setRenaming(null)}
onSave={async (name) => {
await request('POST', `${base}/${encodedPath(`${path}/${renaming}`)}`, SITE_PATH_SHAPE, { rename: parts.concat(name).join('/') })
setRenaming(null)
await refresh()
}}
/>
)}
{confirm && <ConfirmDialog {...confirm} />}
</Box>
)
}
+170
View File
@@ -0,0 +1,170 @@
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField } from '@mui/material'
import { useState, type FormEvent } from 'react'
import { CONNECTION_AUTH, LOCAL_ID_MODES, type SiteToSiteConnection, type SiteToSiteSettings } from './api.ts'
/** The connection names the router accepts: they name its swanctl connection, child and secret. */
const NAME_PATTERN = /^[a-z][a-z0-9-]{0,30}$/
/** The labels of the local identity modes. */
const MODE_LABELS: Record<string, string> = {
fqdn: 'FQDN of the router',
public_ip: 'Public IP, resolved from the FQDN',
internal_ip: 'Internal IP',
}
/** The labels of the authentication methods. */
const AUTH_LABELS: Record<string, string> = {
psk: 'Pre-shared key',
certificate: "Certificates: the router's site-to-site certificate",
}
/** A list typed as text separated by commas. */
function listOf(text: string) {
return text.split(',').map((item) => item.trim()).filter((item) => item !== '')
}
/**
* A dialog that adds a site-to-site connection, or, given a connection, edits its settings: the remote peer's
* addresses, its identity and subnets, the local subnets, the local identity mode and the authentication: a pre-shared
* key, or certificates with the optional PEM certificate of the CA of the peer's certificate, the router's site-to-site
* CA without one. The key is required for a connection with a pre-shared key that has none; otherwise an empty key keeps
* the current one. onSave sends them; the dialog closes when it succeeds and shows the router's message when it fails.
*/
export default function SiteToSiteDialog(props: {
connection?: SiteToSiteConnection
onSave: (name: string, settings: SiteToSiteSettings, psk: string) => Promise<void>
onCancel: () => void
}) {
const adding = props.connection === undefined
const [name, setName] = useState(props.connection?.name ?? '')
const [addrs, setAddrs] = useState(props.connection?.addrs.join(', ') ?? '')
const [id, setId] = useState(props.connection?.id ?? '')
const [cidrs, setCidrs] = useState(props.connection?.cidrs.join(', ') ?? '')
const [localCidrs, setLocalCidrs] = useState(props.connection?.local_cidrs.join(', ') ?? '')
const [mode, setMode] = useState(props.connection?.local_id_mode || 'fqdn')
const [auth, setAuth] = useState(props.connection?.auth || 'psk')
const [psk, setPsk] = useState('')
const [remoteCa, setRemoteCa] = useState(props.connection?.remote_ca ?? '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const nameProblem = adding && name !== '' && !NAME_PATTERN.test(name)
? 'Up to 31 lower case letters, digits and dashes, starting with a letter.'
: ''
const idProblem = id.trim().startsWith('@') ? 'Without a leading @.' : ''
const keyless = adding || !(props.connection?.psk_set || props.connection?.psk_file)
const pskProblem = auth === 'psk' && keyless && psk === '' ? 'Required.' : ''
const remoteCaProblem = remoteCa.trim() !== '' && !remoteCa.includes('-----BEGIN CERTIFICATE-----') ? 'A PEM certificate.' : ''
const blocked = name === '' || nameProblem !== '' || idProblem !== '' || listOf(addrs).length === 0 || id.trim() === ''
|| listOf(cidrs).length === 0 || listOf(localCidrs).length === 0 || pskProblem !== '' || remoteCaProblem !== ''
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave(name, {
addrs: listOf(addrs),
id: id.trim(),
cidrs: listOf(cidrs),
local_cidrs: listOf(localCidrs),
local_id_mode: mode,
auth,
remote_ca: auth === 'certificate' ? remoteCa.trim() : '',
}, auth === 'psk' ? psk : '')
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>{adding ? 'Add site-to-site connection' : `Edit site-to-site connection ${props.connection?.name}`}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<TextField
label="Name"
value={name}
disabled={!adding}
autoFocus={adding}
autoComplete="off"
error={nameProblem !== ''}
helperText={nameProblem}
onChange={(event) => setName(event.target.value)}
/>
<TextField
label="Remote peer"
value={addrs}
autoFocus={!adding}
autoComplete="off"
helperText="Addresses or FQDNs of the remote gateway, separated by commas."
onChange={(event) => setAddrs(event.target.value)}
/>
<TextField
label="Remote identity"
value={id}
autoComplete="off"
error={idProblem !== ''}
helperText={idProblem || 'The IKE identity of the remote peer: an FQDN or an IP address.'}
onChange={(event) => setId(event.target.value)}
/>
<TextField
label="Remote CIDRs"
value={cidrs}
autoComplete="off"
helperText="Remote subnets reachable through the tunnel, separated by commas."
onChange={(event) => setCidrs(event.target.value)}
/>
<TextField
label="Local CIDRs"
value={localCidrs}
autoComplete="off"
helperText="Local subnets advertised into the tunnel, separated by commas."
onChange={(event) => setLocalCidrs(event.target.value)}
/>
<TextField select label="Local identity mode" value={mode} onChange={(event) => setMode(event.target.value)}>
{LOCAL_ID_MODES.map((item) => <MenuItem key={item} value={item}>{MODE_LABELS[item]}</MenuItem>)}
</TextField>
<TextField select label="Authentication" value={auth} onChange={(event) => setAuth(event.target.value)}>
{CONNECTION_AUTH.map((item) => <MenuItem key={item} value={item}>{AUTH_LABELS[item]}</MenuItem>)}
</TextField>
{auth === 'psk' ? (
<TextField
label="Pre-shared key"
type="password"
value={psk}
autoComplete="new-password"
error={pskProblem !== ''}
helperText={pskProblem || 'Leave empty to keep the current key.'}
onChange={(event) => setPsk(event.target.value)}
/>
) : (
<TextField
label="Remote CA certificate"
value={remoteCa}
multiline
minRows={4}
maxRows={12}
autoComplete="off"
error={remoteCaProblem !== ''}
helperText={remoteCaProblem || "The PEM certificate of the CA of the peer's certificate; empty for the router's site-to-site CA."}
slotProps={{ htmlInput: { sx: { fontFamily: 'monospace', fontSize: 12 } } }}
onChange={(event) => setRemoteCa(event.target.value)}
/>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>
{adding ? 'Add' : 'Save'}
</Button>
</DialogActions>
</Dialog>
)
}
+185
View File
@@ -0,0 +1,185 @@
import Add from '@mui/icons-material/Add'
import Cancel from '@mui/icons-material/Cancel'
import CheckCircle from '@mui/icons-material/CheckCircle'
import Delete from '@mui/icons-material/Delete'
import Edit from '@mui/icons-material/Edit'
import Refresh from '@mui/icons-material/Refresh'
import {
Alert, Box, Button, IconButton, Table, TableBody, TableCell, TableHead, TableRow, Tooltip, Typography,
} from '@mui/material'
import { useCallback, useEffect, useState } from 'react'
import ConfirmDialog, { type ConfirmDialogProps } from './ConfirmDialog.tsx'
import { Section } from './RouterPanel.tsx'
import SiteToSiteDialog from './SiteToSiteDialog.tsx'
import Toolbar from './Toolbar.tsx'
import {
ApiError, REMOVED_SHAPE, SITE_TO_SITE_CONNECTION_SHAPE, SITE_TO_SITE_SHAPE, request, type SiteToSiteConnection,
} from './api.ts'
/** The note shown after a change, which the router applies when vpn-router-setup has run again. */
const APPLIED = 'The router applies the change in the background; Established follows once the tunnel is up again.'
/**
* The router's site-to-site connections: list with their remote peers, subnets, local identity mode, authentication
* (whether a pre-shared key is set, or which CA the peer's certificate comes from) and whether the tunnel is
* established; add, edit and remove. The router applies a change by
* running its setup again, so the list is read again after each.
*/
export default function SiteToSitePanel() {
const [connections, setConnections] = useState<SiteToSiteConnection[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [unavailable, setUnavailable] = useState(false)
const [note, setNote] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [adding, setAdding] = useState(false)
const [editing, setEditing] = useState<SiteToSiteConnection | null>(null)
const [confirm, setConfirm] = useState<ConfirmDialogProps | null>(null)
const refresh = useCallback(async () => {
setBusy(true)
try {
const found = await request<{ connections: SiteToSiteConnection[] }>('GET', '/site-to-site', SITE_TO_SITE_SHAPE)
setConnections(found.connections)
setError(null)
setUnavailable(false)
} catch (caught) {
const failure = caught as ApiError
setConnections(null)
setUnavailable(failure.status === 404)
setError(failure.message)
} finally {
setBusy(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
function remove(connection: SiteToSiteConnection) {
setConfirm({
title: 'Remove site-to-site connection',
message: `Remove ${connection.name}? Its tunnel is taken down.`,
confirmLabel: 'Remove',
destructive: true,
onCancel: () => setConfirm(null),
onConfirm: () => {
setConfirm(null)
setBusy(true)
request('DELETE', `/site-to-site/${connection.name}`, REMOVED_SHAPE)
.then(() => {
setError(null)
setNote(APPLIED)
})
.catch((caught: Error) => setError(caught.message))
.finally(() => void refresh())
},
})
}
return (
<Box component="section">
<Toolbar description="IPsec site-to-site connections.">
<Button variant="contained" startIcon={<Add />} disabled={busy || connections === null} onClick={() => setAdding(true)}>
Add connection
</Button>
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>
Refresh
</Button>
</Toolbar>
{error && <Alert severity={unavailable ? 'info' : 'error'} sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
{note && <Alert severity="info" sx={{ mb: 2 }} onClose={() => setNote(null)}>{note}</Alert>}
{connections && (
<Section title="Site-to-site connections">
<Box sx={{ overflowX: 'auto' }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Remote peer</TableCell>
<TableCell>Remote identity</TableCell>
<TableCell>Remote CIDRs</TableCell>
<TableCell>Local CIDRs</TableCell>
<TableCell>Local identity mode</TableCell>
<TableCell>Authentication</TableCell>
<TableCell>Established</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{connections.length === 0 && (
<TableRow>
<TableCell colSpan={9} sx={{ color: 'text.secondary' }}>None.</TableCell>
</TableRow>
)}
{connections.map((connection) => (
<TableRow key={connection.name} hover>
<TableCell>{connection.name}</TableCell>
<TableCell>{connection.addrs.join(', ') || '-'}</TableCell>
<TableCell>{connection.id || '-'}</TableCell>
<TableCell sx={{ fontFamily: 'monospace' }}>{connection.cidrs.join(', ') || '-'}</TableCell>
<TableCell sx={{ fontFamily: 'monospace' }}>{connection.local_cidrs.join(', ') || '-'}</TableCell>
<TableCell>{connection.local_id_mode}</TableCell>
<TableCell>
{connection.auth === 'certificate'
? `certificate, ${connection.remote_ca ? 'remote CA' : 'site-to-site CA'}`
: connection.psk_file
? <Tooltip title={connection.psk_file}><span>key from file</span></Tooltip>
: connection.psk_set ? 'key set' : <Typography variant="body2" component="span" color="error">key missing</Typography>}
</TableCell>
<TableCell>
{connection.established === null
? <Tooltip title="The router cannot tell."><span>-</span></Tooltip>
: connection.established
? <CheckCircle fontSize="small" color="success" aria-label="established" />
: <Cancel fontSize="small" color="error" aria-label="not established" />}
</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
<Tooltip title="Edit">
<span>
<IconButton size="small" disabled={busy} onClick={() => setEditing(connection)}><Edit fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title="Remove">
<span>
<IconButton size="small" color="error" disabled={busy} onClick={() => remove(connection)}><Delete fontSize="small" /></IconButton>
</span>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
</Section>
)}
{adding && (
<SiteToSiteDialog
onCancel={() => setAdding(false)}
onSave={async (name, settings, psk) => {
await request('POST', '/site-to-site', SITE_TO_SITE_CONNECTION_SHAPE, { name, ...settings, psk })
setAdding(false)
setNote(APPLIED)
await refresh()
}}
/>
)}
{editing && (
<SiteToSiteDialog
connection={editing}
onCancel={() => setEditing(null)}
onSave={async (_name, settings, psk) => {
await request('PUT', `/site-to-site/${editing.name}`, SITE_TO_SITE_CONNECTION_SHAPE, psk === '' ? settings : { ...settings, psk })
setEditing(null)
setNote(APPLIED)
await refresh()
}}
/>
)}
{confirm && <ConfirmDialog {...confirm} />}
</Box>
)
}
+63
View File
@@ -0,0 +1,63 @@
import Refresh from '@mui/icons-material/Refresh'
import { Alert, Box, Button, Stack } from '@mui/material'
import { Grid, IPSEC_HEADS, Section, duration, ipsecRows, text } from './RouterPanel.tsx'
import Toolbar from './Toolbar.tsx'
import { DETAILS_SHAPE, request, type RouterDetails } from './api.ts'
import { useRead } from './useRead.ts'
/** Reads the router's /config. */
const readDetails = () => request<RouterDetails>('GET', '/config', DETAILS_SHAPE)
/**
* How long a site-to-site connection has been up: the age of its oldest established IKE SA; "down" without one, and
* "unknown" when the router could not list its SAs.
*/
function uptimeOf(sas: RouterDetails['ipsec']['sas'], name: string) {
if (sas === null) {
return 'unknown'
}
const ages = sas.filter((sa) => sa.name === name && sa.state === 'ESTABLISHED' && sa.established !== undefined).map((sa) => Number(sa.established))
return ages.length === 0 ? 'down' : duration(Math.max(...ages))
}
/**
* The state of the router's site-to-site connections, from its /config: each with the identities and subnets the
* router resolved for it and how long it has been up, and their IPsec security associations, child SAs, connections
* and connection children.
*/
export default function SiteToSiteStatusPanel() {
const { data: details, error, busy, refresh } = useRead(readDetails)
const names = new Set((details?.network.site_to_site ?? []).map((connection) => connection.name))
const { sas, childSas, connections, children } = ipsecRows(details, (name) => names.has(name))
return (
<Box component="section">
<Toolbar description="The state of the site-to-site connections.">
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>Refresh</Button>
</Toolbar>
{error && <Alert severity="error" sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
{details && (
<Stack sx={{ gap: 2 }}>
<Section title="Site-to-site connections">
<Grid
head={['Connection', 'Local identity', 'Remote identity', 'Local CIDRs', 'Remote CIDRs', 'Uptime']}
rows={(details.network.site_to_site ?? []).map((connection) => [
connection.name,
text(connection.local_identity),
text(connection.remote_identity),
text(connection.local_cidrs),
text(connection.remote_cidrs),
uptimeOf(details.ipsec.sas, connection.name),
])}
/>
</Section>
<Section title="IPsec security associations"><Grid head={IPSEC_HEADS.sas} rows={sas} /></Section>
<Section title="IPsec child SAs"><Grid head={IPSEC_HEADS.childSas} rows={childSas} /></Section>
<Section title="IPsec connections"><Grid head={IPSEC_HEADS.connections} rows={connections} /></Section>
<Section title="IPsec connection children"><Grid head={IPSEC_HEADS.children} rows={children} /></Section>
</Stack>
)}
</Box>
)
}
+260
View File
@@ -0,0 +1,260 @@
import Add from '@mui/icons-material/Add'
import Block from '@mui/icons-material/Block'
import CheckCircle from '@mui/icons-material/CheckCircle'
import Delete from '@mui/icons-material/Delete'
import Edit from '@mui/icons-material/Edit'
import FolderOpen from '@mui/icons-material/FolderOpen'
import Refresh from '@mui/icons-material/Refresh'
import Settings from '@mui/icons-material/Settings'
import { Alert, Box, Button, Checkbox, Chip, FormControlLabel, IconButton, Link, Stack, Table, TableBody, TableCell, TableHead, TableRow, Tooltip } from '@mui/material'
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import ConfirmDialog, { type ConfirmDialogProps } from './ConfirmDialog.tsx'
import { Copyable, Pairs, Section } from './RouterPanel.tsx'
import SearchField, { matches } from './SearchField.tsx'
import SiteDialog, { type SiteSettings } from './SiteDialog.tsx'
import SitesSettingsDialog from './SitesSettingsDialog.tsx'
import Toolbar from './Toolbar.tsx'
import {
ApiError, DETAILS_SHAPE, GROUPS_SHAPE, REMOVED_SHAPE, SITES_SETTINGS_SHAPE, SITES_SHAPE, SITE_DNS_ADDRESS_LABELS,
SITE_DNS_LABELS, SITE_SHAPE, SITE_TLS_LABELS, request, type Group, type RouterDetails, type Site, type Sites,
} from './api.ts'
/** The URLs of a site: its scheme, each host name and its port when it is not the scheme's default. */
function siteUrls(site: Site) {
const scheme = site.tls === 'none' ? 'http' : 'https'
const port = site.port === (site.tls === 'none' ? 80 : 443) ? '' : `:${site.port}`
return site.hosts.filter((host) => !host.startsWith('*.')).map((host) => `${scheme}://${host.includes(':') ? `[${host}]` : host}${port}`)
}
/**
* The web sites of the router's /sites, which Caddy serves from folders on the router. The Settings panel shows the
* sites section, whether they are served, the largest upload and WebDAV with its URL, edited with Settings. The list
* shows each site with its URLs, port, interfaces, TLS, editors group, DNS and state: add, edit, enable, disable,
* remove, with its folder when asked, and open its files on the Files tab with onOpenFiles.
*/
export default function SitesPanel(props: { onOpenFiles: (name: string) => void }) {
const [sites, setSites] = useState<Sites | null>(null)
const [fqdn, setFqdn] = useState('')
const [cas, setCas] = useState<string[]>([])
const [groups, setGroups] = useState<string[]>([])
const [error, setError] = useState<string | null>(null)
const [unavailable, setUnavailable] = useState(false)
const [busy, setBusy] = useState(false)
const [adding, setAdding] = useState(false)
const [editing, setEditing] = useState<Site | null>(null)
const [settings, setSettings] = useState(false)
const [confirm, setConfirm] = useState<ConfirmDialogProps | null>(null)
const [search, setSearch] = useState('')
const refresh = useCallback(async () => {
setBusy(true)
try {
const [found, groupList, details] = await Promise.all([
request<Sites>('GET', '/sites', SITES_SHAPE),
request<{ groups: Group[] }>('GET', '/groups', GROUPS_SHAPE),
request<RouterDetails>('GET', '/config', DETAILS_SHAPE),
])
setSites(found)
setGroups(groupList.groups.map((group) => group.name))
const wan = details.configuration.wan ?? {}
const pki = details.configuration.pki ?? {}
setFqdn(typeof wan.local_fqdn === 'string' ? wan.local_fqdn : '')
setCas(typeof pki.issuing_cas === 'object' && pki.issuing_cas !== null ? Object.keys(pki.issuing_cas) : [])
setError(null)
setUnavailable(false)
} catch (caught) {
const failure = caught as ApiError
setSites(null)
setUnavailable(failure.status === 404)
setError(failure.message)
} finally {
setBusy(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
/** Runs a change of a site, then reads the list again; a failure is shown above the list. */
async function change(work: () => Promise<unknown>) {
setBusy(true)
try {
await work()
setError(null)
} catch (caught) {
setError((caught as Error).message)
}
await refresh()
}
function remove(site: Site) {
let content = false
setConfirm({
title: 'Remove site',
message: `Remove ${site.name}?`,
confirmLabel: 'Remove',
destructive: true,
children: (
<FormControlLabel
control={<Checkbox onChange={(event) => { content = event.target.checked }} />}
label={`Remove its folder ${site.directory}`}
/>
),
onCancel: () => setConfirm(null),
onConfirm: () => {
setConfirm(null)
void change(() => request('DELETE', `/sites/${site.name}${content ? '?content=true' : ''}`, REMOVED_SHAPE))
},
})
}
const section = sites?.settings
const shown = (sites?.sites ?? []).filter((site) => matches(search, [site.name, ...site.hosts, site.editors]))
return (
<Box component="section">
<Toolbar description="Static web sites Caddy serves from folders on the router.">
<SearchField value={search} onChange={setSearch} />
<Button variant="outlined" startIcon={<Settings />} disabled={busy || !section} onClick={() => setSettings(true)}>
Settings
</Button>
<Button variant="contained" startIcon={<Add />} disabled={busy || sites === null} onClick={() => setAdding(true)}>
Add
</Button>
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>
Refresh
</Button>
</Toolbar>
{error && <Alert severity={unavailable ? 'info' : 'error'} sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
{section && (
<Box sx={{ mb: 2 }}>
<Section title="Settings">
<Pairs
rows={[
['State', <Chip key="state" size="small" label={section.enabled ? 'served' : 'not served'} color={section.enabled ? 'success' : 'default'} />],
['Largest upload', `${section.upload_max_mb} MiB`],
['WebDAV', <Chip key="webdav" size="small" label={section.webdav ? 'enabled' : 'disabled'} color={section.webdav ? 'success' : 'default'} />],
...(section.webdav && fqdn
? [['WebDAV URL', <Copyable key="url" value={`https://${fqdn}:${section.webdav_port}/`} label="WebDAV URL" />]] as [string, ReactNode][]
: []),
]}
/>
</Section>
</Box>
)}
{sites && (
<Section title="Sites">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>URLs</TableCell>
<TableCell>Port</TableCell>
<TableCell>Listen</TableCell>
<TableCell>TLS</TableCell>
<TableCell>Editors</TableCell>
<TableCell>DNS</TableCell>
<TableCell>State</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{shown.length === 0 && (
<TableRow>
<TableCell colSpan={9} sx={{ color: 'text.secondary' }}>None.</TableCell>
</TableRow>
)}
{shown.map((site) => (
<TableRow key={site.name} hover>
<TableCell>{site.name}</TableCell>
<TableCell>
<Stack>
{siteUrls(site).map((url) => <Link key={url} href={url} target="_blank" rel="noreferrer">{url}</Link>)}
{site.hosts.length === 0 && 'any host name'}
</Stack>
</TableCell>
<TableCell>{site.port}</TableCell>
<TableCell>{site.listen}</TableCell>
<TableCell>{SITE_TLS_LABELS[site.tls]}{site.tls === 'ca' ? ` (${site.ca})` : ''}</TableCell>
<TableCell>{site.editors || '-'}</TableCell>
<TableCell>{SITE_DNS_LABELS[site.dns]}{site.dns === 'router' ? `, ${SITE_DNS_ADDRESS_LABELS[site.dns_address]}` : ''}</TableCell>
<TableCell sx={{ color: site.enabled ? 'success.main' : 'text.secondary' }}>{site.enabled ? 'enabled' : 'disabled'}</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
<Tooltip title="Files">
<span>
<IconButton size="small" onClick={() => props.onOpenFiles(site.name)}><FolderOpen fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title="Edit">
<span>
<IconButton size="small" disabled={busy} onClick={() => setEditing(site)}><Edit fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title={site.enabled ? 'Disable' : 'Enable'}>
<span>
<IconButton
size="small"
disabled={busy}
onClick={() => void change(() => request('PUT', `/sites/${site.name}`, SITE_SHAPE, { enabled: !site.enabled }))}
>
{site.enabled ? <Block fontSize="small" /> : <CheckCircle fontSize="small" />}
</IconButton>
</span>
</Tooltip>
<Tooltip title="Remove">
<span>
<IconButton size="small" color="error" disabled={busy} onClick={() => remove(site)}><Delete fontSize="small" /></IconButton>
</span>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
)}
{adding && (
<SiteDialog
cas={cas}
groups={groups}
onCancel={() => setAdding(false)}
onSave={async (name, values: SiteSettings) => {
await request('POST', '/sites', SITE_SHAPE, { name, ...values })
setAdding(false)
await refresh()
}}
/>
)}
{editing && (
<SiteDialog
site={editing}
cas={cas}
groups={groups}
onCancel={() => setEditing(null)}
onSave={async (name, values: SiteSettings) => {
await request('PUT', `/sites/${name}`, SITE_SHAPE, values)
setEditing(null)
await refresh()
}}
/>
)}
{settings && section && (
<SitesSettingsDialog
current={section}
onCancel={() => setSettings(false)}
onSave={async (values) => {
await request('PUT', '/sites', SITES_SETTINGS_SHAPE, values)
setSettings(false)
await refresh()
}}
/>
)}
{confirm && <ConfirmDialog {...confirm} />}
</Box>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, Stack, Switch, TextField } from '@mui/material'
import { useState, type FormEvent } from 'react'
import type { SitesSettings } from './api.ts'
/** The largest upload limit the router accepts, in MiB. */
const UPLOAD_MAX_MB = 10240
/**
* A dialog that edits the router's sites section: whether the sites are served, the largest upload in MiB, and WebDAV
* with its port. onSave sends the section; the dialog closes when it succeeds and shows the router's message when it
* fails.
*/
export default function SitesSettingsDialog(props: {
current: SitesSettings
onSave: (settings: SitesSettings) => Promise<void>
onCancel: () => void
}) {
const [enabled, setEnabled] = useState(props.current.enabled)
const [uploadMax, setUploadMax] = useState(String(props.current.upload_max_mb))
const [webdav, setWebdav] = useState(props.current.webdav)
const [port, setPort] = useState(String(props.current.webdav_port))
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const uploadProblem = /^\d{1,5}$/.test(uploadMax) && Number(uploadMax) >= 1 && Number(uploadMax) <= UPLOAD_MAX_MB
? '' : `1 to ${UPLOAD_MAX_MB} MiB.`
const portProblem = /^\d{1,5}$/.test(port) && Number(port) >= 1 && Number(port) <= 65535 ? '' : 'A port from 1 to 65535.'
const blocked = uploadProblem !== '' || portProblem !== ''
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave({ enabled, upload_max_mb: Number(uploadMax), webdav, webdav_port: Number(port) })
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="xs" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>Web sites settings</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<FormControlLabel control={<Switch checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />} label="Serve the sites" />
<TextField
label="Largest upload (MiB)"
value={uploadMax}
autoComplete="off"
error={uploadProblem !== ''}
helperText={uploadProblem || 'The largest file or zip archive uploaded.'}
slotProps={{ htmlInput: { inputMode: 'numeric' } }}
onChange={(event) => setUploadMax(event.target.value.trim())}
/>
<FormControlLabel control={<Switch checked={webdav} onChange={(event) => setWebdav(event.target.checked)} />} label="WebDAV" />
<TextField
label="WebDAV port"
value={port}
autoComplete="off"
error={portProblem !== ''}
helperText={portProblem || 'The HTTPS port of WebDAV.'}
slotProps={{ htmlInput: { inputMode: 'numeric' } }}
onChange={(event) => setPort(event.target.value.trim())}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel} disabled={saving}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Save</Button>
</DialogActions>
</Dialog>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { Box } from '@mui/material'
import { Board, useBoard, type BoardPanel } from './PanelBoard.tsx'
import UpdatesNotice from './UpdatesNotice.tsx'
/** The localStorage key of the tab's layout. */
const LAYOUT_KEY = 'vpn-router-manager.system-layout'
/**
* The router's operating system: the System updates panel lists the packages apt-get upgrade would upgrade, with Check
* update and Upgrade (UpdatesNotice with system). The panel is on a PanelBoard whose layout is kept in the browser.
*/
export default function SystemPanel() {
const board = useBoard(LAYOUT_KEY)
const panels: BoardPanel[] = [
{ id: 'updates', title: 'System updates', wide: true, fixed: true, content: <UpdatesNotice system /> },
]
return (
<Box component="section">
<Board board={board} panels={panels} />
</Box>
)
}
+136
View File
@@ -0,0 +1,136 @@
import {
Alert, Autocomplete, Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField, Typography,
} from '@mui/material'
import { useState, type FormEvent } from 'react'
import { NAME_PATTERN, NEW_TEMPLATE, daysProblem } from './PkiSettingsDialog.tsx'
import {
BUILTIN_TEMPLATES, KEY_USAGES, OID_PATTERN, PKI_KEYS, SAN_TYPES, TEMPLATE_SUBJECTS, type Pki, type PkiTemplate,
} from './api.ts'
/**
* A dialog that adds a certificate template, or with name changes one, a built-in template included: its name, fixed
* once it exists, the key the router makes, the lifetime, the key usages, the extended key usages, built in or custom,
* the kind of common name, the subject alternative names allowed and the certificate policy OIDs. onSave gets the name and the options; the dialog closes when it
* succeeds and shows the router's message when it fails.
*/
export default function TemplateDialog(props: {
pki: Pki
name?: string
onSave: (name: string, values: PkiTemplate) => Promise<void>
onCancel: () => void
}) {
const [name, setName] = useState(props.name ?? '')
const [values, setValues] = useState<PkiTemplate>(props.name !== undefined ? props.pki.templates[props.name] : NEW_TEMPLATE)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const change = (next: Partial<PkiTemplate>) => setValues({ ...values, ...next })
const nameError = props.name !== undefined ? ''
: !NAME_PATTERN.test(name.trim()) ? 'Up to 31 lower case letters, digits and dashes, starting with a letter.'
: props.pki.templates[name.trim()] !== undefined ? 'A template has this name.' : ''
const daysError = daysProblem(values.days)
const usageError = values.key_usage.length === 0 ? 'At least one key usage.' : ''
const wrongPolicies = values.policies.filter((item) => !OID_PATTERN.test(item))
const policiesError = wrongPolicies.length > 0 ? `Not OIDs in dotted form: ${wrongPolicies.join(', ')}.` : ''
const blocked = nameError !== '' || daysError !== '' || usageError !== '' || policiesError !== ''
const builtin = props.name !== undefined && (BUILTIN_TEMPLATES as readonly string[]).includes(props.name)
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
await props.onSave(name.trim(), values)
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="sm" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>{props.name === undefined ? 'Add template' : `Edit template ${props.name}`}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<Typography variant="body2" color="text.secondary">
{builtin
? 'A built-in template: its options can be changed, and Reset brings back its defaults.'
: 'Applies to the certificates issued afterwards; the issuing CAs name the templates they issue.'}
</Typography>
<TextField
label="Name"
value={name}
disabled={props.name !== undefined}
autoComplete="off"
error={nameError !== ''}
helperText={nameError}
onChange={(event) => setName(event.target.value)}
/>
<Stack direction="row" sx={{ gap: 2 }}>
<TextField select fullWidth label="Key" value={values.key} onChange={(event) => change({ key: event.target.value })}>
{PKI_KEYS.map((item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
</TextField>
<TextField
fullWidth
label="Days"
type="number"
value={values.days}
error={daysError !== ''}
helperText={daysError}
onChange={(event) => change({ days: Math.trunc(Number(event.target.value) || 0) })}
/>
<TextField select fullWidth label="Subject" value={values.subject} onChange={(event) => change({ subject: event.target.value })}>
{TEMPLATE_SUBJECTS.map((item) => <MenuItem key={item} value={item}>{item}</MenuItem>)}
</TextField>
</Stack>
<Autocomplete
multiple
options={[...KEY_USAGES]}
value={values.key_usage}
onChange={(_event, next) => change({ key_usage: next })}
renderInput={(params) => <TextField {...params} label="Key usage" error={usageError !== ''} helperText={usageError} />}
/>
<Autocomplete
multiple
options={Object.keys(props.pki.extended_key_usages)}
value={values.extended_key_usage}
onChange={(_event, next) => change({ extended_key_usage: next })}
renderInput={(params) => (
<TextField {...params} label="Extended key usage" helperText="Custom usages are added on the Extended key usages tab." />
)}
/>
<Autocomplete
multiple
freeSolo
options={[]}
value={values.policies}
onChange={(_event, next) => change({ policies: next.map((item) => item.trim()).filter((item) => item !== '') })}
renderInput={(params) => (
<TextField
{...params}
label="Certificate policies"
error={policiesError !== ''}
helperText={policiesError || 'Policy OIDs in dotted form; type one and press Enter.'}
/>
)}
/>
<Autocomplete
multiple
options={[...SAN_TYPES]}
value={values.sans}
onChange={(_event, next) => change({ sans: next })}
renderInput={(params) => <TextField {...params} label="Alternative names" helperText="The kinds of subject alternative names allowed." />}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>Save</Button>
</DialogActions>
</Dialog>
)
}
+26
View File
@@ -0,0 +1,26 @@
import DarkMode from '@mui/icons-material/DarkMode'
import LightMode from '@mui/icons-material/LightMode'
import SettingsBrightness from '@mui/icons-material/SettingsBrightness'
import { IconButton, Tooltip, useColorScheme } from '@mui/material'
import type { ReactNode } from 'react'
type Mode = 'light' | 'dark' | 'system'
const NEXT: Record<Mode, Mode> = { light: 'dark', dark: 'system', system: 'light' }
const ICONS: Record<Mode, ReactNode> = { light: <LightMode />, dark: <DarkMode />, system: <SettingsBrightness /> }
/** An icon button cycling light, dark and system theme. MUI stores the choice; mode is undefined on the first render. */
export default function ThemeMenu() {
const { mode, setMode } = useColorScheme()
if (!mode) {
return null
}
return (
<Tooltip title={`Theme: ${mode}. Switch to ${NEXT[mode]}.`}>
<IconButton aria-label={`theme ${mode}, switch to ${NEXT[mode]}`} onClick={() => setMode(NEXT[mode])}>
{ICONS[mode]}
</IconButton>
</Tooltip>
)
}
+12
View File
@@ -0,0 +1,12 @@
import { Stack, Typography } from '@mui/material'
import type { ReactNode } from 'react'
/** The row above a tab's content: its description on the left, which takes the free width, and its buttons on the right. */
export default function Toolbar(props: { description: ReactNode; children?: ReactNode }) {
return (
<Stack direction="row" sx={{ mb: 2, alignItems: 'center', gap: 2, flexWrap: 'wrap', '& .MuiButton-root': { flexShrink: 0, whiteSpace: 'nowrap' } }}>
<Typography variant="body2" color="text.disabled" sx={{ flex: '1 1 0', minWidth: 0 }}>{props.description}</Typography>
{props.children}
</Stack>
)
}
+274
View File
@@ -0,0 +1,274 @@
import Refresh from '@mui/icons-material/Refresh'
import Upgrade from '@mui/icons-material/Upgrade'
import {
Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography,
} from '@mui/material'
import { useCallback, useEffect, useState } from 'react'
import ConfirmDialog from './ConfirmDialog.tsx'
import LogView from './LogView.tsx'
import { INFO_SHAPE, UPDATES_SHAPE, UPGRADE_SHAPE, request, type Updates, type UpgradeState } from './api.ts'
/** Seconds between reads while an upgrade or a reboot is watched. */
const POLL_SECONDS = 3
/** The words for each state of the upgrade. */
const STATES: Record<UpgradeState['state'], string> = {
idle: 'No upgrade has run.',
running: 'The upgrade is running.',
succeeded: 'The upgrade succeeded.',
failed: 'The upgrade failed.',
interrupted: 'The upgrade was interrupted before it ended; the next upgrade finishes what dpkg left.',
}
/**
* The update information of the router's package, read when shown: a table of the package with its installed
* version and, when the repository apt is configured with has a newer one, that version, as of apt's last update of
* its package lists. With system, that of the system's packages instead: a table of the packages apt-get upgrade
* would upgrade, each with its installed and its new version, from /system-updates, upgraded by /system-upgrade.
*
* Check update, always in the top right corner, has the manager update apt's package lists, and the information is
* read again every POLL_SECONDS until the update ends.
*
* With updates available, Upgrade is added beside it and starts the upgrade of the packages on the router after a confirmation, and a dialog
* follows it every POLL_SECONDS with its output. The upgrade restarts the manager and the router's web service, so a
* failed read is shown as a restart and reading goes on. When the manager's package changed, Reload page loads its new
* web application. A failed or interrupted upgrade is shown with its output and can be started again. When a package
* asks for a reboot, Reboot reboots the router after a confirmation, and the page reloads once the manager answers again.
*
* onBusy is called with true while a check, an upgrade or a reboot runs and with false when it ends.
*/
export default function UpdatesNotice(props: { onBusy?: (busy: boolean) => void; system?: boolean }) {
const updatesPath = props.system ? '/system-updates' : '/updates'
const upgradePath = props.system ? '/system-upgrade' : '/upgrade'
const [updates, setUpdates] = useState<Updates | null>(null)
const [error, setError] = useState<string | null>(null)
const [upgrade, setUpgrade] = useState<UpgradeState | null>(null)
const [unreachable, setUnreachable] = useState(false)
const [watching, setWatching] = useState(false)
const [confirming, setConfirming] = useState<'upgrade' | 'reboot' | null>(null)
const [problem, setProblem] = useState<string | null>(null)
const [rebooting, setRebooting] = useState(false)
const read = useCallback(async () => {
try {
const next = await request<UpgradeState>('GET', upgradePath, UPGRADE_SHAPE)
setUpgrade(next)
setUnreachable(false)
if (next.state !== 'running') {
request<Updates>('GET', updatesPath, UPDATES_SHAPE).then(setUpdates, (caught: Error) => setError(caught.message))
}
} catch {
setUnreachable(true)
}
}, [updatesPath, upgradePath])
useEffect(() => {
request<Updates>('GET', updatesPath, UPDATES_SHAPE).then(setUpdates, (caught: Error) => setError(caught.message))
void read()
}, [read, updatesPath])
// A check for updates is followed until the manager's update of the package lists ends.
const checking = updates?.checking === true
useEffect(() => {
if (!checking) {
return
}
const timer = window.setInterval(() => {
request<Updates>('GET', updatesPath, UPDATES_SHAPE).then(setUpdates, (caught: Error) => setError(caught.message))
}, POLL_SECONDS * 1000)
return () => window.clearInterval(timer)
}, [checking, updatesPath])
async function check() {
setProblem(null)
try {
await request('POST', '/updates', () => true)
setUpdates(await request<Updates>('GET', updatesPath, UPDATES_SHAPE))
} catch (caught) {
setProblem((caught as Error).message)
}
}
// An upgrade is followed while its dialog is open or while it runs.
const running = upgrade?.state === 'running'
// The owner is told while a check for updates, an upgrade or a reboot runs, so it reads nothing else meanwhile, and
// that none runs when this notice goes away, as in a folded panel.
const { onBusy } = props
const busy = checking || running || upgrade?.rebooting === true || rebooting
useEffect(() => {
onBusy?.(busy)
return () => onBusy?.(false)
}, [onBusy, busy])
useEffect(() => {
if (!watching && !running) {
return
}
const timer = window.setInterval(() => void read(), POLL_SECONDS * 1000)
return () => window.clearInterval(timer)
}, [watching, running, read])
// After a reboot, the page reloads once the manager has stopped answering and answers again.
useEffect(() => {
if (!rebooting) {
return
}
let gone = false
const timer = window.setInterval(() => {
request('GET', '/info', INFO_SHAPE).then(() => {
if (gone) {
window.location.reload()
}
}, () => {
gone = true
})
}, POLL_SECONDS * 1000)
return () => window.clearInterval(timer)
}, [rebooting])
async function start(action: 'upgrade' | 'reboot') {
setConfirming(null)
setProblem(null)
try {
await request('POST', action === 'upgrade' ? upgradePath : '/reboot', () => true)
if (action === 'upgrade') {
setWatching(true)
await read()
} else {
setRebooting(true)
}
} catch (caught) {
setProblem((caught as Error).message)
}
}
const managerChanged = upgrade?.state === 'succeeded' && upgrade.before?.['vpn-router'] !== upgrade.after?.['vpn-router']
const newer = updates?.available ? updates.packages.filter((item) => item.update) : []
const checkedAt = checking
? 'checking for updates...'
: updates?.checked ? `as of ${new Date(updates.checked).toLocaleString()}` : ''
return (
<Stack sx={{ mb: 2, gap: 1 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, '& .MuiButton-root': { flexShrink: 0, whiteSpace: 'nowrap' } }}>
<Typography variant="subtitle2" sx={{ flex: '1 1 0', minWidth: 0 }}>
{props.system ? 'Packages to upgrade' : 'Packages'}
<Typography component="span" variant="caption" color="text.secondary" sx={{ ml: 1 }}>{checkedAt}</Typography>
</Typography>
{newer.length > 0 && (
<Button size="small" variant="contained" startIcon={<Upgrade />} disabled={running || checking} onClick={() => setConfirming('upgrade')}>
Upgrade
</Button>
)}
<Button size="small" variant="outlined" startIcon={<Refresh />} disabled={!updates?.available || running || checking} onClick={() => void check()}>
Check update
</Button>
</Stack>
{error && <Typography variant="body2" color="text.secondary">Update information is not available: {error}</Typography>}
{!error && updates && !updates.available && (
<Typography variant="body2" color="text.secondary">Update information is not available here: apt cannot be asked.</Typography>
)}
{!error && updates?.available && (
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ width: 260 }}>Package</TableCell>
<TableCell>Version</TableCell>
</TableRow>
</TableHead>
<TableBody>
{updates.packages.length === 0 && (
<TableRow>
<TableCell colSpan={2} sx={{ color: 'text.secondary' }}>None; every package is up to date.</TableCell>
</TableRow>
)}
{updates.packages.map((item) => (
<TableRow key={item.name}>
<TableCell>{item.name}</TableCell>
<TableCell sx={{ color: item.update ? 'warning.main' : undefined }}>
{item.installed ?? 'not installed'}
{item.update ? `, ${item.candidate} available` : ''}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{problem && <Alert severity="error" onClose={() => setProblem(null)}>{problem}</Alert>}
{upgrade && ['running', 'failed', 'interrupted'].includes(upgrade.state) && !watching && (
<Alert
severity={running ? 'info' : 'warning'}
action={<Button color="inherit" size="small" onClick={() => setWatching(true)}>Show</Button>}
>
{STATES[upgrade.state]}
</Alert>
)}
{upgrade?.reboot_required && (
<Alert
severity="warning"
action={
<Button color="inherit" size="small" disabled={running || upgrade.rebooting || rebooting} onClick={() => setConfirming('reboot')}>
Reboot
</Button>
}
>
{upgrade.rebooting || rebooting
? 'The router is rebooting; the page reloads when the Router Manager answers again.'
: `The router needs a reboot${upgrade.reboot_packages.length > 0 ? ` for ${upgrade.reboot_packages.join(', ')}` : ''}.`}
</Alert>
)}
{confirming === 'upgrade' && (
<ConfirmDialog
title={props.system ? "Upgrade the system's packages" : "Upgrade the router's package"}
message={props.system
? `The ${newer.length} packages listed are upgraded on the router with apt-get upgrade; none is installed or removed. Services restart during the upgrade, the Router Manager among them when vpn-router is listed, and the page may not answer for a while.`
: 'vpn-router is upgraded on the router. The Router Manager and the router\'s web service restart during the upgrade, and the page may not answer for a while.'}
confirmLabel="Upgrade"
destructive={false}
onConfirm={() => void start('upgrade')}
onCancel={() => setConfirming(null)}
/>
)}
{confirming === 'reboot' && (
<ConfirmDialog
title="Reboot the router"
message={'The router reboots in a few seconds. Its VPN connections are interrupted until it is up again.'}
confirmLabel="Reboot"
destructive
onConfirm={() => void start('reboot')}
onCancel={() => setConfirming(null)}
/>
)}
<Dialog open={watching} onClose={() => !running && setWatching(false)} fullWidth maxWidth="md">
<DialogTitle>{props.system ? 'System upgrade' : 'Upgrade'}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 1 }}>
{unreachable && <Alert severity="info">The Router Manager is restarting...</Alert>}
{upgrade && (
<Typography variant="body2">
{STATES[upgrade.state]}
{upgrade.user && upgrade.started ? ` Started by ${upgrade.user} at ${new Date(upgrade.started).toLocaleString()}.` : ''}
{upgrade.exit_status !== null && upgrade.state !== 'succeeded' ? ` apt-get ended with status ${upgrade.exit_status}.` : ''}
</Typography>
)}
{upgrade?.state === 'succeeded' && upgrade.after && (
<Typography variant="body2">
{Object.entries(upgrade.after).map(([name, version]) => `${name} ${upgrade.before?.[name] ?? 'none'} -> ${version ?? 'none'}`).join(', ')}
</Typography>
)}
<LogView lines={upgrade?.log ?? null} empty="(no output yet)" />
</Stack>
</DialogContent>
<DialogActions>
{managerChanged && <Button variant="contained" onClick={() => window.location.reload()}>Reload page</Button>}
{(upgrade?.state === 'failed' || upgrade?.state === 'interrupted') && (
<Button onClick={() => setConfirming('upgrade')}>Upgrade again</Button>
)}
<Button disabled={running} onClick={() => setWatching(false)}>Close</Button>
</DialogActions>
</Dialog>
</Stack>
)
}
+105
View File
@@ -0,0 +1,105 @@
import { Alert, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, Stack, TextField } from '@mui/material'
import { useState, type FormEvent } from 'react'
import PersonFields from './PersonFields.tsx'
import type { Person } from './api.ts'
import { personComplete, personProblems, trimmed } from './person.ts'
/** The user names vpn-router-user accepts. */
const NAME_PATTERN = /^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$/
/**
* A dialog that asks for a password, typed twice. Without name it adds a user and asks for the name too, and either
* the first name, the last name and the e-mail address of a VPN user, all required, or, with Manager access only, an
* optional e-mail address for a Manager-only account. onSave sends them, the person fields as the user gives them;
* the dialog closes when it succeeds and shows the router's message when it fails.
*/
export default function UserDialog(props: {
title: string
name?: string
saveLabel: string
onSave: (name: string, password: string, person: Partial<Person>) => Promise<void>
onCancel: () => void
}) {
const adding = props.name === undefined
const [name, setName] = useState(props.name ?? '')
const [managerOnly, setManagerOnly] = useState(false)
const [person, setPerson] = useState<Person>({ first_name: '', last_name: '', email: '' })
const [password, setPassword] = useState('')
const [again, setAgain] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const nameProblem = adding && name !== '' && !NAME_PATTERN.test(name)
? 'Up to 32 lower case letters, digits and dashes, starting with a letter and not ending with a dash.'
: ''
const againProblem = again !== '' && again !== password ? 'The passwords differ.' : ''
const personBlocked = adding && (managerOnly ? personProblems(person).email !== '' : !personComplete(person))
const blocked = name === '' || password === '' || again !== password || nameProblem !== '' || personBlocked
async function submit(event: FormEvent) {
event.preventDefault()
if (blocked) {
return
}
setSaving(true)
setError(null)
try {
const email = person.email.trim()
await props.onSave(name, password, !adding ? {} : managerOnly ? (email === '' ? {} : { email }) : trimmed(person))
} catch (caught) {
setError((caught as Error).message)
setSaving(false)
}
}
return (
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && props.onCancel()} maxWidth="xs" fullWidth slotProps={{ paper: { component: 'form', onSubmit: submit } }}>
<DialogTitle>{props.title}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
{error && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{error}</Alert>}
<TextField
label="Name"
value={name}
disabled={!adding}
autoFocus={adding}
autoComplete="off"
error={nameProblem !== ''}
helperText={nameProblem}
onChange={(event) => setName(event.target.value)}
/>
{adding && (
<FormControlLabel
control={<Checkbox checked={managerOnly} onChange={(event) => setManagerOnly(event.target.checked)} />}
label="Manager access only: no names, no certificate, no VPN access"
/>
)}
{adding && <PersonFields person={person} onChange={setPerson} emailOnly={managerOnly} />}
<TextField
label="Password"
type="password"
value={password}
autoFocus={!adding}
autoComplete="new-password"
onChange={(event) => setPassword(event.target.value)}
/>
<TextField
label="Password again"
type="password"
value={again}
autoComplete="new-password"
error={againProblem !== ''}
helperText={againProblem}
onChange={(event) => setAgain(event.target.value)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving || blocked}>
{props.saveLabel}
</Button>
</DialogActions>
</Dialog>
)
}
+355
View File
@@ -0,0 +1,355 @@
import Add from '@mui/icons-material/Add'
import Apple from '@mui/icons-material/Apple'
import Block from '@mui/icons-material/Block'
import CheckCircle from '@mui/icons-material/CheckCircle'
import Delete from '@mui/icons-material/Delete'
import Download from '@mui/icons-material/Download'
import Edit from '@mui/icons-material/Edit'
import Key from '@mui/icons-material/Key'
import KeyOff from '@mui/icons-material/KeyOff'
import LockOpen from '@mui/icons-material/LockOpen'
import PhonelinkLock from '@mui/icons-material/PhonelinkLock'
import PhonelinkErase from '@mui/icons-material/PhonelinkErase'
import Refresh from '@mui/icons-material/Refresh'
import {
Alert, Box, Button, IconButton, Table, TableBody, TableCell, TableHead, TableRow, ToggleButton, ToggleButtonGroup, Tooltip,
} from '@mui/material'
import { useCallback, useEffect, useState } from 'react'
import ConfirmDialog, { type ConfirmDialogProps } from './ConfirmDialog.tsx'
import OtpDialog from './OtpDialog.tsx'
import PersonDialog from './PersonDialog.tsx'
import ProfileDialog from './ProfileDialog.tsx'
import { Section } from './RouterPanel.tsx'
import SearchField, { matches } from './SearchField.tsx'
import Toolbar from './Toolbar.tsx'
import UserDialog from './UserDialog.tsx'
import {
ApiError, PROFILE_SHAPE, REMOVED_SHAPE, STATUS_SHAPE, USERS_SHAPE, USER_SHAPE, request, type Console, type Person, type RouterStatus, type User,
} from './api.ts'
import { saveFile } from './saveFile.ts'
/** The kinds of users the list shows: all, VPN users or Manager-only accounts. */
type Kind = 'all' | 'vpn' | 'manager'
/** How console login is changed, which the Manager cannot do. */
const CONSOLE_HELP = 'Console login is switched on the router: vpn-router-user console <name> --posix | --account LOCAL | --off'
/** The console login of a user as a cell: off, the user's own POSIX account with its uid, or the local account, refused without one-time passwords. */
function consoleText(login: Console | null, otp: boolean) {
if (login === null || !login.enabled) {
return login === null ? '-' : 'off'
}
const text = login.local ? `local ${login.account}` : `LDAP POSIX (uid ${login.uid ?? '-'})`
return otp ? text : `${text}, refused: no OTP`
}
/**
* The users of the router's directory, which /users answers without its service accounts (ServiceAccountsPanel): list with their names and
* e-mail addresses, filtered by kind and by a search in the user name, the names and the e-mail address, add, edit the details, set the password, enrol for one-time passwords and remove the enrolment,
* remove the passkeys, disable, enable, unlock after failed logins, remove, the OpenVPN profile, offered while the
* router's status shows OpenVPN, and the Apple VPN profile of IKEv2, offered while it shows IKEv2 access; console
* login is shown, not changed.
*/
export default function UsersPanel() {
const [users, setUsers] = useState<User[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [unavailable, setUnavailable] = useState(false)
const [busy, setBusy] = useState(false)
const [adding, setAdding] = useState(false)
const [password, setPassword] = useState<string | null>(null)
const [details, setDetails] = useState<User | null>(null)
const [enrolling, setEnrolling] = useState<string | null>(null)
const [confirm, setConfirm] = useState<ConfirmDialogProps | null>(null)
const [openvpn, setOpenvpn] = useState<boolean | null>(null)
const [p2s, setP2s] = useState<boolean | null>(null)
const [appleProfile, setAppleProfile] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [kind, setKind] = useState<Kind>('all')
const refresh = useCallback(async () => {
setBusy(true)
// The status has openvpn and p2s while the router runs OpenVPN and IKEv2, which the profiles need; null while it is
// not known.
request<RouterStatus>('GET', '/status/full', STATUS_SHAPE)
.then((status) => {
setOpenvpn(status.openvpn !== undefined)
setP2s(status.p2s !== undefined)
})
.catch(() => {
setOpenvpn(null)
setP2s(null)
})
try {
setUsers((await request<{ users: User[] }>('GET', '/users', USERS_SHAPE)).users)
setError(null)
setUnavailable(false)
} catch (caught) {
const failure = caught as ApiError
setUsers(null)
setUnavailable(failure.status === 404)
setError(failure.message)
} finally {
setBusy(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
/** Runs a change of a user, then reads the list again; a failure is shown above the list. */
async function change(work: () => Promise<unknown>) {
setBusy(true)
try {
await work()
setError(null)
} catch (caught) {
setError((caught as Error).message)
}
await refresh()
}
async function download(name: string) {
await change(async () => {
const { profile } = await request<{ profile: string }>('GET', `/users/${name}/profile`, PROFILE_SHAPE)
saveFile(`${name}.ovpn`, profile, 'application/x-openvpn-profile')
})
}
function removeOtp(name: string) {
setConfirm({
title: 'Remove the one-time passwords',
message: `Remove the TOTP secret of ${name}? The user's authenticator app no longer matches the router.`,
confirmLabel: 'Remove',
destructive: true,
onCancel: () => setConfirm(null),
onConfirm: () => {
setConfirm(null)
void change(() => request('DELETE', `/users/${name}/otp`, USER_SHAPE))
},
})
}
function removePasskeys(name: string) {
setConfirm({
title: 'Remove the passkeys',
message: `Remove all passkeys of ${name}? The user signs in with the password until adding a passkey again.`,
confirmLabel: 'Remove',
destructive: true,
onCancel: () => setConfirm(null),
onConfirm: () => {
setConfirm(null)
void change(() => request('DELETE', `/users/${name}/passkeys`, USER_SHAPE))
},
})
}
function remove(name: string) {
setConfirm({
title: 'Remove user',
message: `Remove ${name} and revoke the user's certificate? The user can no longer connect.`,
confirmLabel: 'Remove',
destructive: true,
onCancel: () => setConfirm(null),
onConfirm: () => {
setConfirm(null)
void change(() => request('DELETE', `/users/${name}`, REMOVED_SHAPE))
},
})
}
const shown = (users ?? []).filter((user) => (kind === 'all' || (kind === 'manager') === user.manager_only)
&& matches(search, [user.name, user.first_name, user.last_name, user.email]))
return (
<Box component="section">
<Toolbar description="Users of remote access.">
<ToggleButtonGroup size="small" exclusive value={kind} onChange={(_event, next: Kind | null) => next && setKind(next)}>
<ToggleButton value="all">All</ToggleButton>
<ToggleButton value="vpn">VPN users</ToggleButton>
<ToggleButton value="manager">Manager-only</ToggleButton>
</ToggleButtonGroup>
<SearchField value={search} onChange={setSearch} />
<Button variant="contained" startIcon={<Add />} disabled={busy || users === null} onClick={() => setAdding(true)}>
Add
</Button>
<Button variant="outlined" startIcon={<Refresh />} disabled={busy} onClick={() => void refresh()}>
Refresh
</Button>
</Toolbar>
{error && <Alert severity={unavailable ? 'info' : 'error'} sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
{users && (
<Section title="Users">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>User name</TableCell>
<TableCell>Name</TableCell>
<TableCell>E-mail</TableCell>
<TableCell>State</TableCell>
<TableCell>Certificate</TableCell>
<TableCell>OTP</TableCell>
<TableCell>Passkeys</TableCell>
<TableCell>Console</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{shown.length === 0 && (
<TableRow>
<TableCell colSpan={9} sx={{ color: 'text.secondary' }}>None.</TableCell>
</TableRow>
)}
{shown.map((user) => (
<TableRow key={user.name} hover>
<TableCell>{user.name}</TableCell>
<TableCell sx={user.manager_only ? { color: 'text.secondary', fontStyle: 'italic' } : undefined}>
{user.manager_only ? 'Manager-only account' : [user.first_name, user.last_name].filter(Boolean).join(' ') || '-'}
</TableCell>
<TableCell>{user.email ?? '-'}</TableCell>
<TableCell sx={{ color: user.enabled && !user.locked ? 'success.main' : 'warning.main' }}>
{!user.enabled ? 'disabled' : user.locked ? 'locked' : 'enabled'}
</TableCell>
<TableCell>{user.certificate ? 'issued' : 'missing'}</TableCell>
<TableCell>{user.otp ? 'enrolled' : '-'}</TableCell>
<TableCell>{user.passkeys.length || '-'}</TableCell>
<TableCell>
<Tooltip title={CONSOLE_HELP}>
<span>{consoleText(user.console, user.otp)}</span>
</Tooltip>
</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
<Tooltip title={user.manager_only ? 'Edit the e-mail' : 'Edit the name and e-mail'}>
<span>
<IconButton size="small" disabled={busy} onClick={() => setDetails(user)}><Edit fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title="Set the password">
<span>
<IconButton size="small" disabled={busy} onClick={() => setPassword(user.name)}><Key fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title={user.otp ? 'Enrol again for one-time passwords' : 'Enrol for one-time passwords'}>
<span>
<IconButton size="small" disabled={busy} onClick={() => setEnrolling(user.name)}><PhonelinkLock fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title="Remove the one-time passwords">
<span>
<IconButton size="small" disabled={busy || !user.otp} onClick={() => removeOtp(user.name)}><PhonelinkErase fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title="Remove the passkeys">
<span>
<IconButton size="small" disabled={busy || user.passkeys.length === 0} onClick={() => removePasskeys(user.name)}><KeyOff fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title={user.enabled ? 'Disable' : 'Enable'}>
<span>
<IconButton
size="small"
disabled={busy}
onClick={() => void change(() => request('POST', `/users/${user.name}/${user.enabled ? 'disable' : 'enable'}`, USER_SHAPE))}
>
{user.enabled ? <Block fontSize="small" /> : <CheckCircle fontSize="small" />}
</IconButton>
</span>
</Tooltip>
<Tooltip title="Unlock after failed logins">
<span>
<IconButton
size="small"
disabled={busy || !user.locked}
onClick={() => void change(() => request('POST', `/users/${user.name}/unlock`, USER_SHAPE))}
>
<LockOpen fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={openvpn === false ? 'OpenVPN is not enabled on the router' : 'Download the OpenVPN profile'}>
<span>
<IconButton size="small" disabled={busy || !user.certificate || openvpn === false} onClick={() => void download(user.name)}><Download fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title={p2s === false ? 'IKEv2 is not enabled on the router' : 'Download the Apple VPN profile (IKEv2)'}>
<span>
<IconButton size="small" disabled={busy || !user.certificate || p2s === false} onClick={() => setAppleProfile(user.name)}><Apple fontSize="small" /></IconButton>
</span>
</Tooltip>
<Tooltip title="Remove">
<span>
<IconButton size="small" color="error" disabled={busy} onClick={() => remove(user.name)}><Delete fontSize="small" /></IconButton>
</span>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
)}
{adding && (
<UserDialog
title="Add user"
saveLabel="Add"
onCancel={() => setAdding(false)}
onSave={async (name, secret, person) => {
await request('POST', '/users', USER_SHAPE, { name, password: secret, ...person })
setAdding(false)
await refresh()
}}
/>
)}
{details && (
<PersonDialog
user={details}
onCancel={() => setDetails(null)}
onSave={async (person) => {
// Only the fields that changed are sent, and nothing when none did.
const changed = Object.fromEntries((Object.keys(person) as (keyof Person)[])
.filter((key) => person[key] !== (details[key] ?? ''))
.map((key) => [key, person[key]]))
if (Object.keys(changed).length > 0) {
await request('PUT', `/users/${details.name}`, USER_SHAPE, changed)
}
setDetails(null)
if (Object.keys(changed).length > 0) {
await refresh()
}
}}
/>
)}
{password && (
<UserDialog
title={`Set the password of ${password}`}
name={password}
saveLabel="Set"
onCancel={() => setPassword(null)}
onSave={async (name, secret) => {
await request('PUT', `/users/${name}/password`, USER_SHAPE, { password: secret })
setPassword(null)
}}
/>
)}
{enrolling && (
<OtpDialog
name={enrolling}
path={`/users/${enrolling}/otp`}
onCancel={() => setEnrolling(null)}
onSave={async (secret, code) => {
await request('PUT', `/users/${enrolling}/otp`, USER_SHAPE, { secret, code })
setEnrolling(null)
await refresh()
}}
/>
)}
{appleProfile && (
<ProfileDialog kind="apple" name={appleProfile} path={`/users/${appleProfile}/apple-profile`} own={false} otp={false} onClose={() => setAppleProfile(null)} />
)}
{confirm && <ConfirmDialog {...confirm} />}
</Box>
)
}
+53
View File
@@ -0,0 +1,53 @@
import Check from '@mui/icons-material/Check'
import ContentCopy from '@mui/icons-material/ContentCopy'
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, Switch, Typography } from '@mui/material'
import { useState } from 'react'
import type { WireGuardServer } from './api.ts'
import { saveBrowserSettings, useBrowserSettings } from './settings.ts'
import { clientConfig } from './wireguard.ts'
/** The lines of a configuration a WireGuard client fills in for a new empty tunnel, as the Windows client does. */
const PREFILLED_LINES = 2
/**
* A dialog that shows a device's WireGuard configuration, ready to copy: the router as its peer (server) and the
* device's address, its peer's IPs, or a placeholder without them (ips null). The device's private key is a
* placeholder, which the router never has; a switch leaves out the [Interface] and PrivateKey lines, which a client such
* as WireGuard for Windows fills in for a new empty tunnel, and keeps its position in the browser's settings. Copy
* puts the configuration as shown on the clipboard.
*/
export default function WireGuardConfigDialog(props: { title: string; server: WireGuardServer; ips: string[] | null; onClose: () => void }) {
const [copied, setCopied] = useState(false)
const settings = useBrowserSettings()
const prefilled = settings.wireguardPrefilled
const full = clientConfig(props.server, props.ips === null ? '<the IPs of this device>' : props.ips.join(', '))
const text = prefilled ? full.split('\n').slice(PREFILLED_LINES).join('\n') : full
return (
<Dialog open onClose={props.onClose} maxWidth="sm" fullWidth>
<DialogTitle>{props.title}</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{[
prefilled ? 'Paste it below the private key the client made.' : "Replace the private key with the device's own.",
props.ips === null ? 'Replace the address with the IPs of its peer on the Peers table.' : '',
].filter(Boolean).join(' ')}
</Typography>
<FormControlLabel
sx={{ mb: 1 }}
control={<Switch checked={prefilled} onChange={(event) => { saveBrowserSettings({ ...settings, wireguardPrefilled: event.target.checked }); setCopied(false) }} />}
label="Without [Interface] and PrivateKey, which the client fills in (WireGuard for Windows)"
/>
<Box component="pre" sx={{ m: 0, p: 2, borderRadius: 1, bgcolor: 'action.hover', overflowX: 'auto', fontFamily: 'monospace', fontSize: 13 }}>
{text}
</Box>
</DialogContent>
<DialogActions>
<Button startIcon={copied ? <Check /> : <ContentCopy />} onClick={() => void navigator.clipboard.writeText(text).then(() => setCopied(true))}>
{copied ? 'Copied' : 'Copy'}
</Button>
<Button variant="contained" onClick={props.onClose}>Close</Button>
</DialogActions>
</Dialog>
)
}
+167
View File
@@ -0,0 +1,167 @@
import Add from '@mui/icons-material/Add'
import Delete from '@mui/icons-material/Delete'
import Download from '@mui/icons-material/Download'
import Visibility from '@mui/icons-material/Visibility'
import { Alert, Button, IconButton, Stack, Table, TableBody, TableCell, TableHead, TableRow, Tooltip, Typography } from '@mui/material'
import { useState } from 'react'
import ConfirmDialog, { type ConfirmDialogProps } from './ConfirmDialog.tsx'
import PeerDialog from './PeerDialog.tsx'
import WireGuardConfigDialog from './WireGuardConfigDialog.tsx'
import { Section } from './RouterPanel.tsx'
import { USER_SHAPE, request, type User, type Users, type WireGuardPeer, type WireGuardServer } from './api.ts'
import { saveFile } from './saveFile.ts'
import { clientConfig, nextFreeAddress } from './wireguard.ts'
/**
* The WireGuard peers of the router's VPN users, each a device with its public key and IPs, of the users the page read
* with GET /users (users, null while unread): a table of the peers with their user, removed by their button after a
* confirmation, and Add peer, whose dialog adds a peer to a user, with IPs that start as the next free address of the
* network of the router's WireGuard address. A change replaces the user's peers with PUT /users/<name>; the router
* writes them into wg0.conf in the background, and onChanged has the page read again. Each peer's configuration for its
* device, with its IPs, the router's public key, endpoint and allowed IPs (server), and a placeholder where the device's
* private key goes, which the router never has, shows in a dialog ready to copy (the eye) and downloads.
*/
export default function WireGuardPeers(props: { server: WireGuardServer; users: Users | null; onChanged: () => void }) {
const answer = props.users
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [adding, setAdding] = useState(false)
const [shown, setShown] = useState<{ owner: User; peer: WireGuardPeer } | null>(null)
const [confirm, setConfirm] = useState<ConfirmDialogProps | null>(null)
/** Replaces the peers of a user, then has the page read again, the peers with it; a failure is shown above the table. */
async function save(owner: User, peers: WireGuardPeer[]) {
setBusy(true)
setError(null)
try {
await request('PUT', `/users/${owner.name}`, USER_SHAPE, { wireguard_peers: peers })
props.onChanged()
return true
} catch (caught) {
setError((caught as Error).message)
return false
} finally {
setBusy(false)
}
}
const vpnUsers = (answer?.users ?? []).filter((candidate) => !candidate.manager_only && !candidate.service)
const rows = vpnUsers.flatMap((owner) => owner.wireguard_peers.map((peer) => ({ owner, peer })))
/** Downloads the configuration of a peer's device as <user>-<n>.conf. */
function download(peerOwner: User, peer: WireGuardPeer) {
saveFile(`${peerOwner.name}-${peerOwner.wireguard_peers.indexOf(peer) + 1}.conf`, clientConfig(props.server, peer.ips.join(', ')), 'text/plain')
}
/** Adds the peer to the user's peers; a failure stays in the dialog. */
async function add(name: string, peer: WireGuardPeer) {
const owner = vpnUsers.find((candidate) => candidate.name === name)
await request('PUT', `/users/${name}`, USER_SHAPE, { wireguard_peers: [...(owner?.wireguard_peers ?? []), peer] })
setAdding(false)
props.onChanged()
}
return (
<Section title="Peers" wide>
{error && <Alert severity="error" sx={{ mb: 2, whiteSpace: 'pre-line' }}>{error}</Alert>}
{!answer && !error && <Typography variant="body2" color="text.secondary">Reading the peers...</Typography>}
{answer && (
<>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>User</TableCell>
<TableCell>Public key</TableCell>
<TableCell>IPs</TableCell>
<TableCell align="right" />
</TableRow>
</TableHead>
<TableBody>
{rows.length === 0 && (
<TableRow>
<TableCell colSpan={4} sx={{ color: 'text.secondary' }}>None.</TableCell>
</TableRow>
)}
{rows.map(({ owner: peerOwner, peer }) => (
<TableRow key={peer.public_key}>
<TableCell sx={{ color: peerOwner.enabled ? undefined : 'text.disabled' }}>
{peerOwner.enabled ? peerOwner.name : `${peerOwner.name} (disabled)`}
</TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all' }}>{peer.public_key}</TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontSize: 12 }}>{peer.ips.join(', ')}</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
<Tooltip title={props.server.public_key && props.server.endpoint
? 'Show the configuration of the device'
: "The router's public key or endpoint is not known yet"}>
<span>
<IconButton size="small" disabled={!props.server.public_key || !props.server.endpoint} onClick={() => setShown({ owner: peerOwner, peer })}>
<Visibility fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={props.server.public_key && props.server.endpoint
? 'Download the configuration of the device'
: "The router's public key or endpoint is not known yet"}>
<span>
<IconButton size="small" disabled={!props.server.public_key || !props.server.endpoint} onClick={() => download(peerOwner, peer)}>
<Download fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Remove the peer">
<span>
<IconButton
size="small"
disabled={busy}
onClick={() => setConfirm({
title: 'Remove the peer',
message: `Remove the peer ${peer.public_key} (${peer.ips.join(', ')}) of ${peerOwner.name}? The device can no longer connect.`,
confirmLabel: 'Remove',
destructive: true,
onConfirm: () => {
setConfirm(null)
void save(peerOwner, peerOwner.wireguard_peers.filter((other) => other.public_key !== peer.public_key))
},
onCancel: () => setConfirm(null),
})}
>
<Delete fontSize="small" />
</IconButton>
</span>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Stack direction="row" sx={{ mt: 1, alignItems: 'center', justifyContent: 'flex-end', gap: 2 }}>
{vpnUsers.length === 0 && (
<Typography variant="body2" color="text.secondary">There is no VPN user to add a peer to; add one on the Identities page.</Typography>
)}
<Button size="small" variant="outlined" startIcon={<Add />} disabled={busy || vpnUsers.length === 0} onClick={() => setAdding(true)}>
Add peer
</Button>
</Stack>
</>
)}
{adding && answer && (
<PeerDialog
users={vpnUsers.map((candidate) => candidate.name)}
keys={rows.map((row) => row.peer.public_key)}
proposed={nextFreeAddress(answer.wireguard.address, rows.flatMap((row) => row.peer.ips))}
onSave={add}
onCancel={() => setAdding(false)}
/>
)}
{shown && (
<WireGuardConfigDialog
title={`WireGuard configuration of ${shown.owner.name}'s device ${shown.peer.ips.join(', ')}`}
server={props.server}
ips={shown.peer.ips}
onClose={() => setShown(null)}
/>
)}
{confirm && <ConfirmDialog {...confirm} />}
</Section>
)
}
+1158
View File
File diff suppressed because it is too large. Load diff
+44
View File
@@ -0,0 +1,44 @@
import '@fontsource/roboto/300.css'
import '@fontsource/roboto/400.css'
import '@fontsource/roboto/500.css'
import '@fontsource/roboto/700.css'
import { CssBaseline, ThemeProvider, createTheme } from '@mui/material'
import { StrictMode, type ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import Card from './Card.tsx'
import { INFO_SHAPE, request, type Info } from './api.ts'
// Light, dark and system modes; the choice is stored in localStorage by MUI.
const theme = createTheme({ colorSchemes: { dark: true }, cssVariables: { colorSchemeSelector: 'class' } })
const root = createRoot(document.getElementById('root')!)
function render(node: ReactNode) {
root.render(
<StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
{node}
</ThemeProvider>
</StrictMode>,
)
}
// The first call is tried up to 20 times, 500 ms apart.
async function bootstrap() {
let lastError = ''
for (let attempt = 0; attempt < 20; attempt++) {
try {
render(<App info={await request<Info>('GET', '/info', INFO_SHAPE)} />)
return
} catch (error) {
lastError = (error as Error).message
render(<Card title="Connecting" message="Waiting for the manager to answer." />)
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
render(<Card title="Not available" message={lastError} />)
}
void bootstrap()
+14
View File
@@ -0,0 +1,14 @@
/**
* Monaco with its editor features and the syntax colours of its languages, without the language services, so the
* editor worker is the only worker; the Shell page's editor imports this module on demand.
*/
import * as monaco from 'monaco-editor/editor'
import 'monaco-editor/features/register.all'
import 'monaco-editor/languages/definitions/register.all'
import EditorWorker from 'monaco-editor/editor/editor.worker?worker'
self.MonacoEnvironment = {
getWorker: () => new EditorWorker(),
}
export default monaco
+28
View File
@@ -0,0 +1,28 @@
import type { Person } from './api.ts'
/** The e-mail addresses vpn-router-user accepts: one @, no spaces, and a dot in the domain. */
const EMAIL_PATTERN = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
/** The longest first or last name vpn-router-user accepts. */
const PERSON_NAME_MAX = 64
/** The problem of each person field, or '' while it is empty or good. */
export function personProblems(person: Person): Record<keyof Person, string> {
const name = (value: string) => (value.trim().length > PERSON_NAME_MAX ? `At most ${PERSON_NAME_MAX} characters.` : '')
return {
first_name: name(person.first_name),
last_name: name(person.last_name),
email: person.email.trim() !== '' && !EMAIL_PATTERN.test(person.email.trim()) ? 'Not an e-mail address.' : '',
}
}
/** Whether every person field is filled in and has no problem. */
export function personComplete(person: Person) {
const problems = personProblems(person)
return (Object.keys(person) as (keyof Person)[]).every((key) => person[key].trim() !== '' && problems[key] === '')
}
/** The person fields with the surrounding spaces removed, as they are sent. */
export function trimmed(person: Person): Person {
return { first_name: person.first_name.trim(), last_name: person.last_name.trim(), email: person.email.trim() }
}
+8
View File
@@ -0,0 +1,8 @@
/** Saves a text or bytes as a file of the browser, named name, of the content type type. */
export function saveFile(name: string, content: BlobPart, type: string) {
const link = document.createElement('a')
link.href = URL.createObjectURL(new Blob([content], { type }))
link.download = name
link.click()
URL.revokeObjectURL(link.href)
}
+71
View File
@@ -0,0 +1,71 @@
import { useSyncExternalStore } from 'react'
/**
* The settings of this browser: the seconds between the automatic reads of the Overview and the Metrics tab, 0 for
* manual, and whether the WireGuard configuration dialog leaves out the lines a client fills in (wireguardPrefilled).
*/
export type BrowserSettings = { routerRefresh: number; metricsRefresh: number; wireguardPrefilled: boolean }
/** The localStorage key of the settings. */
const KEY = 'vpn-router-manager.settings'
/** The event raised on window when the settings change in this tab. */
const CHANGED = 'vpn-router-manager-settings'
export const DEFAULT_SETTINGS: BrowserSettings = { routerRefresh: 10, metricsRefresh: 60, wireguardPrefilled: false }
/** The refresh choices of both tabs, in seconds; 0 is manual. */
export const REFRESH_CHOICES = [0, 5, 10, 30, 60, 300, 900]
/** A refresh choice in words. */
export function refreshLabel(seconds: number) {
return seconds === 0 ? 'Manual' : seconds < 60 ? `Every ${seconds} s` : `Every ${seconds / 60} min`
}
let cached: { text: string | null; settings: BrowserSettings } | null = null
/** The settings in localStorage, each that is missing or not one of its choices replaced by its default. */
function snapshot(): BrowserSettings {
const text = window.localStorage.getItem(KEY)
if (cached?.text === text) {
return cached.settings
}
let stored: Record<string, unknown> = {}
try {
const value: unknown = JSON.parse(text ?? 'null')
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
stored = value as Record<string, unknown>
}
} catch {
// Settings that cannot be read are replaced by the defaults.
}
const choice = (value: unknown, choices: number[], fallback: number) =>
(typeof value === 'number' && choices.includes(value) ? value : fallback)
const settings = {
routerRefresh: choice(stored.routerRefresh, REFRESH_CHOICES, DEFAULT_SETTINGS.routerRefresh),
metricsRefresh: choice(stored.metricsRefresh, REFRESH_CHOICES, DEFAULT_SETTINGS.metricsRefresh),
wireguardPrefilled: typeof stored.wireguardPrefilled === 'boolean' ? stored.wireguardPrefilled : DEFAULT_SETTINGS.wireguardPrefilled,
}
cached = { text, settings }
return settings
}
function subscribe(onChange: () => void) {
window.addEventListener(CHANGED, onChange)
window.addEventListener('storage', onChange)
return () => {
window.removeEventListener(CHANGED, onChange)
window.removeEventListener('storage', onChange)
}
}
/** The settings of this browser, followed when they change here or in another tab. */
export function useBrowserSettings(): BrowserSettings {
return useSyncExternalStore(subscribe, snapshot)
}
/** Keeps the settings in localStorage and tells every user of useBrowserSettings. */
export function saveBrowserSettings(settings: BrowserSettings) {
window.localStorage.setItem(KEY, JSON.stringify(settings))
window.dispatchEvent(new Event(CHANGED))
}
+30
View File
@@ -0,0 +1,30 @@
import { useCallback, useEffect, useState } from 'react'
/**
* What load answers, read at once and again by refresh: data, null until the first answer; error, the message of the
* last failure, null after a success; and busy while a read runs. load is read again whenever it changes, so it is
* kept stable, with useCallback where it depends on props.
*/
export function useRead<T>(load: () => Promise<T>) {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const refresh = useCallback(async () => {
setBusy(true)
try {
setData(await load())
setError(null)
} catch (caught) {
setError((caught as Error).message)
} finally {
setBusy(false)
}
}, [load])
useEffect(() => {
void refresh()
}, [refresh])
return { data, error, busy, refresh }
}
+104
View File
@@ -0,0 +1,104 @@
import type { WireGuardServer } from './api.ts'
/**
* The WireGuard configuration of a device: its address, a placeholder for its private key, which the router never has,
* the router's resolver as its DNS server while it runs, and the router as its peer, with the router's public key,
* endpoint and allowed IPs.
*/
export function clientConfig(server: WireGuardServer, address: string): string {
return [
'[Interface]',
'PrivateKey = <the private key of this device>',
`Address = ${address}`,
...(server.dns.length ? [`DNS = ${server.dns.join(', ')}`] : []),
'',
'[Peer]',
`PublicKey = ${server.public_key}`,
`Endpoint = ${server.endpoint}`,
`AllowedIPs = ${server.allowed_ips.join(', ')}`,
'PersistentKeepalive = 25',
'',
].join('\n')
}
/** An IPv4 address as a number, or null when the text is not one. */
function addressOf(text: string): number | null {
const parts = text.split('.')
if (parts.length !== 4 || !parts.every((part) => /^(0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255)) {
return null
}
return parts.reduce((value, part) => value * 256 + Number(part), 0)
}
/** A number as an IPv4 address. */
function textOf(value: number): string {
return [24, 16, 8, 0].map((shift) => Math.floor(value / 2 ** shift) % 256).join('.')
}
/** An IPv4 address or network, a.b.c.d or a.b.c.d/n, as its first address and size, or null when it is not one. */
function networkOf(text: string, strict: boolean): { first: number; size: number } | null {
const [address, prefix, ...rest] = text.trim().split('/')
const value = addressOf(address)
const length = prefix === undefined ? 32 : /^\d{1,2}$/.test(prefix) ? Number(prefix) : NaN
if (value === null || rest.length > 0 || !(length >= 0 && length <= 32)) {
return null
}
const size = 2 ** (32 - length)
const first = value - (value % size)
return strict && first !== value ? null : { first, size }
}
/**
* Why the router's own WireGuard address with its prefix, wireguard.address, is refused, or '' when it is an address of
* its network other than the network's and the broadcast address, as the router requires.
*/
export function routerAddressProblem(text: string): string {
const network = networkOf(text, false)
const own = addressOf(text.trim().split('/')[0])
if (network === null || own === null || !text.includes('/')) {
return 'An IPv4 address with a prefix length, such as 192.168.200.1/24.'
}
if (network.size > 2 && (own === network.first || own === network.first + network.size - 1)) {
return `The network or broadcast address of its network; give the router's own address in it, such as ${textOf(network.first + 1)}/${32 - Math.log2(network.size)}.`
}
return ''
}
/** Why a WireGuard public key is refused, or '' when it is 32 bytes in base64. */
export function keyProblem(key: string): string {
return /^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$/.test(key.trim()) ? '' : 'A WireGuard public key: 44 characters of base64 ending with =.'
}
/** Why a list of a peer's IPs, separated by commas, is refused, or '' when each is an IPv4 address or network. */
export function ipsProblem(text: string): string {
const ips = ipsOf(text)
if (ips.length === 0) {
return 'At least one IP.'
}
const wrong = ips.find((ip) => networkOf(ip, true) === null)
return wrong === undefined ? '' : `${wrong} is not an IPv4 address or network, such as 10.10.0.2 or 10.20.0.0/24.`
}
/** The IPs of a list separated by commas, a single address written as /32. */
export function ipsOf(text: string): string[] {
return text.split(',').map((ip) => ip.trim()).filter((ip) => ip !== '').map((ip) => (ip.includes('/') ? ip : `${ip}/32`))
}
/**
* The first address of the network of the router's address (wireguard.address, such as 192.168.200.1/24) that is
* neither the router's own nor covered by one of the taken IPs, as a.b.c.d/32; '' when there is none.
*/
export function nextFreeAddress(routerAddress: string, taken: string[]): string {
const router = networkOf(routerAddress, false)
const own = addressOf(routerAddress.split('/')[0])
if (router === null || own === null) {
return ''
}
const used = taken.map((ip) => networkOf(ip, false)).filter((network) => network !== null)
for (let value = router.first + 1; value < router.first + router.size - 1; value += 1) {
if (value !== own && !used.some((network) => value >= network.first && value < network.first + network.size)) {
return `${textOf(value)}/32`
}
}
return ''
}