346 lines
9.4 KiB
TypeScript
346 lines
9.4 KiB
TypeScript
|
|
/**
|
||
|
|
* Nginx Manager for Onebox
|
||
|
|
*
|
||
|
|
* Manages Nginx reverse proxy configurations for services
|
||
|
|
*/
|
||
|
|
|
||
|
|
import * as plugins from './onebox.plugins.ts';
|
||
|
|
import { logger } from './onebox.logging.ts';
|
||
|
|
import { OneboxDatabase } from './onebox.classes.database.ts';
|
||
|
|
|
||
|
|
export class OneboxNginxManager {
|
||
|
|
private oneboxRef: any;
|
||
|
|
private database: OneboxDatabase;
|
||
|
|
private configDir = '/etc/nginx/sites-available';
|
||
|
|
private enabledDir = '/etc/nginx/sites-enabled';
|
||
|
|
|
||
|
|
constructor(oneboxRef: any) {
|
||
|
|
this.oneboxRef = oneboxRef;
|
||
|
|
this.database = oneboxRef.database;
|
||
|
|
|
||
|
|
// Allow custom nginx config directory
|
||
|
|
const customDir = this.database.getSetting('nginxConfigDir');
|
||
|
|
if (customDir) {
|
||
|
|
this.configDir = customDir;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Initialize nginx manager
|
||
|
|
*/
|
||
|
|
async init(): Promise<void> {
|
||
|
|
try {
|
||
|
|
// Ensure directories exist
|
||
|
|
await Deno.mkdir(this.configDir, { recursive: true });
|
||
|
|
await Deno.mkdir(this.enabledDir, { recursive: true });
|
||
|
|
|
||
|
|
logger.info('Nginx manager initialized');
|
||
|
|
} catch (error) {
|
||
|
|
logger.error(`Failed to initialize Nginx manager: ${error.message}`);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create nginx config for a service
|
||
|
|
*/
|
||
|
|
async createConfig(serviceId: number, domain: string, port: number): Promise<void> {
|
||
|
|
try {
|
||
|
|
logger.info(`Creating Nginx config for ${domain}`);
|
||
|
|
|
||
|
|
const service = this.database.getServiceByID(serviceId);
|
||
|
|
if (!service) {
|
||
|
|
throw new Error(`Service not found: ${serviceId}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get container IP (or use container name for DNS resolution within Docker network)
|
||
|
|
const containerName = `onebox-${service.name}`;
|
||
|
|
|
||
|
|
// Generate config
|
||
|
|
const config = this.generateConfig(domain, containerName, port);
|
||
|
|
|
||
|
|
// Write config file
|
||
|
|
const configPath = `${this.configDir}/onebox-${service.name}.conf`;
|
||
|
|
await Deno.writeTextFile(configPath, config);
|
||
|
|
|
||
|
|
// Create symlink in sites-enabled
|
||
|
|
const enabledPath = `${this.enabledDir}/onebox-${service.name}.conf`;
|
||
|
|
try {
|
||
|
|
await Deno.remove(enabledPath);
|
||
|
|
} catch {
|
||
|
|
// Ignore if doesn't exist
|
||
|
|
}
|
||
|
|
await Deno.symlink(configPath, enabledPath);
|
||
|
|
|
||
|
|
logger.success(`Nginx config created: ${domain}`);
|
||
|
|
} catch (error) {
|
||
|
|
logger.error(`Failed to create Nginx config: ${error.message}`);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate nginx configuration
|
||
|
|
*/
|
||
|
|
private generateConfig(domain: string, upstream: string, port: number): string {
|
||
|
|
return `# Onebox-managed configuration for ${domain}
|
||
|
|
# Generated at ${new Date().toISOString()}
|
||
|
|
|
||
|
|
upstream onebox_${domain.replace(/\./g, '_')} {
|
||
|
|
server ${upstream}:${port};
|
||
|
|
}
|
||
|
|
|
||
|
|
# HTTP server (redirect to HTTPS or serve directly)
|
||
|
|
server {
|
||
|
|
listen 80;
|
||
|
|
listen [::]:80;
|
||
|
|
server_name ${domain};
|
||
|
|
|
||
|
|
# ACME challenge for Let's Encrypt
|
||
|
|
location /.well-known/acme-challenge/ {
|
||
|
|
root /var/www/certbot;
|
||
|
|
}
|
||
|
|
|
||
|
|
# Redirect to HTTPS (will be enabled after SSL is configured)
|
||
|
|
# location / {
|
||
|
|
# return 301 https://$server_name$request_uri;
|
||
|
|
# }
|
||
|
|
|
||
|
|
# Proxy to container (remove after SSL is configured)
|
||
|
|
location / {
|
||
|
|
proxy_pass http://onebox_${domain.replace(/\./g, '_')};
|
||
|
|
proxy_set_header Host $host;
|
||
|
|
proxy_set_header X-Real-IP $remote_addr;
|
||
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||
|
|
|
||
|
|
# WebSocket support
|
||
|
|
proxy_http_version 1.1;
|
||
|
|
proxy_set_header Upgrade $http_upgrade;
|
||
|
|
proxy_set_header Connection "upgrade";
|
||
|
|
|
||
|
|
# Timeouts
|
||
|
|
proxy_connect_timeout 60s;
|
||
|
|
proxy_send_timeout 60s;
|
||
|
|
proxy_read_timeout 60s;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# HTTPS server (uncomment after SSL is configured)
|
||
|
|
# server {
|
||
|
|
# listen 443 ssl http2;
|
||
|
|
# listen [::]:443 ssl http2;
|
||
|
|
# server_name ${domain};
|
||
|
|
#
|
||
|
|
# ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
|
||
|
|
# ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;
|
||
|
|
#
|
||
|
|
# # SSL configuration
|
||
|
|
# ssl_protocols TLSv1.2 TLSv1.3;
|
||
|
|
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||
|
|
# ssl_prefer_server_ciphers on;
|
||
|
|
#
|
||
|
|
# location / {
|
||
|
|
# proxy_pass http://onebox_${domain.replace(/\./g, '_')};
|
||
|
|
# proxy_set_header Host $host;
|
||
|
|
# proxy_set_header X-Real-IP $remote_addr;
|
||
|
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
|
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||
|
|
#
|
||
|
|
# # WebSocket support
|
||
|
|
# proxy_http_version 1.1;
|
||
|
|
# proxy_set_header Upgrade $http_upgrade;
|
||
|
|
# proxy_set_header Connection "upgrade";
|
||
|
|
#
|
||
|
|
# # Timeouts
|
||
|
|
# proxy_connect_timeout 60s;
|
||
|
|
# proxy_send_timeout 60s;
|
||
|
|
# proxy_read_timeout 60s;
|
||
|
|
# }
|
||
|
|
# }
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update nginx config to enable SSL
|
||
|
|
*/
|
||
|
|
async enableSSL(domain: string): Promise<void> {
|
||
|
|
try {
|
||
|
|
logger.info(`Enabling SSL for ${domain}`);
|
||
|
|
|
||
|
|
// Find service by domain
|
||
|
|
const services = this.database.getAllServices();
|
||
|
|
const service = services.find((s) => s.domain === domain);
|
||
|
|
|
||
|
|
if (!service) {
|
||
|
|
throw new Error(`Service not found for domain: ${domain}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const configPath = `${this.configDir}/onebox-${service.name}.conf`;
|
||
|
|
let config = await Deno.readTextFile(configPath);
|
||
|
|
|
||
|
|
// Enable HTTPS redirect and HTTPS server block
|
||
|
|
config = config.replace('# location / {\n # return 301', 'location / {\n return 301');
|
||
|
|
config = config.replace('# }', '}');
|
||
|
|
config = config.replace(/# server \{[\s\S]*?# \}/m, (match) =>
|
||
|
|
match.replace(/# /g, '')
|
||
|
|
);
|
||
|
|
|
||
|
|
// Comment out HTTP proxy location
|
||
|
|
config = config.replace(
|
||
|
|
/# Proxy to container \(remove after SSL is configured\)[\s\S]*?location \/ \{[\s\S]*?\n \}/,
|
||
|
|
(match) => `# ${match.replace(/\n/g, '\n # ')}`
|
||
|
|
);
|
||
|
|
|
||
|
|
await Deno.writeTextFile(configPath, config);
|
||
|
|
|
||
|
|
logger.success(`SSL enabled for ${domain}`);
|
||
|
|
} catch (error) {
|
||
|
|
logger.error(`Failed to enable SSL for ${domain}: ${error.message}`);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Remove nginx config for a service
|
||
|
|
*/
|
||
|
|
async removeConfig(serviceId: number): Promise<void> {
|
||
|
|
try {
|
||
|
|
const service = this.database.getServiceByID(serviceId);
|
||
|
|
if (!service) {
|
||
|
|
throw new Error(`Service not found: ${serviceId}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.info(`Removing Nginx config for ${service.name}`);
|
||
|
|
|
||
|
|
// Remove symlink
|
||
|
|
const enabledPath = `${this.enabledDir}/onebox-${service.name}.conf`;
|
||
|
|
try {
|
||
|
|
await Deno.remove(enabledPath);
|
||
|
|
} catch {
|
||
|
|
// Ignore if doesn't exist
|
||
|
|
}
|
||
|
|
|
||
|
|
// Remove config file
|
||
|
|
const configPath = `${this.configDir}/onebox-${service.name}.conf`;
|
||
|
|
try {
|
||
|
|
await Deno.remove(configPath);
|
||
|
|
} catch {
|
||
|
|
// Ignore if doesn't exist
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.success(`Nginx config removed for ${service.name}`);
|
||
|
|
} catch (error) {
|
||
|
|
logger.error(`Failed to remove Nginx config: ${error.message}`);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Test nginx configuration
|
||
|
|
*/
|
||
|
|
async test(): Promise<boolean> {
|
||
|
|
try {
|
||
|
|
const command = new Deno.Command('nginx', {
|
||
|
|
args: ['-t'],
|
||
|
|
stdout: 'piped',
|
||
|
|
stderr: 'piped',
|
||
|
|
});
|
||
|
|
|
||
|
|
const { code, stderr } = await command.output();
|
||
|
|
|
||
|
|
if (code !== 0) {
|
||
|
|
const errorMsg = new TextDecoder().decode(stderr);
|
||
|
|
logger.error(`Nginx config test failed: ${errorMsg}`);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.success('Nginx configuration is valid');
|
||
|
|
return true;
|
||
|
|
} catch (error) {
|
||
|
|
logger.error(`Failed to test Nginx config: ${error.message}`);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Reload nginx
|
||
|
|
*/
|
||
|
|
async reload(): Promise<void> {
|
||
|
|
try {
|
||
|
|
// Test config first
|
||
|
|
const isValid = await this.test();
|
||
|
|
if (!isValid) {
|
||
|
|
throw new Error('Nginx configuration is invalid');
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.info('Reloading Nginx...');
|
||
|
|
|
||
|
|
const command = new Deno.Command('systemctl', {
|
||
|
|
args: ['reload', 'nginx'],
|
||
|
|
stdout: 'piped',
|
||
|
|
stderr: 'piped',
|
||
|
|
});
|
||
|
|
|
||
|
|
const { code, stderr } = await command.output();
|
||
|
|
|
||
|
|
if (code !== 0) {
|
||
|
|
const errorMsg = new TextDecoder().decode(stderr);
|
||
|
|
throw new Error(`Nginx reload failed: ${errorMsg}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.success('Nginx reloaded successfully');
|
||
|
|
} catch (error) {
|
||
|
|
logger.error(`Failed to reload Nginx: ${error.message}`);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get nginx status
|
||
|
|
*/
|
||
|
|
async getStatus(): Promise<string> {
|
||
|
|
try {
|
||
|
|
const command = new Deno.Command('systemctl', {
|
||
|
|
args: ['status', 'nginx'],
|
||
|
|
stdout: 'piped',
|
||
|
|
stderr: 'piped',
|
||
|
|
});
|
||
|
|
|
||
|
|
const { code, stdout } = await command.output();
|
||
|
|
const output = new TextDecoder().decode(stdout);
|
||
|
|
|
||
|
|
if (code === 0 || output.includes('active (running)')) {
|
||
|
|
return 'running';
|
||
|
|
} else if (output.includes('inactive') || output.includes('dead')) {
|
||
|
|
return 'stopped';
|
||
|
|
} else if (output.includes('failed')) {
|
||
|
|
return 'failed';
|
||
|
|
} else {
|
||
|
|
return 'unknown';
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
logger.error(`Failed to get Nginx status: ${error.message}`);
|
||
|
|
return 'unknown';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if nginx is installed
|
||
|
|
*/
|
||
|
|
async isInstalled(): Promise<boolean> {
|
||
|
|
try {
|
||
|
|
const command = new Deno.Command('which', {
|
||
|
|
args: ['nginx'],
|
||
|
|
stdout: 'piped',
|
||
|
|
stderr: 'piped',
|
||
|
|
});
|
||
|
|
|
||
|
|
const { code } = await command.output();
|
||
|
|
return code === 0;
|
||
|
|
} catch {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|