From 9a1051b9af80f4d281432d0b4f562cb15b9ac554 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Mon, 24 Mar 2025 10:52:29 -0400 Subject: [PATCH] feat: reimplement websocket to use hono --- app/layouts/dashboard.tsx | 2 +- app/routes/machines/action.tsx | 17 +- app/routes/machines/components/machine.tsx | 27 +- app/routes/machines/overview.tsx | 15 +- app/server/README.md | 1 + app/server/headscale/config-loader.ts | 11 + app/server/index.ts | 18 +- app/server/web/agent.ts | 273 +++++++++++++++++++++ app/utils/integration/loader.ts | 4 +- app/utils/mutex.ts | 32 --- app/utils/oidc.ts | 130 +--------- app/utils/{useAgent.ts => use-agent.tsx} | 0 app/utils/ws-agent.ts | 158 ------------ 13 files changed, 337 insertions(+), 351 deletions(-) create mode 100644 app/server/web/agent.ts delete mode 100644 app/utils/mutex.ts rename app/utils/{useAgent.ts => use-agent.tsx} (100%) delete mode 100644 app/utils/ws-agent.ts diff --git a/app/layouts/dashboard.tsx b/app/layouts/dashboard.tsx index bd405f1..67d656e 100644 --- a/app/layouts/dashboard.tsx +++ b/app/layouts/dashboard.tsx @@ -16,7 +16,7 @@ export async function loader({ // We shouldn't session invalidate if Headscale is down if (healthy) { try { - await context.client.get('/api/v1/apikey', session.get('api_key')!); + await context.client.get('v1/apikey', session.get('api_key')!); } catch (error) { if (error instanceof ResponseError) { log.debug('api', 'API Key validation failed %o', error); diff --git a/app/routes/machines/action.tsx b/app/routes/machines/action.tsx index 664a740..1c38def 100644 --- a/app/routes/machines/action.tsx +++ b/app/routes/machines/action.tsx @@ -1,7 +1,7 @@ import type { ActionFunctionArgs } from 'react-router'; import type { LoadContext } from '~/server'; +import log from '~/utils/log'; import { send } from '~/utils/res'; -import log from '~server/utils/log'; // TODO: Turn this into the same thing as dns-actions like machine-actions!!! export async function menuAction({ @@ -24,16 +24,13 @@ export async function menuAction({ switch (method) { case 'delete': { - await context.client.delete( - `/api/v1/node/${id}`, - session.get('api_key')!, - ); + await context.client.delete(`v1/node/${id}`, session.get('api_key')!); return { message: 'Machine removed' }; } case 'expire': { await context.client.post( - `/api/v1/node/${id}/expire`, + `v1/node/${id}/expire`, session.get('api_key')!, ); return { message: 'Machine expired' }; @@ -51,7 +48,7 @@ export async function menuAction({ const name = String(data.get('name')); await context.client.post( - `/api/v1/node/${id}/rename/${name}`, + `v1/node/${id}/rename/${name}`, session.get('api_key')!, ); return { message: 'Machine renamed' }; @@ -72,7 +69,7 @@ export async function menuAction({ const postfix = enabled ? 'enable' : 'disable'; await context.client.post( - `/api/v1/routes/${route}/${postfix}`, + `v1/routes/${route}/${postfix}`, session.get('api_key')!, ); return { message: 'Route updated' }; @@ -95,7 +92,7 @@ export async function menuAction({ await Promise.all( routes.map(async (route) => { await context.client.post( - `/api/v1/routes/${route}/${postfix}`, + `v1/routes/${route}/${postfix}`, session.get('api_key')!, ); }), @@ -156,7 +153,7 @@ export async function menuAction({ return { message: 'Tags updated' }; } catch (error) { - log.debug('APIC', 'Failed to update tags: %s', error); + log.debug('api', 'Failed to update tags: %s', error); return send( { message: 'Failed to update tags' }, { diff --git a/app/routes/machines/components/machine.tsx b/app/routes/machines/components/machine.tsx index e78932a..f2dc1d1 100644 --- a/app/routes/machines/components/machine.tsx +++ b/app/routes/machines/components/machine.tsx @@ -152,18 +152,21 @@ export default function MachineRow({ - - {stats !== undefined ? ( - <> -

{hinfo.getTSVersion(stats)}

-

- {hinfo.getOSInfo(stats)} -

- - ) : ( -

Unknown

- )} - + {/* We pass undefined when agents are not enabled */} + {isAgent !== undefined ? ( + + {stats !== undefined ? ( + <> +

{hinfo.getTSVersion(stats)}

+

+ {hinfo.getOSInfo(stats)} +

+ + ) : ( +

Unknown

+ )} + + ) : undefined} - Version + {/* We only want to show the version column if there are agents */} + {data.agents !== undefined ? ( + Version + ) : undefined} Last Seen @@ -120,7 +121,9 @@ export default function Page() { users={data.users} magic={data.magic} stats={stats?.[machine.nodeKey]} - isAgent={data.agents.includes(machine.id)} + // If we pass undefined, the column will not be rendered + // This is useful for when there are no agents configured + isAgent={data.agents?.includes(machine.id)} /> ))} diff --git a/app/server/README.md b/app/server/README.md index 45dd0b8..3c30f4f 100644 --- a/app/server/README.md +++ b/app/server/README.md @@ -16,5 +16,6 @@ server │ ├── config-loader.ts: Loads the Headscale configuration (if available). │ ├── config-schema.ts: Defines the schema for the Headscale configuration. ├── web/ +│ ├── agent.ts: Handles setting up the agent WebSocket if needed. │ ├── oidc.ts: Loads and validates an OIDC configuration (if available). │ ├── sessions.ts: Initializes the session store and methods to manage it. diff --git a/app/server/headscale/config-loader.ts b/app/server/headscale/config-loader.ts index cb664b0..b4a9010 100644 --- a/app/server/headscale/config-loader.ts +++ b/app/server/headscale/config-loader.ts @@ -1,4 +1,5 @@ import { constants, access, readFile, writeFile } from 'node:fs/promises'; +import { setTimeout } from 'node:timers/promises'; import { type } from 'arktype'; import { Document, parseDocument } from 'yaml'; import log from '~/utils/log'; @@ -17,6 +18,7 @@ class HeadscaleConfig { private document?: Document; private access: 'rw' | 'ro' | 'no'; private path?: string; + private writeLock = false; constructor( access: 'rw' | 'ro' | 'no', @@ -100,8 +102,17 @@ class HeadscaleConfig { 'Writing updated Headscale configuration to %s', this.path, ); + + // We need to lock the writeLock so that we don't try to write + // to the file while we're already writing to it + while (this.writeLock) { + await setTimeout(100); + } + + this.writeLock = true; await writeFile(this.path, this.document.toString(), 'utf8'); this.config = config; + this.writeLock = false; return; } } diff --git a/app/server/index.ts b/app/server/index.ts index f76adb0..cc8a2c6 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -1,10 +1,13 @@ import { versions } from 'node:process'; +import type { UpgradeWebSocket } from 'hono/ws'; import { createHonoServer } from 'react-router-hono-server/node'; +import type { WebSocket } from 'ws'; import log from '~/utils/log'; import { configureConfig, configureLogger, envVariables } from './config/env'; import { loadConfig } from './config/loader'; import { createApiClient } from './headscale/api-client'; import { loadHeadscaleConfig } from './headscale/config-loader'; +import { loadAgentSocket } from './web/agent'; import { createOidcClient } from './web/oidc'; import { createSessionStorage } from './web/sessions'; @@ -49,6 +52,12 @@ const appLoadContext = { config.headscale.tls_cert_path, ), + agents: await loadAgentSocket( + config.server.agent.authkey, + config.server.agent.cache_path, + config.server.agent.ttl, + ), + oidc: config.oidc ? await createOidcClient(config.oidc) : undefined, }; @@ -65,7 +74,14 @@ export default await createHonoServer({ return appLoadContext; }, - configure(server) {}, + configure(server, { upgradeWebSocket }) { + if (appLoadContext.agents !== undefined) { + // We need this since we cannot pass the WSEvents context + (upgradeWebSocket as UpgradeWebSocket)( + appLoadContext.agents.configureSocket, + ); + } + }, listeningListener(info) { console.log(`Server is listening on http://localhost:${info.port}`); }, diff --git a/app/server/web/agent.ts b/app/server/web/agent.ts new file mode 100644 index 0000000..dfeae51 --- /dev/null +++ b/app/server/web/agent.ts @@ -0,0 +1,273 @@ +import { createHash } from 'node:crypto'; +import { open, readFile, writeFile } from 'node:fs/promises'; +import { setTimeout } from 'node:timers/promises'; +import { getConnInfo } from '@hono/node-server/conninfo'; +import { type } from 'arktype'; +import type { Context } from 'hono'; +import type { WSContext, WSEvents } from 'hono/ws'; +import { WebSocket } from 'ws'; +import { HostInfo } from '~/types'; +import log from '~/utils/log'; + +export async function loadAgentSocket( + authkey: string, + path: string, + ttl: number, +) { + if (authkey.length === 0) { + return; + } + + try { + const handle = await open(path, 'w'); + log.info('agent', 'Using agent cache file at %s', path); + await handle.close(); + } catch (error) { + log.info('agent', 'Agent cache file not accessible at %s', path); + log.debug('agent', 'Error details: %s', error); + return; + } + + const cache = new TimedCache(ttl, path); + return new AgentManager(cache, authkey); +} + +class AgentManager { + private cache: TimedCache; + private agents: Map; + private timers: Map; + private authkey: string; + + constructor(cache: TimedCache, authkey: string) { + this.cache = cache; + this.authkey = authkey; + this.agents = new Map(); + this.timers = new Map(); + } + + tailnetIDs() { + return Array.from(this.agents.keys()); + } + + // Request data from all connected agents + // This does not return anything, but caches the data which then needs to be + // queried by the caller separately. + requestData(nodeList: string[]) { + const NodeIDs = [...new Set(nodeList)]; + NodeIDs.map((node) => { + log.debug('agent', 'Requesting agent data for %s', node); + }); + + for (const agent of this.agents.values()) { + agent.send(JSON.stringify({ NodeIDs })); + } + } + + // Since we are using Node, Hono is built on 'ws' WebSocket types. + configureSocket(c: Context): WSEvents { + return { + onOpen: (_, ws) => { + const id = c.req.header('x-headplane-tailnet-id'); + if (!id) { + log.warn( + 'agent', + 'Rejecting an agent WebSocket connection without a tailnet ID', + ); + ws.close(1008, 'ERR_INVALID_TAILNET_ID'); + return; + } + + const auth = c.req.header('authorization'); + if (auth !== `Bearer ${this.authkey}`) { + log.warn('agent', 'Rejecting an unauthorized WebSocket connection'); + + const info = getConnInfo(c); + if (info.remote.address) { + log.warn('agent', 'Agent source IP: %s', info.remote.address); + } + + ws.close(1008, 'ERR_UNAUTHORIZED'); + return; + } + + const pinger = setInterval(() => { + if (ws.readyState !== 1) { + clearInterval(pinger); + return; + } + + ws.raw?.ping(); + }, 30000); + + this.agents.set(id, ws); + this.timers.set(id, pinger); + }, + + onClose: () => { + const id = c.req.header('x-headplane-tailnet-id'); + if (!id) { + return; + } + + clearInterval(this.timers.get(id)); + this.agents.delete(id); + }, + + onError: (event, ws) => { + const id = c.req.header('x-headplane-tailnet-id'); + if (!id) { + return; + } + + clearInterval(this.timers.get(id)); + if (event instanceof ErrorEvent) { + log.error('agent', 'WebSocket error: %s', event.message); + } + + log.debug('agent', 'Closing agent WebSocket connection'); + ws.close(1011, 'ERR_INTERNAL_ERROR'); + }, + + // This is where we receive the data from the agent + // Requests are made in the AgentManager.requestData function + onMessage: (event, ws) => { + const id = c.req.header('x-headplane-tailnet-id'); + if (!id) { + return; + } + + const data = JSON.parse(event.data.toString()); + log.debug('agent', 'Received agent data from %s', id); + for (const [node, info] of Object.entries(data)) { + this.cache.set(node, info); + log.debug('agent', 'Cached HostInfo for %s', node); + } + }, + }; + } +} + +const diskSchema = type({ + key: 'string', + value: 'unknown', + expires: 'number?', +}).array(); + +// A persistent HashMap with a TTL for each key +class TimedCache { + private _cache = new Map(); + private _timings = new Map(); + + // Default TTL is 1 minute + private defaultTTL: number; + private filePath: string; + private writeLock = false; + + // Last flush ID is essentially a hash of the flush contents + // Prevents unnecessary flushing if nothing has changed + private lastFlushId = ''; + + constructor(defaultTTL: number, filePath: string) { + this.defaultTTL = defaultTTL; + this.filePath = filePath; + + // Load the cache from disk and then queue flushes every 10 seconds + this.load().then(() => { + setInterval(() => this.flush(), 10000); + }); + } + + set(key: string, value: V, ttl: number = this.defaultTTL) { + this._cache.set(key, value); + this._timings.set(key, Date.now() + ttl); + } + + get(key: string) { + const value = this._cache.get(key); + if (!value) { + return; + } + + const expires = this._timings.get(key); + if (!expires || expires < Date.now()) { + this._cache.delete(key); + this._timings.delete(key); + return; + } + + return value; + } + + // Map into a Record without any TTLs + toJSON() { + const result: Record = {}; + for (const [key, value] of this._cache.entries()) { + result[key] = value; + } + + return result; + } + + // WARNING: This function expects that this.filePath is NOT ENOENT + private async load() { + const data = await readFile(this.filePath, 'utf-8'); + const cache = () => { + try { + return JSON.parse(data); + } catch (e) { + return undefined; + } + }; + + const diskData = cache(); + if (diskData === undefined) { + log.error('agent', 'Failed to load cache at %s', this.filePath); + return; + } + + const cacheData = diskSchema(diskData); + if (cacheData instanceof type.errors) { + log.debug('agent', 'Failed to load cache at %s', this.filePath); + log.debug('agent', 'Error details: %s', cacheData.toString()); + + // Skip loading the cache (it should be overwritten soon) + return; + } + + for (const { key, value, expires } of diskData) { + this._cache.set(key, value); + this._timings.set(key, expires); + } + + log.info('agent', 'Loaded cache from %s', this.filePath); + } + + private async flush() { + const data = Array.from(this._cache.entries()).map(([key, value]) => { + return { key, value, expires: this._timings.get(key) }; + }); + + if (data.length === 0) { + return; + } + + // Calculate the hash of the data + const dumpData = JSON.stringify(data); + const sha = createHash('sha256').update(dumpData).digest('hex'); + if (sha === this.lastFlushId) { + return; + } + + // We need to lock the writeLock so that we don't try to write + // to the file while we're already writing to it + while (this.writeLock) { + await setTimeout(100); + } + + this.writeLock = true; + await writeFile(this.filePath, dumpData, 'utf-8'); + log.debug('agent', 'Flushed cache to %s', this.filePath); + this.lastFlushId = sha; + this.writeLock = false; + } +} diff --git a/app/utils/integration/loader.ts b/app/utils/integration/loader.ts index 1cd25ee..f38af4c 100644 --- a/app/utils/integration/loader.ts +++ b/app/utils/integration/loader.ts @@ -1,6 +1,6 @@ +import log from '~/utils/log'; import { hp_getConfig } from '~server/context/global'; -import { HeadplaneConfig } from '~server/context/parser'; -import log from '~server/utils/log'; +import type { HeadplaneConfig } from '~server/context/parser'; import { Integration } from './abstract'; // import dockerIntegration from './docker'; // import kubernetesIntegration from './kubernetes'; diff --git a/app/utils/mutex.ts b/app/utils/mutex.ts deleted file mode 100644 index e8b3ea0..0000000 --- a/app/utils/mutex.ts +++ /dev/null @@ -1,32 +0,0 @@ -class Mutex { - private locked = false; - private queue: (() => void)[] = []; - - constructor(locked: boolean) { - this.locked = locked; - } - - acquire() { - return new Promise((resolve) => { - if (!this.locked) { - this.locked = true; - resolve(); - } else { - this.queue.push(resolve); - } - }); - } - - release() { - if (this.queue.length > 0) { - const next = this.queue.shift(); - next?.(); - } else { - this.locked = false; - } - } -} - -export default function mutex(locked = false) { - return new Mutex(locked); -} diff --git a/app/utils/oidc.ts b/app/utils/oidc.ts index 4b93493..8afb58c 100644 --- a/app/utils/oidc.ts +++ b/app/utils/oidc.ts @@ -1,11 +1,6 @@ -import { readFile } from 'node:fs/promises'; import * as client from 'openid-client'; import { Configuration } from 'openid-client'; -import { hp_getSingleton, hp_setSingleton } from '~server/context/global'; -import { HeadplaneConfig } from '~server/context/parser'; -import log from '~server/utils/log'; - -type OidcConfig = NonNullable; +import log from '~/utils/log'; // We try our best to infer the callback URI of our Headplane instance // By default it is always //oidc/callback @@ -35,72 +30,6 @@ export function getRedirectUri(req: Request) { return url.href; } -let oidcSecret: string | undefined = undefined; -export function getOidcSecret() { - return oidcSecret; -} - -async function resolveClientSecret(oidc: OidcConfig) { - if (!oidc.client_secret && !oidc.client_secret_path) { - return; - } - - if (oidc.client_secret_path) { - // We need to interpolate environment variables into the path - // Path formatting can be like ${ENV_NAME}/path/to/secret - let path = oidc.client_secret_path; - const matches = path.match(/\${(.*?)}/g); - - if (matches) { - for (const match of matches) { - const env = match.slice(2, -1); - const value = process.env[env]; - if (!value) { - log.error('CFGX', 'Environment variable %s is not set', env); - return; - } - - log.debug('CFGX', 'Interpolating %s with %s', match, value); - path = path.replace(match, value); - } - } - - try { - log.debug('CFGX', 'Reading client secret from %s', path); - const secret = await readFile(path, 'utf-8'); - if (secret.trim().length === 0) { - log.error('CFGX', 'Empty OIDC client secret'); - return; - } - - oidcSecret = secret; - } catch (error) { - log.error('CFGX', 'Failed to read client secret from %s', path); - log.error('CFGX', 'Error: %s', error); - log.debug('CFGX', 'Error details: %o', error); - } - } - - if (oidc.client_secret) { - oidcSecret = oidc.client_secret; - } -} - -function clientAuthMethod( - method: string, -): (secret: string) => client.ClientAuth { - switch (method) { - case 'client_secret_post': - return client.ClientSecretPost; - case 'client_secret_basic': - return client.ClientSecretBasic; - case 'client_secret_jwt': - return client.ClientSecretJwt; - default: - throw new Error('Invalid client authentication method'); - } -} - export async function beginAuthFlow( config: Configuration, redirect_uri: string, @@ -243,60 +172,3 @@ export function formatError(error: unknown) { }, }; } - -export async function testOidc(oidc: OidcConfig) { - await resolveClientSecret(oidc); - if (!oidcSecret) { - log.debug( - 'OIDC', - 'Cannot validate OIDC configuration without a client secret', - ); - return false; - } - - log.debug('OIDC', 'Discovering OIDC configuration from %s', oidc.issuer); - const secret = await resolveClientSecret(oidc); - const config = await client.discovery( - new URL(oidc.issuer), - oidc.client_id, - oidc.client_secret, - clientAuthMethod(oidc.token_endpoint_auth_method)(oidcSecret), - ); - - const meta = config.serverMetadata(); - if (meta.authorization_endpoint === undefined) { - return false; - } - - log.debug('OIDC', 'Authorization endpoint: %s', meta.authorization_endpoint); - log.debug('OIDC', 'Token endpoint: %s', meta.token_endpoint); - - if (meta.response_types_supported) { - if (meta.response_types_supported.includes('code') === false) { - log.error('OIDC', 'OIDC server does not support code flow'); - return false; - } - } else { - log.warn('OIDC', 'OIDC server does not advertise response_types_supported'); - } - - if (meta.token_endpoint_auth_methods_supported) { - if ( - meta.token_endpoint_auth_methods_supported.includes( - oidc.token_endpoint_auth_method, - ) === false - ) { - log.error( - 'OIDC', - 'OIDC server does not support %s', - oidc.token_endpoint_auth_method, - ); - - return false; - } - } - - log.debug('OIDC', 'OIDC configuration is valid'); - hp_setSingleton('oidc_client', config); - return true; -} diff --git a/app/utils/useAgent.ts b/app/utils/use-agent.tsx similarity index 100% rename from app/utils/useAgent.ts rename to app/utils/use-agent.tsx diff --git a/app/utils/ws-agent.ts b/app/utils/ws-agent.ts deleted file mode 100644 index fb8a3b8..0000000 --- a/app/utils/ws-agent.ts +++ /dev/null @@ -1,158 +0,0 @@ -// Handlers for the Local Agent on the server side -import { readFile, writeFile } from 'node:fs/promises'; -import { setTimeout as pSetTimeout } from 'node:timers/promises'; -import type { LoaderFunctionArgs } from 'react-router'; -import { WebSocket } from 'ws'; -import type { HostInfo } from '~/types'; -import log from '~server/utils/log'; - -// Essentially a HashMap which invalidates entries after a certain time. -// It also is capable of syncing as a compressed file to disk. -class TimedCache { - private _cache = new Map(); - private _timeCache = new Map(); - private defaultTTL: number; - private filepath: string; - private writeLock = false; - - constructor(defaultTTL: number, filepath: string) { - this.defaultTTL = defaultTTL; - this.filepath = filepath; - } - - async set(key: K, value: V, ttl: number = this.defaultTTL) { - this._cache.set(key, value); - this._timeCache.set(key, Date.now() + ttl); - await this.syncToFile(); - } - - async get(key: K) { - const entry = this._cache.get(key); - if (!entry) { - return; - } - - const expires = this._timeCache.get(key); - if (!expires || expires < Date.now()) { - this._cache.delete(key); - this._timeCache.delete(key); - await this.syncToFile(); - return; - } - - return entry; - } - - async loadFromFile() { - try { - const data = await readFile(this.filepath, 'utf-8'); - const cache = JSON.parse(data); - for (const { key, value, expires } of cache) { - this._cache.set(key, value); - this._timeCache.set(key, expires); - } - } catch (e) { - if (e.code === 'ENOENT') { - log.debug('CACH', 'Cache file not found, creating new cache'); - return; - } - - log.error('CACH', 'Failed to load cache from file', e); - } - } - - private async syncToFile() { - while (this.writeLock) { - await pSetTimeout(100); - } - - this.writeLock = true; - const data = Array.from(this._cache.entries()).map(([key, value]) => { - return { key, value, expires: this._timeCache.get(key) }; - }); - - await writeFile(this.filepath, JSON.stringify(data), 'utf-8'); - await this.loadFromFile(); - this.writeLock = false; - } -} - -let cache: TimedCache | undefined; -export async function initAgentCache(defaultTTL: number, filepath: string) { - cache = new TimedCache(defaultTTL, filepath); - await pSetTimeout(500); - await cache.loadFromFile(); -} - -let agentSocket: WebSocket | undefined; -// TODO: Actually type this? -export function initAgentSocket(context: LoaderFunctionArgs['context']) { - if (!context.ws) { - return; - } - - const client = context.ws.clients.values().next().value; - agentSocket = client; -} - -// Check the cache and then attempt the websocket query -// If we aren't connected to an agent, then debug log and return the cache -export async function queryAgent(nodes: string[]) { - return; - if (!cache) { - log.error('CACH', 'Cache not initialized'); - return; - } - - const cached: Record = {}; - await Promise.all( - nodes.map(async (node) => { - const cachedData = await cache?.get(node); - if (cachedData) { - cached[node] = cachedData; - } - }), - ); - - const uncached = nodes.filter((node) => !cached[node]); - - // No need to query the agent if we have all the data cached - if (uncached.length === 0) { - return cached; - } - - // We don't have an agent socket, so we can't query the agent - // and we just return the cached values available instead - if (!agentSocket) { - return cached; - } - - agentSocket?.send(JSON.stringify({ NodeIDs: uncached })); - // biome-ignore lint: bruh - const returnData = await new Promise | void>( - (resolve, reject) => { - const timeout = setTimeout(() => { - agentSocket?.removeAllListeners('message'); - resolve(); - }, 3000); - - agentSocket?.on('message', async (message: string) => { - const data = JSON.parse(message.toString()); - if (Object.keys(data).length === 0) { - resolve(); - } - - agentSocket?.removeAllListeners('message'); - resolve(data); - }); - }, - ); - - // if (returnData) { - // for await (const [node, info] of Object.entries(returnData)) { - // await cache?.set(node, info); - // } - // } - - return returnData ? { ...cached, ...returnData } : cached; -}