| … | … | @@ -0,0 +1,259 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | import { Registry } from '../lib/registry.js' | |
| 4 | import { Deployer } from '../lib/deployer.js' | |
| 5 | import { generateConfig } from '../lib/config-generator.js' | |
| 6 | ||
| 7 | const args = process.argv.slice(2) | |
| 8 | const command = args[0] | |
| 9 | const subcommand = args[1] | |
| 10 | ||
| 11 | function parseFlags (args) { | |
| 12 | const flags = {} | |
| 13 | for (let i = 0; i < args.length; i++) { | |
| 14 | if (args[i].startsWith('--')) { | |
| 15 | const key = args[i].slice(2) | |
| 16 | const val = args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true | |
| 17 | flags[key] = val | |
| 18 | if (val !== true) i++ | |
| 19 | } | |
| 20 | } | |
| 21 | return flags | |
| 22 | } | |
| 23 | ||
| 24 | function usage () { | |
| 25 | console.log(`Usage: 7rnx <command> [options] | |
| 26 | ||
| 27 | Node Management: | |
| 28 | 7rnx node add <petname> --ip <vpn-ip> [--keet-ident <key>] | |
| 29 | 7rnx node list | |
| 30 | 7rnx node remove <petname> | |
| 31 | ||
| 32 | Service Management: | |
| 33 | 7rnx service add <node> <name> --port <port> [--prefix <path>] [--root-path <path>] | |
| 34 | 7rnx service list [node] | |
| 35 | 7rnx service remove <node> <name> | |
| 36 | 7rnx service status [node] Check which services are actually listening | |
| 37 | ||
| 38 | Deployment: | |
| 39 | 7rnx deploy Deploy config to 7rnx.net and reload Apache | |
| 40 | 7rnx deploy --dry-run Show generated config without deploying | |
| 41 | 7rnx status Check Apache status on 7rnx.net | |
| 42 | ||
| 43 | Remote: | |
| 44 | 7rnx remote config [path] Fetch live config from 7rnx.net (default: SSL vhost) | |
| 45 | 7rnx remote ls List sites-enabled configs | |
| 46 | ||
| 47 | Web UI: | |
| 48 | 7rnx ui [port] Start dashboard (default: 7777) | |
| 49 | 7rnx ui --network Bind to 0.0.0.0 (LAN access) | |
| 50 | ||
| 51 | Config: | |
| 52 | 7rnx config Show current registry | |
| 53 | 7rnx config set-server --host <ip> --user <user>`) | |
| 54 | } | |
| 55 | ||
| 56 | function main () { | |
| 57 | if (!command || command === 'help' || command === '--help') { | |
| 58 | usage() | |
| 59 | process.exit(0) | |
| 60 | } | |
| 61 | ||
| 62 | const registry = new Registry().load() | |
| 63 | ||
| 64 | switch (command) { | |
| 65 | case 'node': | |
| 66 | handleNode(registry, subcommand, args.slice(2)) | |
| 67 | break | |
| 68 | case 'service': | |
| 69 | handleService(registry, subcommand, args.slice(2)) | |
| 70 | break | |
| 71 | case 'deploy': | |
| 72 | handleDeploy(registry, args.slice(1)) | |
| 73 | break | |
| 74 | case 'status': | |
| 75 | handleStatus(registry) | |
| 76 | break | |
| 77 | case 'remote': | |
| 78 | handleRemote(registry, subcommand, args.slice(2)) | |
| 79 | break | |
| 80 | case 'ui': | |
| 81 | handleUi(args.slice(1)) | |
| 82 | break | |
| 83 | case 'config': | |
| 84 | handleConfig(registry, subcommand, args.slice(2)) | |
| 85 | break | |
| 86 | default: | |
| 87 | console.error(`Unknown command: ${command}`) | |
| 88 | usage() | |
| 89 | process.exit(1) | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | function handleNode (registry, sub, rest) { | |
| 94 | switch (sub) { | |
| 95 | case 'add': { | |
| 96 | const petname = rest[0] | |
| 97 | const flags = parseFlags(rest.slice(1)) | |
| 98 | if (!petname || !flags.ip) { | |
| 99 | console.error('Usage: 7rnx node add <petname> --ip <vpn-ip> [--keet-ident <key>]') | |
| 100 | process.exit(1) | |
| 101 | } | |
| 102 | registry.addNode(petname, flags.ip, flags['keet-ident']) | |
| 103 | console.log(`Added node "${petname}" (${flags.ip})`) | |
| 104 | break | |
| 105 | } | |
| 106 | case 'list': { | |
| 107 | const nodes = registry.listNodes() | |
| 108 | if (nodes.length === 0) { | |
| 109 | console.log('No nodes registered.') | |
| 110 | return | |
| 111 | } | |
| 112 | for (const n of nodes) { | |
| 113 | const ident = n.keetIdent ? ` [${n.keetIdent.slice(0, 8)}...]` : '' | |
| 114 | console.log(` ${n.petname}\t${n.ip}${ident}`) | |
| 115 | } | |
| 116 | break | |
| 117 | } | |
| 118 | case 'remove': { | |
| 119 | const petname = rest[0] | |
| 120 | if (!petname) { | |
| 121 | console.error('Usage: 7rnx node remove <petname>') | |
| 122 | process.exit(1) | |
| 123 | } | |
| 124 | const services = registry.listServices(petname) | |
| 125 | registry.removeNode(petname) | |
| 126 | console.log(`Removed node "${petname}" and ${services.length} service(s)`) | |
| 127 | break | |
| 128 | } | |
| 129 | default: | |
| 130 | console.error('Usage: 7rnx node <add|list|remove>') | |
| 131 | process.exit(1) | |
| 132 | } | |
| 133 | } | |
| 134 | ||
| 135 | function handleService (registry, sub, rest) { | |
| 136 | switch (sub) { | |
| 137 | case 'add': { | |
| 138 | const nodePetname = rest[0] | |
| 139 | const serviceName = rest[1] | |
| 140 | const flags = parseFlags(rest.slice(2)) | |
| 141 | if (!nodePetname || !serviceName || !flags.port) { | |
| 142 | console.error('Usage: 7rnx service add <node> <name> --port <port> [--prefix <path>] [--root-path <path>]') | |
| 143 | process.exit(1) | |
| 144 | } | |
| 145 | registry.addService(nodePetname, serviceName, flags.port, flags.prefix, flags['root-path']) | |
| 146 | const urlPath = flags['root-path'] || `/${nodePetname}/${serviceName}/` | |
| 147 | console.log(`Added service "${serviceName}" on "${nodePetname}" → ${urlPath} → :${flags.port}`) | |
| 148 | break | |
| 149 | } | |
| 150 | case 'list': { | |
| 151 | const nodePetname = rest[0] | |
| 152 | const services = registry.listServices(nodePetname) | |
| 153 | if (services.length === 0) { | |
| 154 | console.log(nodePetname ? `No services on node "${nodePetname}".` : 'No services registered.') | |
| 155 | return | |
| 156 | } | |
| 157 | for (const s of services) { | |
| 158 | const urlPath = s.rootPath || `/${s.node}/${s.name}/` | |
| 159 | console.log(` ${s.node}/${s.name}\t:${s.port}\t→ ${urlPath}`) | |
| 160 | } | |
| 161 | break | |
| 162 | } | |
| 163 | case 'remove': { | |
| 164 | const nodePetname = rest[0] | |
| 165 | const serviceName = rest[1] | |
| 166 | if (!nodePetname || !serviceName) { | |
| 167 | console.error('Usage: 7rnx service remove <node> <name>') | |
| 168 | process.exit(1) | |
| 169 | } | |
| 170 | registry.removeService(nodePetname, serviceName) | |
| 171 | console.log(`Removed service "${serviceName}" from "${nodePetname}"`) | |
| 172 | break | |
| 173 | } | |
| 174 | case 'status': { | |
| 175 | const nodePetname = rest[0] | |
| 176 | const deployer = new Deployer(registry.data) | |
| 177 | const services = registry.listServices(nodePetname) | |
| 178 | if (services.length === 0) { | |
| 179 | console.log(nodePetname ? `No services on node "${nodePetname}".` : 'No services registered.') | |
| 180 | return | |
| 181 | } | |
| 182 | const results = deployer.checkServices(registry.data.nodes, services) | |
| 183 | for (const r of results) { | |
| 184 | const icon = r.status === 'UP' ? '\x1b[32mUP\x1b[0m' : '\x1b[31m' + r.status + '\x1b[0m' | |
| 185 | const urlPath = services.find(s => s.node === r.node && s.name === r.name) | |
| 186 | const url = urlPath?.rootPath || `/${r.node}/${r.name}/` | |
| 187 | console.log(` ${icon}\t${r.node}/${r.name}\t:${r.port}\t${url}`) | |
| 188 | } | |
| 189 | break | |
| 190 | } | |
| 191 | default: | |
| 192 | console.error('Usage: 7rnx service <add|list|remove|status>') | |
| 193 | process.exit(1) | |
| 194 | } | |
| 195 | } | |
| 196 | ||
| 197 | function handleDeploy (registry, rest) { | |
| 198 | const flags = parseFlags(rest) | |
| 199 | const deployer = new Deployer(registry.data) | |
| 200 | ||
| 201 | if (flags['dry-run']) { | |
| 202 | console.log(deployer.dryRun()) | |
| 203 | return | |
| 204 | } | |
| 205 | ||
| 206 | deployer.deploy() | |
| 207 | } | |
| 208 | ||
| 209 | function handleStatus (registry) { | |
| 210 | const deployer = new Deployer(registry.data) | |
| 211 | deployer.status() | |
| 212 | } | |
| 213 | ||
| 214 | function handleRemote (registry, sub, rest) { | |
| 215 | const deployer = new Deployer(registry.data) | |
| 216 | ||
| 217 | switch (sub) { | |
| 218 | case 'config': { | |
| 219 | const configPath = rest[0] || '/etc/apache2/sites-enabled/000-default-le-ssl.conf' | |
| 220 | console.log(`# Fetching ${configPath} from ${registry.data.server.host}\n`) | |
| 221 | const content = deployer.fetchRemoteConfig(configPath) | |
| 222 | if (content) console.log(content) | |
| 223 | break | |
| 224 | } | |
| 225 | case 'ls': { | |
| 226 | const listing = deployer.listRemoteConfigs() | |
| 227 | if (listing) console.log(listing.trim()) | |
| 228 | break | |
| 229 | } | |
| 230 | default: | |
| 231 | console.error('Usage: 7rnx remote <config|ls>') | |
| 232 | process.exit(1) | |
| 233 | } | |
| 234 | } | |
| 235 | ||
| 236 | async function handleUi (rest) { | |
| 237 | const flags = parseFlags(rest) | |
| 238 | const port = rest.find(a => !a.startsWith('--')) || 7777 | |
| 239 | const { startServer } = await import('../lib/web.js') | |
| 240 | startServer(Number(port), !!flags.network) | |
| 241 | } | |
| 242 | ||
| 243 | function handleConfig (registry, sub, rest) { | |
| 244 | if (sub === 'set-server') { | |
| 245 | const flags = parseFlags(rest) | |
| 246 | if (!flags.host && !flags.user) { | |
| 247 | console.error('Usage: 7rnx config set-server --host <ip> --user <user>') | |
| 248 | process.exit(1) | |
| 249 | } | |
| 250 | registry.setServer(flags.host, flags.user) | |
| 251 | console.log(`Server: ${registry.data.server.user}@${registry.data.server.host}`) | |
| 252 | return | |
| 253 | } | |
| 254 | ||
| 255 | // Default: show registry | |
| 256 | console.log(JSON.stringify(registry.data, null, 2)) | |
| 257 | } | |
| 258 | ||
| 259 | main() |
| … | … | @@ -0,0 +1,54 @@ |
| 1 | export function generateConfig (registry) { | |
| 2 | const lines = [] | |
| 3 | const now = new Date().toISOString() | |
| 4 | ||
| 5 | lines.push('# Auto-generated by 7rnx-proxy — DO NOT EDIT MANUALLY') | |
| 6 | lines.push(`# Generated: ${now}`) | |
| 7 | lines.push('#') | |
| 8 | lines.push('# Included from the SSL VirtualHost in 000-default-le-ssl.conf') | |
| 9 | lines.push('') | |
| 10 | ||
| 11 | // Group services by node | |
| 12 | const nodeMap = new Map() | |
| 13 | for (const node of registry.nodes) { | |
| 14 | nodeMap.set(node.petname, { | |
| 15 | node, | |
| 16 | services: registry.services.filter(s => s.node === node.petname) | |
| 17 | }) | |
| 18 | } | |
| 19 | ||
| 20 | for (const [petname, { node, services }] of nodeMap) { | |
| 21 | if (services.length === 0) continue | |
| 22 | ||
| 23 | const hasRootPaths = services.some(s => s.rootPath) | |
| 24 | const label = hasRootPaths | |
| 25 | ? `${petname} (${node.ip}) — root-level paths (legacy)` | |
| 26 | : `${petname} (${node.ip})` | |
| 27 | ||
| 28 | lines.push(`# === ${label} ===`) | |
| 29 | lines.push('') | |
| 30 | ||
| 31 | for (const svc of services) { | |
| 32 | const urlPath = svc.rootPath || `/${petname}/${svc.name}` | |
| 33 | const urlPathClean = urlPath.endsWith('/') ? urlPath.slice(0, -1) : urlPath | |
| 34 | const backend = `http://${node.ip}:${svc.port}` | |
| 35 | ||
| 36 | lines.push(`<Location ${urlPathClean}>`) | |
| 37 | lines.push(` ProxyPass ${backend}`) | |
| 38 | lines.push(` ProxyPassReverse ${backend}`) | |
| 39 | lines.push(' ProxyPreserveHost Off') | |
| 40 | lines.push(' RequestHeader set X-Forwarded-Proto "https"') | |
| 41 | lines.push(' RequestHeader set X-Forwarded-Port "443"') | |
| 42 | lines.push(` RequestHeader set X-Forwarded-Prefix "${urlPathClean}"`) | |
| 43 | lines.push('</Location>') | |
| 44 | lines.push('') | |
| 45 | ||
| 46 | // WebSocket rewrite rule | |
| 47 | lines.push(`RewriteCond %{HTTP:Upgrade} =websocket [NC]`) | |
| 48 | lines.push(`RewriteRule ^${urlPathClean}(.*)$ ws://${node.ip}:${svc.port}$1 [P,L]`) | |
| 49 | lines.push('') | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | return lines.join('\n') | |
| 54 | } |
| … | … | @@ -0,0 +1,142 @@ |
| 1 | import { execSync } from 'node:child_process' | |
| 2 | import fs from 'node:fs' | |
| 3 | import os from 'node:os' | |
| 4 | import path from 'node:path' | |
| 5 | import { generateConfig } from './config-generator.js' | |
| 6 | ||
| 7 | export class Deployer { | |
| 8 | constructor (registry) { | |
| 9 | this.registry = registry | |
| 10 | this.server = registry.server | |
| 11 | } | |
| 12 | ||
| 13 | _ssh (cmd) { | |
| 14 | const full = `ssh ${this.server.user}@${this.server.host} "${cmd}"` | |
| 15 | return execSync(full, { encoding: 'utf8', timeout: 30000 }) | |
| 16 | } | |
| 17 | ||
| 18 | _scp (localPath, remotePath) { | |
| 19 | const full = `scp ${localPath} ${this.server.user}@${this.server.host}:${remotePath}` | |
| 20 | return execSync(full, { encoding: 'utf8', timeout: 30000 }) | |
| 21 | } | |
| 22 | ||
| 23 | dryRun () { | |
| 24 | return generateConfig(this.registry) | |
| 25 | } | |
| 26 | ||
| 27 | deploy () { | |
| 28 | const config = generateConfig(this.registry) | |
| 29 | ||
| 30 | // Write to temp file | |
| 31 | const tmpFile = path.join(os.tmpdir(), '7rnx-proxy.conf') | |
| 32 | fs.writeFileSync(tmpFile, config) | |
| 33 | ||
| 34 | try { | |
| 35 | // Upload config to remote /tmp | |
| 36 | console.log('Uploading config to 7rnx.net...') | |
| 37 | this._scp(tmpFile, '/tmp/7rnx-proxy.conf') | |
| 38 | ||
| 39 | // Validate config before applying | |
| 40 | console.log('Running config test...') | |
| 41 | const testResult = this._ssh('sudo apachectl configtest 2>&1') | |
| 42 | console.log(testResult.trim()) | |
| 43 | ||
| 44 | // Copy to apache2 dir (Included by SSL vhost) and reload | |
| 45 | console.log('Applying config and reloading Apache...') | |
| 46 | this._ssh('sudo cp /tmp/7rnx-proxy.conf /etc/apache2/7rnx-proxy.conf && sudo systemctl reload apache2') | |
| 47 | ||
| 48 | console.log('Deploy complete.') | |
| 49 | } finally { | |
| 50 | fs.unlinkSync(tmpFile) | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | status () { | |
| 55 | try { | |
| 56 | console.log('Apache status:') | |
| 57 | const status = this._ssh('sudo systemctl is-active apache2 2>&1') | |
| 58 | console.log(' Service:', status.trim()) | |
| 59 | ||
| 60 | const test = this._ssh('sudo apachectl configtest 2>&1') | |
| 61 | console.log(' Config:', test.trim()) | |
| 62 | ||
| 63 | const conf = this._ssh('test -f /etc/apache2/7rnx-proxy.conf && echo "present" || echo "missing"') | |
| 64 | console.log(' 7rnx-proxy.conf:', conf.trim()) | |
| 65 | } catch (err) { | |
| 66 | console.error('Failed to check status:', err.message) | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | fetchRemoteConfig (configPath) { | |
| 71 | try { | |
| 72 | return this._ssh(`cat ${configPath}`) | |
| 73 | } catch (err) { | |
| 74 | console.error(`Failed to read ${configPath}:`, err.message) | |
| 75 | return null | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | checkServices (nodes, services) { | |
| 80 | const results = [] | |
| 81 | const nodesByIp = new Map() | |
| 82 | for (const n of nodes) nodesByIp.set(n.petname, n) | |
| 83 | ||
| 84 | // Group services by node IP to batch SSH calls | |
| 85 | const byIp = new Map() | |
| 86 | for (const svc of services) { | |
| 87 | const node = nodesByIp.get(svc.node) | |
| 88 | if (!node) continue | |
| 89 | if (!byIp.has(node.ip)) byIp.set(node.ip, { node, services: [] }) | |
| 90 | byIp.get(node.ip).services.push(svc) | |
| 91 | } | |
| 92 | ||
| 93 | for (const [ip, { node, services: svcs }] of byIp) { | |
| 94 | // Build a single command that checks all ports on this node | |
| 95 | const portChecks = svcs.map(s => s.port).join(' ') | |
| 96 | const cmd = `for p in ${portChecks}; do if ss -tlnp 2>/dev/null | grep -q ":$p "; then echo "$p UP"; else echo "$p DOWN"; fi; done` | |
| 97 | ||
| 98 | let output | |
| 99 | try { | |
| 100 | if (ip === '10.22.0.1') { | |
| 101 | // tabertron is local — run directly | |
| 102 | output = execSync(`bash -c '${cmd}'`, { encoding: 'utf8', timeout: 10000 }) | |
| 103 | } else { | |
| 104 | output = execSync(`ssh ${node.petname} "${cmd}"`, { encoding: 'utf8', timeout: 10000 }) | |
| 105 | } | |
| 106 | } catch { | |
| 107 | // Node unreachable | |
| 108 | for (const svc of svcs) { | |
| 109 | results.push({ node: node.petname, ip, name: svc.name, port: svc.port, status: 'UNREACHABLE' }) | |
| 110 | } | |
| 111 | continue | |
| 112 | } | |
| 113 | ||
| 114 | const portStatus = new Map() | |
| 115 | for (const line of output.trim().split('\n')) { | |
| 116 | const [port, status] = line.split(' ') | |
| 117 | portStatus.set(Number(port), status) | |
| 118 | } | |
| 119 | ||
| 120 | for (const svc of svcs) { | |
| 121 | results.push({ | |
| 122 | node: node.petname, | |
| 123 | ip, | |
| 124 | name: svc.name, | |
| 125 | port: svc.port, | |
| 126 | status: portStatus.get(svc.port) || 'UNKNOWN' | |
| 127 | }) | |
| 128 | } | |
| 129 | } | |
| 130 | ||
| 131 | return results | |
| 132 | } | |
| 133 | ||
| 134 | listRemoteConfigs () { | |
| 135 | try { | |
| 136 | return this._ssh('ls /etc/apache2/sites-enabled/') | |
| 137 | } catch (err) { | |
| 138 | console.error('Failed to list configs:', err.message) | |
| 139 | return null | |
| 140 | } | |
| 141 | } | |
| 142 | } |
| … | … | @@ -0,0 +1,122 @@ |
| 1 | import fs from 'node:fs' | |
| 2 | import path from 'node:path' | |
| 3 | import os from 'node:os' | |
| 4 | ||
| 5 | const CONFIG_DIR = path.join(os.homedir(), '.config', '7rnx-proxy') | |
| 6 | const REGISTRY_PATH = path.join(CONFIG_DIR, 'registry.json') | |
| 7 | ||
| 8 | const DEFAULT_REGISTRY = { | |
| 9 | server: { | |
| 10 | host: '10.22.0.3', | |
| 11 | user: 'fleeky', | |
| 12 | configPath: '/etc/apache2/7rnx-proxy.conf' | |
| 13 | }, | |
| 14 | nodes: [], | |
| 15 | services: [] | |
| 16 | } | |
| 17 | ||
| 18 | export class Registry { | |
| 19 | constructor () { | |
| 20 | this.data = null | |
| 21 | } | |
| 22 | ||
| 23 | load () { | |
| 24 | if (!fs.existsSync(REGISTRY_PATH)) { | |
| 25 | this.data = structuredClone(DEFAULT_REGISTRY) | |
| 26 | return this | |
| 27 | } | |
| 28 | this.data = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8')) | |
| 29 | return this | |
| 30 | } | |
| 31 | ||
| 32 | save () { | |
| 33 | fs.mkdirSync(CONFIG_DIR, { recursive: true }) | |
| 34 | fs.writeFileSync(REGISTRY_PATH, JSON.stringify(this.data, null, 2) + '\n') | |
| 35 | return this | |
| 36 | } | |
| 37 | ||
| 38 | addNode (petname, ip, keetIdent) { | |
| 39 | if (!petname || !ip) throw new Error('petname and ip are required') | |
| 40 | ||
| 41 | const existing = this.data.nodes.find(n => n.petname === petname) | |
| 42 | if (existing) throw new Error(`Node "${petname}" already exists`) | |
| 43 | ||
| 44 | const ipExists = this.data.nodes.find(n => n.ip === ip) | |
| 45 | if (ipExists) throw new Error(`IP ${ip} already used by node "${ipExists.petname}"`) | |
| 46 | ||
| 47 | this.data.nodes.push({ | |
| 48 | petname, | |
| 49 | ip, | |
| 50 | keetIdent: keetIdent || null, | |
| 51 | addedAt: new Date().toISOString() | |
| 52 | }) | |
| 53 | this.save() | |
| 54 | return this | |
| 55 | } | |
| 56 | ||
| 57 | removeNode (petname) { | |
| 58 | const idx = this.data.nodes.findIndex(n => n.petname === petname) | |
| 59 | if (idx === -1) throw new Error(`Node "${petname}" not found`) | |
| 60 | ||
| 61 | this.data.nodes.splice(idx, 1) | |
| 62 | this.data.services = this.data.services.filter(s => s.node !== petname) | |
| 63 | this.save() | |
| 64 | return this | |
| 65 | } | |
| 66 | ||
| 67 | listNodes () { | |
| 68 | return this.data.nodes | |
| 69 | } | |
| 70 | ||
| 71 | getNode (petname) { | |
| 72 | return this.data.nodes.find(n => n.petname === petname) || null | |
| 73 | } | |
| 74 | ||
| 75 | addService (nodePetname, serviceName, port, prefix, rootPath) { | |
| 76 | const node = this.getNode(nodePetname) | |
| 77 | if (!node) throw new Error(`Node "${nodePetname}" not found`) | |
| 78 | ||
| 79 | if (!serviceName || !port) throw new Error('serviceName and port are required') | |
| 80 | ||
| 81 | const existing = this.data.services.find( | |
| 82 | s => s.node === nodePetname && s.name === serviceName | |
| 83 | ) | |
| 84 | if (existing) throw new Error(`Service "${serviceName}" already exists on node "${nodePetname}"`) | |
| 85 | ||
| 86 | this.data.services.push({ | |
| 87 | node: nodePetname, | |
| 88 | name: serviceName, | |
| 89 | port: Number(port), | |
| 90 | prefix: prefix || '/', | |
| 91 | rootPath: rootPath || null, | |
| 92 | addedAt: new Date().toISOString() | |
| 93 | }) | |
| 94 | this.save() | |
| 95 | return this | |
| 96 | } | |
| 97 | ||
| 98 | removeService (nodePetname, serviceName) { | |
| 99 | const idx = this.data.services.findIndex( | |
| 100 | s => s.node === nodePetname && s.name === serviceName | |
| 101 | ) | |
| 102 | if (idx === -1) throw new Error(`Service "${serviceName}" not found on node "${nodePetname}"`) | |
| 103 | ||
| 104 | this.data.services.splice(idx, 1) | |
| 105 | this.save() | |
| 106 | return this | |
| 107 | } | |
| 108 | ||
| 109 | listServices (nodePetname) { | |
| 110 | if (nodePetname) { | |
| 111 | return this.data.services.filter(s => s.node === nodePetname) | |
| 112 | } | |
| 113 | return this.data.services | |
| 114 | } | |
| 115 | ||
| 116 | setServer (host, user) { | |
| 117 | if (host) this.data.server.host = host | |
| 118 | if (user) this.data.server.user = user | |
| 119 | this.save() | |
| 120 | return this | |
| 121 | } | |
| 122 | } |
| … | … | @@ -0,0 +1,131 @@ |
| 1 | import http from 'node:http' | |
| 2 | import fs from 'node:fs' | |
| 3 | import path from 'node:path' | |
| 4 | import { fileURLToPath } from 'node:url' | |
| 5 | import { execSync } from 'node:child_process' | |
| 6 | import { Registry } from './registry.js' | |
| 7 | import { Deployer } from './deployer.js' | |
| 8 | import { generateConfig } from './config-generator.js' | |
| 9 | ||
| 10 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) | |
| 11 | const WEB_DIR = path.join(__dirname, '..', 'web') | |
| 12 | ||
| 13 | export function startServer (port, network) { | |
| 14 | const host = network ? '0.0.0.0' : '127.0.0.1' | |
| 15 | const server = http.createServer(handleRequest) | |
| 16 | server.listen(port, host, () => { | |
| 17 | const addr = network ? `http://0.0.0.0:${port}` : `http://localhost:${port}` | |
| 18 | console.log(`7rnx-proxy dashboard: ${addr}`) | |
| 19 | }) | |
| 20 | return server | |
| 21 | } | |
| 22 | ||
| 23 | async function handleRequest (req, res) { | |
| 24 | const url = new URL(req.url, 'http://localhost') | |
| 25 | ||
| 26 | if (url.pathname === '/' || url.pathname === '/index.html') { | |
| 27 | return serveFile(res, 'index.html', 'text/html') | |
| 28 | } | |
| 29 | ||
| 30 | if (url.pathname.startsWith('/api/')) { | |
| 31 | return handleApi(req, res, url) | |
| 32 | } | |
| 33 | ||
| 34 | res.writeHead(404) | |
| 35 | res.end('Not found') | |
| 36 | } | |
| 37 | ||
| 38 | function serveFile (res, filename, contentType) { | |
| 39 | const filePath = path.join(WEB_DIR, filename) | |
| 40 | try { | |
| 41 | const content = fs.readFileSync(filePath, 'utf8') | |
| 42 | res.writeHead(200, { 'Content-Type': contentType }) | |
| 43 | res.end(content) | |
| 44 | } catch { | |
| 45 | res.writeHead(404) | |
| 46 | res.end('Not found') | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | async function handleApi (req, res, url) { | |
| 51 | res.setHeader('Content-Type', 'application/json') | |
| 52 | ||
| 53 | try { | |
| 54 | const registry = new Registry().load() | |
| 55 | const deployer = new Deployer(registry.data) | |
| 56 | ||
| 57 | switch (url.pathname) { | |
| 58 | case '/api/status': { | |
| 59 | const services = registry.listServices() | |
| 60 | const results = deployer.checkServices(registry.data.nodes, services) | |
| 61 | res.writeHead(200) | |
| 62 | res.end(JSON.stringify({ | |
| 63 | nodes: registry.listNodes(), | |
| 64 | services: services.map(s => { | |
| 65 | const check = results.find(r => r.node === s.node && r.name === s.name) | |
| 66 | return { ...s, status: check ? check.status : 'UNKNOWN' } | |
| 67 | }) | |
| 68 | })) | |
| 69 | break | |
| 70 | } | |
| 71 | ||
| 72 | case '/api/config': { | |
| 73 | res.writeHead(200) | |
| 74 | res.end(JSON.stringify({ config: registry.data })) | |
| 75 | break | |
| 76 | } | |
| 77 | ||
| 78 | case '/api/dry-run': { | |
| 79 | const config = generateConfig(registry.data) | |
| 80 | res.writeHead(200) | |
| 81 | res.end(JSON.stringify({ config })) | |
| 82 | break | |
| 83 | } | |
| 84 | ||
| 85 | case '/api/remote-config': { | |
| 86 | const configPath = url.searchParams.get('path') || '/etc/apache2/sites-enabled/000-default-le-ssl.conf' | |
| 87 | const content = deployer.fetchRemoteConfig(configPath) | |
| 88 | res.writeHead(200) | |
| 89 | res.end(JSON.stringify({ config: content })) | |
| 90 | break | |
| 91 | } | |
| 92 | ||
| 93 | case '/api/deploy': { | |
| 94 | if (req.method !== 'POST') { | |
| 95 | res.writeHead(405) | |
| 96 | res.end(JSON.stringify({ error: 'POST required' })) | |
| 97 | break | |
| 98 | } | |
| 99 | try { | |
| 100 | deployer.deploy() | |
| 101 | res.writeHead(200) | |
| 102 | res.end(JSON.stringify({ ok: true, message: 'Deploy complete' })) | |
| 103 | } catch (err) { | |
| 104 | res.writeHead(500) | |
| 105 | res.end(JSON.stringify({ ok: false, error: err.message })) | |
| 106 | } | |
| 107 | break | |
| 108 | } | |
| 109 | ||
| 110 | case '/api/apache-status': { | |
| 111 | try { | |
| 112 | const active = deployer._ssh('sudo systemctl is-active apache2 2>&1').trim() | |
| 113 | const test = deployer._ssh('sudo apachectl configtest 2>&1').trim() | |
| 114 | res.writeHead(200) | |
| 115 | res.end(JSON.stringify({ active, configTest: test })) | |
| 116 | } catch (err) { | |
| 117 | res.writeHead(500) | |
| 118 | res.end(JSON.stringify({ error: err.message })) | |
| 119 | } | |
| 120 | break | |
| 121 | } | |
| 122 | ||
| 123 | default: | |
| 124 | res.writeHead(404) | |
| 125 | res.end(JSON.stringify({ error: 'Unknown endpoint' })) | |
| 126 | } | |
| 127 | } catch (err) { | |
| 128 | res.writeHead(500) | |
| 129 | res.end(JSON.stringify({ error: err.message })) | |
| 130 | } | |
| 131 | } |
| … | … | @@ -0,0 +1,13 @@ |
| 1 | { | |
| 2 | "name": "7rnx-proxy", | |
| 3 | "version": "1.0.0", | |
| 4 | "description": "Reverse proxy management CLI for 7rnx.net Apache2 server", | |
| 5 | "type": "module", | |
| 6 | "bin": { | |
| 7 | "7rnx": "./bin/7rnx" | |
| 8 | }, | |
| 9 | "scripts": { | |
| 10 | "test": "node --test test/" | |
| 11 | }, | |
| 12 | "license": "MIT" | |
| 13 | } |
| … | … | @@ -0,0 +1,61 @@ |
| 1 | #!/bin/bash | |
| 2 | # Run this on 7rnx.net (10.22.0.3) as fleeky | |
| 3 | # One-time setup: sudoers + migrate inline proxy rules to Include file | |
| 4 | ||
| 5 | set -e | |
| 6 | ||
| 7 | VHOST="/etc/apache2/sites-enabled/000-default-le-ssl.conf" | |
| 8 | PROXY_CONF="/etc/apache2/7rnx-proxy.conf" | |
| 9 | ||
| 10 | echo "=== Step 1: Sudoers rules ===" | |
| 11 | ||
| 12 | sudo tee /etc/sudoers.d/7rnx-proxy > /dev/null << 'EOF' | |
| 13 | # Allow fleeky to deploy proxy config and reload Apache (no password) | |
| 14 | fleeky ALL=(root) NOPASSWD: /bin/cp /tmp/7rnx-proxy.conf /etc/apache2/7rnx-proxy.conf | |
| 15 | fleeky ALL=(root) NOPASSWD: /usr/sbin/apachectl configtest | |
| 16 | fleeky ALL=(root) NOPASSWD: /bin/systemctl reload apache2 | |
| 17 | EOF | |
| 18 | ||
| 19 | sudo chmod 0440 /etc/sudoers.d/7rnx-proxy | |
| 20 | sudo visudo -c -f /etc/sudoers.d/7rnx-proxy | |
| 21 | echo "Sudoers: OK" | |
| 22 | ||
| 23 | echo "" | |
| 24 | echo "=== Step 2: Add Include to SSL vhost ===" | |
| 25 | ||
| 26 | if grep -q "Include /etc/apache2/7rnx-proxy.conf" "$VHOST"; then | |
| 27 | echo "Include already present, skipping." | |
| 28 | else | |
| 29 | # Backup first | |
| 30 | sudo cp "$VHOST" "${VHOST}.pre-7rnx-backup" | |
| 31 | echo "Backup: ${VHOST}.pre-7rnx-backup" | |
| 32 | ||
| 33 | # Add Include just before </VirtualHost> closing tag | |
| 34 | sudo sed -i '/<\/VirtualHost>/i\ # 7rnx-proxy managed config\n Include /etc/apache2/7rnx-proxy.conf' "$VHOST" | |
| 35 | echo "Include directive added." | |
| 36 | fi | |
| 37 | ||
| 38 | echo "" | |
| 39 | echo "=== Step 3: Create empty proxy conf ===" | |
| 40 | ||
| 41 | if [ ! -f "$PROXY_CONF" ]; then | |
| 42 | echo "# Placeholder — will be replaced by 7rnx deploy" | sudo tee "$PROXY_CONF" > /dev/null | |
| 43 | echo "Created empty $PROXY_CONF" | |
| 44 | else | |
| 45 | echo "$PROXY_CONF already exists, skipping." | |
| 46 | fi | |
| 47 | ||
| 48 | echo "" | |
| 49 | echo "=== Step 4: Validate ===" | |
| 50 | ||
| 51 | sudo apachectl configtest | |
| 52 | ||
| 53 | echo "" | |
| 54 | echo "=== Done ===" | |
| 55 | echo "" | |
| 56 | echo "Next steps:" | |
| 57 | echo " 1. Review the vhost: cat $VHOST" | |
| 58 | echo " 2. From tabertron, run: 7rnx deploy" | |
| 59 | echo " 3. Verify services still work" | |
| 60 | echo " 4. Once confirmed, remove inline proxy rules from $VHOST" | |
| 61 | echo " (the inline rules and 7rnx-proxy.conf will coexist safely — same targets)" |
| … | … | @@ -0,0 +1,551 @@ |
| 1 | <!DOCTYPE html> | |
| 2 | <html lang="en"> | |
| 3 | <head> | |
| 4 | <meta charset="utf-8"> | |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | <title>7rnx-proxy</title> | |
| 7 | <style> | |
| 8 | :root { | |
| 9 | --bg: #1a1a2e; | |
| 10 | --bg-secondary: #16213e; | |
| 11 | --bg-card: #1c2541; | |
| 12 | --text: #e4e4e4; | |
| 13 | --text-muted: #8b8b9e; | |
| 14 | --accent: #e94560; | |
| 15 | --accent-hover: #ff6b81; | |
| 16 | --green: #10b981; | |
| 17 | --red: #ef4444; | |
| 18 | --yellow: #f59e0b; | |
| 19 | --border: #2a2a4a; | |
| 20 | --code-bg: #0d1117; | |
| 21 | --input-bg: #16213e; | |
| 22 | } | |
| 23 | ||
| 24 | * { margin: 0; padding: 0; box-sizing: border-box; } | |
| 25 | ||
| 26 | body { | |
| 27 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| 28 | background: var(--bg); | |
| 29 | color: var(--text); | |
| 30 | line-height: 1.5; | |
| 31 | } | |
| 32 | ||
| 33 | .topbar { | |
| 34 | height: 48px; | |
| 35 | background: var(--bg-secondary); | |
| 36 | border-bottom: 1px solid var(--border); | |
| 37 | display: flex; | |
| 38 | align-items: center; | |
| 39 | padding: 0 24px; | |
| 40 | gap: 16px; | |
| 41 | position: fixed; | |
| 42 | top: 0; | |
| 43 | left: 0; | |
| 44 | right: 0; | |
| 45 | z-index: 10; | |
| 46 | } | |
| 47 | ||
| 48 | .topbar h1 { | |
| 49 | font-size: 1rem; | |
| 50 | font-weight: 600; | |
| 51 | color: var(--accent); | |
| 52 | letter-spacing: 0.5px; | |
| 53 | } | |
| 54 | ||
| 55 | .topbar .sep { | |
| 56 | color: var(--border); | |
| 57 | } | |
| 58 | ||
| 59 | .topbar .apache-status { | |
| 60 | font-size: 0.75rem; | |
| 61 | color: var(--text-muted); | |
| 62 | margin-left: auto; | |
| 63 | } | |
| 64 | ||
| 65 | .topbar .apache-status .dot { | |
| 66 | display: inline-block; | |
| 67 | width: 8px; | |
| 68 | height: 8px; | |
| 69 | border-radius: 50%; | |
| 70 | margin-right: 4px; | |
| 71 | vertical-align: middle; | |
| 72 | } | |
| 73 | ||
| 74 | .topbar .apache-status .dot.up { background: var(--green); } | |
| 75 | .topbar .apache-status .dot.down { background: var(--red); } | |
| 76 | .topbar .apache-status .dot.unknown { background: var(--yellow); } | |
| 77 | ||
| 78 | .container { | |
| 79 | max-width: 1100px; | |
| 80 | margin: 0 auto; | |
| 81 | padding: 72px 24px 48px; | |
| 82 | } | |
| 83 | ||
| 84 | .grid { | |
| 85 | display: grid; | |
| 86 | grid-template-columns: 1fr 1fr; | |
| 87 | gap: 16px; | |
| 88 | margin-bottom: 24px; | |
| 89 | } | |
| 90 | ||
| 91 | .card { | |
| 92 | background: var(--bg-card); | |
| 93 | border: 1px solid var(--border); | |
| 94 | border-radius: 6px; | |
| 95 | padding: 16px; | |
| 96 | } | |
| 97 | ||
| 98 | .card h2 { | |
| 99 | font-size: 0.8rem; | |
| 100 | text-transform: uppercase; | |
| 101 | letter-spacing: 1px; | |
| 102 | color: var(--text-muted); | |
| 103 | margin-bottom: 12px; | |
| 104 | } | |
| 105 | ||
| 106 | .card.full { | |
| 107 | grid-column: 1 / -1; | |
| 108 | } | |
| 109 | ||
| 110 | /* Nodes */ | |
| 111 | .node-list { | |
| 112 | list-style: none; | |
| 113 | } | |
| 114 | ||
| 115 | .node-list li { | |
| 116 | display: flex; | |
| 117 | align-items: center; | |
| 118 | gap: 12px; | |
| 119 | padding: 8px 0; | |
| 120 | border-bottom: 1px solid var(--border); | |
| 121 | font-size: 0.9rem; | |
| 122 | } | |
| 123 | ||
| 124 | .node-list li:last-child { | |
| 125 | border-bottom: none; | |
| 126 | } | |
| 127 | ||
| 128 | .node-name { | |
| 129 | font-weight: 600; | |
| 130 | min-width: 100px; | |
| 131 | } | |
| 132 | ||
| 133 | .node-ip { | |
| 134 | font-family: 'SF Mono', 'Fira Code', monospace; | |
| 135 | font-size: 0.8rem; | |
| 136 | color: var(--text-muted); | |
| 137 | } | |
| 138 | ||
| 139 | /* Services table */ | |
| 140 | .svc-table { | |
| 141 | width: 100%; | |
| 142 | border-collapse: collapse; | |
| 143 | font-size: 0.85rem; | |
| 144 | } | |
| 145 | ||
| 146 | .svc-table th { | |
| 147 | text-align: left; | |
| 148 | padding: 8px 12px; | |
| 149 | border-bottom: 2px solid var(--border); | |
| 150 | color: var(--text-muted); | |
| 151 | font-weight: 500; | |
| 152 | font-size: 0.75rem; | |
| 153 | text-transform: uppercase; | |
| 154 | letter-spacing: 0.5px; | |
| 155 | } | |
| 156 | ||
| 157 | .svc-table td { | |
| 158 | padding: 10px 12px; | |
| 159 | border-bottom: 1px solid var(--border); | |
| 160 | } | |
| 161 | ||
| 162 | .svc-table tr:last-child td { | |
| 163 | border-bottom: none; | |
| 164 | } | |
| 165 | ||
| 166 | .svc-table tr:hover td { | |
| 167 | background: rgba(233, 69, 96, 0.05); | |
| 168 | } | |
| 169 | ||
| 170 | .status-badge { | |
| 171 | display: inline-flex; | |
| 172 | align-items: center; | |
| 173 | gap: 6px; | |
| 174 | padding: 2px 10px; | |
| 175 | border-radius: 12px; | |
| 176 | font-size: 0.75rem; | |
| 177 | font-weight: 600; | |
| 178 | text-transform: uppercase; | |
| 179 | letter-spacing: 0.5px; | |
| 180 | } | |
| 181 | ||
| 182 | .status-badge.up { | |
| 183 | background: rgba(16, 185, 129, 0.15); | |
| 184 | color: var(--green); | |
| 185 | } | |
| 186 | ||
| 187 | .status-badge.down { | |
| 188 | background: rgba(239, 68, 68, 0.15); | |
| 189 | color: var(--red); | |
| 190 | } | |
| 191 | ||
| 192 | .status-badge.unknown { | |
| 193 | background: rgba(245, 158, 11, 0.15); | |
| 194 | color: var(--yellow); | |
| 195 | } | |
| 196 | ||
| 197 | .port { | |
| 198 | font-family: 'SF Mono', 'Fira Code', monospace; | |
| 199 | font-size: 0.8rem; | |
| 200 | color: var(--accent); | |
| 201 | } | |
| 202 | ||
| 203 | .url-path { | |
| 204 | font-family: 'SF Mono', 'Fira Code', monospace; | |
| 205 | font-size: 0.8rem; | |
| 206 | } | |
| 207 | ||
| 208 | /* Actions */ | |
| 209 | .actions { | |
| 210 | display: flex; | |
| 211 | gap: 8px; | |
| 212 | flex-wrap: wrap; | |
| 213 | } | |
| 214 | ||
| 215 | .btn { | |
| 216 | padding: 8px 16px; | |
| 217 | border: 1px solid var(--border); | |
| 218 | border-radius: 6px; | |
| 219 | background: var(--bg-secondary); | |
| 220 | color: var(--text); | |
| 221 | font-size: 0.85rem; | |
| 222 | cursor: pointer; | |
| 223 | transition: all 0.15s; | |
| 224 | } | |
| 225 | ||
| 226 | .btn:hover { | |
| 227 | border-color: var(--accent); | |
| 228 | color: var(--accent); | |
| 229 | } | |
| 230 | ||
| 231 | .btn.primary { | |
| 232 | background: var(--accent); | |
| 233 | border-color: var(--accent); | |
| 234 | color: white; | |
| 235 | } | |
| 236 | ||
| 237 | .btn.primary:hover { | |
| 238 | background: var(--accent-hover); | |
| 239 | border-color: var(--accent-hover); | |
| 240 | } | |
| 241 | ||
| 242 | .btn:disabled { | |
| 243 | opacity: 0.5; | |
| 244 | cursor: not-allowed; | |
| 245 | } | |
| 246 | ||
| 247 | /* Config viewer */ | |
| 248 | .config-viewer { | |
| 249 | background: var(--code-bg); | |
| 250 | border: 1px solid var(--border); | |
| 251 | border-radius: 6px; | |
| 252 | padding: 16px; | |
| 253 | font-family: 'SF Mono', 'Fira Code', monospace; | |
| 254 | font-size: 0.8rem; | |
| 255 | line-height: 1.6; | |
| 256 | overflow-x: auto; | |
| 257 | white-space: pre; | |
| 258 | max-height: 400px; | |
| 259 | overflow-y: auto; | |
| 260 | color: var(--text-muted); | |
| 261 | display: none; | |
| 262 | } | |
| 263 | ||
| 264 | .config-viewer.visible { | |
| 265 | display: block; | |
| 266 | margin-top: 12px; | |
| 267 | } | |
| 268 | ||
| 269 | /* Toast */ | |
| 270 | .toast { | |
| 271 | position: fixed; | |
| 272 | bottom: 24px; | |
| 273 | right: 24px; | |
| 274 | padding: 12px 20px; | |
| 275 | border-radius: 6px; | |
| 276 | font-size: 0.85rem; | |
| 277 | z-index: 100; | |
| 278 | animation: slidein 0.2s ease; | |
| 279 | max-width: 400px; | |
| 280 | } | |
| 281 | ||
| 282 | .toast.success { | |
| 283 | background: var(--green); | |
| 284 | color: white; | |
| 285 | } | |
| 286 | ||
| 287 | .toast.error { | |
| 288 | background: var(--red); | |
| 289 | color: white; | |
| 290 | } | |
| 291 | ||
| 292 | @keyframes slidein { | |
| 293 | from { transform: translateY(20px); opacity: 0; } | |
| 294 | to { transform: translateY(0); opacity: 1; } | |
| 295 | } | |
| 296 | ||
| 297 | /* Summary bar */ | |
| 298 | .summary { | |
| 299 | display: flex; | |
| 300 | gap: 24px; | |
| 301 | margin-bottom: 16px; | |
| 302 | } | |
| 303 | ||
| 304 | .summary .stat { | |
| 305 | font-size: 0.85rem; | |
| 306 | color: var(--text-muted); | |
| 307 | } | |
| 308 | ||
| 309 | .summary .stat strong { | |
| 310 | color: var(--text); | |
| 311 | font-size: 1.1rem; | |
| 312 | } | |
| 313 | ||
| 314 | /* Loading */ | |
| 315 | .loading { | |
| 316 | color: var(--text-muted); | |
| 317 | font-size: 0.85rem; | |
| 318 | padding: 16px 0; | |
| 319 | } | |
| 320 | ||
| 321 | @keyframes pulse { | |
| 322 | 0%, 100% { opacity: 1; } | |
| 323 | 50% { opacity: 0.4; } | |
| 324 | } | |
| 325 | ||
| 326 | .loading { animation: pulse 1.5s infinite; } | |
| 327 | ||
| 328 | /* Responsive */ | |
| 329 | @media (max-width: 768px) { | |
| 330 | .grid { grid-template-columns: 1fr; } | |
| 331 | .topbar { padding: 0 12px; } | |
| 332 | .container { padding: 60px 12px 24px; } | |
| 333 | .svc-table { font-size: 0.8rem; } | |
| 334 | .svc-table th, .svc-table td { padding: 8px 6px; } | |
| 335 | } | |
| 336 | </style> | |
| 337 | </head> | |
| 338 | <body> | |
| 339 | ||
| 340 | <div class="topbar"> | |
| 341 | <h1>7rnx-proxy</h1> | |
| 342 | <span class="sep">|</span> | |
| 343 | <span style="font-size:0.8rem;color:var(--text-muted)">reverse proxy dashboard</span> | |
| 344 | <span class="apache-status"> | |
| 345 | <span class="dot unknown" id="apache-dot"></span> | |
| 346 | <span id="apache-label">checking...</span> | |
| 347 | </span> | |
| 348 | </div> | |
| 349 | ||
| 350 | <div class="container"> | |
| 351 | <div class="summary" id="summary"> | |
| 352 | <div class="stat"><strong id="node-count">-</strong> nodes</div> | |
| 353 | <div class="stat"><strong id="svc-count">-</strong> services</div> | |
| 354 | <div class="stat"><strong id="up-count">-</strong> up</div> | |
| 355 | <div class="stat"><strong id="down-count">-</strong> down</div> | |
| 356 | </div> | |
| 357 | ||
| 358 | <div class="grid"> | |
| 359 | <div class="card"> | |
| 360 | <h2>Nodes</h2> | |
| 361 | <ul class="node-list" id="node-list"> | |
| 362 | <li class="loading">loading...</li> | |
| 363 | </ul> | |
| 364 | </div> | |
| 365 | ||
| 366 | <div class="card"> | |
| 367 | <h2>Actions</h2> | |
| 368 | <div class="actions" style="flex-direction:column;gap:12px;"> | |
| 369 | <div class="actions"> | |
| 370 | <button class="btn" onclick="refreshStatus()">Refresh</button> | |
| 371 | <button class="btn" onclick="showDryRun()">Dry Run</button> | |
| 372 | <button class="btn" onclick="showRemoteConfig()">Live Config</button> | |
| 373 | </div> | |
| 374 | <div class="actions"> | |
| 375 | <button class="btn primary" onclick="deploy()" id="deploy-btn">Deploy to 7rnx.net</button> | |
| 376 | </div> | |
| 377 | </div> | |
| 378 | </div> | |
| 379 | ||
| 380 | <div class="card full"> | |
| 381 | <h2>Services</h2> | |
| 382 | <table class="svc-table"> | |
| 383 | <thead> | |
| 384 | <tr> | |
| 385 | <th>Status</th> | |
| 386 | <th>Node</th> | |
| 387 | <th>Service</th> | |
| 388 | <th>Port</th> | |
| 389 | <th>URL</th> | |
| 390 | </tr> | |
| 391 | </thead> | |
| 392 | <tbody id="svc-body"> | |
| 393 | <tr><td colspan="5" class="loading">loading...</td></tr> | |
| 394 | </tbody> | |
| 395 | </table> | |
| 396 | </div> | |
| 397 | ||
| 398 | <div class="card full" id="config-card" style="display:none;"> | |
| 399 | <h2 id="config-title">Config</h2> | |
| 400 | <div class="config-viewer visible" id="config-viewer"></div> | |
| 401 | </div> | |
| 402 | </div> | |
| 403 | </div> | |
| 404 | ||
| 405 | <script> | |
| 406 | async function api (endpoint, opts) { | |
| 407 | const res = await fetch('/api/' + endpoint, opts) | |
| 408 | return res.json() | |
| 409 | } | |
| 410 | ||
| 411 | function toast (msg, type) { | |
| 412 | const el = document.createElement('div') | |
| 413 | el.className = 'toast ' + type | |
| 414 | el.textContent = msg | |
| 415 | document.body.appendChild(el) | |
| 416 | setTimeout(() => el.remove(), 4000) | |
| 417 | } | |
| 418 | ||
| 419 | function badge (status) { | |
| 420 | const cls = status === 'UP' ? 'up' : status === 'DOWN' ? 'down' : 'unknown' | |
| 421 | return `<span class="status-badge ${cls}">${status}</span>` | |
| 422 | } | |
| 423 | ||
| 424 | async function refreshStatus () { | |
| 425 | try { | |
| 426 | const data = await api('status') | |
| 427 | ||
| 428 | // Nodes | |
| 429 | const nodeList = document.getElementById('node-list') | |
| 430 | if (data.nodes.length === 0) { | |
| 431 | nodeList.innerHTML = '<li style="color:var(--text-muted)">No nodes registered</li>' | |
| 432 | } else { | |
| 433 | nodeList.innerHTML = data.nodes.map(n => { | |
| 434 | const ident = n.keetIdent ? `<span style="color:var(--text-muted);font-size:0.75rem;font-family:monospace">${n.keetIdent.slice(0, 12)}...</span>` : '' | |
| 435 | return `<li><span class="node-name">${n.petname}</span><span class="node-ip">${n.ip}</span>${ident}</li>` | |
| 436 | }).join('') | |
| 437 | } | |
| 438 | ||
| 439 | // Services | |
| 440 | const svcBody = document.getElementById('svc-body') | |
| 441 | if (data.services.length === 0) { | |
| 442 | svcBody.innerHTML = '<tr><td colspan="5" style="color:var(--text-muted)">No services registered</td></tr>' | |
| 443 | } else { | |
| 444 | svcBody.innerHTML = data.services.map(s => { | |
| 445 | const urlPath = s.rootPath || `/${s.node}/${s.name}/` | |
| 446 | const extUrl = `https://7rnx.net${urlPath}` | |
| 447 | return `<tr> | |
| 448 | <td>${badge(s.status)}</td> | |
| 449 | <td>${s.node}</td> | |
| 450 | <td>${s.name}</td> | |
| 451 | <td><span class="port">:${s.port}</span></td> | |
| 452 | <td><a href="${extUrl}" target="_blank" class="url-path" style="color:var(--text);text-decoration:none;">${urlPath}</a></td> | |
| 453 | </tr>` | |
| 454 | }).join('') | |
| 455 | } | |
| 456 | ||
| 457 | // Summary | |
| 458 | document.getElementById('node-count').textContent = data.nodes.length | |
| 459 | document.getElementById('svc-count').textContent = data.services.length | |
| 460 | const up = data.services.filter(s => s.status === 'UP').length | |
| 461 | const down = data.services.filter(s => s.status === 'DOWN').length | |
| 462 | document.getElementById('up-count').textContent = up | |
| 463 | document.getElementById('up-count').style.color = up > 0 ? 'var(--green)' : 'var(--text)' | |
| 464 | document.getElementById('down-count').textContent = down | |
| 465 | document.getElementById('down-count').style.color = down > 0 ? 'var(--red)' : 'var(--text)' | |
| 466 | ||
| 467 | } catch (err) { | |
| 468 | toast('Failed to refresh: ' + err.message, 'error') | |
| 469 | } | |
| 470 | } | |
| 471 | ||
| 472 | async function checkApache () { | |
| 473 | try { | |
| 474 | const data = await api('apache-status') | |
| 475 | const dot = document.getElementById('apache-dot') | |
| 476 | const label = document.getElementById('apache-label') | |
| 477 | if (data.active === 'active') { | |
| 478 | dot.className = 'dot up' | |
| 479 | label.textContent = 'apache2 active' | |
| 480 | } else { | |
| 481 | dot.className = 'dot down' | |
| 482 | label.textContent = 'apache2 ' + data.active | |
| 483 | } | |
| 484 | } catch { | |
| 485 | document.getElementById('apache-dot').className = 'dot unknown' | |
| 486 | document.getElementById('apache-label').textContent = 'unreachable' | |
| 487 | } | |
| 488 | } | |
| 489 | ||
| 490 | async function showDryRun () { | |
| 491 | const card = document.getElementById('config-card') | |
| 492 | const viewer = document.getElementById('config-viewer') | |
| 493 | const title = document.getElementById('config-title') | |
| 494 | title.textContent = 'Generated Config (Dry Run)' | |
| 495 | card.style.display = 'block' | |
| 496 | viewer.textContent = 'loading...' | |
| 497 | try { | |
| 498 | const data = await api('dry-run') | |
| 499 | viewer.textContent = data.config | |
| 500 | } catch (err) { | |
| 501 | viewer.textContent = 'Error: ' + err.message | |
| 502 | } | |
| 503 | } | |
| 504 | ||
| 505 | async function showRemoteConfig () { | |
| 506 | const card = document.getElementById('config-card') | |
| 507 | const viewer = document.getElementById('config-viewer') | |
| 508 | const title = document.getElementById('config-title') | |
| 509 | title.textContent = 'Live Config (7rnx.net)' | |
| 510 | card.style.display = 'block' | |
| 511 | viewer.textContent = 'loading...' | |
| 512 | try { | |
| 513 | const data = await api('remote-config') | |
| 514 | viewer.textContent = data.config || 'No config found' | |
| 515 | } catch (err) { | |
| 516 | viewer.textContent = 'Error: ' + err.message | |
| 517 | } | |
| 518 | } | |
| 519 | ||
| 520 | async function deploy () { | |
| 521 | const btn = document.getElementById('deploy-btn') | |
| 522 | if (!confirm('Deploy config to 7rnx.net and reload Apache?')) return | |
| 523 | btn.disabled = true | |
| 524 | btn.textContent = 'Deploying...' | |
| 525 | try { | |
| 526 | const data = await api('deploy', { method: 'POST' }) | |
| 527 | if (data.ok) { | |
| 528 | toast('Deploy complete', 'success') | |
| 529 | refreshStatus() | |
| 530 | checkApache() | |
| 531 | } else { | |
| 532 | toast('Deploy failed: ' + data.error, 'error') | |
| 533 | } | |
| 534 | } catch (err) { | |
| 535 | toast('Deploy failed: ' + err.message, 'error') | |
| 536 | } finally { | |
| 537 | btn.disabled = false | |
| 538 | btn.textContent = 'Deploy to 7rnx.net' | |
| 539 | } | |
| 540 | } | |
| 541 | ||
| 542 | // Init | |
| 543 | refreshStatus() | |
| 544 | checkApache() | |
| 545 | ||
| 546 | // Auto-refresh every 30s | |
| 547 | setInterval(refreshStatus, 30000) | |
| 548 | </script> | |
| 549 | ||
| 550 | </body> | |
| 551 | </html> |