mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-21 17:58:27 +02:00
Merge branch 'feature/setup-token-security' of github.com:adrianeastles/pangolin into adrianeastles-feature/setup-token-security
This commit is contained in:
commit
4f3cd71e1e
15 changed files with 447 additions and 18 deletions
134
install/main.go
134
install/main.go
|
@ -215,6 +215,28 @@ func main() {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Looks like you already installed, so I am going to do the setup...")
|
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() {
|
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.Println("Installation complete!")
|
||||||
fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain)
|
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
|
// Basic configuration
|
||||||
fmt.Println("\n=== Basic Configuration ===")
|
fmt.Println("\n=== Basic Configuration ===")
|
||||||
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
|
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.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.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
|
// Email configuration
|
||||||
fmt.Println("\n=== Email Configuration ===")
|
fmt.Println("\n=== Email Configuration ===")
|
||||||
|
@ -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()))
|
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 {
|
func generateRandomSecretKey() string {
|
||||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
const length = 32
|
const length = 32
|
||||||
|
|
|
@ -967,6 +967,9 @@
|
||||||
"actionDeleteSite": "Delete Site",
|
"actionDeleteSite": "Delete Site",
|
||||||
"actionGetSite": "Get Site",
|
"actionGetSite": "Get Site",
|
||||||
"actionListSites": "List Sites",
|
"actionListSites": "List Sites",
|
||||||
|
"setupToken": "Setup Token",
|
||||||
|
"setupTokenPlaceholder": "Enter the setup token from the server console",
|
||||||
|
"setupTokenRequired": "Setup token is required",
|
||||||
"actionUpdateSite": "Update Site",
|
"actionUpdateSite": "Update Site",
|
||||||
"actionListSiteRoles": "List Allowed Site Roles",
|
"actionListSiteRoles": "List Allowed Site Roles",
|
||||||
"actionCreateResource": "Create Resource",
|
"actionCreateResource": "Create Resource",
|
||||||
|
|
|
@ -593,6 +593,14 @@ export const webauthnChallenge = pgTable("webauthnChallenge", {
|
||||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull() // Unix timestamp
|
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<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Site = InferSelectModel<typeof sites>;
|
export type Site = InferSelectModel<typeof sites>;
|
||||||
|
@ -638,3 +646,4 @@ export type OlmSession = InferSelectModel<typeof olmSessions>;
|
||||||
export type UserClient = InferSelectModel<typeof userClients>;
|
export type UserClient = InferSelectModel<typeof userClients>;
|
||||||
export type RoleClient = InferSelectModel<typeof roleClients>;
|
export type RoleClient = InferSelectModel<typeof roleClients>;
|
||||||
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
||||||
|
export type SetupToken = InferSelectModel<typeof setupTokens>;
|
||||||
|
|
|
@ -187,6 +187,14 @@ export const webauthnChallenge = sqliteTable("webauthnChallenge", {
|
||||||
expiresAt: integer("expiresAt").notNull() // Unix timestamp
|
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", {
|
export const newts = sqliteTable("newt", {
|
||||||
newtId: text("id").primaryKey(),
|
newtId: text("id").primaryKey(),
|
||||||
secretHash: text("secretHash").notNull(),
|
secretHash: text("secretHash").notNull(),
|
||||||
|
@ -680,3 +688,4 @@ export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||||
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
||||||
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
||||||
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
||||||
|
export type SetupToken = InferSelectModel<typeof setupTokens>;
|
||||||
|
|
|
@ -10,6 +10,7 @@ export * from "./resetPassword";
|
||||||
export * from "./requestPasswordReset";
|
export * from "./requestPasswordReset";
|
||||||
export * from "./setServerAdmin";
|
export * from "./setServerAdmin";
|
||||||
export * from "./initialSetupComplete";
|
export * from "./initialSetupComplete";
|
||||||
|
export * from "./validateSetupToken";
|
||||||
export * from "./changePassword";
|
export * from "./changePassword";
|
||||||
export * from "./checkResourceSession";
|
export * from "./checkResourceSession";
|
||||||
export * from "./securityKey";
|
export * from "./securityKey";
|
||||||
|
|
|
@ -8,14 +8,15 @@ import logger from "@server/logger";
|
||||||
import { hashPassword } from "@server/auth/password";
|
import { hashPassword } from "@server/auth/password";
|
||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
import { response } from "@server/lib";
|
import { response } from "@server/lib";
|
||||||
import { db, users } from "@server/db";
|
import { db, users, setupTokens } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
|
||||||
export const bodySchema = z.object({
|
export const bodySchema = z.object({
|
||||||
email: z.string().toLowerCase().email(),
|
email: z.string().toLowerCase().email(),
|
||||||
password: passwordSchema
|
password: passwordSchema,
|
||||||
|
setupToken: z.string().min(1, "Setup token is required")
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SetServerAdminBody = z.infer<typeof bodySchema>;
|
export type SetServerAdminBody = z.infer<typeof bodySchema>;
|
||||||
|
@ -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
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
|
@ -58,7 +79,18 @@ export async function setServerAdmin(
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const userId = generateId(15);
|
const userId = generateId(15);
|
||||||
|
|
||||||
await db.insert(users).values({
|
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,
|
userId: userId,
|
||||||
email: email,
|
email: email,
|
||||||
type: UserType.Internal,
|
type: UserType.Internal,
|
||||||
|
@ -68,6 +100,7 @@ export async function setServerAdmin(
|
||||||
serverAdmin: true,
|
serverAdmin: true,
|
||||||
emailVerified: true
|
emailVerified: true
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return response<SetServerAdminResponse>(res, {
|
return response<SetServerAdminResponse>(res, {
|
||||||
data: null,
|
data: null,
|
||||||
|
|
84
server/routers/auth/validateSetupToken.ts
Normal file
84
server/routers/auth/validateSetupToken.ts
Normal file
|
@ -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<any> {
|
||||||
|
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<ValidateSetupTokenResponse>(res, {
|
||||||
|
data: {
|
||||||
|
valid: false,
|
||||||
|
message: "Invalid or expired setup token"
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Token validation completed",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<ValidateSetupTokenResponse>(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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1033,6 +1033,7 @@ authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);
|
||||||
|
|
||||||
authRouter.put("/set-server-admin", auth.setServerAdmin);
|
authRouter.put("/set-server-admin", auth.setServerAdmin);
|
||||||
authRouter.get("/initial-setup-complete", auth.initialSetupComplete);
|
authRouter.get("/initial-setup-complete", auth.initialSetupComplete);
|
||||||
|
authRouter.post("/validate-setup-token", auth.validateSetupToken);
|
||||||
|
|
||||||
// Security Key routes
|
// Security Key routes
|
||||||
authRouter.post(
|
authRouter.post(
|
||||||
|
|
73
server/setup/ensureSetupToken.ts
Normal file
73
server/setup/ensureSetupToken.ts
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,11 @@
|
||||||
import { ensureActions } from "./ensureActions";
|
import { ensureActions } from "./ensureActions";
|
||||||
import { copyInConfig } from "./copyInConfig";
|
import { copyInConfig } from "./copyInConfig";
|
||||||
import { clearStaleData } from "./clearStaleData";
|
import { clearStaleData } from "./clearStaleData";
|
||||||
|
import { ensureSetupToken } from "./ensureSetupToken";
|
||||||
|
|
||||||
export async function runSetupFunctions() {
|
export async function runSetupFunctions() {
|
||||||
await copyInConfig(); // copy in the config to the db as needed
|
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 ensureActions(); // make sure all of the actions are in the db and the roles
|
||||||
await clearStaleData();
|
await clearStaleData();
|
||||||
|
await ensureSetupToken(); // ensure setup token exists for initial setup
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import path from "path";
|
||||||
import m1 from "./scriptsPg/1.6.0";
|
import m1 from "./scriptsPg/1.6.0";
|
||||||
import m2 from "./scriptsPg/1.7.0";
|
import m2 from "./scriptsPg/1.7.0";
|
||||||
import m3 from "./scriptsPg/1.8.0";
|
import m3 from "./scriptsPg/1.8.0";
|
||||||
|
import m4 from "./scriptsPg/1.9.0";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
|
@ -16,7 +17,8 @@ import m3 from "./scriptsPg/1.8.0";
|
||||||
const migrations = [
|
const migrations = [
|
||||||
{ version: "1.6.0", run: m1 },
|
{ version: "1.6.0", run: m1 },
|
||||||
{ version: "1.7.0", run: m2 },
|
{ 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
|
// Add new migrations here as they are created
|
||||||
] as {
|
] as {
|
||||||
version: string;
|
version: string;
|
||||||
|
|
|
@ -25,6 +25,7 @@ import m20 from "./scriptsSqlite/1.5.0";
|
||||||
import m21 from "./scriptsSqlite/1.6.0";
|
import m21 from "./scriptsSqlite/1.6.0";
|
||||||
import m22 from "./scriptsSqlite/1.7.0";
|
import m22 from "./scriptsSqlite/1.7.0";
|
||||||
import m23 from "./scriptsSqlite/1.8.0";
|
import m23 from "./scriptsSqlite/1.8.0";
|
||||||
|
import m24 from "./scriptsSqlite/1.9.0";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
|
@ -49,6 +50,7 @@ const migrations = [
|
||||||
{ version: "1.6.0", run: m21 },
|
{ version: "1.6.0", run: m21 },
|
||||||
{ version: "1.7.0", run: m22 },
|
{ version: "1.7.0", run: m22 },
|
||||||
{ version: "1.8.0", run: m23 },
|
{ version: "1.8.0", run: m23 },
|
||||||
|
{ version: "1.9.0", run: m24 },
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
25
server/setup/scriptsPg/1.9.0.ts
Normal file
25
server/setup/scriptsPg/1.9.0.ts
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
35
server/setup/scriptsSqlite/1.9.0.ts
Normal file
35
server/setup/scriptsSqlite/1.9.0.ts
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -31,6 +31,7 @@ import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
setupToken: z.string().min(1, "Setup token is required"),
|
||||||
email: z.string().email({ message: "Invalid email address" }),
|
email: z.string().email({ message: "Invalid email address" }),
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
confirmPassword: z.string()
|
confirmPassword: z.string()
|
||||||
|
@ -52,6 +53,7 @@ export default function InitialSetupPage() {
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
setupToken: "",
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
confirmPassword: ""
|
confirmPassword: ""
|
||||||
|
@ -63,6 +65,7 @@ export default function InitialSetupPage() {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await api.put("/auth/set-server-admin", {
|
const res = await api.put("/auth/set-server-admin", {
|
||||||
|
setupToken: values.setupToken,
|
||||||
email: values.email,
|
email: values.email,
|
||||||
password: values.password
|
password: values.password
|
||||||
});
|
});
|
||||||
|
@ -102,6 +105,23 @@ export default function InitialSetupPage() {
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="setupToken"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("setupToken")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder={t("setupTokenPlaceholder")}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue