Initial commit: get everything rolling
This commit is contained in:
12
README.md
12
README.md
@@ -1,3 +1,13 @@
|
||||
# SyncTool
|
||||
|
||||
The software that runs on my Pi USB Parking station project. Written in Node.
|
||||
The software that runs on my Pi USB Parking station project. Written in Node.
|
||||
|
||||
## Features
|
||||
- Highly configurable
|
||||
- Doesn't rely on anything Node itself doesn't include
|
||||
- Spins up and manges Copyparty, Rclone, Rsync, and Open Notebook upload.
|
||||
- (probably) doesn't suck.
|
||||
### code transparency
|
||||
Most of the services/ modules were written by Gemini
|
||||
index.js was written by me and most of usbkey-lifecycle.js
|
||||
sysfs-functions was writen by gemini
|
||||
63
config.json
Normal file
63
config.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"system": {
|
||||
"mountPoint": "/mnt/flashdrive",
|
||||
"ledPath": "/sys/class/leds/ACT",
|
||||
"cacheFile": "./data/sync_state.json",
|
||||
"targetUuid": "7AEB-FF21",
|
||||
"maxFailedMounts": 2
|
||||
},
|
||||
|
||||
"copyparty": {
|
||||
"enabled": true,
|
||||
"binaryPath": "/opt/usb-syncer/copyparty-sfx.py",
|
||||
"port": 3923,
|
||||
"user": "pi",
|
||||
"password": "changeme",
|
||||
"volumeName": "flash",
|
||||
"permissions": "rwmd",
|
||||
"extraArgs": ["--no-reload", "--q"]
|
||||
},
|
||||
|
||||
"rsync": {
|
||||
"enabled": true,
|
||||
"nasPath": "/mnt/nas/flash_backup",
|
||||
"bidirectional": true,
|
||||
"deleteRemote": false,
|
||||
"bypassEmptyFolder": true,
|
||||
"exclude": [
|
||||
".Trash-*",
|
||||
"System Volume Information",
|
||||
".Spotlight-V100",
|
||||
".fseventsd",
|
||||
"*.tmp"
|
||||
],
|
||||
"flags": ["-avu", "--update", "--modify-window=1"]
|
||||
},
|
||||
|
||||
"rclone": {
|
||||
"enabled": true,
|
||||
"remote": "gdrive:FlashBackup",
|
||||
"bidirectional": true,
|
||||
"bandwidthLimit": null,
|
||||
"exclude": [
|
||||
".*",
|
||||
"*.tmp"
|
||||
],
|
||||
"flags": ["--update", "--transfers=2", "--checkers=4"]
|
||||
},
|
||||
|
||||
"openNotebook": {
|
||||
"enabled": true,
|
||||
"endpoint": "http://192.168.1.50:8000/api/v1",
|
||||
"apiToken": "env:NOTEBOOK_TOKEN",
|
||||
"notebookId": "class-notes-2026",
|
||||
"watchFolder": "classes",
|
||||
"allowedExtensions": [".pdf", ".docx", ".md", ".txt"]
|
||||
},
|
||||
|
||||
"notifications": {
|
||||
"enabled": false,
|
||||
"webhookUrl": "https://ntfy.sh/my-sync-appliance"
|
||||
}
|
||||
}
|
||||
|
||||
107
index.js
Normal file
107
index.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// SyncTool
|
||||
// Read or modify this code if you dare, it may be unilicense but you'll loose your sanity
|
||||
import { execSync, spawn } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, openAsBlob } from 'node:fs';
|
||||
import { isMounted,unmountDrive,mountDrive, driveExists } from './usbkey-lifecycle';
|
||||
import { startCopyparty, stopCopyparty } from './services/copyparty'; //Realistically we should just use promise for this to keep things optimized. But, who cares about optimization!
|
||||
import { start }
|
||||
import data from './config.json' with { type: 'json' }
|
||||
import path from 'node:path';
|
||||
import { start } from 'node:repl';
|
||||
import { runRclone, runRsync } from './services/syncjobs';
|
||||
console.log('SyncTool init')
|
||||
console.log("Don't blame me if stuff bursts into flames")
|
||||
console.log("System info:")
|
||||
// Print enabled servers
|
||||
const serverConfigMap = {
|
||||
'Open Notebook': 'openNotebook',
|
||||
'Rclone': 'rclone',
|
||||
'Rsync': 'rsync',
|
||||
'Copyparty': 'copyparty'
|
||||
}
|
||||
Object.entries(serverConfigMap).forEach(([server, configKey]) => {
|
||||
const isEnabled = data[configKey]?.enabled === true
|
||||
console.log(`${isEnabled ? '✓' : '✗'} ${server} ${isEnabled ? '(enabled)' : '(disabled)'}`)
|
||||
})
|
||||
|
||||
|
||||
|
||||
//Check if the Flash Drive is plugged in
|
||||
if (isMounted(config.system.targetUuid, config.system.mountPoint) == true) {
|
||||
console.log("Drive looks mounted.")
|
||||
var USBKeyMounted = true
|
||||
} else {
|
||||
if (driveExists != null) {
|
||||
var FDDevNode = driveExists()
|
||||
var mountAttempts = 0
|
||||
console.debug("Mount attempts: ", mountAttempts)
|
||||
try {
|
||||
mountDrive(FDDevNode, config.mountPoint)
|
||||
} catch (error) {
|
||||
mountAttempts++
|
||||
console.debug("Mount attempts: ", mountAttempts)
|
||||
if (mountAttempts >= config.maxFailedMounts) {
|
||||
console.error("Failed to mount the drive... Did it disconnect?")
|
||||
exit(1)
|
||||
}
|
||||
} // Otherwise the drive exists and we can move on
|
||||
}
|
||||
}
|
||||
//If RSYNC is on, check if the NAS is mounted and empty:
|
||||
if (config.rsync.enabled == true) {
|
||||
const nasPath = config.rsync.nasPath
|
||||
console.log(`Checking NAS at ${nasPath}:`)
|
||||
try {
|
||||
const files = readdirSync(nasPath)
|
||||
if (files.length === 0) {
|
||||
console.warn("NAS is empty!")
|
||||
} else {
|
||||
console.log(`Found ${files.length} items in NAS`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking NAS: ${error.message}`)
|
||||
}
|
||||
}
|
||||
// Now it's Loadin time
|
||||
// The best part about index.js was when it said "it's loadin time" and loaded all over the place
|
||||
var copypartyStarted = false
|
||||
var OpenNotebookstarted = false
|
||||
var rcloneStarted = false
|
||||
var rcloneRuns = 0
|
||||
var rSYNcStarted = false
|
||||
var rSYNcRuns = 0
|
||||
function loadintime() {
|
||||
//WELCOME TO if HELL!!!!
|
||||
if (config.copyparty.enabled == true) {
|
||||
console.debug("Copyparty enabled, starting")
|
||||
startCopyparty(config)
|
||||
}
|
||||
if (config.rclone.enabled == true) {
|
||||
// Go ahead and run both once
|
||||
rcloneRuns++
|
||||
console.debug("Starting first run for Rclone...")
|
||||
try {
|
||||
await runRclone(config)
|
||||
var rcloneStarted = true
|
||||
} catch (error) {
|
||||
console.warn("Rclone went up in flames: ", error.message)
|
||||
var rcloneStarted = false
|
||||
}
|
||||
}
|
||||
if (config.rsync.enabled == true) {
|
||||
rSYNcRuns++
|
||||
console.debug("Starting first run for Rsync...")
|
||||
try {
|
||||
await runRsync(config)
|
||||
var rSYNcStarted = true
|
||||
} catch (error) {
|
||||
console.warn("Rsync went up in flames: ", error.message)
|
||||
var rSYNcStarted = false
|
||||
}
|
||||
}
|
||||
if (config.openNotebook.enabled == true) {
|
||||
//do whatever is needed here
|
||||
}
|
||||
console.debug("loadin time complete")
|
||||
}
|
||||
//TODO: Actually do something? idiot
|
||||
16
package.json
Normal file
16
package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "synctool",
|
||||
"version": "0.1.0",
|
||||
"description": "The software that runs on my Pi USB Parking station project. Written in Node.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://warehouse.randomhack.com/unfunny/SyncTool.git"
|
||||
},
|
||||
"license": "Unlicense",
|
||||
"author": "",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "notify-send \"I AM NOT A MORON!\" \"COULD A MORON, [forget to set up the test command]? HUH? COULD A MORON DO THAT? uh oh\""
|
||||
}
|
||||
}
|
||||
45
services/copyparty.js
Normal file
45
services/copyparty.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// services/copyparty.js
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
let processHandle = null;
|
||||
|
||||
export function startCopyparty(config) {
|
||||
const cp = config.copyparty;
|
||||
if (!cp?.enabled || processHandle) return;
|
||||
|
||||
const mountPoint = config.system.mountPoint;
|
||||
|
||||
// volflag :c,hist=/tmp keeps sqlite write hits off the flash drive and sd card
|
||||
const volSpec = `${mountPoint}:${cp.volumeName}:${cp.permissions}:c,hist=/tmp/copyparty`;
|
||||
|
||||
const args = [
|
||||
cp.binaryPath,
|
||||
'-p', String(cp.port),
|
||||
'-a', `${cp.user}:${cp.password}`,
|
||||
'-v', volSpec,
|
||||
...(cp.extraArgs || [])
|
||||
];
|
||||
|
||||
console.log(`[COPYPARTY] Starting server on port ${cp.port}...`);
|
||||
processHandle = spawn('python3', args, {
|
||||
stdio: 'ignore', // swap to 'inherit' if debugging startup flags
|
||||
detached: false
|
||||
});
|
||||
|
||||
processHandle.on('error', (err) => {
|
||||
console.error('[COPYPARTY] Failed to spawn:', err.message);
|
||||
processHandle = null;
|
||||
});
|
||||
|
||||
processHandle.on('exit', (code) => {
|
||||
console.log(`[COPYPARTY] Process exited (code ${code})`);
|
||||
processHandle = null;
|
||||
});
|
||||
}
|
||||
|
||||
export function stopCopyparty() {
|
||||
if (!processHandle) return;
|
||||
console.log('[COPYPARTY] Stopping server...');
|
||||
processHandle.kill('SIGTERM');
|
||||
processHandle = null;
|
||||
}
|
||||
58
services/syncjobs.js
Normal file
58
services/syncjobs.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// services/syncJobs.js
|
||||
// This file handles both
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
function runCmd(bin, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`[EXEC] ${bin} ${args.join(' ')}`);
|
||||
const proc = spawn(bin, args, { stdio: 'inherit' });
|
||||
proc.on('error', reject);
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${bin} failed with exit code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function runRsync(config) {
|
||||
const cfg = config.rsync;
|
||||
if (!cfg?.enabled) return;
|
||||
|
||||
const mountPoint = config.system.mountPoint;
|
||||
const excludes = (cfg.exclude || []).flatMap((pattern) => ['--exclude', pattern]);
|
||||
|
||||
console.log('[RSYNC] Syncing with NAS...');
|
||||
await runCmd('rsync', [...cfg.flags, ...excludes, `${mountPoint}/`, `${cfg.nasPath}/`]);
|
||||
|
||||
if (cfg.bidirectional) {
|
||||
await runCmd('rsync', [
|
||||
...cfg.flags,
|
||||
'--ignore-existing',
|
||||
...excludes,
|
||||
`${cfg.nasPath}/`,
|
||||
`${mountPoint}/`
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRclone(config) {
|
||||
const cfg = config.rclone;
|
||||
if (!cfg?.enabled) return;
|
||||
|
||||
const mountPoint = config.system.mountPoint;
|
||||
const excludes = (cfg.exclude || []).flatMap((pattern) => ['--exclude', pattern]);
|
||||
|
||||
console.log('[RCLONE] Syncing with cloud...');
|
||||
await runCmd('rclone', ['copy', ...cfg.flags, ...excludes, mountPoint, cfg.remote]);
|
||||
|
||||
if (cfg.bidirectional) {
|
||||
await runCmd('rclone', [
|
||||
'copy',
|
||||
'--ignore-existing',
|
||||
...cfg.flags,
|
||||
...excludes,
|
||||
cfg.remote,
|
||||
mountPoint
|
||||
]);
|
||||
}
|
||||
}
|
||||
80
sysfs-functions.js
Normal file
80
sysfs-functions.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// sysfs functions
|
||||
const fs = require('node:fs');
|
||||
|
||||
const LED_PATH = fs.existsSync('/sys/class/leds/ACT')
|
||||
? '/sys/class/leds/ACT'
|
||||
: '/sys/class/leds/led0';
|
||||
|
||||
// detach kernel trigger once at startup
|
||||
try {
|
||||
fs.writeFileSync(`${LED_PATH}/trigger`, 'none');
|
||||
} catch (err) {
|
||||
console.error(`[LED] failed to claim sysfs led:`, err.message);
|
||||
}
|
||||
|
||||
let blinkTimer = null;
|
||||
|
||||
function setBrightness(val) {
|
||||
try {
|
||||
fs.writeFileSync(`${LED_PATH}/brightness`, val.toString());
|
||||
} catch (err) {
|
||||
console.error(`[LED] error writing brightness:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function turnOff() {
|
||||
stopBlink();
|
||||
setBrightness(0);
|
||||
}
|
||||
|
||||
function turnOn() {
|
||||
stopBlink();
|
||||
setBrightness(1);
|
||||
}
|
||||
|
||||
function getState() {
|
||||
try {
|
||||
return parseInt(fs.readFileSync(`${LED_PATH}/brightness`, 'utf8').trim(), 10);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
setBrightness(getState() === 1 ? 0 : 1);
|
||||
}
|
||||
|
||||
function startBlink(interval = 500) {
|
||||
stopBlink();
|
||||
let state = 1;
|
||||
setBrightness(state);
|
||||
|
||||
blinkTimer = setInterval(() => {
|
||||
state = state === 1 ? 0 : 1;
|
||||
setBrightness(state);
|
||||
}, interval);
|
||||
}
|
||||
|
||||
function stopBlink() {
|
||||
if (blinkTimer) {
|
||||
clearInterval(blinkTimer);
|
||||
blinkTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDefault() {
|
||||
stopBlink();
|
||||
try {
|
||||
fs.writeFileSync(`${LED_PATH}/trigger`, 'mmc0');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
turnOff,
|
||||
turnOn,
|
||||
toggle,
|
||||
getState,
|
||||
startBlink,
|
||||
stopBlink,
|
||||
restoreDefault
|
||||
};
|
||||
61
usbkey-lifecycle.js
Normal file
61
usbkey-lifecycle.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// handles mounting/unmounting the USB drive
|
||||
import { execSync } from 'node:child_process';
|
||||
import { mkdirSync, rmdirSync, readFileSync, realpathSync, existsSync } from 'node:fs';
|
||||
|
||||
export function isMounted(mountPath) {
|
||||
try {
|
||||
return readFileSync('/proc/mounts', 'utf8').includes(` ${mountPath} `);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function mountDrive(devNode, mountPath) {
|
||||
if (isMounted(mountPath)) {
|
||||
console.log(`[MOUNT] ${mountPath} is already mounted.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// ensure the directory exists first
|
||||
mkdirSync(mountPath, { recursive: true });
|
||||
|
||||
// mount with open permissions so copyparty/rsync don't hit EACCES
|
||||
execSync(`mount -o umask=000,uid=1000,gid=1000 ${devNode} ${mountPath}`);
|
||||
console.log(`[MOUNT] success: ${devNode} -> ${mountPath}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[MOUNT] failed:`, err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function unmountDrive(mountPath) {
|
||||
if (!isMounted(mountPath)) {
|
||||
// cleanup orphaned directory just in case
|
||||
try { rmdirSync(mountPath); } catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[UNMOUNT] flushing buffers and unmounting ${mountPath}...`);
|
||||
execSync('sync'); // force kernel to write any pending data to the usb
|
||||
execSync(`umount -l ${mountPath}`); // lazy unmount in case a process is stuck reading
|
||||
rmdirSync(mountPath);
|
||||
console.log(`[UNMOUNT] cleanly unmounted and removed directory.`);
|
||||
} catch (err) {
|
||||
console.error(`[UNMOUNT] failed:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function driveExists(targetuid) {
|
||||
const linuidpath = '/dev/disk/by-uid/${targetuid}'
|
||||
if (!existsSync(linuidpath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return realpathSync(linuidpath)
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user