synfull 0.1.0 → 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 +4 -4
- package/package.json +1 -1
- package/scripts/synfull.cjs +78 -28
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Synfull is a local-first host monitoring dashboard for Windows and Linux, provid
|
|
|
4
4
|
|
|
5
5
|
## Install from npm
|
|
6
6
|
|
|
7
|
-
Requirements: Node.js 18+, Python 3.10+, and npm. The CLI creates its own Python virtual environment and installs the backend dependencies when needed. Linux
|
|
7
|
+
Requirements: Node.js 18+, Python 3.10+, and npm. The CLI creates its own Python virtual environment and installs the backend dependencies when needed. On Ubuntu/Debian, install `python3-venv` (or the matching `python3.X-venv` package) if venv creation reports that pip is missing. Linux may also require build prerequisites when a dependency has no wheel for the host platform.
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
npm install -g synfull
|
|
@@ -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.')
|
|
@@ -61,6 +94,21 @@ function ensureRuntime() {
|
|
|
61
94
|
const python = pythonCommand()
|
|
62
95
|
run(python.command, [...python.prefix, '-m', 'venv', path.join(APP_DIR, '.venv')])
|
|
63
96
|
}
|
|
97
|
+
const pipAvailable = spawnSync(RUNTIME, ['-m', 'pip', '--version'], { stdio: 'ignore', windowsHide: true }).status === 0
|
|
98
|
+
if (!pipAvailable) {
|
|
99
|
+
const python = pythonCommand()
|
|
100
|
+
const repair = spawnSync(python.command, [...python.prefix, '-m', 'venv', '--upgrade-deps', '--upgrade', path.join(APP_DIR, '.venv')], { encoding: 'utf8', windowsHide: true })
|
|
101
|
+
if (repair.status !== 0) {
|
|
102
|
+
const detail = (repair.stderr || repair.stdout || '').trim()
|
|
103
|
+
if (detail) console.error(detail)
|
|
104
|
+
const platformFix = WINDOWS
|
|
105
|
+
? 'Repair or reinstall Python with its pip/venv components, then rerun `synfull setup`.'
|
|
106
|
+
: 'On Ubuntu/Debian install venv support (`sudo apt install python3-venv`, or the matching `python3.X-venv` package), remove the incomplete venv at ~/.local/share/synfull/app/.venv, then rerun `synfull setup`.'
|
|
107
|
+
throw new Error(`The Python virtual environment is missing pip. ${platformFix}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const pipReady = spawnSync(RUNTIME, ['-m', 'pip', '--version'], { stdio: 'ignore', windowsHide: true }).status === 0
|
|
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`.')
|
|
64
112
|
const requirementsPath = path.join(APP_DIR, 'backend', 'requirements-runtime.txt')
|
|
65
113
|
const requirementsHash = crypto.createHash('sha256').update(fs.readFileSync(requirementsPath)).digest('hex')
|
|
66
114
|
const installedHashPath = path.join(APP_DIR, '.runtime-requirements-hash')
|
|
@@ -78,7 +126,7 @@ function ensureRuntime() {
|
|
|
78
126
|
function serverEnvironment() {
|
|
79
127
|
return {
|
|
80
128
|
...process.env,
|
|
81
|
-
SYNFULL_BIND_HOST:
|
|
129
|
+
SYNFULL_BIND_HOST: selectedBindHost(),
|
|
82
130
|
SYNFULL_BIND_PORT: process.env.SYNFULL_BIND_PORT || '8000',
|
|
83
131
|
SYNFULL_STATE_DIR: STATE_DIR,
|
|
84
132
|
SYNFULL_BOOTSTRAP_DEFAULT_ADMIN: '1',
|
|
@@ -87,9 +135,12 @@ function serverEnvironment() {
|
|
|
87
135
|
}
|
|
88
136
|
function startServer(foreground) {
|
|
89
137
|
ensureRuntime()
|
|
90
|
-
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]
|
|
91
142
|
if (foreground) {
|
|
92
|
-
const child = spawn(RUNTIME, args, { cwd: APP_DIR, env
|
|
143
|
+
const child = spawn(RUNTIME, args, { cwd: APP_DIR, env, stdio: 'inherit', windowsHide: true })
|
|
93
144
|
child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
|
|
94
145
|
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill(signal))
|
|
95
146
|
return
|
|
@@ -99,8 +150,7 @@ function startServer(foreground) {
|
|
|
99
150
|
try { process.kill(pid, 0); console.log(`Synfull is already running (PID ${pid}).`); return } catch { fs.rmSync(PID_FILE, { force: true }) }
|
|
100
151
|
}
|
|
101
152
|
const output = fs.openSync(LOG_FILE, 'a')
|
|
102
|
-
const
|
|
103
|
-
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 })
|
|
104
154
|
fs.closeSync(output)
|
|
105
155
|
child.once('error', error => {
|
|
106
156
|
fs.rmSync(PID_FILE, { force: true })
|
|
@@ -109,7 +159,8 @@ function startServer(foreground) {
|
|
|
109
159
|
})
|
|
110
160
|
child.once('spawn', () => {
|
|
111
161
|
fs.writeFileSync(PID_FILE, String(child.pid))
|
|
112
|
-
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.`)
|
|
113
164
|
})
|
|
114
165
|
child.unref()
|
|
115
166
|
console.log(`Logs: ${LOG_FILE}`)
|
|
@@ -134,43 +185,44 @@ function status() {
|
|
|
134
185
|
pid = fs.readFileSync(PID_FILE, 'utf8').trim()
|
|
135
186
|
try { process.kill(Number(pid), 0); running = true } catch { fs.rmSync(PID_FILE, { force: true }) }
|
|
136
187
|
}
|
|
188
|
+
const bindHost = selectedBindHost()
|
|
137
189
|
console.log(`Synfull ${VERSION}: ${running ? `running (PID ${pid})` : 'stopped'}`)
|
|
138
|
-
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'}`)
|
|
139
191
|
console.log(`Data: ${STATE_DIR}`)
|
|
140
192
|
}
|
|
141
193
|
function windowsTaskXml() {
|
|
142
|
-
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-]+/)
|
|
143
196
|
if (!sid) throw new Error('Could not determine the current Windows account SID.')
|
|
144
197
|
const exe = process.execPath.replace(/&/g, '&').replace(/</g, '<')
|
|
145
198
|
const cli = CLI.replace(/&/g, '&').replace(/</g, '<')
|
|
146
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>`
|
|
147
200
|
}
|
|
148
201
|
function installStartup() {
|
|
149
|
-
if (!WINDOWS && process.getuid && process.getuid() !== 0)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (!WINDOWS) {
|
|
153
|
-
const serviceUser = process.env.SUDO_USER || os.userInfo().username
|
|
154
|
-
if (serviceUser === 'root') throw new Error('Do not install Synfull as root. Re-run `sudo synfull install-service` from your normal account.')
|
|
155
|
-
}
|
|
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())
|
|
156
205
|
ensureRuntime()
|
|
206
|
+
const bindHost = selectedBindHost()
|
|
207
|
+
const port = process.env.SYNFULL_BIND_PORT || '8000'
|
|
157
208
|
if (WINDOWS) {
|
|
158
209
|
const xmlPath = path.join(os.tmpdir(), `synfull-task-${process.pid}.xml`)
|
|
159
210
|
fs.writeFileSync(xmlPath, `\ufeff${windowsTaskXml()}`, 'utf16le')
|
|
160
211
|
try { run('schtasks.exe', ['/Create', '/TN', 'Synfull', '/XML', xmlPath, '/F']) } finally { fs.rmSync(xmlPath, { force: true }) }
|
|
161
|
-
console.log(
|
|
212
|
+
console.log(`Synfull will start at sign-in and restart after failures. Bind address: ${bindHost}.`)
|
|
162
213
|
} else {
|
|
163
214
|
const serviceUser = process.env.SUDO_USER || os.userInfo().username
|
|
164
215
|
fs.mkdirSync(STATE_DIR, { recursive: true })
|
|
165
216
|
run('chown', ['-R', serviceUser, HOME, STATE_DIR])
|
|
166
217
|
const escaped = value => value.replace(/\\/g, '\\\\').replace(/ /g, '\\x20').replace(/%/g, '%%')
|
|
167
|
-
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`
|
|
168
219
|
const unitPath = '/etc/systemd/system/synfull-npm.service'
|
|
169
220
|
fs.writeFileSync(unitPath, unit, { mode: 0o644 })
|
|
170
221
|
try { run('systemctl', ['daemon-reload']); run('systemctl', ['enable', '--now', 'synfull-npm.service']) }
|
|
171
222
|
catch (error) { fs.rmSync(unitPath, { force: true }); throw error }
|
|
172
|
-
console.log(
|
|
223
|
+
console.log(`Synfull is installed as a systemd service and starts at boot. Bind address: ${bindHost}.`)
|
|
173
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.')
|
|
174
226
|
}
|
|
175
227
|
function uninstallStartup() {
|
|
176
228
|
if (WINDOWS) run('schtasks.exe', ['/Delete', '/TN', 'Synfull', '/F'])
|
|
@@ -178,29 +230,27 @@ function uninstallStartup() {
|
|
|
178
230
|
console.log('Synfull startup registration removed; application data was retained.')
|
|
179
231
|
}
|
|
180
232
|
function help() {
|
|
181
|
-
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.`)
|
|
182
234
|
}
|
|
183
|
-
function main() {
|
|
235
|
+
async function main() {
|
|
184
236
|
const command = process.argv[2]
|
|
185
237
|
if (command === '--version' || command === '-v') { console.log(VERSION); return }
|
|
186
238
|
if (command === '--help' || command === '-h') { help(); return }
|
|
187
239
|
if (!['win32', 'linux'].includes(process.platform)) throw new Error('Synfull npm CLI currently supports Windows and Linux.')
|
|
188
|
-
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}`) }
|
|
189
241
|
else if (command === 'start') startServer(false)
|
|
190
242
|
else if (command === 'run') startServer(true)
|
|
191
243
|
else if (command === 'stop') stopServer()
|
|
192
244
|
else if (command === 'status') status()
|
|
193
245
|
else if (command === 'open') {
|
|
194
|
-
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'}`
|
|
195
248
|
if (WINDOWS) spawn('cmd.exe', ['/c', 'start', '', url], { detached: true, stdio: 'ignore', windowsHide: true }).unref()
|
|
196
|
-
else spawn(
|
|
249
|
+
else spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref()
|
|
197
250
|
}
|
|
198
251
|
else if (command === 'install-service') installStartup()
|
|
199
252
|
else if (command === 'uninstall-service') uninstallStartup()
|
|
200
253
|
else help()
|
|
201
254
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
try { main() } catch (error) { console.error(`Synfull: ${error.message}`); process.exitCode = 1 }
|
|
205
|
-
}
|
|
206
|
-
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 }
|