Files
Plasma-Addon-Synology-NAS/package/contents/code/nas.js
T
Jaroslav Beneš 742d2416d7 Fix: store KWallet passwords via D-Bus (kwallet-query -w never persists)
kwallet-query --write-password returns success but does not create the folder
or persist the secret, so saved host passwords were silently lost and share
enumeration failed with NT_STATUS_LOGON_FAILURE. Write/delete now go through
the kwalletd6 D-Bus API via qdbus6 (open/createFolder/writePassword). Reads
stay on kwallet-query (they work). Also drop smbclient -N (was forcing an
anonymous session) and silence KWallet read stderr. Adds qt6-tools dep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 19:00:27 +02:00

202 lines
8.0 KiB
JavaScript

/*
* 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 APPID = "synology-nas";
var KWD = "org.kde.kwalletd6 /modules/kwalletd6 org.kde.KWallet";
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 + " 2>/dev/null)\"\n" +
"exec smbclient -L \"//$2\" -U \"$3\" -g 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 + " 2>/dev/null; } > \"$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]);
}
// Resolve the user's home directory.
function homeCmd() {
return shCmd("printf %s \"$HOME\"", []);
}
// Open a path in the default file manager.
function openCmd(path) {
return shCmd("exec xdg-open \"$1\"", [path]);
}
// Save a password to KWallet (used by the config page). Uses the kwalletd6
// D-Bus API via qdbus6: `kwallet-query --write-password` reports success but
// does not actually persist (nor create the folder), so we open the wallet,
// ensure the folder exists, and writePassword. The password is in this
// process's argv for the brief write — see the security notes in the README.
function savePasswordCmd(host, password) {
var script =
"h=$(qdbus6 " + KWD + ".open " + WALLET + " 0 " + APPID + ")\n" +
"[ -n \"$h\" ] || exit 1\n" +
"qdbus6 " + KWD + ".createFolder \"$h\" '" + WALLET_FOLDER + "' " + APPID + " >/dev/null\n" +
"exec qdbus6 " + KWD + ".writePassword \"$h\" '" + WALLET_FOLDER + "' \"$1\" \"$2\" " + APPID;
return shCmd(script, [walletKey(host), password]);
}
// Remove a host's stored password (best effort).
function deletePasswordCmd(host) {
var script =
"h=$(qdbus6 " + KWD + ".open " + WALLET + " 0 " + APPID + ")\n" +
"[ -n \"$h\" ] || exit 0\n" +
"qdbus6 " + KWD + ".removeEntry \"$h\" '" + WALLET_FOLDER + "' \"$1\" " + APPID + " >/dev/null 2>&1 || 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 [];
}
}