diff --git a/README.md b/README.md index cb77178..3181516 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ _For detailed documentation, visit [pup.56k.guru](https://pup.56k.guru)._ To install Pup, open your terminal and execute the following command: ```bash -deno run -Ar jsr:@pup/pup@1.0.0-rc.36 setup --channel prerelease +deno run -Ar jsr:@pup/pup@1.0.0-rc.37 setup --channel prerelease ``` This command downloads the latest version of Pup and installs it on your system. The `--channel prerelease` option is included as there is no stable version of Pup yet. Read more abour release diff --git a/application.meta.ts b/application.meta.ts index 0c0d862..d609c22 100644 --- a/application.meta.ts +++ b/application.meta.ts @@ -21,7 +21,7 @@ const Application = { name: "pup", - version: "1.0.0-rc.36", + version: "1.0.0-rc.37", url: "jsr:@pup/pup@$VERSION", canary_url: "https://raw.githubusercontent.com/Hexagon/pup/main/pup.ts", deno: null, /* Minimum stable version of Deno required to run Pup (without --unstable-* flags) */ diff --git a/deno.json b/deno.json index 19be3ab..febf5e5 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@pup/pup", - "version": "1.0.0-rc.36", + "version": "1.0.0-rc.37", "exports": { ".": "./pup.ts", @@ -42,17 +42,17 @@ "@cross/deepmerge": "jsr:@cross/deepmerge@^1.0.0", "@cross/env": "jsr:@cross/env@^1.0.2", "@cross/fs": "jsr:@cross/fs@^0.0.10", - "@cross/jwt": "jsr:@cross/jwt@^0.4.2", + "@cross/jwt": "jsr:@cross/jwt@^0.4.6", "@cross/runtime": "jsr:@cross/runtime@^1.0.0", "@cross/service": "jsr:@cross/service@^1.0.3", "@cross/test": "jsr:@cross/test@^0.0.9", "@cross/utils": "jsr:@cross/utils@^0.12.0", "@hexagon/croner": "jsr:@hexagon/croner@^8.0.2", "@oak/oak": "jsr:@oak/oak@^15.0.0", - "@pup/api-client": "jsr:@pup/api-client@^1.0.1", + "@pup/api-client": "jsr:@pup/api-client@^1.0.2", "@pup/api-definitions": "jsr:@pup/api-definitions@^1.0.1", "@pup/common": "jsr:@pup/common@^1.0.2", - "@pup/plugin": "jsr:@pup/plugin@^1.0.0", + "@pup/plugin": "jsr:@pup/plugin@^1.0.1", "@std/assert": "jsr:@std/assert@^0.223.0", "@std/async": "jsr:@std/async@^0.223.0", "@std/encoding": "jsr:@std/encoding@^0.223.0", diff --git a/docs/src/_data.json b/docs/src/_data.json index fd7155d..9f46ac9 100644 --- a/docs/src/_data.json +++ b/docs/src/_data.json @@ -6,7 +6,7 @@ "description": "Universal Process Manager" }, "substitute": { - "$PUP_VERSION": "1.0.0-rc.36" + "$PUP_VERSION": "1.0.0-rc.37" }, "top_links": [ { diff --git a/docs/src/changelog.md b/docs/src/changelog.md index ddad669..f8c18a5 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -9,6 +9,10 @@ nav_order: 13 All notable changes to this project will be documented in this section. +## [1.0.0-rc.37] - 2024-04-26 + +- fix(plugins): Automatically refresh plugin API tokens prior to expiry + ## [1.0.0-rc.36] - 2024-04-26 ## Added diff --git a/docs/src/examples/plugins/README.md b/docs/src/examples/plugins/README.md index 43fb2f2..590c4f0 100644 --- a/docs/src/examples/plugins/README.md +++ b/docs/src/examples/plugins/README.md @@ -60,6 +60,11 @@ export class PupPlugin extends PluginImplementation { true, ) } + + // Forward api token refreshes to the api client + public async refreshApiToken(apiToken: string): Promise { + this.client.refreshApiToken(apiToken) + } } ``` diff --git a/docs/src/examples/plugins/example-plugin.ts b/docs/src/examples/plugins/example-plugin.ts index c750f8c..3c0cba4 100644 --- a/docs/src/examples/plugins/example-plugin.ts +++ b/docs/src/examples/plugins/example-plugin.ts @@ -55,6 +55,11 @@ export class PupPlugin extends PluginImplementation { }) } + // deno-lint-ignore require-await + public async refreshApiToken(apiToken: string): Promise { + this.client.refreshApiToken(apiToken) + } + // Helper function to send logs via the Rest API public async sendLog(severity: string, message: string) { // Wrap all API calls in try/catch diff --git a/docs/src/examples/plugins/pup.jsonc b/docs/src/examples/plugins/pup.jsonc index be9fbf6..9045302 100644 --- a/docs/src/examples/plugins/pup.jsonc +++ b/docs/src/examples/plugins/pup.jsonc @@ -12,7 +12,7 @@ { // Normally, you would use full uri to plugin, e.g. jsr:@pup/plugin-web-interface // But when developing locally, you'll have to use the full absolute local path - "url": "file:///path/to/pup/docs/src/examples/plugins/example-plugin.ts", + "url": "file:///config/workspace/pup/docs/src/examples/plugins/example-plugin.ts", // It is possible to pass options to a plugin, like this: "options": { diff --git a/lib/common/token.ts b/lib/common/token.ts index 92061a8..b49c322 100644 --- a/lib/common/token.ts +++ b/lib/common/token.ts @@ -5,7 +5,7 @@ * @license MIT */ -import { createJWT, generateKey, JWTPayload, validateJWT } from "@cross/jwt" +import { createJWT, generateKey, JWTPayload, unsafeParseJWT, validateJWT } from "@cross/jwt" import { DEFAULT_SECRET_KEY_ALGORITHM } from "../core/configuration.ts" export interface PupTokenPayload { @@ -37,3 +37,16 @@ export function ValidateToken(token: string, key: CryptoKey): unknown | null { return null // Token invalid } } + +export function SecondsToExpiry(token: string): number | undefined { + try { + const decoded = unsafeParseJWT(token) + if (decoded && decoded.exp) { + return decoded.exp - Math.round(Date.now() / 1000) + } else { + return undefined + } + } catch (_err) { + return undefined + } +} diff --git a/lib/core/configuration.ts b/lib/core/configuration.ts index b667622..c67c4ad 100644 --- a/lib/core/configuration.ts +++ b/lib/core/configuration.ts @@ -27,6 +27,10 @@ export const DEFAULT_REST_API_HOSTNAME = "127.0.0.1" // Prop file constants export const DEFAULT_PROP_FILE_PERMISSIONS = 0o600 +// Plugin constants +export const PLUGIN_TOKEN_EXPIRE_S = 300 // Expire API tokens after 5 minutes +export const PLUGIN_TOKEN_REFRESH_ADVANCE_S = 10 // Renew Plugin API token automatically 10 seconds before expiry + interface Configuration { name?: string api?: ApiConfiguration diff --git a/lib/core/plugin.ts b/lib/core/plugin.ts index 44e1fe2..7f1c826 100644 --- a/lib/core/plugin.ts +++ b/lib/core/plugin.ts @@ -35,6 +35,13 @@ export class Plugin { throw new Error("Plugin missing meta.api") } } + async refreshToken(token: string): Promise { + this.apiToken = token + await this.impl?.refreshApiToken(token) + } + getToken(): string { + return this.apiToken + } async terminate() { if (this.impl?.cleanup) await this.impl?.cleanup() } diff --git a/lib/core/pup.ts b/lib/core/pup.ts index 550e88a..1f8fdbc 100644 --- a/lib/core/pup.ts +++ b/lib/core/pup.ts @@ -11,6 +11,8 @@ import { DEFAULT_SECRET_FILE_PERMISSIONS, type GlobalLoggerConfiguration, MAINTENANCE_INTERVAL_MS, + PLUGIN_TOKEN_EXPIRE_S, + PLUGIN_TOKEN_REFRESH_ADVANCE_S, type ProcessConfiguration, validateConfiguration, WATCHDOG_INTERVAL_MS, @@ -28,8 +30,7 @@ import type { ApiTelemetryData } from "@pup/api-definitions" import { rm } from "@cross/fs" import { findFreePort } from "./port.ts" import { Plugin } from "./plugin.ts" -import { GenerateToken } from "../common/token.ts" - +import { GenerateToken, SecondsToExpiry } from "../common/token.ts" interface InstructionResponse { success: boolean action?: string @@ -134,7 +135,7 @@ class Pup { // Initialize plugins if (this.configuration.plugins) { const secret = await this.secret?.load() - const pluginToken = await GenerateToken(secret!, { consumer: "plugin" }, Date.now() + 720 * 24 * 60 * 60 * 1000) + const pluginToken = await GenerateToken(secret!, { consumer: "plugin" }, Date.now() + PLUGIN_TOKEN_EXPIRE_S * 1000) for (const plugin of this.configuration.plugins) { const newPlugin = new Plugin(plugin, `${this.restApi?.hostname}:${this.restApi?.port}`, pluginToken) let success = true @@ -152,7 +153,6 @@ class Pup { this.logger.error("plugins", `Failed to verify plugin '${plugin.url}': ${e.message}`) success = false } - if (success) { this.plugins.push(newPlugin) this.logger.log("plugins", `Plugin '${newPlugin.impl?.meta.name}@${newPlugin.impl?.meta.version}' loaded from '${plugin.url}'`) @@ -247,7 +247,7 @@ class Pup { * * @private */ - private watchdog = () => { + private watchdog = async () => { this.events.emit("watchdog") // Wrap watchdog operation in a catch to prevent it from ever stopping try { @@ -315,6 +315,25 @@ class Pup { this.logger.error("watchdog", `Heartbeat update failed: ${e}`) } + // Refresh plugin tokens if needed + try { + for (const plugin of this.plugins) { + // Parse token and check seconds left to expiry + const secondsLeft = await SecondsToExpiry(plugin.getToken()) + if (secondsLeft !== undefined && secondsLeft < PLUGIN_TOKEN_REFRESH_ADVANCE_S) { + this.logger.log("plugins", `API token for plugin '${plugin.impl?.meta.name} is about to expire in ${secondsLeft}s. Refreshing token.`) + const secret = this.secret?.fromCache() + if (secret) { + // Send a fresh token to the plugin + const newPluginToken = await GenerateToken(secret!, { consumer: "plugin" }, Date.now() + PLUGIN_TOKEN_EXPIRE_S * 1000) + plugin.refreshToken(newPluginToken) + } + } + } + } catch (e) { + this.logger.error("watchdog", `API Token refresh failed: ${e}`) + } + // Reschedule watchdog if (!this.requestTerminate) { this.watchdogTimer = setTimeout(() => { @@ -337,7 +356,7 @@ class Pup { return resultingPort.toString() }) - // Initializing rest a + // Initializing rest api this.logger.info("rest", "Initializing rest api") // Initialize rest api diff --git a/versions.json b/versions.json index b8b94d1..d6bd860 100644 --- a/versions.json +++ b/versions.json @@ -2,6 +2,20 @@ "canary_url": "https://raw.githubusercontent.com/Hexagon/pup/main/pup.ts", "stable": [], "prerelease": [ + { + "version": "1.0.0-rc.37", + "url": "jsr:@pup/pup@1.0.0-rc.37", + "deno": null, + "deno_unstable": "1.42.0", + "default_permissions": [ + "--allow-env", + "--allow-read", + "--allow-write", + "--allow-sys=loadavg,systemMemoryInfo,osUptime,osRelease,uid,gid", + "--allow-net", + "--allow-run" + ] + }, { "version": "1.0.0-rc.36", "url": "jsr:@pup/pup@1.0.0-rc.36",