diff --git a/.changeset/moody-schools-yawn.md b/.changeset/moody-schools-yawn.md new file mode 100644 index 00000000..4ae7f907 --- /dev/null +++ b/.changeset/moody-schools-yawn.md @@ -0,0 +1,6 @@ +--- +"draupnir": patch +--- + +Add Zero Touch Deployment Login option to Draupnir Bot mode. So the bot can +fetch its own access tokens. diff --git a/apps/draupnir/src/config.ts b/apps/draupnir/src/config.ts index 77a73918..f42a7ac4 100644 --- a/apps/draupnir/src/config.ts +++ b/apps/draupnir/src/config.ts @@ -47,6 +47,12 @@ export function getNonDefaultConfigProperties( if ("pantalaimon" in nonDefault && isConfigRecord(nonDefault.pantalaimon)) { nonDefault.pantalaimon.password = "REDACTED"; } + if ( + "zeroTouchDeploymentSelfLogin" in nonDefault && + isConfigRecord(nonDefault.zeroTouchDeploymentSelfLogin) + ) { + nonDefault.zeroTouchDeploymentSelfLogin.password = "REDACTED"; + } if ( "web" in nonDefault && isConfigRecord(nonDefault.web) && @@ -75,6 +81,11 @@ export interface IConfig { username: string; password: string; }; + zeroTouchDeploymentSelfLogin: { + enabled: boolean; + username: string; + password: string; + }; dataPath: string; /** * If true, Draupnir will only accept invites from users present in managementRoom. @@ -186,7 +197,8 @@ export interface IConfig { isDraupnirConfigOptionUsed: boolean; isAccessTokenPathOptionUsed: boolean; - isPasswordPathOptionUsed: boolean; + isPantalaimonPasswordOptionUsed: boolean; + isZeroTouchDeploymentSelfLoginPasswordOptionUsed: boolean; isHttpAntispamAuthorizationPathOptionUsed: boolean; } | undefined; @@ -201,6 +213,11 @@ const defaultConfig: IConfig = { username: "", password: "", }, + zeroTouchDeploymentSelfLogin: { + enabled: false, + username: "", + password: "", + }, dataPath: "/data/storage", acceptInvitesFromSpace: "!noop:example.org", autojoinOnlyIfManager: true, @@ -315,10 +332,15 @@ function getConfigMeta(): NonNullable { process.argv, "--access-token-path" ), - isPasswordPathOptionUsed: isCommandLineOptionPresent( + isPantalaimonPasswordOptionUsed: isCommandLineOptionPresent( process.argv, "--pantalaimon-password-path" ), + isZeroTouchDeploymentSelfLoginPasswordOptionUsed: + isCommandLineOptionPresent( + process.argv, + "--zero-touch-deployment-self-login-password-path" + ), isHttpAntispamAuthorizationPathOptionUsed: isCommandLineOptionPresent( process.argv, "--http-antispam-authorization-path" @@ -381,6 +403,10 @@ export function configRead(): IConfig { process.argv, "--pantalaimon-password-path" ); + const explicitZeroTouchDeploymentSelfLoginPasswordPath = getCommandLineOption( + process.argv, + "--zero-touch-deployment-self-login-password-path" + ); const explicitHttpAntispamAuthorizationPath = getCommandLineOption( process.argv, "--http-antispam-authorization-path" @@ -393,6 +419,11 @@ export function configRead(): IConfig { explicitPantalaimonPasswordPath ); } + if (explicitZeroTouchDeploymentSelfLoginPasswordPath) { + config.zeroTouchDeploymentSelfLogin.password = readSecretFromPath( + explicitZeroTouchDeploymentSelfLoginPasswordPath + ); + } if (explicitHttpAntispamAuthorizationPath) { config.web.synapseHTTPAntispam.authorization = readSecretFromPath( explicitHttpAntispamAuthorizationPath diff --git a/apps/draupnir/src/index.ts b/apps/draupnir/src/index.ts index 2e344e5b..741a87f7 100644 --- a/apps/draupnir/src/index.ts +++ b/apps/draupnir/src/index.ts @@ -25,6 +25,7 @@ import { DraupnirBotModeToggle } from "./DraupnirBotMode"; import { SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DefaultEventDecoder } from "matrix-protection-suite"; import { makeTopLevelStores } from "./backingstore/DraupnirStores"; +import { getZeroTouchDeploymentAccessToken } from "./zeroTouchDeploymentSelfLogin"; void (async function () { const config = configRead(); @@ -51,6 +52,13 @@ void (async function () { path.join(storagePath, "bot.json") ); + if (config.zeroTouchDeploymentSelfLogin.enabled) { + config.accessToken = await getZeroTouchDeploymentAccessToken( + config, + storage + ); + } + if (config.pantalaimon.use && !config.experimentalRustCrypto) { const pantalaimon = new PantalaimonClient(config.homeserverUrl, storage); client = await pantalaimon.createClientWithCredentials( diff --git a/apps/draupnir/src/zeroTouchDeploymentSelfLogin.ts b/apps/draupnir/src/zeroTouchDeploymentSelfLogin.ts new file mode 100644 index 00000000..88e05728 --- /dev/null +++ b/apps/draupnir/src/zeroTouchDeploymentSelfLogin.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 Catalan Lover +// +// SPDX-License-Identifier: Apache-2.0 + +import { MatrixAuth } from "@vector-im/matrix-bot-sdk"; +import type { IStorageProvider } from "@vector-im/matrix-bot-sdk"; +import type { IConfig } from "./config"; + +const ZERO_TOUCH_ACCESS_TOKEN_KEY = "zero_touch_access_token"; + +type ZeroTouchLogin = ( + homeserverUrl: string, + username: string, + password: string +) => Promise; + +const defaultZeroTouchLogin: ZeroTouchLogin = async ( + homeserverUrl, + username, + password +) => { + const auth = new MatrixAuth(homeserverUrl); + const client = await auth.passwordLogin(username, password); + return client.accessToken; +}; + +export async function getZeroTouchDeploymentAccessToken( + config: Pick, + storage: IStorageProvider, + zeroTouchLogin: ZeroTouchLogin = defaultZeroTouchLogin +): Promise { + const storedToken = await Promise.resolve( + storage.readValue(ZERO_TOUCH_ACCESS_TOKEN_KEY) + ); + if (storedToken) { + return storedToken; + } + + const accessToken = await zeroTouchLogin( + config.homeserverUrl, + config.zeroTouchDeploymentSelfLogin.username, + config.zeroTouchDeploymentSelfLogin.password + ); + await Promise.resolve( + storage.storeValue(ZERO_TOUCH_ACCESS_TOKEN_KEY, accessToken) + ); + return accessToken; +} diff --git a/apps/draupnir/test/unit/zeroTouchDeploymentSelfLoginTest.ts b/apps/draupnir/test/unit/zeroTouchDeploymentSelfLoginTest.ts new file mode 100644 index 00000000..2825e765 --- /dev/null +++ b/apps/draupnir/test/unit/zeroTouchDeploymentSelfLoginTest.ts @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2026 Catalan Lover +// +// SPDX-License-Identifier: Apache-2.0 + +import expect from "expect"; +import type { IStorageProvider } from "@vector-im/matrix-bot-sdk"; +import type { IConfig } from "../../src/config"; +import { getZeroTouchDeploymentAccessToken } from "../../src/zeroTouchDeploymentSelfLogin"; + +describe("zeroTouchDeploymentSelfLogin", function () { + it("boots a client using the configured zero-touch credentials", async function () { + const calls: Array<[string, string, string]> = []; + const config = { + homeserverUrl: "https://homeserver.example", + zeroTouchDeploymentSelfLogin: { + enabled: true, + username: "bot-user", + password: "bot-password", + }, + } satisfies Pick; + const storage: IStorageProvider = { + setSyncToken() {}, + getSyncToken() { + return null; + }, + setFilter() {}, + getFilter() { + return null as never; + }, + readValue() { + return null; + }, + storeValue() {}, + }; + + const result = await getZeroTouchDeploymentAccessToken( + config, + storage, + async (homeserverUrl, username, password) => { + calls.push([homeserverUrl, username, password]); + return "secret-token"; + } + ); + + expect(calls).toEqual([ + ["https://homeserver.example", "bot-user", "bot-password"], + ]); + expect(result).toEqual("secret-token"); + }); +}); diff --git a/config/default.yaml b/config/default.yaml index e4e42064..b46e8ace 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -32,6 +32,24 @@ pantalaimon: # which would allow using secret management systems such as systemd's service credentials. password: your_password +zeroTouchDeploymentSelfLogin: + # Whether or not Draupnir will use zero-touch deployment self-login. + # The purpose of this is to allow Draupnir to login to the homeserver using a username and password on first startup, + # without needing to pre-provision an access token. + # This is especially useful for zero-touch deployments where pre-provisioning an access token may not be practical. + # Its also straight up easier to use for users who don't want to faf around with curl to obtain an access token. + enabled: false + + # The username to login with. + username: draupnir + + # The password Draupnir will login with. + # + # After successfully logging in once, this will be ignored as long as the access token is stored. + # This option can be loaded from a file by passing "--zero-touch-password-path " at the command line, + # which would allow using secret management systems such as systemd's service credentials. + password: your_password + # Experimental usage of the matrix-bot-sdk rust crypto. # This can not be used with Pantalaimon. # Make sure to setup the bot as if you are not using pantalaimon for this.