synfull 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +58 -2
- package/backend/app/__init__.py +0 -0
- package/backend/app/api/__init__.py +0 -0
- package/backend/app/api/routes.py +380 -0
- package/backend/app/collectors/platform/__init__.py +13 -0
- package/backend/app/collectors/platform/base.py +26 -0
- package/backend/app/collectors/platform/linux.py +56 -0
- package/backend/app/collectors/platform/windows.py +54 -0
- package/backend/app/collectors/processes.py +7 -0
- package/backend/app/collectors/system.py +11 -0
- package/backend/app/config.py +51 -0
- package/backend/app/database.py +22 -0
- package/backend/app/integrations/discord_alerts.py +239 -0
- package/backend/app/integrations/status.py +192 -0
- package/backend/app/main.py +52 -0
- package/backend/app/schemas/alerts.py +19 -0
- package/backend/app/schemas/management.py +63 -0
- package/backend/app/schemas/system.py +30 -0
- package/backend/app/services/management.py +1032 -0
- package/backend/requirements-runtime.txt +6 -0
- package/frontend/dist/assets/index-B40UafPr.css +1 -0
- package/frontend/dist/assets/index-ERXSRoTY.js +249 -0
- package/frontend/dist/index.html +14 -0
- package/package.json +41 -4
- package/scripts/synfull.cjs +206 -0
|
@@ -0,0 +1,14 @@
|
|
|
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.0" />
|
|
6
|
+
<meta name="theme-color" content="#0b1220" />
|
|
7
|
+
<title>Synfull</title>
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-ERXSRoTY.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B40UafPr.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "synfull",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local-first cross-platform host monitoring dashboard",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"preferGlobal": true,
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"synfull": "scripts/synfull.cjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"backend/app/**/*.py",
|
|
16
|
+
"backend/requirements-runtime.txt",
|
|
17
|
+
"frontend/dist",
|
|
18
|
+
"scripts/synfull.cjs",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"os": [
|
|
26
|
+
"win32",
|
|
27
|
+
"linux"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "npm --prefix frontend run build",
|
|
31
|
+
"test": "npm run test:backend && npm run test:frontend",
|
|
32
|
+
"test:backend": "python -m pytest backend/tests",
|
|
33
|
+
"test:frontend": "npm --prefix frontend run lint",
|
|
34
|
+
"prepack": "npm --prefix frontend ci && npm --prefix frontend run build"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"monitoring",
|
|
38
|
+
"system-monitor",
|
|
39
|
+
"dashboard",
|
|
40
|
+
"linux",
|
|
41
|
+
"windows"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
const crypto = require('node:crypto')
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const os = require('node:os')
|
|
7
|
+
const path = require('node:path')
|
|
8
|
+
const { spawn, spawnSync } = require('node:child_process')
|
|
9
|
+
const ROOT = path.resolve(__dirname, '..')
|
|
10
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version
|
|
11
|
+
const WINDOWS = process.platform === 'win32'
|
|
12
|
+
function targetAccountHome() {
|
|
13
|
+
if (WINDOWS || !process.env.SUDO_USER) return os.homedir()
|
|
14
|
+
const account = spawnSync('getent', ['passwd', process.env.SUDO_USER], { encoding: 'utf8' })
|
|
15
|
+
return account.status === 0 && account.stdout.trim().split(':')[5] || os.homedir()
|
|
16
|
+
}
|
|
17
|
+
function effectiveHome() {
|
|
18
|
+
if (process.env.SYNFULL_HOME) return process.env.SYNFULL_HOME
|
|
19
|
+
const userHome = targetAccountHome()
|
|
20
|
+
return WINDOWS
|
|
21
|
+
? path.join(process.env.LOCALAPPDATA || path.join(userHome, 'AppData', 'Local'), 'Synfull')
|
|
22
|
+
: path.join(process.env.XDG_DATA_HOME || path.join(userHome, '.local', 'share'), 'synfull')
|
|
23
|
+
}
|
|
24
|
+
const HOME = effectiveHome()
|
|
25
|
+
const APP_DIR = path.join(HOME, 'app')
|
|
26
|
+
const STATE_DIR = process.env.SYNFULL_STATE_DIR || (WINDOWS
|
|
27
|
+
? path.join(process.env.LOCALAPPDATA || path.join(targetAccountHome(), 'AppData', 'Local'), 'Synfull')
|
|
28
|
+
: path.join(process.env.XDG_STATE_HOME || path.join(targetAccountHome(), '.local', 'state'), 'synfull'))
|
|
29
|
+
const RUNTIME = path.join(APP_DIR, '.venv', WINDOWS ? 'Scripts' : 'bin', WINDOWS ? 'python.exe' : 'python')
|
|
30
|
+
const PID_FILE = path.join(HOME, 'synfull.pid')
|
|
31
|
+
const LOG_FILE = path.join(HOME, 'synfull.log')
|
|
32
|
+
const CLI = path.resolve(__filename)
|
|
33
|
+
|
|
34
|
+
function run(command, args, options = {}) {
|
|
35
|
+
const result = spawnSync(command, args, { stdio: 'inherit', windowsHide: true, ...options })
|
|
36
|
+
if (result.error) throw result.error
|
|
37
|
+
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? 'unknown'}`)
|
|
38
|
+
}
|
|
39
|
+
function pythonCommand() {
|
|
40
|
+
const candidates = WINDOWS ? [['py', ['-3']], ['python', []], ['python3', []]] : [['python3', []], ['python', []]]
|
|
41
|
+
for (const [command, prefix] of candidates) {
|
|
42
|
+
const probe = spawnSync(command, [...prefix, '-c', 'import sys; print("%d.%d" % sys.version_info[:2])'], { encoding: 'utf8', windowsHide: true })
|
|
43
|
+
if (probe.status === 0) {
|
|
44
|
+
const [major, minor] = probe.stdout.trim().split('.').map(Number)
|
|
45
|
+
if (major > 3 || (major === 3 && minor >= 10)) return { command, prefix }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
throw new Error('Python 3.10+ was not found. Install Python and ensure it is on PATH, then run `synfull setup`.')
|
|
49
|
+
}
|
|
50
|
+
function ensureRuntime() {
|
|
51
|
+
if (!fs.existsSync(path.join(ROOT, 'backend', 'app')) || !fs.existsSync(path.join(ROOT, 'frontend', 'dist', 'index.html'))) {
|
|
52
|
+
throw new Error('This npm package is missing its backend or built dashboard assets.')
|
|
53
|
+
}
|
|
54
|
+
fs.mkdirSync(HOME, { recursive: true })
|
|
55
|
+
fs.mkdirSync(STATE_DIR, { recursive: true })
|
|
56
|
+
fs.mkdirSync(APP_DIR, { recursive: true })
|
|
57
|
+
for (const name of ['backend', 'frontend']) {
|
|
58
|
+
fs.cpSync(path.join(ROOT, name), path.join(APP_DIR, name), { recursive: true, force: true })
|
|
59
|
+
}
|
|
60
|
+
if (!fs.existsSync(RUNTIME)) {
|
|
61
|
+
const python = pythonCommand()
|
|
62
|
+
run(python.command, [...python.prefix, '-m', 'venv', path.join(APP_DIR, '.venv')])
|
|
63
|
+
}
|
|
64
|
+
const requirementsPath = path.join(APP_DIR, 'backend', 'requirements-runtime.txt')
|
|
65
|
+
const requirementsHash = crypto.createHash('sha256').update(fs.readFileSync(requirementsPath)).digest('hex')
|
|
66
|
+
const installedHashPath = path.join(APP_DIR, '.runtime-requirements-hash')
|
|
67
|
+
const installedHash = fs.existsSync(installedHashPath) ? fs.readFileSync(installedHashPath, 'utf8').trim() : ''
|
|
68
|
+
const dependenciesReady = spawnSync(RUNTIME, ['-c', 'import uvicorn, fastapi, pydantic_settings, sqlalchemy, psutil, docker'], { stdio: 'ignore', windowsHide: true }).status === 0
|
|
69
|
+
if (!dependenciesReady || installedHash !== requirementsHash) {
|
|
70
|
+
run(RUNTIME, ['-m', 'pip', 'install', '--disable-pip-version-check', '-r', requirementsPath])
|
|
71
|
+
fs.writeFileSync(installedHashPath, requirementsHash)
|
|
72
|
+
}
|
|
73
|
+
run(RUNTIME, ['-c', 'import uvicorn, fastapi, pydantic_settings, sqlalchemy, psutil, docker; import app.main'], {
|
|
74
|
+
cwd: APP_DIR,
|
|
75
|
+
env: { ...serverEnvironment(), PYTHONPATH: path.join(APP_DIR, 'backend') },
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
function serverEnvironment() {
|
|
79
|
+
return {
|
|
80
|
+
...process.env,
|
|
81
|
+
SYNFULL_BIND_HOST: '127.0.0.1',
|
|
82
|
+
SYNFULL_BIND_PORT: process.env.SYNFULL_BIND_PORT || '8000',
|
|
83
|
+
SYNFULL_STATE_DIR: STATE_DIR,
|
|
84
|
+
SYNFULL_BOOTSTRAP_DEFAULT_ADMIN: '1',
|
|
85
|
+
PYTHONPATH: path.join(APP_DIR, 'backend'),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function startServer(foreground) {
|
|
89
|
+
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']
|
|
91
|
+
if (foreground) {
|
|
92
|
+
const child = spawn(RUNTIME, args, { cwd: APP_DIR, env: serverEnvironment(), stdio: 'inherit', windowsHide: true })
|
|
93
|
+
child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
|
|
94
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill(signal))
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
if (fs.existsSync(PID_FILE)) {
|
|
98
|
+
const pid = Number(fs.readFileSync(PID_FILE, 'utf8'))
|
|
99
|
+
try { process.kill(pid, 0); console.log(`Synfull is already running (PID ${pid}).`); return } catch { fs.rmSync(PID_FILE, { force: true }) }
|
|
100
|
+
}
|
|
101
|
+
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 })
|
|
104
|
+
fs.closeSync(output)
|
|
105
|
+
child.once('error', error => {
|
|
106
|
+
fs.rmSync(PID_FILE, { force: true })
|
|
107
|
+
console.error(`Synfull failed to start: ${error.message}`)
|
|
108
|
+
process.exitCode = 1
|
|
109
|
+
})
|
|
110
|
+
child.once('spawn', () => {
|
|
111
|
+
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}).`)
|
|
113
|
+
})
|
|
114
|
+
child.unref()
|
|
115
|
+
console.log(`Logs: ${LOG_FILE}`)
|
|
116
|
+
console.log('First login: admin / admin; Synfull will require a new password before proceeding.')
|
|
117
|
+
}
|
|
118
|
+
function stopServer() {
|
|
119
|
+
if (!fs.existsSync(PID_FILE)) { console.log('Synfull is not running.'); return }
|
|
120
|
+
const pid = Number(fs.readFileSync(PID_FILE, 'utf8'))
|
|
121
|
+
if (WINDOWS) {
|
|
122
|
+
const result = spawnSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
|
|
123
|
+
if (result.status !== 0) console.log('Synfull process was already stopped.')
|
|
124
|
+
} else {
|
|
125
|
+
try { process.kill(-pid, 'SIGTERM') } catch { try { process.kill(pid, 'SIGTERM') } catch { console.log('Synfull process was already stopped.') } }
|
|
126
|
+
}
|
|
127
|
+
fs.rmSync(PID_FILE, { force: true })
|
|
128
|
+
console.log('Synfull stopped.')
|
|
129
|
+
}
|
|
130
|
+
function status() {
|
|
131
|
+
let running = false
|
|
132
|
+
let pid = ''
|
|
133
|
+
if (fs.existsSync(PID_FILE)) {
|
|
134
|
+
pid = fs.readFileSync(PID_FILE, 'utf8').trim()
|
|
135
|
+
try { process.kill(Number(pid), 0); running = true } catch { fs.rmSync(PID_FILE, { force: true }) }
|
|
136
|
+
}
|
|
137
|
+
console.log(`Synfull ${VERSION}: ${running ? `running (PID ${pid})` : 'stopped'}`)
|
|
138
|
+
console.log(`Dashboard: http://127.0.0.1:${process.env.SYNFULL_BIND_PORT || '8000'}`)
|
|
139
|
+
console.log(`Data: ${STATE_DIR}`)
|
|
140
|
+
}
|
|
141
|
+
function windowsTaskXml() {
|
|
142
|
+
const sid = spawnSync('whoami.exe', ['/user', '/fo', 'csv', '/nh'], { encoding: 'utf8', windowsHide: true }).stdout.match(/S-1-[0-9-]+/)
|
|
143
|
+
if (!sid) throw new Error('Could not determine the current Windows account SID.')
|
|
144
|
+
const exe = process.execPath.replace(/&/g, '&').replace(/</g, '<')
|
|
145
|
+
const cli = CLI.replace(/&/g, '&').replace(/</g, '<')
|
|
146
|
+
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
|
+
}
|
|
148
|
+
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
|
+
}
|
|
156
|
+
ensureRuntime()
|
|
157
|
+
if (WINDOWS) {
|
|
158
|
+
const xmlPath = path.join(os.tmpdir(), `synfull-task-${process.pid}.xml`)
|
|
159
|
+
fs.writeFileSync(xmlPath, `\ufeff${windowsTaskXml()}`, 'utf16le')
|
|
160
|
+
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.')
|
|
162
|
+
} else {
|
|
163
|
+
const serviceUser = process.env.SUDO_USER || os.userInfo().username
|
|
164
|
+
fs.mkdirSync(STATE_DIR, { recursive: true })
|
|
165
|
+
run('chown', ['-R', serviceUser, HOME, STATE_DIR])
|
|
166
|
+
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`
|
|
168
|
+
const unitPath = '/etc/systemd/system/synfull-npm.service'
|
|
169
|
+
fs.writeFileSync(unitPath, unit, { mode: 0o644 })
|
|
170
|
+
try { run('systemctl', ['daemon-reload']); run('systemctl', ['enable', '--now', 'synfull-npm.service']) }
|
|
171
|
+
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.')
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function uninstallStartup() {
|
|
176
|
+
if (WINDOWS) run('schtasks.exe', ['/Delete', '/TN', 'Synfull', '/F'])
|
|
177
|
+
else { run('systemctl', ['disable', '--now', 'synfull-npm.service']); fs.rmSync('/etc/systemd/system/synfull-npm.service', { force: true }); run('systemctl', ['daemon-reload']) }
|
|
178
|
+
console.log('Synfull startup registration removed; application data was retained.')
|
|
179
|
+
}
|
|
180
|
+
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.`)
|
|
182
|
+
}
|
|
183
|
+
function main() {
|
|
184
|
+
const command = process.argv[2]
|
|
185
|
+
if (command === '--version' || command === '-v') { console.log(VERSION); return }
|
|
186
|
+
if (command === '--help' || command === '-h') { help(); return }
|
|
187
|
+
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}`) }
|
|
189
|
+
else if (command === 'start') startServer(false)
|
|
190
|
+
else if (command === 'run') startServer(true)
|
|
191
|
+
else if (command === 'stop') stopServer()
|
|
192
|
+
else if (command === 'status') status()
|
|
193
|
+
else if (command === 'open') {
|
|
194
|
+
const url = `http://127.0.0.1:${process.env.SYNFULL_BIND_PORT || '8000'}`
|
|
195
|
+
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()
|
|
197
|
+
}
|
|
198
|
+
else if (command === 'install-service') installStartup()
|
|
199
|
+
else if (command === 'uninstall-service') uninstallStartup()
|
|
200
|
+
else help()
|
|
201
|
+
}
|
|
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 }
|