OverviewFilesCommitsIssuesDocsReleasesPeers

7rnx-proxy commits / 9347344

Initial commit: 7rnx reverse proxy management CLI
CLI tool to manage Apache reverse proxy config on 7rnx.net via SSH over hyperident-gateway VPN. Generates Apache config, deploys safely with configtest, includes web dashboard for service status. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
9347344469de41dc8deeb669c3002813bb30a5e8
by plankatron <fleeky@gmail.com> · 2026-03-03T23:32:19.000Z

Changed files (8) +1333 -0

bin/7rnx +259 -0
……@@ -0,0 +1,259 @@
1#!/usr/bin/env node
2
3import { Registry } from '../lib/registry.js'
4import { Deployer } from '../lib/deployer.js'
5import { generateConfig } from '../lib/config-generator.js'
6
7const args = process.argv.slice(2)
8const command = args[0]
9const subcommand = args[1]
10
11function 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
24function usage () {
25 console.log(`Usage: 7rnx <command> [options]
26
27Node Management:
28 7rnx node add <petname> --ip <vpn-ip> [--keet-ident <key>]
29 7rnx node list
30 7rnx node remove <petname>
31
32Service 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
38Deployment:
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
43Remote:
44 7rnx remote config [path] Fetch live config from 7rnx.net (default: SSL vhost)
45 7rnx remote ls List sites-enabled configs
46
47Web UI:
48 7rnx ui [port] Start dashboard (default: 7777)
49 7rnx ui --network Bind to 0.0.0.0 (LAN access)
50
51Config:
52 7rnx config Show current registry
53 7rnx config set-server --host <ip> --user <user>`)
54}
55
56function 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
93function 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
135function 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
197function 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
209function handleStatus (registry) {
210 const deployer = new Deployer(registry.data)
211 deployer.status()
212}
213
214function 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
236async 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
243function 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
259main()
lib/config-generator.js +54 -0
……@@ -0,0 +1,54 @@
1export 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}
lib/deployer.js +142 -0
……@@ -0,0 +1,142 @@
1import { execSync } from 'node:child_process'
2import fs from 'node:fs'
3import os from 'node:os'
4import path from 'node:path'
5import { generateConfig } from './config-generator.js'
6
7export 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}
lib/registry.js +122 -0
……@@ -0,0 +1,122 @@
1import fs from 'node:fs'
2import path from 'node:path'
3import os from 'node:os'
4
5const CONFIG_DIR = path.join(os.homedir(), '.config', '7rnx-proxy')
6const REGISTRY_PATH = path.join(CONFIG_DIR, 'registry.json')
7
8const 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
18export 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}
lib/web.js +131 -0
……@@ -0,0 +1,131 @@
1import http from 'node:http'
2import fs from 'node:fs'
3import path from 'node:path'
4import { fileURLToPath } from 'node:url'
5import { execSync } from 'node:child_process'
6import { Registry } from './registry.js'
7import { Deployer } from './deployer.js'
8import { generateConfig } from './config-generator.js'
9
10const __dirname = path.dirname(fileURLToPath(import.meta.url))
11const WEB_DIR = path.join(__dirname, '..', 'web')
12
13export 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
23async 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
38function 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
50async 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}
package.json +13 -0
……@@ -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}
setup-7rnx-server.sh +61 -0
……@@ -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
5set -e
6
7VHOST="/etc/apache2/sites-enabled/000-default-le-ssl.conf"
8PROXY_CONF="/etc/apache2/7rnx-proxy.conf"
9
10echo "=== Step 1: Sudoers rules ==="
11
12sudo tee /etc/sudoers.d/7rnx-proxy > /dev/null << 'EOF'
13# Allow fleeky to deploy proxy config and reload Apache (no password)
14fleeky ALL=(root) NOPASSWD: /bin/cp /tmp/7rnx-proxy.conf /etc/apache2/7rnx-proxy.conf
15fleeky ALL=(root) NOPASSWD: /usr/sbin/apachectl configtest
16fleeky ALL=(root) NOPASSWD: /bin/systemctl reload apache2
17EOF
18
19sudo chmod 0440 /etc/sudoers.d/7rnx-proxy
20sudo visudo -c -f /etc/sudoers.d/7rnx-proxy
21echo "Sudoers: OK"
22
23echo ""
24echo "=== Step 2: Add Include to SSL vhost ==="
25
26if grep -q "Include /etc/apache2/7rnx-proxy.conf" "$VHOST"; then
27 echo "Include already present, skipping."
28else
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."
36fi
37
38echo ""
39echo "=== Step 3: Create empty proxy conf ==="
40
41if [ ! -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"
44else
45 echo "$PROXY_CONF already exists, skipping."
46fi
47
48echo ""
49echo "=== Step 4: Validate ==="
50
51sudo apachectl configtest
52
53echo ""
54echo "=== Done ==="
55echo ""
56echo "Next steps:"
57echo " 1. Review the vhost: cat $VHOST"
58echo " 2. From tabertron, run: 7rnx deploy"
59echo " 3. Verify services still work"
60echo " 4. Once confirmed, remove inline proxy rules from $VHOST"
61echo " (the inline rules and 7rnx-proxy.conf will coexist safely — same targets)"
web/index.html +551 -0
……@@ -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
26body {
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>
406async function api (endpoint, opts) {
407 const res = await fetch('/api/' + endpoint, opts)
408 return res.json()
409}
410
411function 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
419function badge (status) {
420 const cls = status === 'UP' ? 'up' : status === 'DOWN' ? 'down' : 'unknown'
421 return `<span class="status-badge ${cls}">${status}</span>`
422}
423
424async 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
472async 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
490async 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
505async 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
520async 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
543refreshStatus()
544checkApache()
545
546// Auto-refresh every 30s
547setInterval(refreshStatus, 30000)
548</script>
549
550</body>
551</html>