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 CHANGED
@@ -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.1",
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.')
@@ -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: '127.0.0.1',
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 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]
110
142
  if (foreground) {
111
- 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 })
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 detached = !WINDOWS
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 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.`)
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://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'}`)
158
191
  console.log(`Data: ${STATE_DIR}`)
159
192
  }
160
193
  function windowsTaskXml() {
161
- 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-]+/)
162
196
  if (!sid) throw new Error('Could not determine the current Windows account SID.')
163
197
  const exe = process.execPath.replace(/&/g, '&amp;').replace(/</g, '&lt;')
164
198
  const cli = CLI.replace(/&/g, '&amp;').replace(/</g, '&lt;')
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, '&amp;').replace(/</g, '&lt;')}</WorkingDirectory></Exec></Actions></Task>`
166
200
  }
167
201
  function installStartup() {
168
- if (!WINDOWS && process.getuid && process.getuid() !== 0) {
169
- 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.')
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('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}.`)
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 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`
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('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}.`)
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\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.`)
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 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'}`
214
248
  if (WINDOWS) spawn('cmd.exe', ['/c', 'start', '', url], { detached: true, stdio: 'ignore', windowsHide: true }).unref()
215
- 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()
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
- if (require.main === module) {
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 }