synfull 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/scripts/synfull.cjs +65 -34
package/README.md
CHANGED
|
@@ -13,11 +13,11 @@ synfull start
|
|
|
13
13
|
synfull open
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
During interactive `synfull setup`, you can choose whether to bind to `0.0.0.0` for access from other devices on your LAN; localhost-only (`127.0.0.1`) remains the default, and non-interactive setup stays local-only. LAN binding exposes the login page and server to every reachable network interface: use a trusted LAN, configure the host firewall to allow only the devices/subnet you intend, change the initial `admin` / `admin` password locally before signing in remotely, and do not expose Synfull directly to the public internet. npm installation alone does not register a background service or change system startup.
|
|
17
17
|
|
|
18
18
|
### Commands
|
|
19
19
|
|
|
20
|
-
- `synfull setup` prepares the isolated Python runtime and dependencies.
|
|
20
|
+
- `synfull setup` asks whether to allow LAN access, then prepares the isolated Python runtime and dependencies. The choice is saved in the CLI config and used by later starts and service registration.
|
|
21
21
|
- `synfull start` runs in the background; `synfull run` keeps the server in the foreground.
|
|
22
22
|
- `synfull status`, `synfull stop`, and `synfull open` manage a manually started instance.
|
|
23
23
|
- `synfull install-service` explicitly enables automatic start and restart. On Windows this registers a per-user logon scheduled task. On Linux it creates and enables a system-wide systemd service and therefore requires `sudo`; the service runs as your invoking user, not root.
|
|
@@ -29,7 +29,7 @@ Configuration:
|
|
|
29
29
|
|
|
30
30
|
- `SYNFULL_HOME` selects the CLI runtime/application directory.
|
|
31
31
|
- `SYNFULL_STATE_DIR` selects persistent database and app state.
|
|
32
|
-
- `SYNFULL_BIND_PORT` changes the
|
|
32
|
+
- `SYNFULL_BIND_PORT` changes the port (default `8000`). The bind host is selected during interactive setup and defaults to `127.0.0.1`; `SYNFULL_BIND_HOST=127.0.0.1` or `SYNFULL_BIND_HOST=0.0.0.0` can override it for a single run/service install.
|
|
33
33
|
|
|
34
34
|
## Development from source
|
|
35
35
|
|
package/package.json
CHANGED
package/scripts/synfull.cjs
CHANGED
|
@@ -5,6 +5,7 @@ const crypto = require('node:crypto')
|
|
|
5
5
|
const fs = require('node:fs')
|
|
6
6
|
const os = require('node:os')
|
|
7
7
|
const path = require('node:path')
|
|
8
|
+
const readline = require('node:readline/promises')
|
|
8
9
|
const { spawn, spawnSync } = require('node:child_process')
|
|
9
10
|
const ROOT = path.resolve(__dirname, '..')
|
|
10
11
|
const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version
|
|
@@ -29,6 +30,7 @@ const STATE_DIR = process.env.SYNFULL_STATE_DIR || (WINDOWS
|
|
|
29
30
|
const RUNTIME = path.join(APP_DIR, '.venv', WINDOWS ? 'Scripts' : 'bin', WINDOWS ? 'python.exe' : 'python')
|
|
30
31
|
const PID_FILE = path.join(HOME, 'synfull.pid')
|
|
31
32
|
const LOG_FILE = path.join(HOME, 'synfull.log')
|
|
33
|
+
const CLI_CONFIG = path.join(HOME, 'config.json')
|
|
32
34
|
const CLI = path.resolve(__filename)
|
|
33
35
|
|
|
34
36
|
function run(command, args, options = {}) {
|
|
@@ -47,6 +49,37 @@ function pythonCommand() {
|
|
|
47
49
|
}
|
|
48
50
|
throw new Error('Python 3.10+ was not found. Install Python and ensure it is on PATH, then run `synfull setup`.')
|
|
49
51
|
}
|
|
52
|
+
function configuredBindHost() {
|
|
53
|
+
try {
|
|
54
|
+
const config = JSON.parse(fs.readFileSync(CLI_CONFIG, 'utf8'))
|
|
55
|
+
if (config.bindHost === '0.0.0.0') return '0.0.0.0'
|
|
56
|
+
} catch {}
|
|
57
|
+
return '127.0.0.1'
|
|
58
|
+
}
|
|
59
|
+
function selectedBindHost() {
|
|
60
|
+
const host = process.env.SYNFULL_BIND_HOST || configuredBindHost()
|
|
61
|
+
if (!['127.0.0.1', '0.0.0.0'].includes(host)) throw new Error('SYNFULL_BIND_HOST must be 127.0.0.1 or 0.0.0.0.')
|
|
62
|
+
return host
|
|
63
|
+
}
|
|
64
|
+
function saveBindHost(host) {
|
|
65
|
+
if (!['127.0.0.1', '0.0.0.0'].includes(host)) throw new Error('Bind host must be 127.0.0.1 or 0.0.0.0.')
|
|
66
|
+
fs.mkdirSync(HOME, { recursive: true })
|
|
67
|
+
fs.writeFileSync(CLI_CONFIG, `${JSON.stringify({ bindHost: host }, null, 2)}\n`, { mode: 0o600 })
|
|
68
|
+
}
|
|
69
|
+
async function configureBindHost() {
|
|
70
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return
|
|
71
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
72
|
+
try {
|
|
73
|
+
const answer = await rl.question('Allow other devices on your LAN to access Synfull by binding to 0.0.0.0? (y/N): ')
|
|
74
|
+
const host = /^y(es)?$/i.test(answer.trim()) ? '0.0.0.0' : '127.0.0.1'
|
|
75
|
+
saveBindHost(host)
|
|
76
|
+
console.log(host === '0.0.0.0'
|
|
77
|
+
? 'LAN access enabled. Change the initial admin password from this machine before signing in from another device; use a trusted LAN and configure your firewall.'
|
|
78
|
+
: 'Synfull will only accept connections from this machine.')
|
|
79
|
+
} finally {
|
|
80
|
+
rl.close()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
50
83
|
function ensureRuntime() {
|
|
51
84
|
if (!fs.existsSync(path.join(ROOT, 'backend', 'app')) || !fs.existsSync(path.join(ROOT, 'frontend', 'dist', 'index.html'))) {
|
|
52
85
|
throw new Error('This npm package is missing its backend or built dashboard assets.')
|
|
@@ -64,9 +97,7 @@ function ensureRuntime() {
|
|
|
64
97
|
const pipAvailable = spawnSync(RUNTIME, ['-m', 'pip', '--version'], { stdio: 'ignore', windowsHide: true }).status === 0
|
|
65
98
|
if (!pipAvailable) {
|
|
66
99
|
const python = pythonCommand()
|
|
67
|
-
const repair = spawnSync(python.command, [...python.prefix, '-m', 'venv', '--upgrade-deps', '--upgrade', path.join(APP_DIR, '.venv')], {
|
|
68
|
-
encoding: 'utf8', windowsHide: true,
|
|
69
|
-
})
|
|
100
|
+
const repair = spawnSync(python.command, [...python.prefix, '-m', 'venv', '--upgrade-deps', '--upgrade', path.join(APP_DIR, '.venv')], { encoding: 'utf8', windowsHide: true })
|
|
70
101
|
if (repair.status !== 0) {
|
|
71
102
|
const detail = (repair.stderr || repair.stdout || '').trim()
|
|
72
103
|
if (detail) console.error(detail)
|
|
@@ -77,9 +108,7 @@ function ensureRuntime() {
|
|
|
77
108
|
}
|
|
78
109
|
}
|
|
79
110
|
const pipReady = spawnSync(RUNTIME, ['-m', 'pip', '--version'], { stdio: 'ignore', windowsHide: true }).status === 0
|
|
80
|
-
if (!pipReady)
|
|
81
|
-
throw new Error('The Python virtual environment still has no pip. Install the Python venv support package and recreate the runtime, then rerun `synfull setup`.')
|
|
82
|
-
}
|
|
111
|
+
if (!pipReady) throw new Error('The Python virtual environment still has no pip. Install the Python venv support package and recreate the runtime, then rerun `synfull setup`.')
|
|
83
112
|
const requirementsPath = path.join(APP_DIR, 'backend', 'requirements-runtime.txt')
|
|
84
113
|
const requirementsHash = crypto.createHash('sha256').update(fs.readFileSync(requirementsPath)).digest('hex')
|
|
85
114
|
const installedHashPath = path.join(APP_DIR, '.runtime-requirements-hash')
|
|
@@ -97,7 +126,7 @@ function ensureRuntime() {
|
|
|
97
126
|
function serverEnvironment() {
|
|
98
127
|
return {
|
|
99
128
|
...process.env,
|
|
100
|
-
SYNFULL_BIND_HOST:
|
|
129
|
+
SYNFULL_BIND_HOST: selectedBindHost(),
|
|
101
130
|
SYNFULL_BIND_PORT: process.env.SYNFULL_BIND_PORT || '8000',
|
|
102
131
|
SYNFULL_STATE_DIR: STATE_DIR,
|
|
103
132
|
SYNFULL_BOOTSTRAP_DEFAULT_ADMIN: '1',
|
|
@@ -106,9 +135,12 @@ function serverEnvironment() {
|
|
|
106
135
|
}
|
|
107
136
|
function startServer(foreground) {
|
|
108
137
|
ensureRuntime()
|
|
109
|
-
const
|
|
138
|
+
const env = serverEnvironment()
|
|
139
|
+
const bindHost = env.SYNFULL_BIND_HOST
|
|
140
|
+
const port = process.env.SYNFULL_BIND_PORT || '8000'
|
|
141
|
+
const args = ['-m', 'uvicorn', 'app.main:app', '--app-dir', path.join(APP_DIR, 'backend'), '--host', bindHost, '--port', port]
|
|
110
142
|
if (foreground) {
|
|
111
|
-
const child = spawn(RUNTIME, args, { cwd: APP_DIR, env
|
|
143
|
+
const child = spawn(RUNTIME, args, { cwd: APP_DIR, env, stdio: 'inherit', windowsHide: true })
|
|
112
144
|
child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
|
|
113
145
|
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill(signal))
|
|
114
146
|
return
|
|
@@ -118,8 +150,7 @@ function startServer(foreground) {
|
|
|
118
150
|
try { process.kill(pid, 0); console.log(`Synfull is already running (PID ${pid}).`); return } catch { fs.rmSync(PID_FILE, { force: true }) }
|
|
119
151
|
}
|
|
120
152
|
const output = fs.openSync(LOG_FILE, 'a')
|
|
121
|
-
const
|
|
122
|
-
const child = spawn(RUNTIME, args, { cwd: APP_DIR, env: serverEnvironment(), detached, stdio: ['ignore', output, output], windowsHide: true })
|
|
153
|
+
const child = spawn(RUNTIME, args, { cwd: APP_DIR, env, detached: !WINDOWS, stdio: ['ignore', output, output], windowsHide: true })
|
|
123
154
|
fs.closeSync(output)
|
|
124
155
|
child.once('error', error => {
|
|
125
156
|
fs.rmSync(PID_FILE, { force: true })
|
|
@@ -128,7 +159,8 @@ function startServer(foreground) {
|
|
|
128
159
|
})
|
|
129
160
|
child.once('spawn', () => {
|
|
130
161
|
fs.writeFileSync(PID_FILE, String(child.pid))
|
|
131
|
-
console.log(`Synfull started
|
|
162
|
+
console.log(`Synfull started on ${bindHost}:${port} (PID ${child.pid}).`)
|
|
163
|
+
if (bindHost === '0.0.0.0') console.log(`LAN clients can use http://<server-lan-ip>:${port}; change the initial admin password locally first.`)
|
|
132
164
|
})
|
|
133
165
|
child.unref()
|
|
134
166
|
console.log(`Logs: ${LOG_FILE}`)
|
|
@@ -153,43 +185,44 @@ function status() {
|
|
|
153
185
|
pid = fs.readFileSync(PID_FILE, 'utf8').trim()
|
|
154
186
|
try { process.kill(Number(pid), 0); running = true } catch { fs.rmSync(PID_FILE, { force: true }) }
|
|
155
187
|
}
|
|
188
|
+
const bindHost = selectedBindHost()
|
|
156
189
|
console.log(`Synfull ${VERSION}: ${running ? `running (PID ${pid})` : 'stopped'}`)
|
|
157
|
-
console.log(`Dashboard: http
|
|
190
|
+
console.log(`Dashboard: http://${bindHost === '0.0.0.0' ? '<server-lan-ip>' : '127.0.0.1'}:${process.env.SYNFULL_BIND_PORT || '8000'}`)
|
|
158
191
|
console.log(`Data: ${STATE_DIR}`)
|
|
159
192
|
}
|
|
160
193
|
function windowsTaskXml() {
|
|
161
|
-
const
|
|
194
|
+
const identity = spawnSync('whoami.exe', ['/user', '/fo', 'csv', '/nh'], { encoding: 'utf8', windowsHide: true })
|
|
195
|
+
const sid = (identity.stdout || '').match(/S-1-[0-9-]+/)
|
|
162
196
|
if (!sid) throw new Error('Could not determine the current Windows account SID.')
|
|
163
197
|
const exe = process.execPath.replace(/&/g, '&').replace(/</g, '<')
|
|
164
198
|
const cli = CLI.replace(/&/g, '&').replace(/</g, '<')
|
|
165
199
|
return `<?xml version="1.0" encoding="UTF-16"?>\n<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"><Triggers><LogonTrigger><Enabled>true</Enabled><UserId>${sid[0]}</UserId></LogonTrigger></Triggers><Principals><Principal id="Author"><UserId>${sid[0]}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals><Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><Enabled>true</Enabled></Settings><Actions Context="Author"><Exec><Command>${exe}</Command><Arguments>"${cli}" run</Arguments><WorkingDirectory>${APP_DIR.replace(/&/g, '&').replace(/</g, '<')}</WorkingDirectory></Exec></Actions></Task>`
|
|
166
200
|
}
|
|
167
201
|
function installStartup() {
|
|
168
|
-
if (!WINDOWS && process.getuid && process.getuid() !== 0)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (!WINDOWS) {
|
|
172
|
-
const serviceUser = process.env.SUDO_USER || os.userInfo().username
|
|
173
|
-
if (serviceUser === 'root') throw new Error('Do not install Synfull as root. Re-run `sudo synfull install-service` from your normal account.')
|
|
174
|
-
}
|
|
202
|
+
if (!WINDOWS && process.getuid && process.getuid() !== 0) throw new Error('Installing the system-wide boot service requires administrator permission. Re-run with sudo; the service will still run as your original user.')
|
|
203
|
+
if (!WINDOWS && (process.env.SUDO_USER || os.userInfo().username) === 'root') throw new Error('Do not install Synfull as root. Re-run `sudo synfull install-service` from your normal account.')
|
|
204
|
+
saveBindHost(selectedBindHost())
|
|
175
205
|
ensureRuntime()
|
|
206
|
+
const bindHost = selectedBindHost()
|
|
207
|
+
const port = process.env.SYNFULL_BIND_PORT || '8000'
|
|
176
208
|
if (WINDOWS) {
|
|
177
209
|
const xmlPath = path.join(os.tmpdir(), `synfull-task-${process.pid}.xml`)
|
|
178
210
|
fs.writeFileSync(xmlPath, `\ufeff${windowsTaskXml()}`, 'utf16le')
|
|
179
211
|
try { run('schtasks.exe', ['/Create', '/TN', 'Synfull', '/XML', xmlPath, '/F']) } finally { fs.rmSync(xmlPath, { force: true }) }
|
|
180
|
-
console.log(
|
|
212
|
+
console.log(`Synfull will start at sign-in and restart after failures. Bind address: ${bindHost}.`)
|
|
181
213
|
} else {
|
|
182
214
|
const serviceUser = process.env.SUDO_USER || os.userInfo().username
|
|
183
215
|
fs.mkdirSync(STATE_DIR, { recursive: true })
|
|
184
216
|
run('chown', ['-R', serviceUser, HOME, STATE_DIR])
|
|
185
217
|
const escaped = value => value.replace(/\\/g, '\\\\').replace(/ /g, '\\x20').replace(/%/g, '%%')
|
|
186
|
-
const unit = `[Unit]\nDescription=Synfull
|
|
218
|
+
const unit = `[Unit]\nDescription=Synfull host monitoring\nAfter=network.target\n\n[Service]\nType=simple\nUser=${serviceUser}\nWorkingDirectory=${escaped(APP_DIR)}\nEnvironment=SYNFULL_BIND_HOST=${bindHost}\nEnvironment=SYNFULL_BIND_PORT=${port}\nEnvironment=SYNFULL_STATE_DIR=${escaped(STATE_DIR)}\nEnvironment=SYNFULL_BOOTSTRAP_DEFAULT_ADMIN=1\nEnvironment=PYTHONPATH=${escaped(path.join(APP_DIR, 'backend'))}\nExecStart=${escaped(RUNTIME)} -m uvicorn app.main:app --app-dir ${escaped(path.join(APP_DIR, 'backend'))} --host ${bindHost} --port ${port}\nRestart=always\nRestartSec=3\n\n[Install]\nWantedBy=multi-user.target\n`
|
|
187
219
|
const unitPath = '/etc/systemd/system/synfull-npm.service'
|
|
188
220
|
fs.writeFileSync(unitPath, unit, { mode: 0o644 })
|
|
189
221
|
try { run('systemctl', ['daemon-reload']); run('systemctl', ['enable', '--now', 'synfull-npm.service']) }
|
|
190
222
|
catch (error) { fs.rmSync(unitPath, { force: true }); throw error }
|
|
191
|
-
console.log(
|
|
223
|
+
console.log(`Synfull is installed as a systemd service and starts at boot. Bind address: ${bindHost}.`)
|
|
192
224
|
}
|
|
225
|
+
if (bindHost === '0.0.0.0') console.log('Allow access only from trusted LAN devices, configure your firewall, and change the initial admin password locally before remote sign-in.')
|
|
193
226
|
}
|
|
194
227
|
function uninstallStartup() {
|
|
195
228
|
if (WINDOWS) run('schtasks.exe', ['/Delete', '/TN', 'Synfull', '/F'])
|
|
@@ -197,29 +230,27 @@ function uninstallStartup() {
|
|
|
197
230
|
console.log('Synfull startup registration removed; application data was retained.')
|
|
198
231
|
}
|
|
199
232
|
function help() {
|
|
200
|
-
console.log(`Synfull ${VERSION}\n\nUsage: synfull <command>\n\nCommands:\n setup Prepare local runtime and install Python dependencies\n start Start Synfull in the background\n run Run Synfull in the foreground (for service managers)\n stop Stop a background Synfull process\n status Show status and local URLs\n open Open the local dashboard\n install-service Register start-at-login (Windows) or start-at-boot (Linux)\n uninstall-service Remove startup registration without deleting data\n\
|
|
233
|
+
console.log(`Synfull ${VERSION}\n\nUsage: synfull <command>\n\nCommands:\n setup Prepare local runtime and install Python dependencies\n start Start Synfull in the background\n run Run Synfull in the foreground (for service managers)\n stop Stop a background Synfull process\n status Show status and local URLs\n open Open the local dashboard\n install-service Register start-at-login (Windows) or start-at-boot (Linux)\n uninstall-service Remove startup registration without deleting data\n\nSetup asks whether to allow LAN connections; localhost-only is the default. Use a trusted LAN, open the port in your firewall only as needed, and change the initial admin password locally before remote sign-in.`)
|
|
201
234
|
}
|
|
202
|
-
function main() {
|
|
235
|
+
async function main() {
|
|
203
236
|
const command = process.argv[2]
|
|
204
237
|
if (command === '--version' || command === '-v') { console.log(VERSION); return }
|
|
205
238
|
if (command === '--help' || command === '-h') { help(); return }
|
|
206
239
|
if (!['win32', 'linux'].includes(process.platform)) throw new Error('Synfull npm CLI currently supports Windows and Linux.')
|
|
207
|
-
if (command === 'setup') { ensureRuntime(); console.log(`Runtime ready. Data directory: ${STATE_DIR}`) }
|
|
240
|
+
if (command === 'setup') { await configureBindHost(); ensureRuntime(); console.log(`Runtime ready. Data directory: ${STATE_DIR}`) }
|
|
208
241
|
else if (command === 'start') startServer(false)
|
|
209
242
|
else if (command === 'run') startServer(true)
|
|
210
243
|
else if (command === 'stop') stopServer()
|
|
211
244
|
else if (command === 'status') status()
|
|
212
245
|
else if (command === 'open') {
|
|
213
|
-
const
|
|
246
|
+
const host = selectedBindHost()
|
|
247
|
+
const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${process.env.SYNFULL_BIND_PORT || '8000'}`
|
|
214
248
|
if (WINDOWS) spawn('cmd.exe', ['/c', 'start', '', url], { detached: true, stdio: 'ignore', windowsHide: true }).unref()
|
|
215
|
-
else spawn(
|
|
249
|
+
else spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref()
|
|
216
250
|
}
|
|
217
251
|
else if (command === 'install-service') installStartup()
|
|
218
252
|
else if (command === 'uninstall-service') uninstallStartup()
|
|
219
253
|
else help()
|
|
220
254
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
try { main() } catch (error) { console.error(`Synfull: ${error.message}`); process.exitCode = 1 }
|
|
224
|
-
}
|
|
225
|
-
module.exports = { ensureRuntime, serverEnvironment, APP_DIR, STATE_DIR, ROOT }
|
|
255
|
+
if (require.main === module) main().catch(error => { console.error(`Synfull: ${error.message}`); process.exitCode = 1 })
|
|
256
|
+
module.exports = { ensureRuntime, serverEnvironment, configuredBindHost, selectedBindHost, saveBindHost, APP_DIR, STATE_DIR, ROOT }
|