refactor: pivot from ssh proxy to distributed api model

- Delete SSHFileAdapter, IFileAdapter, LocalFileAdapter (SSH/SFTP stack)
- Remove ssh2, ssh2-sftp-client dependencies; add http-proxy-middleware, http-proxy
- NodeRegistry: remote nodes no longer use Dockerode TCP; new getProxyTarget() returns {apiUrl, apiToken}
- NodeRegistry.testConnection: remote nodes use HTTP GET /api/auth/check instead of docker.info()
- DatabaseService: Node interface swaps SSH/TLS fields for api_url + api_token; legacy columns preserved for DB compat
- FileSystemService: reverted to clean local-only fs.promises; adapter pattern fully removed
- ComposeService: executeRemote() and SSH log streaming deleted; local-only execution remains
- index.ts: add /api/auth/generate-node-token endpoint (long-lived JWT, scope:node_proxy)
- index.ts: authMiddleware now accepts Bearer token in addition to cookie (Sencho-to-Sencho auth)
- index.ts: remote HTTP proxy middleware intercepts all /api/ requests for remote nodes, strips x-node-id, injects Authorization header, proxies to api_url
- index.ts: WS upgrade handler proxies WebSocket connections for remote nodes via http-proxy wsProxyServer
- NodeManager.tsx: form reduced to Name, API URL, API Token; Generate Node Token button added inline
- NodeContext.tsx: Node interface updated to api_url/api_token

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SaelixCode
2026-03-19 12:20:32 -04:00
parent 23730430d7
commit c91c9ed7fd
14 changed files with 522 additions and 899 deletions
+45 -123
View File
@@ -1,81 +1,59 @@
import path from 'path';
import { IFileAdapter } from './fs/IFileAdapter';
import { LocalFileAdapter } from './fs/LocalFileAdapter';
import { SSHFileAdapter } from './fs/SSHFileAdapter';
import fs from 'fs';
import { promises as fsPromises } from 'fs';
import { NodeRegistry } from './NodeRegistry';
/**
* FileSystemService — local-only file I/O for compose stack management.
*
* In the Distributed API model, remote node file operations are handled
* by the remote Sencho instance itself. This service only operates on
* the local filesystem.
*/
export class FileSystemService {
private baseDir: string;
private adapter: IFileAdapter;
private nodeId: number;
constructor(nodeId?: number) {
this.nodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
const node = NodeRegistry.getInstance().getNode(this.nodeId);
if (!node || node.type === 'local' || !node.host) {
this.baseDir = process.env.COMPOSE_DIR || '/app/compose';
this.adapter = new LocalFileAdapter();
} else {
this.baseDir = node.compose_dir;
if (!this.baseDir || typeof this.baseDir !== 'string' || this.baseDir.trim() === '') {
throw new Error(`Remote node "${node.name}" has no compose_dir configured. Please set a compose directory in the Node Manager.`);
}
this.adapter = new SSHFileAdapter(node);
}
this.baseDir = NodeRegistry.getInstance().getComposeDir(
nodeId ?? NodeRegistry.getInstance().getDefaultNodeId()
);
}
public static getInstance(nodeId?: number): FileSystemService {
return new FileSystemService(nodeId);
}
/**
* Check if a directory contains a valid compose file
*/
private async hasComposeFile(dir: string): Promise<boolean> {
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
try {
await this.adapter.access(path.join(dir, file));
await fsPromises.access(path.join(dir, file));
return true;
} catch {
// Continue checking other options
// continue
}
}
return false;
}
/**
* Get the path to the compose file for a stack
* Throws if no compose file is found
*/
private async getComposeFilePath(stackName: string): Promise<string> {
const stackDir = path.join(this.baseDir, stackName);
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
const filePath = path.join(stackDir, file);
try {
await this.adapter.access(filePath);
await fsPromises.access(filePath);
return filePath;
} catch {
// Continue checking other options
// continue
}
}
throw new Error(`No compose file found for stack: ${stackName}`);
}
/**
* Get all stacks (directories containing compose files)
* Returns array of stack names (directory names)
*/
async getStacks(): Promise<string[]> {
try {
const items = await this.adapter.readdir(this.baseDir, { withFileTypes: true });
const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
const stackNames: string[] = [];
for (const item of items) {
@@ -83,46 +61,33 @@ export class FileSystemService {
if (!item.name || typeof item.name !== 'string') continue;
const stackDir = path.join(this.baseDir, item.name);
const hasCompose = await this.hasComposeFile(stackDir);
if (hasCompose) {
if (await this.hasComposeFile(stackDir)) {
stackNames.push(item.name);
}
}
return stackNames;
} catch (error: any) {
const nodeName = NodeRegistry.getInstance().getNode(this.nodeId)?.name || 'Unknown';
console.warn(`[SFTP] Failed to fetch stacks for Node ${nodeName}: ${error.message || error}`);
console.warn(`[FileSystemService] Failed to list stacks: ${error.message}`);
return [];
}
}
/**
* Get the content of a stack's compose file
*/
async getStackContent(stackName: string): Promise<string> {
try {
const filePath = await this.getComposeFilePath(stackName);
return await this.adapter.readFile(filePath, 'utf-8');
return await fsPromises.readFile(filePath, 'utf-8');
} catch (error) {
console.error('Error reading stack content:', error);
throw new Error(`Failed to read stack: ${stackName}`);
}
}
/**
* Save content to a stack's compose file
* Always writes to compose.yaml (standardizing on this filename)
*/
async saveStackContent(stackName: string, content: string): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
const filePath = path.join(stackDir, 'compose.yaml');
const filePath = path.join(this.baseDir, stackName, 'compose.yaml');
console.log('Saving to path:', filePath);
try {
await this.adapter.writeFile(filePath, content, 'utf-8');
await fsPromises.writeFile(filePath, content, 'utf-8');
console.log('File written successfully');
} catch (error) {
console.error('Error writing file:', error);
@@ -130,55 +95,42 @@ export class FileSystemService {
}
}
/**
* Check if a stack has an .env file
*/
async envExists(stackName: string): Promise<boolean> {
const envPath = path.join(this.baseDir, stackName, '.env');
try {
await this.adapter.access(envPath);
await fsPromises.access(path.join(this.baseDir, stackName, '.env'));
return true;
} catch {
return false;
}
}
// Proxy to adapter read/write operations for use in other services and generic routes
async readFile(filePath: string, encoding: BufferEncoding = 'utf-8'): Promise<string> {
return this.adapter.readFile(filePath, encoding);
return fsPromises.readFile(filePath, encoding);
}
async writeFile(filePath: string, content: string, encoding: BufferEncoding = 'utf-8'): Promise<void> {
return this.adapter.writeFile(filePath, content, encoding);
return fsPromises.writeFile(filePath, content, encoding);
}
async access(filePath: string): Promise<void> {
return this.adapter.access(filePath);
return fsPromises.access(filePath);
}
/**
* Get the content of a stack's .env file
*/
async getEnvContent(stackName: string): Promise<string> {
const envPath = path.join(this.baseDir, stackName, '.env');
try {
return await this.adapter.readFile(envPath, 'utf-8');
return await fsPromises.readFile(envPath, 'utf-8');
} catch (error) {
console.error('Error reading env file:', error);
throw new Error(`Failed to read env file for stack: ${stackName}`);
}
}
/**
* Save content to a stack's .env file
*/
async saveEnvContent(stackName: string, content: string): Promise<void> {
const envPath = path.join(this.baseDir, stackName, '.env');
console.log('Saving env to path:', envPath);
try {
await this.adapter.writeFile(envPath, content, 'utf-8');
await fsPromises.writeFile(envPath, content, 'utf-8');
console.log('Env file written successfully');
} catch (error) {
console.error('Error writing env file:', error);
@@ -186,33 +138,22 @@ export class FileSystemService {
}
}
/**
* Create a new stack (directory with boilerplate compose.yaml)
*/
async createStack(stackName: string): Promise<void> {
// Validate stack name (no special characters, not empty)
if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) {
throw new Error('Stack name must contain only alphanumeric characters, underscores, or hyphens');
}
const stackDir = path.join(this.baseDir, stackName);
// Check if directory already exists
try {
await this.adapter.access(stackDir);
await fsPromises.access(stackDir);
throw new Error(`Stack "${stackName}" already exists`);
} catch (error: any) {
if (error.message.includes('already exists')) {
throw error;
}
// Directory doesn't exist, proceed
if (error.message.includes('already exists')) throw error;
}
// Create the directory
await this.adapter.mkdir(stackDir, { recursive: true });
await fsPromises.mkdir(stackDir, { recursive: true });
// Write boilerplate compose.yaml
const composePath = path.join(stackDir, 'compose.yaml');
const boilerplate = `services:
app:
image: nginx:latest
@@ -221,7 +162,7 @@ export class FileSystemService {
restart: always
`;
try {
await this.adapter.writeFile(composePath, boilerplate, 'utf-8');
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), boilerplate, 'utf-8');
console.log('Stack created successfully:', stackName);
} catch (error) {
console.error('Error creating stack:', error);
@@ -229,14 +170,10 @@ export class FileSystemService {
}
}
/**
* Delete a stack (entire directory and its contents)
*/
public async deleteStack(stackName: string): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
try {
await this.adapter.rm(stackDir, { recursive: true, force: true });
await fsPromises.rm(stackDir, { recursive: true, force: true });
console.log('Stack deleted successfully:', stackName);
} catch (error: any) {
if (error.code !== 'ENOENT') {
@@ -246,73 +183,58 @@ export class FileSystemService {
}
}
/**
* Get the base directory path for stacks
*/
getBaseDir(): string {
return this.baseDir;
}
/**
* Migrate existing flat-file stacks to directory-based structure
* This runs automatically on server startup
*/
async migrateFlatToDirectory(): Promise<void> {
try {
// Ensure base directory exists
try {
await this.adapter.access(this.baseDir);
await fsPromises.access(this.baseDir);
} catch {
console.log('Creating compose directory:', this.baseDir);
await this.adapter.mkdir(this.baseDir, { recursive: true });
return; // No files to migrate in a new directory
await fsPromises.mkdir(this.baseDir, { recursive: true });
return;
}
const items = await this.adapter.readdir(this.baseDir, { withFileTypes: true });
const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
for (const item of items) {
// Only process .yml/.yaml files (skip directories and other files)
if (!item.isFile()) continue;
if (!item.name.endsWith('.yml') && !item.name.endsWith('.yaml')) continue;
const stackName = item.name.replace(/\.(yml|yaml)$/, '');
const stackDir = path.join(this.baseDir, stackName);
// Check if target directory already exists
try {
await this.adapter.access(stackDir);
await fsPromises.access(stackDir);
console.log(`Skipping migration for "${stackName}": directory already exists`);
continue;
} catch {
// Directory doesn't exist, proceed with migration
// Directory doesn't exist, proceed
}
console.log(`Migrating stack: ${stackName}`);
await fsPromises.mkdir(stackDir, { recursive: true });
// Create the stack directory
await this.adapter.mkdir(stackDir, { recursive: true });
// Move compose file to new location (standardize on compose.yaml)
const oldComposePath = path.join(this.baseDir, item.name);
const newComposePath = path.join(stackDir, 'compose.yaml');
await this.adapter.rename(oldComposePath, newComposePath);
await fsPromises.rename(oldComposePath, newComposePath);
// Move env file if it exists (old pattern: stackname.env)
const oldEnvPath = path.join(this.baseDir, `${stackName}.env`);
const newEnvPath = path.join(stackDir, '.env');
try {
await this.adapter.access(oldEnvPath);
await this.adapter.rename(oldEnvPath, newEnvPath);
await fsPromises.access(oldEnvPath);
await fsPromises.rename(oldEnvPath, newEnvPath);
console.log(`Migrated env file for: ${stackName}`);
} catch {
// No env file to migrate, that's fine
// No env file to migrate
}
console.log(`Successfully migrated stack: ${stackName}`);
}
} catch (error) {
console.error('Migration error:', error);
// Don't throw - allow the server to start even if migration fails
}
}
}
}