diff --git a/install/main.go b/install/main.go index c9c2fe84..d380591b 100644 --- a/install/main.go +++ b/install/main.go @@ -215,6 +215,28 @@ func main() { } } else { fmt.Println("Looks like you already installed, so I am going to do the setup...") + + // Read existing config to get DashboardDomain + traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml") + if err != nil { + fmt.Printf("Warning: Could not read existing config: %v\n", err) + fmt.Println("You may need to manually enter your domain information.") + config = collectUserInput(reader) + } else { + config.DashboardDomain = traefikConfig.DashboardDomain + config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail + config.BadgerVersion = traefikConfig.BadgerVersion + + // Show detected values and allow user to confirm or re-enter + fmt.Println("Detected existing configuration:") + fmt.Printf("Dashboard Domain: %s\n", config.DashboardDomain) + fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail) + fmt.Printf("Badger Version: %s\n", config.BadgerVersion) + + if !readBool(reader, "Are these values correct?", true) { + config = collectUserInput(reader) + } + } } if !checkIsCrowdsecInstalledInCompose() { @@ -252,6 +274,23 @@ func main() { } } + // Setup Token Section + fmt.Println("\n=== Setup Token ===") + + // Check if containers were started during this installation + containersStarted := false + if (isDockerInstalled() && chosenContainer == Docker) || + (isPodmanInstalled() && chosenContainer == Podman) { + // Try to fetch and display the token if containers are running + containersStarted = true + printSetupToken(chosenContainer, config.DashboardDomain) + } + + // If containers weren't started or token wasn't found, show instructions + if !containersStarted { + showSetupTokenInstructions(chosenContainer, config.DashboardDomain) + } + fmt.Println("Installation complete!") fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain) } @@ -315,10 +354,16 @@ func collectUserInput(reader *bufio.Reader) Config { // Basic configuration fmt.Println("\n=== Basic Configuration ===") config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "") - config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", "pangolin."+config.BaseDomain) - config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true) + + // Set default dashboard domain after base domain is collected + defaultDashboardDomain := "" + if config.BaseDomain != "" { + defaultDashboardDomain = "pangolin." + config.BaseDomain + } + config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", defaultDashboardDomain) config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "") config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true) + config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true) // Email configuration fmt.Println("\n=== Email Configuration ===") @@ -639,8 +684,8 @@ func pullContainers(containerType SupportedContainer) error { } if containerType == Docker { - if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "pull", "--policy", "always"); err != nil { - return fmt.Errorf("failed to pull the containers: %v", err) + if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "pull", "--policy", "always"); err != nil { + return fmt.Errorf("failed to pull the containers: %v", err) } return nil @@ -769,6 +814,91 @@ func waitForContainer(containerName string, containerType SupportedContainer) er return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds())) } +func printSetupToken(containerType SupportedContainer, dashboardDomain string) { + fmt.Println("Waiting for Pangolin to generate setup token...") + + // Wait for Pangolin to be healthy + if err := waitForContainer("pangolin", containerType); err != nil { + fmt.Println("Warning: Pangolin container did not become healthy in time.") + return + } + + // Give a moment for the setup token to be generated + time.Sleep(2 * time.Second) + + // Fetch logs + var cmd *exec.Cmd + if containerType == Docker { + cmd = exec.Command("docker", "logs", "pangolin") + } else { + cmd = exec.Command("podman", "logs", "pangolin") + } + output, err := cmd.Output() + if err != nil { + fmt.Println("Warning: Could not fetch Pangolin logs to find setup token.") + return + } + + // Parse for setup token + lines := strings.Split(string(output), "\n") + for i, line := range lines { + if strings.Contains(line, "=== SETUP TOKEN GENERATED ===") || strings.Contains(line, "=== SETUP TOKEN EXISTS ===") { + // Look for "Token: ..." in the next few lines + for j := i + 1; j < i+5 && j < len(lines); j++ { + trimmedLine := strings.TrimSpace(lines[j]) + if strings.Contains(trimmedLine, "Token:") { + // Extract token after "Token:" + tokenStart := strings.Index(trimmedLine, "Token:") + if tokenStart != -1 { + token := strings.TrimSpace(trimmedLine[tokenStart+6:]) + fmt.Printf("Setup token: %s\n", token) + fmt.Println("") + fmt.Println("This token is required to register the first admin account in the web UI at:") + fmt.Printf("https://%s/auth/initial-setup\n", dashboardDomain) + fmt.Println("") + fmt.Println("Save this token securely. It will be invalid after the first admin is created.") + return + } + } + } + } + } + fmt.Println("Warning: Could not find a setup token in Pangolin logs.") +} + +func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomain string) { + fmt.Println("\n=== Setup Token Instructions ===") + fmt.Println("To get your setup token, you need to:") + fmt.Println("") + fmt.Println("1. Start the containers:") + if containerType == Docker { + fmt.Println(" docker-compose up -d") + } else { + fmt.Println(" podman-compose up -d") + } + fmt.Println("") + fmt.Println("2. Wait for the Pangolin container to start and generate the token") + fmt.Println("") + fmt.Println("3. Check the container logs for the setup token:") + if containerType == Docker { + fmt.Println(" docker logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'") + } else { + fmt.Println(" podman logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'") + } + fmt.Println("") + fmt.Println("4. Look for output like:") + fmt.Println(" === SETUP TOKEN GENERATED ===") + fmt.Println(" Token: [your-token-here]") + fmt.Println(" Use this token on the initial setup page") + fmt.Println("") + fmt.Println("5. Use the token to complete initial setup at:") + fmt.Printf(" https://%s/auth/initial-setup\n", dashboardDomain) + fmt.Println("") + fmt.Println("The setup token is required to register the first admin account.") + fmt.Println("Save it securely - it will be invalid after the first admin is created.") + fmt.Println("================================") +} + func generateRandomSecretKey() string { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" const length = 32 diff --git a/messages/en-US.json b/messages/en-US.json index d1234d72..1936a3dc 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -967,6 +967,9 @@ "actionDeleteSite": "Delete Site", "actionGetSite": "Get Site", "actionListSites": "List Sites", + "setupToken": "Setup Token", + "setupTokenPlaceholder": "Enter the setup token from the server console", + "setupTokenRequired": "Setup token is required", "actionUpdateSite": "Update Site", "actionListSiteRoles": "List Allowed Site Roles", "actionCreateResource": "Create Resource", diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index d307f399..c7a1eebf 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -593,6 +593,14 @@ export const webauthnChallenge = pgTable("webauthnChallenge", { expiresAt: bigint("expiresAt", { mode: "number" }).notNull() // Unix timestamp }); +export const setupTokens = pgTable("setupTokens", { + tokenId: varchar("tokenId").primaryKey(), + token: varchar("token").notNull(), + used: boolean("used").notNull().default(false), + dateCreated: varchar("dateCreated").notNull(), + dateUsed: varchar("dateUsed") +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -638,3 +646,4 @@ export type OlmSession = InferSelectModel; export type UserClient = InferSelectModel; export type RoleClient = InferSelectModel; export type OrgDomains = InferSelectModel; +export type SetupToken = InferSelectModel; diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 10f6686e..4268cd9f 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -187,6 +187,14 @@ export const webauthnChallenge = sqliteTable("webauthnChallenge", { expiresAt: integer("expiresAt").notNull() // Unix timestamp }); +export const setupTokens = sqliteTable("setupTokens", { + tokenId: text("tokenId").primaryKey(), + token: text("token").notNull(), + used: integer("used", { mode: "boolean" }).notNull().default(false), + dateCreated: text("dateCreated").notNull(), + dateUsed: text("dateUsed") +}); + export const newts = sqliteTable("newt", { newtId: text("id").primaryKey(), secretHash: text("secretHash").notNull(), @@ -680,3 +688,4 @@ export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; export type OrgDomains = InferSelectModel; +export type SetupToken = InferSelectModel; diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index cc8fd630..505d12c2 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -10,6 +10,7 @@ export * from "./resetPassword"; export * from "./requestPasswordReset"; export * from "./setServerAdmin"; export * from "./initialSetupComplete"; +export * from "./validateSetupToken"; export * from "./changePassword"; export * from "./checkResourceSession"; export * from "./securityKey"; diff --git a/server/routers/auth/setServerAdmin.ts b/server/routers/auth/setServerAdmin.ts index 7c49753e..ebb95359 100644 --- a/server/routers/auth/setServerAdmin.ts +++ b/server/routers/auth/setServerAdmin.ts @@ -8,14 +8,15 @@ import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; import { passwordSchema } from "@server/auth/passwordSchema"; import { response } from "@server/lib"; -import { db, users } from "@server/db"; -import { eq } from "drizzle-orm"; +import { db, users, setupTokens } from "@server/db"; +import { eq, and } from "drizzle-orm"; import { UserType } from "@server/types/UserTypes"; import moment from "moment"; export const bodySchema = z.object({ email: z.string().toLowerCase().email(), - password: passwordSchema + password: passwordSchema, + setupToken: z.string().min(1, "Setup token is required") }); export type SetServerAdminBody = z.infer; @@ -39,7 +40,27 @@ export async function setServerAdmin( ); } - const { email, password } = parsedBody.data; + const { email, password, setupToken } = parsedBody.data; + + // Validate setup token + const [validToken] = await db + .select() + .from(setupTokens) + .where( + and( + eq(setupTokens.token, setupToken), + eq(setupTokens.used, false) + ) + ); + + if (!validToken) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid or expired setup token" + ) + ); + } const [existing] = await db .select() @@ -58,15 +79,27 @@ export async function setServerAdmin( const passwordHash = await hashPassword(password); const userId = generateId(15); - await db.insert(users).values({ - userId: userId, - email: email, - type: UserType.Internal, - username: email, - passwordHash, - dateCreated: moment().toISOString(), - serverAdmin: true, - emailVerified: true + await db.transaction(async (trx) => { + // Mark the token as used + await trx + .update(setupTokens) + .set({ + used: true, + dateUsed: moment().toISOString() + }) + .where(eq(setupTokens.tokenId, validToken.tokenId)); + + // Create the server admin user + await trx.insert(users).values({ + userId: userId, + email: email, + type: UserType.Internal, + username: email, + passwordHash, + dateCreated: moment().toISOString(), + serverAdmin: true, + emailVerified: true + }); }); return response(res, { diff --git a/server/routers/auth/validateSetupToken.ts b/server/routers/auth/validateSetupToken.ts new file mode 100644 index 00000000..e3c29833 --- /dev/null +++ b/server/routers/auth/validateSetupToken.ts @@ -0,0 +1,84 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, setupTokens } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const validateSetupTokenSchema = z + .object({ + token: z.string().min(1, "Token is required") + }) + .strict(); + +export type ValidateSetupTokenResponse = { + valid: boolean; + message: string; +}; + +export async function validateSetupToken( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = validateSetupTokenSchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { token } = parsedBody.data; + + // Find the token in the database + const [setupToken] = await db + .select() + .from(setupTokens) + .where( + and( + eq(setupTokens.token, token), + eq(setupTokens.used, false) + ) + ); + + if (!setupToken) { + return response(res, { + data: { + valid: false, + message: "Invalid or expired setup token" + }, + success: true, + error: false, + message: "Token validation completed", + status: HttpCode.OK + }); + } + + return response(res, { + data: { + valid: true, + message: "Setup token is valid" + }, + success: true, + error: false, + message: "Token validation completed", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to validate setup token" + ) + ); + } +} \ No newline at end of file diff --git a/server/routers/external.ts b/server/routers/external.ts index 5bae553e..f9ff7377 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1033,6 +1033,7 @@ authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback); authRouter.put("/set-server-admin", auth.setServerAdmin); authRouter.get("/initial-setup-complete", auth.initialSetupComplete); +authRouter.post("/validate-setup-token", auth.validateSetupToken); // Security Key routes authRouter.post( diff --git a/server/setup/ensureSetupToken.ts b/server/setup/ensureSetupToken.ts new file mode 100644 index 00000000..1734b5e6 --- /dev/null +++ b/server/setup/ensureSetupToken.ts @@ -0,0 +1,73 @@ +import { db, setupTokens, users } from "@server/db"; +import { eq } from "drizzle-orm"; +import { generateRandomString, RandomReader } from "@oslojs/crypto/random"; +import moment from "moment"; +import logger from "@server/logger"; + +const random: RandomReader = { + read(bytes: Uint8Array): void { + crypto.getRandomValues(bytes); + } +}; + +function generateToken(): string { + // Generate a 32-character alphanumeric token + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; + return generateRandomString(random, alphabet, 32); +} + +function generateId(length: number): string { + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; + return generateRandomString(random, alphabet, length); +} + +export async function ensureSetupToken() { + try { + // Check if a server admin already exists + const [existingAdmin] = await db + .select() + .from(users) + .where(eq(users.serverAdmin, true)); + + // If admin exists, no need for setup token + if (existingAdmin) { + logger.warn("Server admin exists. Setup token generation skipped."); + return; + } + + // Check if a setup token already exists + const existingTokens = await db + .select() + .from(setupTokens) + .where(eq(setupTokens.used, false)); + + // If unused token exists, display it instead of creating a new one + if (existingTokens.length > 0) { + console.log("=== SETUP TOKEN EXISTS ==="); + console.log("Token:", existingTokens[0].token); + console.log("Use this token on the initial setup page"); + console.log("================================"); + return; + } + + // Generate a new setup token + const token = generateToken(); + const tokenId = generateId(15); + + await db.insert(setupTokens).values({ + tokenId: tokenId, + token: token, + used: false, + dateCreated: moment().toISOString(), + dateUsed: null + }); + + console.log("=== SETUP TOKEN GENERATED ==="); + console.log("Token:", token); + console.log("Use this token on the initial setup page"); + console.log("================================"); + } catch (error) { + console.error("Failed to ensure setup token:", error); + throw error; + } +} \ No newline at end of file diff --git a/server/setup/index.ts b/server/setup/index.ts index d126869a..2dfb633e 100644 --- a/server/setup/index.ts +++ b/server/setup/index.ts @@ -1,9 +1,11 @@ import { ensureActions } from "./ensureActions"; import { copyInConfig } from "./copyInConfig"; import { clearStaleData } from "./clearStaleData"; +import { ensureSetupToken } from "./ensureSetupToken"; export async function runSetupFunctions() { await copyInConfig(); // copy in the config to the db as needed await ensureActions(); // make sure all of the actions are in the db and the roles await clearStaleData(); + await ensureSetupToken(); // ensure setup token exists for initial setup } diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 07ece65b..6b3f20b9 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -8,6 +8,7 @@ import path from "path"; import m1 from "./scriptsPg/1.6.0"; import m2 from "./scriptsPg/1.7.0"; import m3 from "./scriptsPg/1.8.0"; +import m4 from "./scriptsPg/1.9.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -16,7 +17,8 @@ import m3 from "./scriptsPg/1.8.0"; const migrations = [ { version: "1.6.0", run: m1 }, { version: "1.7.0", run: m2 }, - { version: "1.8.0", run: m3 } + { version: "1.8.0", run: m3 }, + { version: "1.9.0", run: m4 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 15dd28d2..5b0850c8 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -25,6 +25,7 @@ import m20 from "./scriptsSqlite/1.5.0"; import m21 from "./scriptsSqlite/1.6.0"; import m22 from "./scriptsSqlite/1.7.0"; import m23 from "./scriptsSqlite/1.8.0"; +import m24 from "./scriptsSqlite/1.9.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -49,6 +50,7 @@ const migrations = [ { version: "1.6.0", run: m21 }, { version: "1.7.0", run: m22 }, { version: "1.8.0", run: m23 }, + { version: "1.9.0", run: m24 }, // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.9.0.ts b/server/setup/scriptsPg/1.9.0.ts new file mode 100644 index 00000000..22259cae --- /dev/null +++ b/server/setup/scriptsPg/1.9.0.ts @@ -0,0 +1,25 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.9.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql` + CREATE TABLE "setupTokens" ( + "tokenId" varchar PRIMARY KEY NOT NULL, + "token" varchar NOT NULL, + "used" boolean DEFAULT false NOT NULL, + "dateCreated" varchar NOT NULL, + "dateUsed" varchar + ); + `); + + console.log(`Added setupTokens table`); + } catch (e) { + console.log("Unable to add setupTokens table:", e); + throw e; + } +} \ No newline at end of file diff --git a/server/setup/scriptsSqlite/1.9.0.ts b/server/setup/scriptsSqlite/1.9.0.ts new file mode 100644 index 00000000..a4a20dda --- /dev/null +++ b/server/setup/scriptsSqlite/1.9.0.ts @@ -0,0 +1,35 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.9.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + db.exec(` + CREATE TABLE 'setupTokens' ( + 'tokenId' text PRIMARY KEY NOT NULL, + 'token' text NOT NULL, + 'used' integer DEFAULT 0 NOT NULL, + 'dateCreated' text NOT NULL, + 'dateUsed' text + ); + `); + })(); + + db.pragma("foreign_keys = ON"); + + console.log(`Added setupTokens table`); + } catch (e) { + console.log("Unable to add setupTokens table:", e); + throw e; + } +} \ No newline at end of file diff --git a/src/app/auth/initial-setup/page.tsx b/src/app/auth/initial-setup/page.tsx index 17e6c2ec..518c5370 100644 --- a/src/app/auth/initial-setup/page.tsx +++ b/src/app/auth/initial-setup/page.tsx @@ -31,6 +31,7 @@ import { passwordSchema } from "@server/auth/passwordSchema"; const formSchema = z .object({ + setupToken: z.string().min(1, "Setup token is required"), email: z.string().email({ message: "Invalid email address" }), password: passwordSchema, confirmPassword: z.string() @@ -52,6 +53,7 @@ export default function InitialSetupPage() { const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { + setupToken: "", email: "", password: "", confirmPassword: "" @@ -63,6 +65,7 @@ export default function InitialSetupPage() { setError(null); try { const res = await api.put("/auth/set-server-admin", { + setupToken: values.setupToken, email: values.email, password: values.password }); @@ -102,6 +105,23 @@ export default function InitialSetupPage() { onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" > + ( + + {t("setupToken")} + + + + + + )} + />