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 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 requires Python build prerequisites when a dependency has no wheel for the host platform.
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
- The dashboard listens on **127.0.0.1:8000** only. The initial local login is username `admin`, password `admin`; Synfull will require you to set a new password of at least 12 characters before accessing the application. Do not expose the service publicly or through an untrusted proxy. npm installation alone does not register a background service or change system startup.
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 localhost port (default `8000`). The listener is deliberately fixed to `127.0.0.1` in npm mode.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synfull",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Local-first cross-platform host monitoring dashboard",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -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: '127.0.0.1',
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 args = ['-m', 'uvicorn', 'app.main:app', '--app-dir', path.join(APP_DIR, 'backend'), '--host', '127.0.0.1', '--port', process.env.SYNFULL_BIND_PORT || '8000']
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: serverEnvironment(), stdio: 'inherit', windowsHide: true })
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 detached = !WINDOWS
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 at http://127.0.0.1:${process.env.SYNFULL_BIND_PORT || '8000'} (PID ${child.pid}).`)
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://127.0.0.1:${process.env.SYNFULL_BIND_PORT || '8000'}`)
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 sid = spawnSync('whoami.exe', ['/user', '/fo', 'csv', '/nh'], { encoding: 'utf8', windowsHide: true }).stdout.match(/S-1-[0-9-]+/)
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, '&amp;').replace(/</g, '&lt;')
145
198
  const cli = CLI.replace(/&/g, '&amp;').replace(/</g, '&lt;')
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, '&amp;').replace(/</g, '&lt;')}</WorkingDirectory></Exec></Actions></Task>`
147
200
  }
148
201
  function installStartup() {
149
- if (!WINDOWS && process.getuid && process.getuid() !== 0) {
150
- 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.')
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('Synfull will start at sign-in and restart after failures. It runs only on 127.0.0.1.')
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 local host monitoring\nAfter=network.target\n\n[Service]\nType=simple\nUser=${serviceUser}\nWorkingDirectory=${escaped(APP_DIR)}\nEnvironment=SYNFULL_BIND_HOST=127.0.0.1\nEnvironment=SYNFULL_BIND_PORT=${process.env.SYNFULL_BIND_PORT || '8000'}\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 127.0.0.1 --port ${process.env.SYNFULL_BIND_PORT || '8000'}\nRestart=always\nRestartSec=3\n\n[Install]\nWantedBy=multi-user.target\n`
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('Synfull is installed as a systemd service and starts at boot. It listens only on 127.0.0.1.')
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\nSynfull binds to localhost only by default. Override data/port with SYNFULL_STATE_DIR, SYNFULL_HOME, and SYNFULL_BIND_PORT.`)
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 url = `http://127.0.0.1:${process.env.SYNFULL_BIND_PORT || '8000'}`
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(process.platform === 'linux' ? 'xdg-open' : 'open', [url], { detached: true, stdio: 'ignore' }).unref()
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
- if (require.main === module) {
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 }