dev-1.7.0 #294

Merged
ZacharyZcR merged 73 commits from main into dev-1.7.0 2025-09-25 04:56:32 +00:00
11 changed files with 479 additions and 49 deletions
Showing only changes of commit 8c004dfcfe - Show all commits

View File

@@ -299,6 +299,7 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (host_id) REFERENCES ssh_data (id),
FOREIGN KEY (user_id) REFERENCES users (id)
);
`);
// Run schema migrations
@@ -435,6 +436,11 @@ const migrateSchema = () => {
"INTEGER REFERENCES ssh_credentials(id)",
);
// AutoStart plaintext columns
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
// SSH credentials table migrations for encryption support
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");

View File

@@ -49,6 +49,11 @@ export const sshData = sqliteTable("ssh_data", {
keyPassword: text("key_password"),
keyType: text("key_type"),
// AutoStart plaintext fields (populated only when autoStart is enabled)
autostartPassword: text("autostart_password"),
autostartKey: text("autostart_key", { length: 8192 }),
autostartKeyPassword: text("autostart_key_password"),
credentialId: integer("credential_id").references(() => sshCredentials.id),
enableTerminal: integer("enable_terminal", { mode: "boolean" })
.notNull()
@@ -168,3 +173,4 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});

View File

@@ -8,13 +8,15 @@ import {
fileManagerPinned,
fileManagerShortcuts,
} from "../db/schema.js";
import { eq, and, desc } from "drizzle-orm";
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
import type { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import multer from "multer";
import { sshLogger } from "../../utils/logger.js";
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { SystemCrypto } from "../../utils/system-crypto.js";
const router = express.Router();
@@ -42,40 +44,76 @@ function isLocalhost(req: Request) {
return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
}
// Internal-only endpoint for autostart (no JWT)
// Internal-only endpoint for autostart - requires internal auth token
router.get("/db/host/internal", async (req: Request, res: Response) => {
if (!isLocalhost(req) && req.headers["x-internal-request"] !== "1") {
sshLogger.warn("Unauthorized attempt to access internal SSH host endpoint");
return res.status(403).json({ error: "Forbidden" });
}
try {
// Internal endpoint - returns encrypted data (autostart will need user unlock)
const data = await SimpleDBOps.selectEncrypted(
db.select().from(sshData),
"ssh_data",
);
const result = data.map((row: any) => {
// Check for internal authentication token using SystemCrypto
const internalToken = req.headers["x-internal-auth-token"];
const systemCrypto = SystemCrypto.getInstance();
const expectedToken = await systemCrypto.getInternalAuthToken();
if (internalToken !== expectedToken) {
sshLogger.warn("Unauthorized attempt to access internal SSH host endpoint", {
source: req.ip,
userAgent: req.headers["user-agent"],
providedToken: internalToken ? "present" : "missing"
});
return res.status(403).json({ error: "Forbidden" });
}
} catch (error) {
sshLogger.error("Failed to validate internal auth token", error);
return res.status(500).json({ error: "Internal server error" });
}
try {
// Query sshData directly for hosts that have autostart plaintext fields populated
const autostartHosts = await db.select()
.from(sshData)
.where(
// Check if any autostart fields are populated (meaning autostart is enabled)
or(
isNotNull(sshData.autostartPassword),
isNotNull(sshData.autostartKey)
)
);
sshLogger.info("Internal autostart endpoint accessed", {
operation: "autostart_internal_access",
configCount: autostartHosts.length,
source: req.ip,
userAgent: req.headers["user-agent"]
});
// Transform to expected format for tunnel service
const result = autostartHosts.map((host) => {
const tunnelConnections = host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [];
return {
...row,
tags:
typeof row.tags === "string"
? row.tags
? row.tags.split(",").filter(Boolean)
: []
: [],
pin: !!row.pin,
enableTerminal: !!row.enableTerminal,
enableTunnel: !!row.enableTunnel,
tunnelConnections: row.tunnelConnections
? JSON.parse(row.tunnelConnections)
: [],
enableFileManager: !!row.enableFileManager,
id: host.id,
userId: host.userId,
name: host.name || `autostart-${host.id}`,
ip: host.ip,
port: host.port,
username: host.username,
password: host.autostartPassword,
key: host.autostartKey,
keyPassword: host.autostartKeyPassword,
authType: host.authType,
enableTunnel: true,
tunnelConnections: tunnelConnections.filter((tunnel: any) => tunnel.autoStart),
pin: false,
enableTerminal: false,
enableFileManager: false,
tags: ["autostart"],
};
});
res.json(result);
} catch (err) {
sshLogger.error("Failed to fetch SSH data (internal)", err);
res.status(500).json({ error: "Failed to fetch SSH data" });
sshLogger.error("Failed to fetch autostart SSH data", err);
res.status(500).json({ error: "Failed to fetch autostart SSH data" });
}
});
@@ -1261,4 +1299,195 @@ router.post(
},
);
// Route: Enable autostart for SSH configuration (requires JWT)
// POST /ssh/autostart/enable
router.post(
"/autostart/enable",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { sshConfigId } = req.body;
if (!sshConfigId || typeof sshConfigId !== "number") {
sshLogger.warn("Missing or invalid sshConfigId in autostart enable request", {
operation: "autostart_enable",
userId,
sshConfigId
});
return res.status(400).json({ error: "Valid sshConfigId is required" });
}
try {
// Validate user has access to decrypt the data
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
sshLogger.warn("User attempted to enable autostart without unlocked data", {
operation: "autostart_enable_failed",
userId,
sshConfigId,
reason: "data_locked"
});
return res.status(400).json({
error: "Failed to enable autostart. Ensure user data is unlocked."
});
}
// Get and decrypt SSH configuration
const sshConfig = await db.select()
.from(sshData)
.where(and(
eq(sshData.id, sshConfigId),
eq(sshData.userId, userId)
));
if (sshConfig.length === 0) {
sshLogger.warn("SSH config not found for autostart enable", {
operation: "autostart_enable_failed",
userId,
sshConfigId,
reason: "config_not_found"
});
return res.status(404).json({
error: "SSH configuration not found"
});
}
const config = sshConfig[0];
// Decrypt sensitive fields
const decryptedConfig = DataCrypto.decryptRecord("ssh_data", config, userId, userDataKey);
// Update the SSH config with plaintext autostart fields
await db.update(sshData)
.set({
autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null,
autostartKeyPassword: decryptedConfig.keyPassword || null,
})
.where(eq(sshData.id, sshConfigId));
sshLogger.success("AutoStart enabled successfully", {
operation: "autostart_enabled",
userId,
sshConfigId,
host: config.ip
});
res.json({
message: "AutoStart enabled successfully",
sshConfigId
});
} catch (error) {
sshLogger.error("Error enabling autostart", error, {
operation: "autostart_enable_error",
userId,
sshConfigId
});
res.status(500).json({ error: "Internal server error" });
}
}
);
// Route: Disable autostart for SSH configuration (requires JWT)
// DELETE /ssh/autostart/disable
router.delete(
"/autostart/disable",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { sshConfigId } = req.body;
if (!sshConfigId || typeof sshConfigId !== "number") {
sshLogger.warn("Missing or invalid sshConfigId in autostart disable request", {
operation: "autostart_disable",
userId,
sshConfigId
});
return res.status(400).json({ error: "Valid sshConfigId is required" });
}
try {
// Clear the autostart plaintext fields for this SSH config
const result = await db.update(sshData)
.set({
autostartPassword: null,
autostartKey: null,
autostartKeyPassword: null,
})
.where(and(
eq(sshData.id, sshConfigId),
eq(sshData.userId, userId)
));
sshLogger.info("AutoStart disabled successfully", {
operation: "autostart_disabled",
userId,
sshConfigId
});
res.json({
message: "AutoStart disabled successfully",
sshConfigId
});
} catch (error) {
sshLogger.error("Error disabling autostart", error, {
operation: "autostart_disable_error",
userId,
sshConfigId
});
res.status(500).json({ error: "Internal server error" });
}
}
);
// Route: Get autostart status for user's SSH configurations (requires JWT)
// GET /ssh/autostart/status
router.get(
"/autostart/status",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
try {
// Query user's SSH configs that have autostart enabled
const autostartConfigs = await db.select()
.from(sshData)
.where(and(
eq(sshData.userId, userId),
or(
isNotNull(sshData.autostartPassword),
isNotNull(sshData.autostartKey)
)
));
// Map to just the basic info needed for status
const statusList = autostartConfigs.map(config => ({
sshConfigId: config.id,
host: config.ip,
port: config.port,
username: config.username,
authType: config.authType
}));
sshLogger.info("AutoStart status retrieved", {
operation: "autostart_status",
userId,
configCount: statusList.length
});
res.json({
autostart_configs: statusList,
total_count: statusList.length
});
} catch (error) {
sshLogger.error("Error getting autostart status", error, {
operation: "autostart_status_error",
userId
});
res.status(500).json({ error: "Internal server error" });
}
}
);
export default router;

View File

@@ -412,9 +412,9 @@ router.delete("/oidc-config", authenticateJWT, async (req, res) => {
}
});
// Route: Get OIDC configuration
// Route: Get OIDC configuration (public - needed for login page)
// GET /users/oidc-config
router.get("/oidc-config", authenticateJWT, async (req, res) => {
router.get("/oidc-config", async (req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
@@ -955,10 +955,36 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
}
});
// Route: Count users
// Route: Check if system requires initial setup (public - for first-time setup detection)
// GET /users/setup-required
router.get("/setup-required", async (req, res) => {
try {
const countResult = db.$client
.prepare("SELECT COUNT(*) as count FROM users")
.get();
const count = (countResult as any)?.count || 0;
res.json({
setup_required: count === 0,
// 不暴露具体用户数量,只返回是否需要初始化
});
} catch (err) {
authLogger.error("Failed to check setup status", err);
res.status(500).json({ error: "Failed to check setup status" });
}
});
// Route: Count users (admin only - for dashboard statistics)
// GET /users/count
router.get("/count", authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
// 只有管理员可以查看用户统计
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user[0] || !user[0].is_admin) {
return res.status(403).json({ error: "Admin access required" });
}
const countResult = db.$client
.prepare("SELECT COUNT(*) as count FROM users")
.get();
@@ -982,9 +1008,9 @@ router.get("/db-health", requireAdmin, async (req, res) => {
}
});
// Route: Get registration allowed status
// Route: Get registration allowed status (public - needed for login page)
// GET /users/registration-allowed
router.get("/registration-allowed", authenticateJWT, async (req, res) => {
router.get("/registration-allowed", async (req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")

View File

@@ -15,6 +15,7 @@ import type {
} from "../../types/index.js";
import { CONNECTION_STATES } from "../../types/index.js";
import { tunnelLogger } from "../utils/logger.js";
import { SystemCrypto } from "../utils/system-crypto.js";
const app = express();
app.use(
@@ -1023,12 +1024,16 @@ app.post("/ssh/tunnel/cancel", (req, res) => {
async function initializeAutoStartTunnels(): Promise<void> {
try {
// Get internal auth token from SystemCrypto
const systemCrypto = SystemCrypto.getInstance();
const internalAuthToken = await systemCrypto.getInternalAuthToken();
const response = await axios.get(
"http://localhost:8081/ssh/db/host/internal",
{
headers: {
"Content-Type": "application/json",
"X-Internal-Request": "1",
"X-Internal-Auth-Token": internalAuthToken,
},
},
);

View File

@@ -8,6 +8,7 @@ import path from "path";
import { AutoSSLSetup } from "./utils/auto-ssl-setup.js";
import { AuthManager } from "./utils/auth-manager.js";
import { DataCrypto } from "./utils/data-crypto.js";
import { SystemCrypto } from "./utils/system-crypto.js";
import { systemLogger, versionLogger } from "./utils/logger.js";
(async () => {
@@ -74,6 +75,15 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
securityIssues.push("DATABASE_KEY should be at least 64 characters in production");
}
if (!process.env.INTERNAL_AUTH_TOKEN) {
systemLogger.warn("INTERNAL_AUTH_TOKEN not set - using auto-generated token (consider setting for production)", {
operation: "security_warning",
note: "Auto-generated tokens are secure but not persistent across deployments"
});
} else if (process.env.INTERNAL_AUTH_TOKEN.length < 32) {
securityIssues.push("INTERNAL_AUTH_TOKEN should be at least 32 characters in production");
}
// Check database file encryption
if (process.env.DB_FILE_ENCRYPTION === 'false') {
securityIssues.push("Database file encryption should be enabled in production");
@@ -114,7 +124,14 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
const authManager = AuthManager.getInstance();
await authManager.initialize();
DataCrypto.initialize();
systemLogger.info("Security system initialized (KEK-DEK architecture)", {
// Initialize system crypto keys (JWT, Database, Internal Auth)
const systemCrypto = SystemCrypto.getInstance();
await systemCrypto.initializeJWTSecret();
await systemCrypto.initializeDatabaseKey();
await systemCrypto.initializeInternalAuthToken();
systemLogger.info("Security system initialized (KEK-DEK architecture + SystemCrypto)", {
operation: "security_init",
});

View File

@@ -16,6 +16,7 @@ class SystemCrypto {
private static instance: SystemCrypto;
private jwtSecret: string | null = null;
private databaseKey: Buffer | null = null;
private internalAuthToken: string | null = null;
private constructor() {}
@@ -109,6 +110,47 @@ class SystemCrypto {
return this.databaseKey!;
}
/**
* Initialize internal auth token - environment variable only
*/
async initializeInternalAuthToken(): Promise<void> {
try {
databaseLogger.info("Initializing internal auth token", {
operation: "internal_auth_init",
});
// Check environment variable
const envToken = process.env.INTERNAL_AUTH_TOKEN;
if (envToken && envToken.length >= 32) {
this.internalAuthToken = envToken;
databaseLogger.info("✅ Using internal auth token from environment variable", {
operation: "internal_auth_env_loaded",
source: "environment"
});
return;
}
// No environment variable - generate and guide user
await this.generateAndGuideInternalAuthToken();
} catch (error) {
databaseLogger.error("Failed to initialize internal auth token", error, {
operation: "internal_auth_init_failed",
});
throw new Error("Internal auth token initialization failed");
}
}
/**
* Get internal auth token
*/
async getInternalAuthToken(): Promise<string> {
if (!this.internalAuthToken) {
await this.initializeInternalAuthToken();
}
return this.internalAuthToken!;
}
/**
* Generate and auto-save to .env file
*/
@@ -155,6 +197,27 @@ class SystemCrypto {
});
}
/**
* Generate and auto-save internal auth token to .env file
*/
private async generateAndGuideInternalAuthToken(): Promise<void> {
const newToken = crypto.randomBytes(32).toString('hex'); // 256-bit token for security
const instanceId = crypto.randomBytes(8).toString('hex');
// Set in memory for current session
this.internalAuthToken = newToken;
// Auto-save to .env file
await this.updateEnvFile("INTERNAL_AUTH_TOKEN", newToken);
databaseLogger.success("🔑 Internal auth token auto-generated and saved to .env", {
operation: "internal_auth_auto_generated",
instanceId,
envVarName: "INTERNAL_AUTH_TOKEN",
note: "Ready for use - no restart required"
});
}

View File

@@ -30,6 +30,8 @@ import {
getCredentials,
getSSHHosts,
updateSSHHost,
enableAutoStart,
disableAutoStart,
} from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import { CredentialSelector } from "@/ui/Desktop/Apps/Credentials/CredentialSelector.tsx";
@@ -436,22 +438,47 @@ export function HostManagerEditor({
submitData.keyType = data.keyType;
}
let savedHost;
if (editingHost && editingHost.id) {
const updatedHost = await updateSSHHost(editingHost.id, submitData);
savedHost = await updateSSHHost(editingHost.id, submitData);
toast.success(t("hosts.hostUpdatedSuccessfully", { name: data.name }));
if (onFormSubmit) {
onFormSubmit(updatedHost);
}
} else {
const newHost = await createSSHHost(submitData);
savedHost = await createSSHHost(submitData);
toast.success(t("hosts.hostAddedSuccessfully", { name: data.name }));
}
if (onFormSubmit) {
onFormSubmit(newHost);
// Handle AutoStart plaintext cache management
if (savedHost && savedHost.id && data.tunnelConnections) {
const hasAutoStartTunnels = data.tunnelConnections.some(tunnel => tunnel.autoStart);
if (hasAutoStartTunnels) {
// User has enabled autoStart on some tunnels
// Need to ensure plaintext cache exists for this host
try {
await enableAutoStart(savedHost.id);
console.log(`AutoStart plaintext cache enabled for SSH host ${savedHost.id}`);
} catch (error) {
console.warn(`Failed to enable AutoStart plaintext cache for SSH host ${savedHost.id}:`, error);
// Don't fail the whole operation if cache setup fails
toast.warning(t("hosts.autoStartEnableFailed", { name: data.name }));
}
} else {
// User has disabled autoStart on all tunnels
// Clean up plaintext cache for this host
try {
await disableAutoStart(savedHost.id);
console.log(`AutoStart plaintext cache disabled for SSH host ${savedHost.id}`);
} catch (error) {
console.warn(`Failed to disable AutoStart plaintext cache for SSH host ${savedHost.id}:`, error);
// Don't fail the whole operation
}
}
}
if (onFormSubmit) {
onFormSubmit(savedHost);
}
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
form.reset();

View File

@@ -13,7 +13,7 @@ import {
getUserInfo,
getRegistrationAllowed,
getOIDCConfig,
getUserCount,
getSetupRequired,
initiatePasswordReset,
verifyPasswordResetCode,
completePasswordReset,
@@ -124,9 +124,9 @@ export function HomepageAuth({
}, []);
useEffect(() => {
getUserCount()
getSetupRequired()
.then((res) => {
if (res.count === 0) {
if (res.setup_required) {
setFirstUser(true);
setTab("signup");
} else {

View File

@@ -12,7 +12,7 @@ import {
getUserInfo,
getRegistrationAllowed,
getOIDCConfig,
getUserCount,
getSetupRequired,
initiatePasswordReset,
verifyPasswordResetCode,
completePasswordReset,
@@ -111,9 +111,9 @@ export function HomepageAuth({
}, []);
useEffect(() => {
getUserCount()
getSetupRequired()
.then((res) => {
if (res.count === 0) {
if (res.setup_required) {
setFirstUser(true);
setTab("signup");
} else {

View File

@@ -742,6 +742,48 @@ export async function getSSHHostById(hostId: number): Promise<SSHHost> {
}
}
// ============================================================================
// SSH AUTOSTART MANAGEMENT
// ============================================================================
export async function enableAutoStart(sshConfigId: number): Promise<any> {
try {
const response = await sshHostApi.post("/autostart/enable", { sshConfigId });
return response.data;
} catch (error) {
handleApiError(error, "enable autostart");
}
}
export async function disableAutoStart(sshConfigId: number): Promise<any> {
try {
const response = await sshHostApi.delete("/autostart/disable", {
data: { sshConfigId }
});
return response.data;
} catch (error) {
handleApiError(error, "disable autostart");
}
}
export async function getAutoStartStatus(): Promise<{
autostart_configs: Array<{
sshConfigId: number;
host: string;
port: number;
username: string;
authType: string;
}>;
total_count: number;
}> {
try {
const response = await sshHostApi.get("/autostart/status");
return response.data;
} catch (error) {
handleApiError(error, "fetch autostart status");
}
}
// ============================================================================
// TUNNEL MANAGEMENT
// ============================================================================
@@ -1453,6 +1495,15 @@ export async function getOIDCConfig(): Promise<any> {
}
}
export async function getSetupRequired(): Promise<{ setup_required: boolean }> {
try {
const response = await authApi.get("/users/setup-required");
return response.data;
} catch (error) {
handleApiError(error, "check setup status");
}
}
export async function getUserCount(): Promise<UserCount> {
try {
const response = await authApi.get("/users/count");