Add nas.js logic layer (command builders + parsers)
Injection-safe sh -c wrapper (static script + positional args), KWallet password flow into a 0600 cred file for mount, smbclient/-g share parsing, findmnt mounted-state parsing, and the pkexec helper invocations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* nas.js — command builders + output parsers for the Synology NAS plasmoid.
|
||||
*
|
||||
* These functions are pure: they build shell command strings and parse the
|
||||
* text those commands produce. The QML layer owns a Plasma5Support executable
|
||||
* DataSource and actually runs them.
|
||||
*
|
||||
* Security model:
|
||||
* - The plasma5support "executable" engine does NOT use a shell; it tokenises
|
||||
* with KShell and runs the program directly. So every command is wrapped as
|
||||
* `sh -c '<STATIC SCRIPT>' _ <arg1> <arg2> ...`. The script text is constant
|
||||
* (no interpolation) and all dynamic, possibly-hostile values arrive as
|
||||
* positional parameters ($1, $2, ...). This makes shell injection via a
|
||||
* share/host/user name impossible.
|
||||
* - Passwords live in KWallet. On mount we read the password *inside* the
|
||||
* shell straight into a 0600 credentials file — it never appears in QML,
|
||||
* in argv, or in `ps`. (The one exception is the initial save in the config
|
||||
* page, where the freshly-typed password is handed to kwallet-query.)
|
||||
*/
|
||||
|
||||
// KWallet folder + wallet used for all stored NAS passwords.
|
||||
var WALLET = "kdewallet";
|
||||
var WALLET_FOLDER = "Synology NAS";
|
||||
var HELPER = "/usr/lib/synology-nas/helper";
|
||||
|
||||
// POSIX single-quote escaping: wrap in '...' and replace ' with '\''.
|
||||
function sq(s) {
|
||||
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
// Build `sh -c '<script>' _ arg1 arg2 ...` with every arg safely quoted.
|
||||
// `script` is trusted constant text; `args` are untrusted runtime values.
|
||||
function shCmd(script, args) {
|
||||
var out = "sh -c " + sq(script) + " _";
|
||||
for (var i = 0; i < (args ? args.length : 0); ++i) {
|
||||
out += " " + sq(args[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// KWallet entry key for a host's credentials.
|
||||
function walletKey(host) {
|
||||
return host.id + "/" + (host.username || "");
|
||||
}
|
||||
|
||||
// Turn a label/host into a safe single path component.
|
||||
function safeComponent(s) {
|
||||
return String(s).replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "_");
|
||||
}
|
||||
|
||||
// Resolve the effective mount root (config override -> home/NAS).
|
||||
function mountRoot(cfg, home, host) {
|
||||
if (host && host.mountRoot && host.mountRoot.length > 0) return host.mountRoot;
|
||||
if (cfg && cfg.length > 0) return cfg;
|
||||
return home + "/NAS";
|
||||
}
|
||||
|
||||
// Absolute mount point for a given host + share.
|
||||
function mountpointFor(cfg, home, host, share) {
|
||||
var root = mountRoot(cfg, home, host);
|
||||
var seg = safeComponent(host.label && host.label.length ? host.label : host.host);
|
||||
return root + "/" + seg + "/" + safeComponent(share);
|
||||
}
|
||||
|
||||
// //host/share as passed to mount.cifs.
|
||||
function unc(host, share) {
|
||||
return "//" + host.host + "/" + share;
|
||||
}
|
||||
|
||||
/* ---- command builders -------------------------------------------------- */
|
||||
|
||||
// Enumerate shares on a host. Password is pulled from KWallet into the PASSWD
|
||||
// env var (read by smbclient) so it never reaches argv.
|
||||
function enumerateCmd(host) {
|
||||
var script =
|
||||
"export PASSWD=\"$(kwallet-query -f 'Synology NAS' -r \"$1\" " + WALLET + ")\"\n" +
|
||||
"exec smbclient -L \"//$2\" -U \"$3\" -g -N 2>/dev/null";
|
||||
return shCmd(script, [walletKey(host), host.host, host.username || ""]);
|
||||
}
|
||||
|
||||
// List currently mounted cifs filesystems (no privilege needed).
|
||||
function listMountedCmd() {
|
||||
return shCmd("exec findmnt -rnt cifs -o TARGET,SOURCE", []);
|
||||
}
|
||||
|
||||
// Mount a share. Reads the password from KWallet into a 0600 cred file, then
|
||||
// invokes the privileged helper via pkexec. The cred file is always removed.
|
||||
//
|
||||
// `settings` = { defaultMountRoot, home, smbVersion, mountOptions,
|
||||
// fileMode, dirMode }.
|
||||
function mountCmd(settings, host, share) {
|
||||
var mp = mountpointFor(settings.defaultMountRoot, settings.home, host, share);
|
||||
var credId = safeComponent(host.id + "-" + share);
|
||||
var script =
|
||||
"set -u\n" +
|
||||
"umask 077\n" +
|
||||
"runtime=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/synology-nas\"\n" +
|
||||
"mkdir -p \"$runtime\" || exit 1\n" +
|
||||
"cf=\"$runtime/$1.cred\"\n" +
|
||||
"trap 'rm -f \"$cf\"' EXIT INT TERM\n" +
|
||||
"{ printf 'username=%s\\n' \"$3\"; printf 'domain=%s\\n' \"$4\"; " +
|
||||
"printf 'password='; kwallet-query -f 'Synology NAS' -r \"$2\" " + WALLET + "; } > \"$cf\" || exit 1\n" +
|
||||
"chmod 600 \"$cf\"\n" +
|
||||
"exec pkexec " + HELPER + " mount \"$5\" \"$6\" \"$cf\" \"$7\" \"$8\" \"$9\" \"${10}\"\n";
|
||||
var args = [
|
||||
credId, // $1 cred file id
|
||||
walletKey(host), // $2 wallet entry
|
||||
host.username || "", // $3
|
||||
host.domain || "", // $4
|
||||
unc(host, share), // $5 //host/share
|
||||
mp, // $6 mountpoint
|
||||
host.vers || settings.smbVersion, // $7 smb version
|
||||
settings.mountOptions, // $8 mount options
|
||||
settings.fileMode, // $9 file mode
|
||||
settings.dirMode // $10 dir mode
|
||||
];
|
||||
return { cmd: shCmd(script, args), mountpoint: mp };
|
||||
}
|
||||
|
||||
// Unmount a share (privileged helper via pkexec).
|
||||
function unmountCmd(mountpoint) {
|
||||
return shCmd("exec pkexec " + HELPER + " unmount \"$1\"", [mountpoint]);
|
||||
}
|
||||
|
||||
// Save a password to KWallet (used by the config page). kwallet-query reads the
|
||||
// secret from stdin; we feed it via printf from a positional param. The value
|
||||
// is in this process's argv for the brief write (unavoidable with a CLI-only
|
||||
// KWallet path) — see the security notes in the README.
|
||||
function savePasswordCmd(host, password) {
|
||||
var script = "printf '%s' \"$2\" | kwallet-query -f 'Synology NAS' -w \"$1\" " + WALLET;
|
||||
return shCmd(script, [walletKey(host), password]);
|
||||
}
|
||||
|
||||
// Remove a host's stored password (best effort; overwrites with empty).
|
||||
function deletePasswordCmd(host) {
|
||||
var script = "printf '' | kwallet-query -f 'Synology NAS' -w \"$1\" " + WALLET + " 2>/dev/null || true";
|
||||
return shCmd(script, [walletKey(host)]);
|
||||
}
|
||||
|
||||
/* ---- parsers ----------------------------------------------------------- */
|
||||
|
||||
// Parse `smbclient -L -g` output into [{name, comment}], dropping admin shares.
|
||||
function parseShares(stdout) {
|
||||
var shares = [];
|
||||
var lines = (stdout || "").split("\n");
|
||||
for (var i = 0; i < lines.length; ++i) {
|
||||
var f = lines[i].split("|");
|
||||
if (f.length >= 2 && f[0] === "Disk") {
|
||||
var name = f[1];
|
||||
if (name.length === 0 || /\$$/.test(name)) continue; // skip IPC$, print$, admin$
|
||||
shares.push({ name: name, comment: f.length >= 3 ? f[2] : "" });
|
||||
}
|
||||
}
|
||||
shares.sort(function (a, b) { return a.name.localeCompare(b.name); });
|
||||
return shares;
|
||||
}
|
||||
|
||||
// Parse `findmnt -rn TARGET,SOURCE` into a set-like object keyed by mountpoint.
|
||||
function parseMounted(stdout) {
|
||||
var set = {};
|
||||
var lines = (stdout || "").split("\n");
|
||||
for (var i = 0; i < lines.length; ++i) {
|
||||
var line = lines[i];
|
||||
if (line.length === 0) continue;
|
||||
var sp = line.indexOf(" ");
|
||||
var target = sp === -1 ? line : line.substring(0, sp);
|
||||
target = target.replace(/\\040/g, " ").replace(/\\011/g, "\t").replace(/\\134/g, "\\");
|
||||
set[target] = true;
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
// Parse the hosts JSON config into an array (never throws).
|
||||
function parseHosts(json) {
|
||||
try {
|
||||
var v = JSON.parse(json || "[]");
|
||||
return Array.isArray(v) ? v : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user