From 5b2a0304e180b812c0b91c13f02683b23bc0537a Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 24 Aug 2026 14:44:18 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20share=20suite=20=E2=80=94=20interactive?= =?UTF-8?q?=20menus=20for=20usb/nfs/smb=20server+client=20on=20share-lib?= =?UTF-8?q?=20domain=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- DOC/howto/share.md | 28 +++ bin/pos | 2 +- bin/pos-share-nfs-client | 341 ++++++++++++++++++++------ bin/pos-share-nfs-server | 257 ++++++++++++++------ bin/pos-share-smb-client | 504 +++++++++++++++++++++++++-------------- bin/pos-share-smb-server | 438 ++++++++++++++++++++++------------ bin/pos-share-usb-server | 150 +++++++++++- lib/share-lib.sh | 318 ++++++++++++++++++++++++ preinstall.sh | 2 +- 10 files changed, 1558 insertions(+), 486 deletions(-) create mode 100644 lib/share-lib.sh diff --git a/AGENTS.md b/AGENTS.md index fc5bb63..9adcc24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,9 +16,9 @@ CRITICAL: real guidance lives in DOC/. When you encounter a reference below, use - **Tool model:** `bin/pos--`, or **category-less** `bin/pos-` for dispatcher/dev-level commands that fit no category (`pos-config`, `pos-tree`) — they dispatch like any tool and show with an empty category in the generated tables. `bin/pos` dispatches by longest-prefix arg matching. New tools are auto-discovered but must be executable (`100755`) and carry a `# POS: ` header right after the shebang; `make gen` only uses the text after the first `— ` (the leading words are convention-only), so keep the one-line description concise. `# POS_FLAGS:` / `# POS_SUBCMDS:` / `# POS_CONFIG:` headers feed tab-completion and the `pos config` scope registry. A missing `# POS:` header hard-fails `make gen`. Legacy `bin/wr-*`, `mp3`, `mp4`, `vbox`, `ssh-load-all` are thin forwarders to `pos` — keep them that way. - **Categories:** `ai`, `communication`, `docker`, `entertainment`, `media`, `network`, `share` (usb, nfs, smb), `ssh`, `system`, plus category-less `config`/`tree`. `pos tree` (bin/pos-tree) is the authoritative structure — it derives the hierarchy from `bin/pos-*` filenames + `# POS:`/`# POS_SUBCMDS:` headers. -- **Generated code:** blocks between `GEN:START`/`GEN:END` markers in `DOC/AGENT_Context_Project.md` (tree, dispatch, selfcontained, filetable, docmap) and `completions/pos.bash` (flags, subcmds, config scopes) are `make gen` output — never hand-edit them. Generators must be **byte-order deterministic** (sort with `LC_ALL=C`, as `scripts/gen-docs.sh` does) or CI's `git diff --exit-code` trips on a locale that collates differently. After touching `bin/pos-*`, run `make gen`, then `make check`, then `make lint` (definition of done: check green + lint ends `0 FAIL, 0 WARN`). `make check` (`scripts/check-sync.sh`) is the self-consistency gate — bash -n + exec-bit check + doc-sync + dispatch smoke; `make lint` (`scripts/lint-conventions.sh`) is the convention gate — it enforces every rule in this file (shebang/strict-mode, exec bits, `# POS:` headers, `-h|--help` present and after deps guards, stdin-readers in `INTERACTIVE_CMDS`, POS.md coverage, plugin/app/unit/wrapper/secrets/env-seam classes — see `DOC/DEV.md → Convention Lint Gate`). Hand-maintained, not gen-checked: `DOC/POS.md`, the line-count rows above the filetable marker in `DOC/AGENT_Context_Project.md` (the non-`pos-*` files — `install.sh`, `preinstall.sh`, `postinstall.sh`, `lib/*`, `features/*`; bump a row's count only when that file's length changes), `bin/pos` usage() EXAMPLES, root README. CI (`.gitea/workflows/lint.yml`, job `gates`) runs the same four commands on every push to main and PR, then records the result as a git tag on the commit: `ci-ok/` or `ci-fail/` (pushes only — check remote tags to see gate status). A red run means gen drift or a gate failure and is a merge-blocker; still run the gates locally too (lint isn't in the pre-commit hook). +- **Generated code:** blocks between `GEN:START`/`GEN:END` markers in `DOC/AGENT_Context_Project.md` (tree, dispatch, selfcontained, filetable, docmap) and `completions/pos.bash` (flags, subcmds, config scopes) are `make gen` output — never hand-edit them. Generators must be **byte-order deterministic** (sort with `LC_ALL=C`, as `scripts/gen-docs.sh` does) or CI's `git diff --exit-code` trips on a locale that collates differently. After touching `bin/pos-*`, run `make gen`, then `make check`, then `make lint` (definition of done: check green + lint ends `0 FAIL, 0 WARN`). `make check` (`scripts/check-sync.sh`) is the self-consistency gate — bash -n + exec-bit check + doc-sync + dispatch smoke; `make lint` (`scripts/lint-conventions.sh`) is the convention gate — it enforces every rule in this file (shebang/strict-mode, exec bits, `# POS:` headers, `-h|--help` present and after deps guards, stdin-readers in `INTERACTIVE_CMDS`, POS.md coverage, plugin/app/unit/wrapper/secrets/env-seam classes — see `DOC/DEV.md → Convention Lint Gate`). Hand-maintained, not gen-checked: `DOC/POS.md`, the line-count rows above the filetable marker in `DOC/AGENT_Context_Project.md` (the non-`pos-*` files — `install.sh`, `preinstall.sh`, `postinstall.sh`, `lib/*`, `features/*`; bump a row's count only when that file's length changes), `bin/pos` usage() EXAMPLES, root README. CI (`.gitea/workflows/lint.yml`, job `gates`) runs the same four commands on every push to main and PR, then records the result as a git tag on the commit: `ci-ok/` or `ci-fail/` (pushes only — query with `scripts/ci-status.sh [--wait] []`; exit 0 green / 1 red / 2 pending). A red run means gen drift or a gate failure and is a merge-blocker; still run the gates locally too (lint isn't in the pre-commit hook). - **Stdin gotcha:** any tool that reads stdin must be added to `INTERACTIVE_CMDS` in `bin/pos` — otherwise the logging `tee` pipe hangs on (or swallows) the prompt. -- **Deps:** apt packages → `PACKAGES` array in `preinstall.sh`; non-apt/manual installers (e.g. `usbsrv`) → `command -v || err "…"` guard inside the tool, never in PACKAGES. +- **Deps:** apt packages → `PACKAGES` array in `preinstall.sh`; non-apt/manual installers (e.g. `usbsrv`) → `command -v || err "…"` guard inside the tool, never in PACKAGES. Hotspot binaries (`create_ap`, `wihotspot*`) are prebuilt in `x64_bin/` (or `arm64_bin/`) and copied by `install.sh` — not apt packages. - **Secrets:** never commit keys/tokens. `config/authorized_keys` and `config/rclone.conf` are gitignored; runtime tool config is `~/.config/linux_post_install/.env` (chmod 600, env-var precedence). Mask tokens in `config` output. - **entertainment plugins:** standalone scripts in `entertainment/` that must NOT source `lib/common.sh` — stdout is the message that gets sent to Telegram (helper chatter would leak into it). Markers: `# POS_PLUGIN: ` + `# POS_KEYS:` declarations. They aren't `pos-*` tools, so `make gen` skips them (no headers/doc tables) — verify with `bash -n` + a live `pos entertainment send --print`. - **ScaleTail templates** are a git submodule (`compose/scale-tail`), absent on fresh clones — run `git submodule update --init` first (only needed for `pos docker compose *`). diff --git a/DOC/howto/share.md b/DOC/howto/share.md index c0acc8b..1e06855 100644 --- a/DOC/howto/share.md +++ b/DOC/howto/share.md @@ -55,6 +55,12 @@ omitted. - **Dedicated USB-over-network box:** set `--port` once, then clients connect to that port. +**Interactive menu:** run `pos share usb server` with no args for a menu +(list / share / unshare / auto-share / disconnect …). The share flow lists +devices and clients from the server as pickers — no IDs to memorize; if the +server listing can't be read, it prints the raw output and falls back to +manual ID entry. + **Troubleshooting:** - `usbsrv: command not found` → the binary isn't installed; get it from incentivespro.com and drop it in `x64_bin/` (or `arm64_bin/`) then re-run @@ -101,6 +107,12 @@ to restrict it** — print the restricted form: - **Read-only backups to a LAN host:** use `(ro,sync,no_subtree_check)` and only `enable` the server where it's needed. +**Interactive menu:** run `pos share nfs server` with no args for a menu +(share / unshare / list / reload / enable / disable / status). The share flow +offers mounted folders as a picker and client-spec presets (open, WireGuard, +LAN, single IP) so you don't hand-type export specs; an inactive +`nfs-server` service or a UFW conflict is offered as a one-key fix. + **Troubleshooting:** - "exportfs not found" → `nfs-kernel-server` isn't installed; `sudo apt install nfs-kernel-server` - Client sees "mount.nfs: Permission denied" → your `/etc/exports` client rule @@ -139,6 +151,11 @@ up — a down/unreachable NFS server can't break boot (with fstab it could). - **One-off mount (no persistence):** `pos share nfs client mount 10.0.0.5:/srv/data /mnt/data` +**Interactive menu:** run `pos share nfs client` with no args for a menu +(mount / persist / unmount / unpersist / list). Mountpoints are offered from +existing mount-layout candidates with manual entry as fallback; unmount and +unpersist tolerate already-absent targets instead of erroring. + **Troubleshooting:** - "mount.nfs not found" → `nfs-common` isn't installed; `sudo apt install nfs-common` - Mount hangs → check the server export (`pos share nfs server list` on the @@ -197,6 +214,11 @@ Both are warnings only — the share is still written. - **Change a share's access later:** re-run `share` with the same name — the block is replaced, not duplicated. +**Interactive menu:** run `pos share smb server` with no args for a menu +(share / unshare / list / users / reload / enable / disable / status). The +share flow offers mounted folders as a picker and walks through read-only / +guest / valid-users confirms; a UFW conflict is offered as a one-key fix. + **Troubleshooting:** - "smbd not found" → `samba` isn't installed; `sudo apt install samba` - Windows can't connect → check the client is in `--users` / has a Samba @@ -259,6 +281,12 @@ fstab it could). `enable --now` arms the automount immediately. persistent units under "Persistent (automount)", so the configured shares are visible even before their first access +**Interactive menu:** run `pos share smb client` with no args for a menu +(enumerate / mount / persist / unmount / unpersist / list). Enter the server, +an empty user tries guest enumeration first (with an auth retry on denial), +then shares and mountpoints are offered as pickers with manual fallback — +the account you authenticated with is reused for the mount. + --- ## Related diff --git a/bin/pos b/bin/pos index 29e0a1b..61a86e0 100755 --- a/bin/pos +++ b/bin/pos @@ -259,7 +259,7 @@ MAIN_LOG="$LOG_DIR/pos.log" log_cmd() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $* → exit $2" >> "$MAIN_LOG"; } # Commands that read from stdin interactively — only log invocation -INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-sync system-backup share-usb-server share-smb-server share-smb-client communication-telegram-listener communication-matrix-listener ai-gemini system-schedule entertainment-config config" +INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-sync system-backup share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai-gemini system-schedule entertainment-config config" for ((i=n-1; i>=0; i--)); do cmd="pos" diff --git a/bin/pos-share-nfs-client b/bin/pos-share-nfs-client index 72335b0..f04f2d7 100755 --- a/bin/pos-share-nfs-client +++ b/bin/pos-share-nfs-client @@ -1,9 +1,14 @@ #!/usr/bin/env bash set -euo pipefail # POS: share nfs-client — Mount NFS shares (ephemeral or persistent systemd mount units) +# POS_SUBCMDS: mount unmount list persist unpersist menu source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh" +source "$(dirname "$0")/../lib/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh" + +# Env seam (testable): where persistent .mount units are written. +UNIT_DIR="${UNIT_DIR:-/etc/systemd/system}" command -v mount.nfs &>/dev/null || err "mount.nfs not found (install nfs-common)" command -v systemd-escape &>/dev/null || err "systemd-escape not found" @@ -21,24 +26,20 @@ Commands: persist Persistent mount via a systemd .mount unit (ordered after network-online.target) unpersist Stop, disable and remove the mount unit + menu Interactive browser (server → export → mountpoint) + +Run without arguments to open the interactive menu. Examples: pos share nfs-client mount 100.100.100.1:/srv/media /mnt/nfs/media pos share nfs-client persist 100.100.100.1:/srv/media /mnt/nfs/media pos share nfs-client list pos share nfs-client unmount /mnt/nfs/media + pos share nfs-client menu EOF exit 0 } -cmd="${1:-}" -case "$cmd" in - -h|--help) usage ;; - mount|unmount|list|persist|unpersist) ;; - "") err "Missing command (mount|unmount|list|persist|unpersist)" ;; - *) err "Unknown command '$cmd' (see --help)" ;; -esac - validate_share() { local what="$1" case "$what" in @@ -56,83 +57,287 @@ validate_dir() { esac } -case "$cmd" in - mount) - what="${2:-}" - where="${3:-}" - [ -n "$what" ] && [ -n "$where" ] || err "Usage: pos share nfs-client mount " - validate_share "$what" - validate_dir "$where" +cmd_mount() { + local what="$1" where="$2" + validate_share "$what" + validate_dir "$where" - sudo mkdir -p "$where" - sudo mount -t nfs -o rw,noatime "$what" "$where" - log "Mounted $what at $where" - ;; + sudo mkdir -p "$where" + sudo mount -t nfs -o rw,noatime "$what" "$where" + log "Mounted $what at $where" +} - unmount) - where="${2:-}" - [ -n "$where" ] || err "Usage: pos share nfs-client unmount " - validate_dir "$where" +cmd_unmount() { + local where="$1" + validate_dir "$where" - sudo umount "$where" - log "Unmounted $where" - ;; + if ! findmnt -r -n -o TARGET -t nfs,nfs4 2>/dev/null | grep -qxF "$where"; then + log "$where is not mounted as NFS — nothing to do" + return 0 + fi + sudo umount "$where" + log "Unmounted $where" +} - list) - if findmnt -t nfs,nfs4 >/dev/null 2>&1; then - findmnt -t nfs,nfs4 - else - echo "No NFS mounts" - fi - ;; +cmd_list() { + local out + if out="$(findmnt -t nfs,nfs4 2>/dev/null)" && [ "$(grep -c . <<<"$out")" -gt 1 ]; then + printf '%s\n' "$out" + else + echo "No NFS mounts" + fi +} - persist) - what="${2:-}" - where="${3:-}" - [ -n "$what" ] && [ -n "$where" ] || err "Usage: pos share nfs-client persist " - validate_share "$what" - validate_dir "$where" - - unit="$(systemd-escape --path --suffix=mount "$where")" - unit_file="/etc/systemd/system/${unit}" - - sudo mkdir -p "$where" - cat </dev/null +write_mount_unit() { # + cat </dev/null [Unit] -Description=NFS mount of ${what} at ${where} +Description=NFS mount of ${2} at ${3} After=network-online.target Wants=network-online.target [Mount] -What=${what} -Where=${where} +What=${2} +Where=${3} Type=nfs Options=defaults,_netdev,rw,noatime UNIT +} + +show_mount_unit() { # (dry-run preview) + cat </dev/null | grep -qxF "$where"; then + mounted=0 + break + fi + sleep 1 + done + if [ "$mounted" -ne 0 ]; then + warn "Unit enabled but ${where} never appeared among NFS mounts — rolling back" + sudo systemctl disable "$unit" 2>/dev/null || true + sudo systemctl stop "$unit" 2>/dev/null || true + sudo rm -f "$unit_file" + sudo systemctl daemon-reload + err "Persistent mount failed — unit removed (${unit})" + fi + fi + log "Persistent NFS mount: ${what} → ${where} (${unit})" + notify_send "NFS mount persisted: ${what} → ${where}" +} + +cmd_unpersist() { + local where="$1" + validate_dir "$where" + + unit="$(systemd-escape --path --suffix=mount "$where")" + unit_file="${UNIT_DIR}/${unit}" + + if [ ! -f "$unit_file" ]; then + warn "No systemd mount unit for $where (${unit})" + exit 0 + fi + + sudo systemctl disable "$unit" 2>/dev/null || true + sudo systemctl stop "$unit" 2>/dev/null || true + sudo rm -f "$unit_file" + sudo systemctl daemon-reload + log "Removed persistent NFS mount: $where" + notify_send "NFS persistent mount removed: $where" +} + +# ── Interactive menu flows ───────────────────────────────────── +menu_pick_export() { # — stdout: server:export · rc 1 cancelled + local host="$1" idx exp + local -a exports=() + if mapfile -t exports < <(share_nfs_exports "$host") && [ "${#exports[@]}" -gt 0 ]; then + if idx="$(share_pick "Pick export on ${host}" "${exports[@]}")"; then + exp="${exports[$((idx - 1))]}" + else + return 1 + fi + else + exp="$(share_ask_value "Export path on ${host} (e.g. /srv/media)")" || return 1 + [ -n "$exp" ] || { warn "No export path given"; return 1; } + fi + case "$exp" in + /*) echo "${host}:${exp}" ;; + *) echo "${host}:/${exp}" ;; + esac +} + +menu_ask_mountpoint() { # stdout: absolute path · rc 1 cancelled + local idx dir cand + local -a cands=() + if mapfile -t cands < <(share_folder_candidates) && [ "${#cands[@]}" -gt 0 ]; then + if idx="$(share_pick "Mountpoint" "${cands[@]}")"; then + cand="${cands[$((idx - 1))]}" + dir="${cand%% (*}" # strip "(mounted fstype)" annotation + case "$dir" in + /etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*) + warn "Refusing system path as mountpoint" + return 1 + ;; + *) + echo "$dir" + return 0 + ;; + esac + fi + fi + dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1 + [ -n "$dir" ] || { warn "No mountpoint given"; return 1; } + echo "$dir" +} + +menu_mount() { + local mode="$1" host what where + host="$(share_ask_value "NFS server (host or IP)")" || return 1 + [ -n "$host" ] || { warn "No server given"; return 1; } + + if ! share_port_probe "$host" 2049; then + warn "${host} does not answer on TCP/2049 (nfsd down, or a firewall blocks it)." + confirm "Try anyway?" n || return 1 + fi + + what="$(menu_pick_export "$host")" || return 1 + where="$(menu_ask_mountpoint)" || return 1 + + if [ "$mode" = "persist" ]; then + cmd_persist "$what" "$where" + else + cmd_mount "$what" "$where" + fi +} + +menu_unmount() { + local idx where + local -a targets=() + if mapfile -t targets < <(findmnt -r -n -o TARGET -t nfs,nfs4 2>/dev/null | tail -n +2) && + [ "${#targets[@]}" -gt 0 ]; then + idx="$(share_pick "Unmount which NFS mount?" "${targets[@]}")" || return 1 + where="${targets[$((idx - 1))]}" + else + where="$(share_ask_value "Local mountpoint to unmount")" || return 1 + [ -n "$where" ] || return 1 + fi + cmd_unmount "$where" +} + +menu_unpersist() { + local idx uf where unit + local -a items=() paths=() + for uf in "${UNIT_DIR}"/*.mount; do + grep -q '^Type=nfs' "$uf" 2>/dev/null || continue + where="$(sed -n 's/^Where=//p' "$uf")" + [ -n "$where" ] || continue + paths+=("$where") + items+=("$where") + done + if [ "${#items[@]}" -gt 0 ]; then + idx="$(share_pick "Remove which persistent NFS mount?" "${items[@]}")" || return 1 + where="${paths[$((idx - 1))]}" + else + where="$(share_ask_value "Local mountpoint whose unit to remove")" || return 1 + [ -n "$where" ] || return 1 + fi + cmd_unpersist "$where" +} + +run_menu() { + share_menu_guard || exit 1 + while true; do + local choice + choice="$(share_menu_run "NFS client" \ + "Mount an export (one-shot)" \ + "Persist an export (systemd .mount unit)" \ + "List active NFS mounts" \ + "Unmount a mounted share" \ + "Remove a persistent mount")" || return 0 + case "$choice" in + 1) menu_mount ephemeral ;; + 2) menu_mount persist ;; + 3) cmd_list ;; + 4) menu_unmount ;; + 5) menu_unpersist ;; + esac + done +} + +cmd="${1:-}" +case "$cmd" in + -h|--help) usage ;; + ""|menu) + run_menu + exit 0 + ;; + mount|unmount|list|persist|unpersist) ;; + *) err "Unknown command '$cmd' (see --help)" ;; +esac + +case "$cmd" in + mount) + [ $# -ge 3 ] || err "Usage: pos share nfs-client mount " + cmd_mount "$2" "$3" + ;; + + unmount) + [ $# -ge 2 ] || err "Usage: pos share nfs-client unmount " + cmd_unmount "$2" + ;; + + list) + cmd_list + ;; + + persist) + [ $# -ge 3 ] || err "Usage: pos share nfs-client persist " + cmd_persist "$2" "$3" ;; unpersist) - where="${2:-}" - [ -n "$where" ] || err "Usage: pos share nfs-client unpersist " - validate_dir "$where" - - unit="$(systemd-escape --path --suffix=mount "$where")" - unit_file="/etc/systemd/system/${unit}" - - if [ ! -f "$unit_file" ]; then - warn "No systemd mount unit for $where (${unit})" - exit 0 - fi - - sudo systemctl disable "$unit" 2>/dev/null || true - sudo systemctl stop "$unit" 2>/dev/null || true - sudo rm -f "$unit_file" - sudo systemctl daemon-reload - log "Removed persistent NFS mount: $where" - notify_send "NFS persistent mount removed: $where" + [ $# -ge 2 ] || err "Usage: pos share nfs-client unpersist " + cmd_unpersist "$2" ;; esac diff --git a/bin/pos-share-nfs-server b/bin/pos-share-nfs-server index 4b28b80..dcf5a35 100755 --- a/bin/pos-share-nfs-server +++ b/bin/pos-share-nfs-server @@ -1,12 +1,17 @@ #!/usr/bin/env bash set -euo pipefail # POS: share nfs-server — Manage the NFS kernel server (status, share/unshare exports, enable/disable) +# POS_SUBCMDS: status share unshare list reload enable disable menu source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh" +source "$(dirname "$0")/../lib/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh" command -v exportfs &>/dev/null || err "exportfs not found (install nfs-kernel-server)" +# Env seam (testable): which exports file is managed. +EXPORTS_FILE="${EXPORTS_FILE:-/etc/exports}" + usage() { cat < [args] @@ -22,6 +27,9 @@ Commands: reload Re-apply /etc/exports after hand edits enable Start nfs-server and enable it on boot disable Stop nfs-server and disable it on boot + menu Interactive browser (folder → client preset → share) + +Run without arguments to open the interactive menu. Clients are /etc/exports entries — restrict them to your trusted network: pos share nfs-server share /mnt/hdd '100.64.0.0/10(rw,sync,no_subtree_check)' # Tailscale CGNAT @@ -33,6 +41,7 @@ Examples: pos share nfs-server share /mnt/hdd pos share nfs-server list pos share nfs-server unshare /mnt/hdd + pos share nfs-server menu EOF exit 0 } @@ -40,13 +49,10 @@ EOF cmd="${1:-}" case "$cmd" in -h|--help) usage ;; - status|share|unshare|list|reload|enable|disable) ;; - "") err "Missing command (status|share|unshare|list|reload|enable|disable)" ;; + ""|menu|status|share|unshare|list|reload|enable|disable) ;; *) err "Unknown command '$cmd' (see --help)" ;; esac -EXPORTS_FILE=/etc/exports - require_root_dir() { local path="$1" case "$path" in @@ -56,79 +62,184 @@ require_root_dir() { [ -d "$path" ] || err "Path not found: $path" } -case "$cmd" in - status) - if systemctl is-active --quiet nfs-server 2>/dev/null; then - ok "nfs-server: running" - else - warn "nfs-server: not running (enable with 'pos share nfs-server enable')" - fi +cmd_status() { + if systemctl is-active --quiet nfs-server 2>/dev/null; then + ok "nfs-server: running" + else + warn "nfs-server: not running (enable with 'pos share nfs-server enable')" + fi + echo + section "Exports" + exportfs -v 2>/dev/null || echo " (none)" +} + +cmd_share() { + local path="$1" client="${2:-*(rw,sync,no_subtree_check)}" + require_root_dir "$path" + if [ "$client" = "*(rw,sync,no_subtree_check)" ]; then + warn "Generic export '$client' — ANY client can mount $path. Restrict it, e.g.:" + echo " pos share nfs-server share $path '100.64.0.0/10(rw,sync,no_subtree_check)'" + echo " pos share nfs-server share $path '10.10.0.0/24(rw,sync,no_subtree_check)'" echo - section "Exports" - exportfs -v 2>/dev/null || echo " (none)" - ;; + fi + local tmp + tmp="$(mktemp)" + awk -v p="$path" '$1 != p' "$EXPORTS_FILE" > "$tmp" + echo "$path $client" >> "$tmp" + sudo cp "$tmp" "$EXPORTS_FILE" + rm -f "$tmp" + + sudo exportfs -ra + log "Exported: $path $client" + notify_send "NFS share added: $path $client" + + # Advisory post-checks (never abort the share operation). + if ! share_service_active nfs-server; then + share_offer_fix "The nfs-server service is not running" \ + sudo systemctl enable --now nfs-server + fi + if share_ufw_blocks_ports '2049|111|\bnfs\b'; then + warn "ufw is active but has no NFS rule — clients will be blocked." + share_offer_fix "Allow NFS through ufw" sudo ufw allow 2049/tcp + fi +} + +cmd_unshare() { + local path="$1" + require_root_dir "$path" + + if ! awk -v p="$path" '$1 == p {found=1} END {exit !found}' "$EXPORTS_FILE"; then + warn "No export for $path in $EXPORTS_FILE" + exit 0 + fi + + local tmp + tmp="$(mktemp)" + awk -v p="$path" '$1 != p' "$EXPORTS_FILE" > "$tmp" + sudo cp "$tmp" "$EXPORTS_FILE" + rm -f "$tmp" + + sudo exportfs -ra + log "Removed export: $path" + notify_send "NFS share removed: $path" +} + +cmd_list() { + exportfs -v 2>/dev/null || echo "No exports" +} + +cmd_reload() { + sudo exportfs -ra + log "NFS exports reloaded" +} + +cmd_enable() { + sudo systemctl enable --now nfs-server + log "nfs-server enabled (starts on boot)" + notify_send "NFS server enabled" +} + +cmd_disable() { + sudo systemctl disable --now nfs-server + log "nfs-server disabled (will not start on boot)" + notify_send "NFS server disabled" +} + +# ── Interactive menu flows ───────────────────────────────────── +menu_pick_folder() { # stdout: folder path · rc 1 cancelled + local idx dir cand + local -a cands=() + if mapfile -t cands < <(share_folder_candidates) && [ "${#cands[@]}" -gt 0 ]; then + if idx="$(share_pick "Share which folder?" "${cands[@]}")"; then + cand="${cands[$((idx - 1))]}" + dir="${cand%% (*}" # strip "(mounted fstype)" annotation + [ -d "$dir" ] || { warn "Folder vanished: $dir"; return 1; } + echo "$dir" + return 0 + fi + return 1 + fi + dir="$(share_ask_value "Folder to share (absolute path)")" || return 1 + [ -n "$dir" ] || { warn "No folder given"; return 1; } + echo "$dir" +} + +menu_share() { + local dir idx spec + dir="$(menu_pick_folder)" || return 1 + + local -a specs=( + "100.64.0.0/10(rw,sync,no_subtree_check) — Tailscale CGNAT range" + "10.10.0.0/24(rw,sync,no_subtree_check) — WireGuard subnet" + "192.168.1.0/24(rw,sync,no_subtree_check) — LAN subnet" + "192.168.1.0/24(ro,sync,no_subtree_check) — LAN read-only" + "* (rw,sync,no_subtree_check) — ANY client (unsafe)" + ) + if idx="$(share_pick "Client access for ${dir}" "${specs[@]}")"; then + spec="$(sed 's/[[:space:]]*—.*//' <<<"${specs[$((idx - 1))]}")" + [ "$spec" = "*" ] && spec="*(rw,sync,no_subtree_check)" + else + spec="$(share_ask_value "Client spec (e.g. 10.10.0.0/24(rw,sync))" "")" || return 1 + [ -n "$spec" ] || spec="*(rw,sync,no_subtree_check)" + fi + cmd_share "$dir" "$spec" +} + +menu_unshare() { + local idx path + local -a paths=() + if mapfile -t paths < <(awk 'NF > 0 && $1 !~ /^#/ {print $1}' "$EXPORTS_FILE" 2>/dev/null) && + [ "${#paths[@]}" -gt 0 ]; then + idx="$(share_pick "Remove which export?" "${paths[@]}")" || return 1 + path="${paths[$((idx - 1))]}" + else + path="$(share_ask_value "Exported path to remove")" || return 1 + [ -n "$path" ] || return 1 + fi + cmd_unshare "$path" +} + +run_menu() { + share_menu_guard || exit 1 + while true; do + local choice + choice="$(share_menu_run "NFS server" \ + "Show status (service + exports)" \ + "Share a folder" \ + "Remove an export" \ + "List current exports" \ + "Reload exports after hand edits" \ + "Enable service on boot" \ + "Disable service")" || return 0 + case "$choice" in + 1) cmd_status ;; + 2) menu_share ;; + 3) menu_unshare ;; + 4) cmd_list ;; + 5) cmd_reload ;; + 6) cmd_enable ;; + 7) cmd_disable ;; + esac + done +} + +case "$cmd" in + ""|menu) + run_menu + exit 0 + ;; + status) cmd_status ;; share) - path="${2:-}" - client="${3:-*(rw,sync,no_subtree_check)}" - [ -n "$path" ] || err "Usage: pos share nfs-server share [client]" - require_root_dir "$path" - if [ "$client" = "*(rw,sync,no_subtree_check)" ]; then - warn "Generic export '$client' — ANY client can mount $path. Restrict it, e.g.:" - echo " pos share nfs-server share $path '100.64.0.0/10(rw,sync,no_subtree_check)'" - echo " pos share nfs-server share $path '10.10.0.0/24(rw,sync,no_subtree_check)'" - echo - fi - - tmp="$(mktemp)" - awk -v p="$path" '$1 != p' "$EXPORTS_FILE" > "$tmp" - echo "$path $client" >> "$tmp" - sudo cp "$tmp" "$EXPORTS_FILE" - rm -f "$tmp" - - sudo exportfs -ra - log "Exported: $path $client" - notify_send "NFS share added: $path $client" + [ $# -ge 2 ] || err "Usage: pos share nfs-server share [client]" + cmd_share "$2" "${3:-*(rw,sync,no_subtree_check)}" ;; - unshare) - path="${2:-}" - [ -n "$path" ] || err "Usage: pos share nfs-server unshare " - require_root_dir "$path" - - if ! awk -v p="$path" '$1 == p {found=1} END {exit !found}' "$EXPORTS_FILE"; then - warn "No export for $path in $EXPORTS_FILE" - exit 0 - fi - - tmp="$(mktemp)" - awk -v p="$path" '$1 != p' "$EXPORTS_FILE" > "$tmp" - sudo cp "$tmp" "$EXPORTS_FILE" - rm -f "$tmp" - - sudo exportfs -ra - log "Removed export: $path" - notify_send "NFS share removed: $path" - ;; - - list) - exportfs -v 2>/dev/null || echo "No exports" - ;; - - reload) - sudo exportfs -ra - log "NFS exports reloaded" - ;; - - enable) - sudo systemctl enable --now nfs-server - log "nfs-server enabled (starts on boot)" - notify_send "NFS server enabled" - ;; - - disable) - sudo systemctl disable --now nfs-server - log "nfs-server disabled (will not start on boot)" - notify_send "NFS server disabled" + [ $# -ge 2 ] || err "Usage: pos share nfs-server unshare " + cmd_unshare "$2" ;; + list) cmd_list ;; + reload) cmd_reload ;; + enable) cmd_enable ;; + disable) cmd_disable ;; esac diff --git a/bin/pos-share-smb-client b/bin/pos-share-smb-client index 6079059..afe8d49 100755 --- a/bin/pos-share-smb-client +++ b/bin/pos-share-smb-client @@ -1,10 +1,11 @@ #!/usr/bin/env bash set -euo pipefail # POS: share smb-client — Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) -# POS_SUBCMDS: mount unmount list persist unpersist +# POS_SUBCMDS: mount unmount list persist unpersist menu source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh" +source "$(dirname "$0")/../lib/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh" SMB_CREDS_DIR="${SMB_CREDS_DIR:-/etc/samba/credentials}" UNIT_DIR="${UNIT_DIR:-/etc/systemd/system}" @@ -53,8 +54,7 @@ EOF cmd="${1:-}" case "$cmd" in -h|--help) usage ;; - mount|unmount|list|persist|unpersist) ;; - "") err "Missing command (mount|unmount|list|persist|unpersist)" ;; + ""|menu|mount|unmount|list|persist|unpersist) ;; *) err "Unknown command '$cmd' (see --help)" ;; esac @@ -200,156 +200,150 @@ read_test_or_rollback() { Details: DOC/howto/share.md (SMB troubleshooting)" } -case "$cmd" in - mount) - what="${2:-}" - where="${3:-}" - user="${4:-}" - [ -n "$what" ] && [ -n "$where" ] || err "Usage: pos share smb-client mount [user]" - validate_share "$what" - validate_dir "$where" - split_share "$what" +cmd_mount() { # [user] + local what="$1" where="$2" user="${3:-}" + local src creds opts + validate_share "$what" + validate_dir "$where" + split_share "$what" - probe_server "$SERVER" + probe_server "$SERVER" - if src="$(mounted_src "$where")"; [ -n "$src" ]; then - err "$where is already mounted (source: ${src:-unknown}) — nothing done. + if src="$(mounted_src "$where")"; [ -n "$src" ]; then + err "$where is already mounted (source: ${src:-unknown}) — nothing done. Unmount it first: pos share smb-client unmount $where" - fi + fi - ensure_mountpoint "$where" + ensure_mountpoint "$where" - if [ -n "$user" ]; then - creds="$(make_creds "$user")" - trap 'rm -f "$creds"' EXIT - opts="credentials=$creds,$(mount_opts)" - try_mount "$what" "$where" "$opts" || { - rm -f "$creds" - trap - EXIT - diagnose_mount_failure "${MOUNT_ERR:-}" - } + if [ -n "$user" ]; then + creds="$(make_creds "$user")" + trap 'rm -f "$creds"' EXIT + opts="credentials=$creds,$(mount_opts)" + try_mount "$what" "$where" "$opts" || { rm -f "$creds" trap - EXIT - else - warn "No user — attempting guest mount (works only if the server allows guest access)" - try_mount "$what" "$where" "guest,$(mount_opts)" || diagnose_mount_failure "${MOUNT_ERR:-}" - fi + diagnose_mount_failure "${MOUNT_ERR:-}" + } + rm -f "$creds" + trap - EXIT + else + warn "No user — attempting guest mount (works only if the server allows guest access)" + try_mount "$what" "$where" "guest,$(mount_opts)" || diagnose_mount_failure "${MOUNT_ERR:-}" + fi - read_test_or_rollback "$where" "$what" - log "Mounted $what at $where (read test passed)" - notify_send "SMB mounted: $what → $where" - ;; + read_test_or_rollback "$where" "$what" + log "Mounted $what at $where (read test passed)" + notify_send "SMB mounted: $what → $where" +} - unmount) - where="${2:-}" - [ -n "$where" ] || err "Usage: pos share smb-client unmount " - validate_dir "$where" +cmd_unmount() { # + local where="$1" src out + validate_dir "$where" - src="$(mounted_src "$where")" - if [ -z "$src" ]; then - log "Nothing mounted at $where" - exit 0 + src="$(mounted_src "$where")" + if [ -z "$src" ]; then + log "Nothing mounted at $where" + exit 0 + fi + if ! out="$(sudo umount "$where" 2>&1)"; then + if grep -qE "busy|in use" <<<"$out"; then + warn "$where is busy (${src})" + confirm "Force a lazy unmount now?" y && { run sudo umount -l "$where"; log "Lazy-unmounted $where"; exit 0; } + err "Still mounted. Find the blocker: sudo lsof +D $where (or fuser -vm $where)" fi - if ! out="$(sudo umount "$where" 2>&1)"; then - if grep -qE "busy|in use" <<<"$out"; then - warn "$where is busy (${src})" - confirm "Force a lazy unmount now?" y && { run sudo umount -l "$where"; log "Lazy-unmounted $where"; exit 0; } - err "Still mounted. Find the blocker: sudo lsof +D $where (or fuser -vm $where)" - fi - err "Unmount failed: $out" - fi - log "Unmounted $where" - notify_send "SMB unmounted: $where" - ;; + err "Unmount failed: $out" + fi + log "Unmounted $where" + notify_send "SMB unmounted: $where" +} - list) - found=0 - active_mounts="$(findmnt -t cifs 2>/dev/null || true)" - if [ -n "$active_mounts" ]; then - printf 'Active mounts:\n' - printf '%s\n' "$active_mounts" - found=1 - fi - persistent=() - for unit in "${UNIT_DIR}"/*.mount; do - [ -e "$unit" ] || continue - grep -q '^Type=cifs$' "$unit" || continue - what="$(sed -n 's/^What=//p' "$unit")" - where="$(sed -n 's/^Where=//p' "$unit")" - [ -n "$what" ] && [ -n "$where" ] || continue - persistent+=("$where|$what") +cmd_list() { + local found=0 active_mounts unit what where + local -a persistent=() + active_mounts="$(findmnt -t cifs 2>/dev/null || true)" + if [ -n "$active_mounts" ]; then + printf 'Active mounts:\n' + printf '%s\n' "$active_mounts" + found=1 + fi + for unit in "${UNIT_DIR}"/*.mount; do + [ -e "$unit" ] || continue + grep -q '^Type=cifs$' "$unit" || continue + what="$(sed -n 's/^What=//p' "$unit")" + where="$(sed -n 's/^Where=//p' "$unit")" + [ -n "$what" ] && [ -n "$where" ] || continue + persistent+=("$where|$what") + done + if [ "${#persistent[@]}" -gt 0 ]; then + found=1 + printf 'Persistent (automount):\n' + for entry in "${persistent[@]}"; do + printf ' %-44s %s\n' "${entry%%|*}" "${entry#*|}" done - if [ "${#persistent[@]}" -gt 0 ]; then - found=1 - printf 'Persistent (automount):\n' - for entry in "${persistent[@]}"; do - printf ' %-44s %s\n' "${entry%%|*}" "${entry#*|}" - done - fi - [ "$found" -eq 1 ] || echo "No SMB mounts" - ;; + fi + [ "$found" -eq 1 ] || echo "No SMB mounts" +} - persist) - what="${2:-}" - where="${3:-}" - user="${4:-}" - [ -n "$what" ] && [ -n "$where" ] || err "Usage: pos share smb-client persist [user]" - validate_share "$what" - validate_dir "$where" - split_share "$what" +cmd_persist() { # [user] + local what="$1" where="$2" user="${3:-}" + local src unit auto_unit unit_file auto_file opts creds_file tmp out verified + validate_share "$what" + validate_dir "$where" + split_share "$what" - probe_server "$SERVER" + probe_server "$SERVER" - if src="$(mounted_src "$where")"; [ -n "$src" ]; then - err "$where is already mounted (source: ${src:-unknown}) — nothing done. + if src="$(mounted_src "$where")"; [ -n "$src" ]; then + err "$where is already mounted (source: ${src:-unknown}) — nothing done. Active mount + automount units conflict; unmount first: pos share smb-client unmount $where" - fi + fi - unit="$(systemd-escape --path --suffix=mount "$where")" - auto_unit="${unit%.mount}.automount" - unit_file="${UNIT_DIR}/${unit}" - auto_file="${UNIT_DIR}/${auto_unit}" + unit="$(systemd-escape --path --suffix=mount "$where")" + auto_unit="${unit%.mount}.automount" + unit_file="${UNIT_DIR}/${unit}" + auto_file="${UNIT_DIR}/${auto_unit}" - if [ -e "$unit_file" ] || [ -e "$auto_file" ]; then - warn "Units for $where already exist — they will be REPLACED:" - if [ -e "$unit_file" ]; then warn " ${unit_file}"; fi - if [ -e "$auto_file" ]; then warn " ${auto_file}"; fi - confirm "Replace them?" n || err "Aborted — units left untouched" - fi + if [ -e "$unit_file" ] || [ -e "$auto_file" ]; then + warn "Units for $where already exist — they will be REPLACED:" + if [ -e "$unit_file" ]; then warn " ${unit_file}"; fi + if [ -e "$auto_file" ]; then warn " ${auto_file}"; fi + confirm "Replace them?" n || err "Aborted — units left untouched" + fi - opts="$(mount_opts),_netdev,noexec" - creds_file="" - if [ -n "$user" ]; then - creds_file="$SMB_CREDS_DIR/$(basename "$where")" - sudo mkdir -p "$SMB_CREDS_DIR" - tmp="$(make_creds "$user")" - sudo install -m 600 "$tmp" "$creds_file" - rm -f "$tmp" - opts="credentials=$creds_file,$opts" - else - warn "No user — persisting a guest mount (works only if the server allows guest access)" - opts="guest,$opts" - fi + opts="$(mount_opts),_netdev,noexec" + creds_file="" + if [ -n "$user" ]; then + creds_file="$SMB_CREDS_DIR/$(basename "$where")" + sudo mkdir -p "$SMB_CREDS_DIR" + tmp="$(make_creds "$user")" + sudo install -m 600 "$tmp" "$creds_file" + rm -f "$tmp" + opts="credentials=$creds_file,$opts" + else + warn "No user — persisting a guest mount (works only if the server allows guest access)" + opts="guest,$opts" + fi - rollback_persist() { - warn "Rolling back everything this command created…" - sudo systemctl disable "$auto_unit" 2>/dev/null || true - sudo systemctl stop "$auto_unit" 2>/dev/null || true - sudo systemctl disable "$unit" 2>/dev/null || true - sudo systemctl stop "$unit" 2>/dev/null || true - sudo rm -f "$unit_file" "$auto_file" - if [ -n "$creds_file" ]; then sudo rm -f "$creds_file"; fi - sudo systemctl daemon-reload 2>/dev/null || true - } + rollback_persist() { + warn "Rolling back everything this command created…" + sudo systemctl disable "$auto_unit" 2>/dev/null || true + sudo systemctl stop "$auto_unit" 2>/dev/null || true + sudo systemctl disable "$unit" 2>/dev/null || true + sudo systemctl stop "$unit" 2>/dev/null || true + sudo rm -f "$unit_file" "$auto_file" + if [ -n "$creds_file" ]; then sudo rm -f "$creds_file"; fi + sudo systemctl daemon-reload 2>/dev/null || true + } - if [ "${DRY_RUN:-0}" -eq 1 ]; then - log "(dry-run) write $unit_file + $auto_file (Type=cifs, Options=$opts)" - log "(dry-run) daemon-reload + enable --now $auto_unit" - exit 0 - fi + if [ "${DRY_RUN:-0}" -eq 1 ]; then + log "(dry-run) write $unit_file + $auto_file (Type=cifs, Options=$opts)" + log "(dry-run) daemon-reload + enable --now $auto_unit" + exit 0 + fi - cat </dev/null + cat </dev/null [Unit] Description=SMB mount of ${what} at ${where} After=network-online.target @@ -361,7 +355,7 @@ Where=${where} Type=cifs Options=${opts} UNIT - cat </dev/null + cat </dev/null [Unit] Description=Automount of SMB share ${what} at ${where} @@ -371,70 +365,212 @@ Where=${where} [Install] WantedBy=multi-user.target UNIT - sudo systemctl daemon-reload + sudo systemctl daemon-reload - # Enable, then prove the automount actually serves the share before - # declaring victory — a broken unit here would bite months later. - if ! out="$(sudo systemctl enable --now "$auto_unit" 2>&1)"; then - rollback_persist - err "Could not enable $auto_unit: $out" - fi + # Enable, then prove the automount actually serves the share before + # declaring victory — a broken unit here would bite months later. + if ! out="$(sudo systemctl enable --now "$auto_unit" 2>&1)"; then + rollback_persist + err "Could not enable $auto_unit: $out" + fi - ls "$where" >/dev/null 2>&1 || true # poke the automount - verified=0 - for _ in 1 2 3 4 5 6 7 8 9 10; do - if [ -n "$(mounted_src "$where")" ]; then verified=1; break; fi - sleep 0.5 - done - if [ "$verified" -ne 1 ]; then - rollback_persist - err "Automount did not trigger for $where — units removed again. + ls "$where" >/dev/null 2>&1 || true # poke the automount + verified=0 + for _ in 1 2 3 4 5 6 7 8 9 10; do + if [ -n "$(mounted_src "$where")" ]; then verified=1; break; fi + sleep 0.5 + done + if [ "$verified" -ne 1 ]; then + rollback_persist + err "Automount did not trigger for $where — units removed again. Check: systemctl status $auto_unit" - fi + fi - # Same read-proof as one-shot mounts: catch permission problems now. - if ! timeout 5 ls -A "$where" >/dev/null 2>&1; then - rollback_persist - err "Automount triggered but $where is not readable — units + credentials removed. + # Same read-proof as one-shot mounts: catch permission problems now. + if ! timeout 5 ls -A "$where" >/dev/null 2>&1; then + rollback_persist + err "Automount triggered but $where is not readable — units + credentials removed. Login works but file permissions don't — fix unix perms on the server (the shared folder itself needs r-x for the connecting user). Details: DOC/howto/share.md (SMB troubleshooting)" - fi + fi - log "Persistent SMB mount (automount): ${what} → ${where} (${auto_unit})" - log "Verified: automount triggers and the share is readable" - notify_send "SMB mount persisted: ${what} → ${where}" + log "Persistent SMB mount (automount): ${what} → ${where} (${auto_unit})" + log "Verified: automount triggers and the share is readable" + notify_send "SMB mount persisted: ${what} → ${where}" +} + +cmd_unpersist() { # + local where="$1" unit auto_unit unit_file auto_file + validate_dir "$where" + + unit="$(systemd-escape --path --suffix=mount "$where")" + auto_unit="${unit%.mount}.automount" + unit_file="${UNIT_DIR}/${unit}" + auto_file="${UNIT_DIR}/${auto_unit}" + + if [ ! -f "$unit_file" ] && [ ! -f "$auto_file" ]; then + log "No persistent SMB mount for $where — nothing to do" + exit 0 + fi + + sudo systemctl disable "$auto_unit" 2>/dev/null || true + sudo systemctl stop "$auto_unit" 2>/dev/null || true + sudo systemctl disable "$unit" 2>/dev/null || true + sudo systemctl stop "$unit" 2>/dev/null || true + sudo rm -f "$unit_file" "$auto_file" + sudo rm -f "$SMB_CREDS_DIR/$(basename "$where")" + sudo rmdir "$SMB_CREDS_DIR" 2>/dev/null || true + sudo systemctl daemon-reload + + if [ -n "$(mounted_src "$where")" ]; then + warn "$where is still mounted (something holds it open)" + if confirm "Force a lazy unmount now?" y; then run sudo umount -l "$where"; fi + fi + log "Removed persistent SMB mount: $where" + notify_send "SMB persistent mount removed: $where" +} + +# ── Interactive menu flows ───────────────────────────────────── +menu_ask_mountpoint() { # stdout: absolute path · rc 1 cancelled + local idx dir cand + local -a cands=() + if mapfile -t cands < <(share_folder_candidates) && [ "${#cands[@]}" -gt 0 ]; then + if idx="$(share_pick "Mountpoint" "${cands[@]}")"; then + cand="${cands[$((idx - 1))]}" + dir="${cand%% (*}" # strip "(mounted fstype)" annotation + case "$dir" in + /etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*) + warn "Refusing system path as mountpoint" + return 1 + ;; + *) + echo "$dir" + return 0 + ;; + esac + fi + fi + dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1 + [ -n "$dir" ] || { warn "No mountpoint given"; return 1; } + echo "$dir" +} + +menu_mount() { # ephemeral|persist + local mode="$1" host idx share what where + host="$(share_ask_value "SMB server (host or IP)")" || return 1 + [ -n "$host" ] || { warn "No server given"; return 1; } + + # Empty answer = guest enumeration (with an interactive auth retry inside + # the helper); a named user authenticates right away. SMB_AUTH_USER tells + # us which account ended up being used so the mount reuses it. + local user="" + read -rp "Samba user (empty = try guest): " user || return 1 + SMB_AUTH_USER="" + + local -a shares=() + if mapfile -t shares < <(share_smb_shares "$host" "$user") && [ "${#shares[@]}" -gt 0 ]; then + idx="$(share_pick "Pick share on ${host}" "${shares[@]}")" || return 1 + share="${shares[$((idx - 1))]}" + else + share="$(share_ask_value "Share name on ${host} (e.g. media)")" || return 1 + [ -n "$share" ] || { warn "No share name given"; return 1; } + fi + user="${SMB_AUTH_USER:-$user}" + + what="//${host}/${share}" + where="$(menu_ask_mountpoint)" || return 1 + + if [ "$mode" = "persist" ]; then + cmd_persist "$what" "$where" "$user" + else + cmd_mount "$what" "$where" "$user" + fi +} + +menu_unmount() { + local idx row where + local -a targets=() + if mapfile -t rows < <(findmnt -rnf -t cifs -o SOURCE,TARGET 2>/dev/null) && + [ "${#rows[@]}" -gt 0 ]; then + for row in "${rows[@]}"; do + targets+=("${row##* }") # last field of raw mode = TARGET + done + idx="$(share_pick "Unmount which SMB mount?" "${targets[@]}")" || return 1 + where="${targets[$((idx - 1))]}" + else + where="$(share_ask_value "Local mountpoint to unmount")" || return 1 + [ -n "$where" ] || return 1 + fi + cmd_unmount "$where" +} + +menu_unpersist() { + local idx uf where unit_w + local -a paths=() + for uf in "${UNIT_DIR}"/*.mount; do + grep -q '^Type=cifs$' "$uf" 2>/dev/null || continue + unit_w="$(sed -n 's/^Where=//p' "$uf")" + [ -n "$unit_w" ] || continue + paths+=("$unit_w") + done + if [ "${#paths[@]}" -gt 0 ]; then + idx="$(share_pick "Remove which persistent SMB mount?" "${paths[@]}")" || return 1 + where="${paths[$((idx - 1))]}" + else + where="$(share_ask_value "Local mountpoint whose units to remove")" || return 1 + [ -n "$where" ] || return 1 + fi + cmd_unpersist "$where" +} + +run_menu() { + share_menu_guard || exit 1 + while true; do + local choice + choice="$(share_menu_run "SMB client" \ + "Mount a share (one-shot)" \ + "Persist a share (automount units)" \ + "List active + persistent mounts" \ + "Unmount a mounted share" \ + "Remove a persistent mount")" || return 0 + case "$choice" in + 1) menu_mount ephemeral ;; + 2) menu_mount persist ;; + 3) cmd_list ;; + 4) menu_unmount ;; + 5) menu_unpersist ;; + esac + done +} + +case "$cmd" in + ""|menu) + run_menu + exit 0 + ;; + + mount) + [ $# -ge 3 ] || err "Usage: pos share smb-client mount [user]" + cmd_mount "$2" "$3" "${4:-}" + ;; + + unmount) + [ $# -ge 2 ] || err "Usage: pos share smb-client unmount " + cmd_unmount "$2" + ;; + + list) + cmd_list + ;; + + persist) + [ $# -ge 3 ] || err "Usage: pos share smb-client persist [user]" + cmd_persist "$2" "$3" "${4:-}" ;; unpersist) - where="${2:-}" - [ -n "$where" ] || err "Usage: pos share smb-client unpersist " - validate_dir "$where" - - unit="$(systemd-escape --path --suffix=mount "$where")" - auto_unit="${unit%.mount}.automount" - unit_file="${UNIT_DIR}/${unit}" - auto_file="${UNIT_DIR}/${auto_unit}" - - if [ ! -f "$unit_file" ] && [ ! -f "$auto_file" ]; then - log "No persistent SMB mount for $where — nothing to do" - exit 0 - fi - - sudo systemctl disable "$auto_unit" 2>/dev/null || true - sudo systemctl stop "$auto_unit" 2>/dev/null || true - sudo systemctl disable "$unit" 2>/dev/null || true - sudo systemctl stop "$unit" 2>/dev/null || true - sudo rm -f "$unit_file" "$auto_file" - sudo rm -f "$SMB_CREDS_DIR/$(basename "$where")" - sudo rmdir "$SMB_CREDS_DIR" 2>/dev/null || true - sudo systemctl daemon-reload - - if [ -n "$(mounted_src "$where")" ]; then - warn "$where is still mounted (something holds it open)" - if confirm "Force a lazy unmount now?" y; then run sudo umount -l "$where"; fi - fi - log "Removed persistent SMB mount: $where" - notify_send "SMB persistent mount removed: $where" + [ $# -ge 2 ] || err "Usage: pos share smb-client unpersist " + cmd_unpersist "$2" ;; esac diff --git a/bin/pos-share-smb-server b/bin/pos-share-smb-server index 92fa166..b646f2d 100755 --- a/bin/pos-share-smb-server +++ b/bin/pos-share-smb-server @@ -1,10 +1,11 @@ #!/usr/bin/env bash set -euo pipefail # POS: share smb-server — Manage the Samba server (status, share/unshare exports, users, enable/disable) -# POS_SUBCMDS: status share unshare list adduser deluser reload enable disable +# POS_SUBCMDS: status share unshare list adduser deluser reload enable disable menu source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh" +source "$(dirname "$0")/../lib/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh" command -v smbd &>/dev/null || err "smbd not found (install samba)" command -v smbcontrol &>/dev/null || err "smbcontrol not found (install samba)" @@ -48,8 +49,7 @@ SMB_CONF="${SMB_CONF:-/etc/samba/smb.conf}" cmd="${1:-}" case "$cmd" in -h|--help) usage ;; - status|share|unshare|list|adduser|deluser|reload|enable|disable) ;; - "") err "Missing command (status|share|unshare|list|adduser|deluser|reload|enable|disable)" ;; + ""|menu|status|share|unshare|list|adduser|deluser|reload|enable|disable) ;; *) err "Unknown command '$cmd' (see --help)" ;; esac @@ -106,15 +106,9 @@ reload_config() { # rc 0 = Samba reachable through the firewall (or nothing to check), # rc 1 = ufw active but no Samba rule → clients can't reach 139/445. +# Thin wrapper: the decision core lives in lib/share-lib.sh. ufw_blocks_samba() { - command -v ufw >/dev/null 2>&1 || return 1 - local st - st="$(sudo ufw status 2>/dev/null)" || return 1 - grep -q "^Status: active" <<<"$st" || return 1 - if grep -qiE 'samba|(^|[^0-9])(137|138|139|445)([^0-9]|$)' <<<"$st"; then - return 1 - fi - return 0 + share_ufw_blocks_ports 'samba|(^|[^0-9])(137|138|139|445)([^0-9]|$)' } # Warn when another managed share already exports the same path under a @@ -141,171 +135,307 @@ list_shares() { printf '%s\n' "$out" | grep -Fv '[global]' | sed 's/\[\(.*\)\]/ \1/' } -case "$cmd" in - status) - if systemctl is-active --quiet smbd 2>/dev/null; then - ok "smbd: running" - else - warn "smbd: not running (enable with 'pos share smb-server enable')" - fi - echo - section "Shares" - list_shares - echo - section "Users" - sudo pdbedit -L 2>/dev/null | cut -d: -f1 | sed 's/^/ /' || echo " (none)" - echo - section "Ports" - if ss -tln 2>/dev/null | awk '$4 ~ /:(139|445)$/ { f = 1 } END { exit !f }'; then - ok "smbd listening on :139/:445" - else - warn "nothing listening on :139/:445 locally (smbd down or bound elsewhere)" - fi - if command -v ufw >/dev/null 2>&1 && sudo ufw status 2>/dev/null | grep -q "^Status: active"; then - if ufw_blocks_samba; then - warn "firewall: ufw active, NO Samba rule — clients can't connect. Fix: sudo ufw allow Samba" - else - ok "firewall: ufw active, Samba allowed" - fi - else - ok "firewall: no active ufw — nothing blocking SMB ports" - fi - ;; - - share) - path="${2:-}" - [ -n "$path" ] || err "Usage: pos share smb-server share [name] [--read-only|--guest|--users u1,u2]" - case "$path" in - -*) err "Usage: pos share smb-server share [name] [--read-only|--guest|--users u1,u2]" ;; - esac - require_root_dir "$path" - check_traversal "$path" - [ -f "$SMB_CONF" ] || err "No smb.conf at $SMB_CONF (is samba installed?)" - - shift 2 - name="" ro=0 guest=0 users="" - while [ $# -gt 0 ]; do - case "$1" in - --read-only) ro=1; shift ;; - --guest) guest=1; shift ;; - --users) [ $# -ge 2 ] || err "--users needs a value"; users="$2"; shift 2 ;; - -*) err "unknown option '$1'" ;; - *) name="$1"; shift ;; - esac - done - name="${name:-$(basename "$path")}" - validate_share_name "$name" - dup_path_share "$path" "$name" - - if [ "$guest" -eq 1 ]; then - warn "guest access on — ANY network user can access $path. Restrict with --users." - elif [ -z "$users" ]; then - warn "No valid users — any Samba account can access $path. Restrict with --users u1,u2." - fi - [ -n "$users" ] && check_samba_users "$users" +cmd_status() { + if systemctl is-active --quiet smbd 2>/dev/null; then + ok "smbd: running" + else + warn "smbd: not running (enable with 'pos share smb-server enable')" + fi + echo + section "Shares" + list_shares + echo + section "Users" + sudo pdbedit -L 2>/dev/null | cut -d: -f1 | sed 's/^/ /' || echo " (none)" + echo + section "Ports" + if ss -tln 2>/dev/null | awk '$4 ~ /:(139|445)$/ { f = 1 } END { exit !f }'; then + ok "smbd listening on :139/:445" + else + warn "nothing listening on :139/:445 locally (smbd down or bound elsewhere)" + fi + if command -v ufw >/dev/null 2>&1 && sudo ufw status 2>/dev/null | grep -q "^Status: active"; then if ufw_blocks_samba; then - warn "ufw is active but allows no Samba traffic — clients can't reach ports 139/445. - Fix on this machine: sudo ufw allow Samba" + warn "firewall: ufw active, NO Samba rule — clients can't connect. Fix: sudo ufw allow Samba" + else + ok "firewall: ufw active, Samba allowed" fi + else + ok "firewall: no active ufw — nothing blocking SMB ports" + fi +} - ro_val=no; [ "$ro" -eq 1 ] && ro_val=yes - guest_val=no; [ "$guest" -eq 1 ] && guest_val=yes - block="[$name] +cmd_share() { + local path="$1" + [ -n "$path" ] || err "Usage: pos share smb-server share [name] [--read-only|--guest|--users u1,u2]" + case "$path" in + -*) err "Usage: pos share smb-server share [name] [--read-only|--guest|--users u1,u2]" ;; + esac + require_root_dir "$path" + check_traversal "$path" + [ -f "$SMB_CONF" ] || err "No smb.conf at $SMB_CONF (is samba installed?)" + + shift + local name="" ro=0 guest=0 users="" + while [ $# -gt 0 ]; do + case "$1" in + --read-only) ro=1; shift ;; + --guest) guest=1; shift ;; + --users) [ $# -ge 2 ] || err "--users needs a value"; users="$2"; shift 2 ;; + -*) err "unknown option '$1'" ;; + *) name="$1"; shift ;; + esac + done + name="${name:-$(basename "$path")}" + validate_share_name "$name" + dup_path_share "$path" "$name" + + if [ "$guest" -eq 1 ]; then + warn "guest access on — ANY network user can access $path. Restrict with --users." + elif [ -z "$users" ]; then + warn "No valid users — any Samba account can access $path. Restrict with --users u1,u2." + fi + [ -n "$users" ] && check_samba_users "$users" + if ufw_blocks_samba; then + warn "ufw is active but allows no Samba traffic — clients can't reach ports 139/445. + Fix on this machine: sudo ufw allow Samba" + fi + + local ro_val=no guest_val=no block tmp + [ "$ro" -eq 1 ] && ro_val=yes + [ "$guest" -eq 1 ] && guest_val=yes + block="[$name] path = $path browseable = yes read only = $ro_val guest ok = $guest_val" - [ -n "$users" ] && block="$block + [ -n "$users" ] && block="$block valid users = $users" - tmp="$(mktemp)" - awk -v s="# >>> pos-managed share: $name" -v e="# <<< end pos-managed share" ' - $0 == s {inblock=1} - $0 == e && inblock == 1 {inblock=0; next} - !inblock {print} - ' "$SMB_CONF" > "$tmp" - { - echo - echo "# >>> pos-managed share: $name" - printf '%s\n' "$block" - echo "# <<< end pos-managed share" - } >> "$tmp" + tmp="$(mktemp)" + awk -v s="# >>> pos-managed share: $name" -v e="# <<< end pos-managed share" ' + $0 == s {inblock=1} + $0 == e && inblock == 1 {inblock=0; next} + !inblock {print} + ' "$SMB_CONF" > "$tmp" + { + echo + echo "# >>> pos-managed share: $name" + printf '%s\n' "$block" + echo "# <<< end pos-managed share" + } >> "$tmp" - testparm -s "$tmp" >/dev/null || { rm -f "$tmp"; err "Invalid smb.conf — changes not applied (see testparm -s $SMB_CONF)"; } - sudo cp "$tmp" "$SMB_CONF" - rm -f "$tmp" - reload_config - log "Share added: [$name] → $path" - notify_send "SMB share added: $name ($path)" + testparm -s "$tmp" >/dev/null || { rm -f "$tmp"; err "Invalid smb.conf — changes not applied (see testparm -s $SMB_CONF)"; } + sudo cp "$tmp" "$SMB_CONF" + rm -f "$tmp" + reload_config + log "Share added: [$name] → $path" + notify_send "SMB share added: $name ($path)" +} + +cmd_unshare() { + local name="$1" tmp + validate_share_name "$name" + [ -f "$SMB_CONF" ] || err "No smb.conf at $SMB_CONF (is samba installed?)" + + if ! grep -Fq "# >>> pos-managed share: $name" "$SMB_CONF"; then + warn "No share '$name' found in $SMB_CONF" + exit 0 + fi + + tmp="$(mktemp)" + awk -v s="# >>> pos-managed share: $name" -v e="# <<< end pos-managed share" ' + $0 == s {inblock=1} + $0 == e && inblock == 1 {inblock=0; next} + !inblock {print} + ' "$SMB_CONF" > "$tmp" + sudo cp "$tmp" "$SMB_CONF" + rm -f "$tmp" + reload_config + log "Removed share: $name" + notify_send "SMB share removed: $name" +} + +cmd_list() { + list_shares +} + +cmd_adduser() { + local user="$1" + [ -n "$user" ] || err "Usage: pos share smb-server adduser " + id -u "$user" >/dev/null 2>&1 || err "No system user '$user' — create it first (e.g. sudo adduser $user)" + if sudo pdbedit -L 2>/dev/null | cut -d: -f1 | grep -qxF "$user"; then + warn "'$user' already has a Samba account — nothing to do." + log "Reset the password with: sudo smbpasswd $user" + exit 0 + fi + sudo smbpasswd -a "$user" + log "Samba user added: $user" + notify_send "SMB user added: $user" +} + +cmd_deluser() { + local user="$1" + [ -n "$user" ] || err "Usage: pos share smb-server deluser " + if ! sudo pdbedit -L 2>/dev/null | cut -d: -f1 | grep -qxF "$user"; then + log "'$user' is not a Samba user — nothing to remove" + exit 0 + fi + sudo smbpasswd -x "$user" + log "Samba user removed: $user" + notify_send "SMB user removed: $user" +} + +cmd_reload() { + reload_config +} + +cmd_enable() { + sudo systemctl enable --now smbd + log "smbd enabled (starts on boot)" + notify_send "SMB server enabled" +} + +cmd_disable() { + sudo systemctl disable --now smbd + log "smbd disabled (will not start on boot)" + notify_send "SMB server disabled" +} + +# ── Interactive menu flows ───────────────────────────────────── +menu_pick_folder() { # stdout: folder path · rc 1 cancelled + local idx dir cand + local -a cands=() + if mapfile -t cands < <(share_folder_candidates) && [ "${#cands[@]}" -gt 0 ]; then + if idx="$(share_pick "Share which folder?" "${cands[@]}")"; then + cand="${cands[$((idx - 1))]}" + dir="${cand%% (*}" # strip "(mounted fstype)" annotation + [ -d "$dir" ] || { warn "Folder vanished: $dir"; return 1; } + echo "$dir" + return 0 + fi + return 1 + fi + dir="$(share_ask_value "Folder to share (absolute path)")" || return 1 + [ -n "$dir" ] || { warn "No folder given"; return 1; } + echo "$dir" +} + +menu_share() { + local dir name users + local -a args=() + dir="$(menu_pick_folder)" || return 1 + name="$(share_ask_value "Share name" "$(basename "$dir")")" || return 1 + [ -n "$name" ] || name="$(basename "$dir")" + + if confirm "Read-only share?" n; then + args+=(--read-only) + fi + if confirm "Guest access (ANY network user, no login)?" n; then + args+=(--guest) + else + users="$(share_ask_value "Restrict to Samba users (comma-separated, empty = any account)")" || return 1 + [ -n "$users" ] && args+=(--users "$users") + fi + cmd_share "$dir" "$name" ${args[@]+"${args[@]}"} +} + +menu_unshare() { + local idx name + local -a names=() + if mapfile -t names < <(awk '/^# >>> pos-managed share: /{ sub(/^# >>> pos-managed share: /, ""); print }' "$SMB_CONF" 2>/dev/null) && + [ "${#names[@]}" -gt 0 ]; then + idx="$(share_pick "Remove which share?" "${names[@]}")" || return 1 + name="${names[$((idx - 1))]}" + else + name="$(share_ask_value "Share name to remove")" || return 1 + [ -n "$name" ] || return 1 + fi + cmd_unshare "$name" +} + +menu_adduser() { + local user + user="$(share_ask_value "System user to give a Samba account")" || return 1 + [ -n "$user" ] || return 1 + cmd_adduser "$user" +} + +menu_deluser() { + local idx user + local -a users=() + if mapfile -t users < <(sudo pdbedit -L 2>/dev/null | cut -d: -f1) && + [ "${#users[@]}" -gt 0 ]; then + idx="$(share_pick "Remove which Samba user?" "${users[@]}")" || return 1 + user="${users[$((idx - 1))]}" + else + user="$(share_ask_value "Samba user to remove")" || return 1 + [ -n "$user" ] || return 1 + fi + cmd_deluser "$user" +} + +run_menu() { + share_menu_guard || exit 1 + while true; do + local choice + choice="$(share_menu_run "Samba server" \ + "Show status (shares/users/ports/firewall)" \ + "Share a folder" \ + "Remove a share" \ + "List current shares" \ + "Add a Samba user" \ + "Remove a Samba user" \ + "Validate + reload config" \ + "Enable service on boot" \ + "Disable service")" || return 0 + case "$choice" in + 1) cmd_status ;; + 2) menu_share ;; + 3) menu_unshare ;; + 4) cmd_list ;; + 5) menu_adduser ;; + 6) menu_deluser ;; + 7) cmd_reload ;; + 8) cmd_enable ;; + 9) cmd_disable ;; + esac + done +} + +case "$cmd" in + ""|menu) + run_menu + exit 0 + ;; + + status) cmd_status ;; + + share) + [ $# -ge 2 ] || err "Usage: pos share smb-server share [name] [--read-only|--guest|--users u1,u2]" + cmd_share "${@:2}" ;; unshare) - name="${2:-}" - [ -n "$name" ] || err "Usage: pos share smb-server unshare " - validate_share_name "$name" - [ -f "$SMB_CONF" ] || err "No smb.conf at $SMB_CONF (is samba installed?)" - - if ! grep -Fq "# >>> pos-managed share: $name" "$SMB_CONF"; then - warn "No share '$name' found in $SMB_CONF" - exit 0 - fi - - tmp="$(mktemp)" - awk -v s="# >>> pos-managed share: $name" -v e="# <<< end pos-managed share" ' - $0 == s {inblock=1} - $0 == e && inblock == 1 {inblock=0; next} - !inblock {print} - ' "$SMB_CONF" > "$tmp" - sudo cp "$tmp" "$SMB_CONF" - rm -f "$tmp" - reload_config - log "Removed share: $name" - notify_send "SMB share removed: $name" + [ $# -ge 2 ] || err "Usage: pos share smb-server unshare " + cmd_unshare "$2" ;; - list) - list_shares - ;; + list) cmd_list ;; adduser) - user="${2:-}" - [ -n "$user" ] || err "Usage: pos share smb-server adduser " - id -u "$user" >/dev/null 2>&1 || err "No system user '$user' — create it first (e.g. sudo adduser $user)" - if sudo pdbedit -L 2>/dev/null | cut -d: -f1 | grep -qxF "$user"; then - warn "'$user' already has a Samba account — nothing to do." - log "Reset the password with: sudo smbpasswd $user" - exit 0 - fi - sudo smbpasswd -a "$user" - log "Samba user added: $user" - notify_send "SMB user added: $user" + [ $# -ge 2 ] || err "Usage: pos share smb-server adduser " + cmd_adduser "$2" ;; deluser) - user="${2:-}" - [ -n "$user" ] || err "Usage: pos share smb-server deluser " - if ! sudo pdbedit -L 2>/dev/null | cut -d: -f1 | grep -qxF "$user"; then - log "'$user' is not a Samba user — nothing to remove" - exit 0 - fi - sudo smbpasswd -x "$user" - log "Samba user removed: $user" - notify_send "SMB user removed: $user" + [ $# -ge 2 ] || err "Usage: pos share smb-server deluser " + cmd_deluser "$2" ;; - reload) - reload_config - ;; + reload) cmd_reload ;; - enable) - sudo systemctl enable --now smbd - log "smbd enabled (starts on boot)" - notify_send "SMB server enabled" - ;; + enable) cmd_enable ;; - disable) - sudo systemctl disable --now smbd - log "smbd disabled (will not start on boot)" - notify_send "SMB server disabled" - ;; + disable) cmd_disable ;; esac diff --git a/bin/pos-share-usb-server b/bin/pos-share-usb-server index 7aba683..0c9ae92 100755 --- a/bin/pos-share-usb-server +++ b/bin/pos-share-usb-server @@ -1,8 +1,9 @@ #!/usr/bin/env bash set -euo pipefail # POS: share usb-server — USB Redirector server control (--ls, --share; prompts when args omitted) -# POS_FLAGS: --ls --ls-shared --share --unshare --auto-share --callback --close-callback --auto-connect --disconnect --nickname --timeout --port --info --version +# POS_FLAGS: --ls --ls-shared --share --unshare --auto-share --callback --close-callback --auto-connect --disconnect --nickname --timeout --port --info --version menu source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" +source "$(dirname "$0")/../lib/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh" usage() { cat </dev/null \ || err "usbsrv not found — install the USB Redirector server (https://www.incentivespro.com/usb-server.html)" +# ── Interactive menu flows ───────────────────────────────────── +# Picker-first prompting: parsed listings feed the filtered picker; when the +# server is unreachable or nothing parses, fall back to the same raw-listing + +# manual-entry prompts the explicit commands have always used. +menu_pick_from_records() { # — stdout: chosen ID · rc 1 cancel/fallback + local prompt="$1"; shift + local idx rec + local -a ids=() + if recs="$("$@")" && [ -n "$recs" ]; then + while IFS= read -r rec; do + [ -n "$rec" ] || continue + ids+=("${rec%%|*}") + done <<<"$recs" + if [ "${#ids[@]}" -gt 0 ]; then + if idx="$(share_pick "$prompt" "${ids[@]}")"; then + echo "${ids[$((idx - 1))]}" + return 0 + fi + fi + fi + return 1 +} + +menu_share() { + local dev client + if ! dev="$(menu_pick_from_records "Share which USB device?" share_usb_devices)"; then + cmd_ls >/dev/null 2>&1 || true + read -rp "Enter device ID to share: " dev + [ -n "$dev" ] || { warn "Cancelled"; return 1; } + fi + if ! client="$(menu_pick_from_records "Connect to which client?" share_usb_clients)"; then + read -rp "Enter client ID to connect to: " client + [ -n "$client" ] || { warn "Cancelled"; return 1; } + fi + cmd_share "$dev" "$client" +} + +menu_unshare() { + local dev + if ! dev="$(menu_pick_from_records "Stop sharing which device?" share_usb_devices)"; then + cmd_ls_shared >/dev/null 2>&1 || true + read -rp "Enter device ID to unshare: " dev + [ -n "$dev" ] || { warn "Cancelled"; return 1; } + fi + cmd_unshare "$dev" +} + +menu_callback() { + local addr + addr="$(share_ask_value "Client address:port for callback (e.g. 192.168.1.100:32032)")" || return 1 + [ -n "$addr" ] || { warn "Cancelled"; return 1; } + cmd_callback "$addr" +} + +menu_close_callback() { + local target + if ! target="$(menu_pick_from_records "Close callback of which client?" share_usb_clients)"; then + target="$(share_ask_value "Client, client id or 'all' to close callback")" || return 1 + [ -n "$target" ] || { warn "Cancelled"; return 1; } + fi + cmd_close_callback "$target" +} + +menu_auto_connect() { + local mode client + mode="$(share_ask_value "Remote auto-connect on or off?" "on")" || return 1 + case "$mode" in on|off) ;; *) warn "must be on/off"; return 1 ;; esac + if ! client="$(menu_pick_from_records "Toggle auto-connect for which client?" share_usb_clients)"; then + client="$(share_ask_value "Client or client id")" || return 1 + [ -n "$client" ] || { warn "Cancelled"; return 1; } + fi + cmd_auto_connect "$mode" "$client" +} + +menu_disconnect() { + local dev + if ! dev="$(menu_pick_from_records "Disconnect which device?" share_usb_devices)"; then + dev="$(share_ask_value "Device ID or 'all' to disconnect from clients")" || return 1 + [ -n "$dev" ] || { warn "Cancelled"; return 1; } + fi + cmd_disconnect "$dev" +} + +menu_nickname() { + local dev nick + if ! dev="$(menu_pick_from_records "Nickname which device?" share_usb_devices)"; then + dev="$(share_ask_value "Device ID")" || return 1 + [ -n "$dev" ] || { warn "Cancelled"; return 1; } + fi + nick="$(share_ask_value "Nickname (empty removes it)")" || return 1 + cmd_nickname "$dev" "$nick" +} + +menu_timeout() { + local dev sec + if ! dev="$(menu_pick_from_records "Set inactivity timeout for which device?" share_usb_devices)"; then + dev="$(share_ask_value "Device ID")" || return 1 + [ -n "$dev" ] || { warn "Cancelled"; return 1; } + fi + sec="$(share_ask_value "Timeout in seconds (0 disables)" "0")" || return 1 + cmd_timeout "$dev" "$sec" +} + +menu_port() { + local port + port="$(share_ask_value "New TCP port")" || return 1 + [ -n "$port" ] || { warn "Cancelled"; return 1; } + cmd_port "$port" +} + +run_menu() { + share_menu_guard || exit 1 + while true; do + local choice + choice="$(share_menu_run "USB Redirector server" \ + "List host devices and connected clients" \ + "Share a device with a client" \ + "Stop sharing a device" \ + "Disconnect device(s) from clients" \ + "Auto-share on/off" \ + "Create a callback connection" \ + "Close a client callback" \ + "Client remote auto-connect on/off" \ + "Set a device nickname" \ + "Set a device inactivity timeout" \ + "Set the TCP port")" || return 0 + case "$choice" in + 1) cmd_ls ;; + 2) menu_share ;; + 3) menu_unshare ;; + 4) menu_disconnect ;; + 5) cmd_auto_share ;; + 6) menu_callback ;; + 7) menu_close_callback ;; + 8) menu_auto_connect ;; + 9) menu_nickname ;; + 10) menu_timeout ;; + 11) menu_port ;; + esac + done +} + cmd="${1:-}" case "$cmd" in - -h|--help|"") usage ;; + -h|--help) usage ;; esac case "$cmd" in - --ls|--ls-shared|--share|--unshare|--auto-share|--callback|--close-callback|--auto-connect|--disconnect|--nickname|--timeout|--port|--info|--version) ;; + ""|menu|--ls|--ls-shared|--share|--unshare|--auto-share|--callback|--close-callback|--auto-connect|--disconnect|--nickname|--timeout|--port|--info|--version) ;; *) err "Unknown flag '$cmd'" ;; esac case "$cmd" in + ""|menu) run_menu ;; --ls) cmd_ls ;; --ls-shared) cmd_ls_shared ;; --share) shift; cmd_share "$@" ;; diff --git a/lib/share-lib.sh b/lib/share-lib.sh new file mode 100644 index 0000000..7e099c7 --- /dev/null +++ b/lib/share-lib.sh @@ -0,0 +1,318 @@ +# lib/share-lib.sh — precondition probes, remote listings and advisories for +# the `pos share` suite (nfs/smb/usb tools), plus compat shims to the +# category-neutral menu layer (lib/menu-lib.sh). +# +# Contracts (all of them, no exceptions): +# * Defines ONLY `share_*` functions — sourcing never clobbers a tool's own +# helpers (same discipline as lib/notify.sh). +# * Requires common.sh to be sourced by the CALLER first (log/warn/err/ +# confirm/run/spawn + CYAN/RESET colors are used, never defined here). +# * NEVER exits and never terminates the caller: every function returns, +# failures are signalled through the return code. +# * Display goes to stderr, results go to stdout — any function whose result +# is meant to be command-substituted prints ONLY the result on stdout +# (matches the ui_pick contract in the telegram/matrix listeners). +# * Performs NO file writes of its own — every path a tool persists stays +# tool-owned (the env-seam surface does not grow here). +# +# Function index: +# share_menu_run <item...> shim → menu_run (lib/menu-lib.sh) +# share_pick <prompt> <item...> shim → menu_pick (lib/menu-lib.sh) +# share_ask_value <label> [default] shim → menu_ask_value (lib/menu-lib.sh) +# share_menu_guard shim → menu_guard (lib/menu-lib.sh) +# share_require_bin <bin> <hint> dependency probe (rc only) +# share_port_probe <host> <port> TCP reachability probe (rc only) +# share_service_active <unit> systemd unit state probe (rc only) +# share_ufw_blocks_ports <egrep> ufw-blocking decision (rc only) +# share_offer_fix <desc> <cmd...> advisory remediation offer (rc always 0) +# share_nfs_exports <host> remote export list via showmount +# share_smb_shares <host> [user] remote Disk-share list via smbclient +# share_usb_devices / _clients usbsrv listing records as "ID|display" +# share_folder_candidates mountpoint/dir candidates for sharing + +# ── Menu primitives live in lib/menu-lib.sh (category-neutral) ─ +# The generic interactive layer was extracted there; these thin shims keep the +# public `share_*` names/contracts identical for all five share tools. Delegation +# preserves rc semantics 1:1 (guard rc, EOF → rc 1, index/value → stdout only). +source "$(dirname "${BASH_SOURCE[0]}")/../lib/menu-lib.sh" 2>/dev/null \ + || source "$(dirname "${BASH_SOURCE[0]}")/menu-lib.sh" 2>/dev/null \ + || source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null \ + || source "$(dirname "$0")/menu-lib.sh" + +share_menu_guard() { menu_guard "$@"; } +share_menu_run() { menu_run "$@"; } +share_pick() { menu_pick "$@"; } +share_ask_value() { menu_ask_value "$@"; } + +# ── Dependency probe (NOT an err wrapper) ────────────────────── +# rc 0 present · rc 1 absent. Callers decide between err() and graceful +# degradation; <hint> documents intent at call sites and is intentionally +# not printed here (message policy belongs to the caller). +share_require_bin() { + command -v "$1" >/dev/null 2>&1 +} + +# ── TCP reachability probe (generalized probe_server core) ───── +# rc 0 reachable within 3s · rc 1 unreachable/no-route. Message policy (targeted +# hints, firewall wording) belongs to the caller. +share_port_probe() { + timeout 3 bash -c "exec 3<>/dev/tcp/${1}/${2}" 2>/dev/null +} + +# ── systemd unit state probe ─────────────────────────────────── +# rc 0 active · rc 1 inactive/unqueryable (nonzero systemctl codes normalized). +share_service_active() { + if systemctl is-active --quiet "$1" 2>/dev/null; then + return 0 + fi + return 1 +} + +# ── Bounded path probe ───────────────────────────────────────── +# share_path_probe <-d|-w> <path> — rc 0 when the stat answers within 2s. +# Guards discovery against wedged network mountpoints where a plain +# `[ -d … ]` would block forever. +share_path_probe() { + timeout 2 bash -c '[ "$1" "$2" ]' _ "$1" "$2" 2>/dev/null +} + +# ── ufw blocking decision ────────────────────────────────────── +# Core moved verbatim from pos-share-smb-server's ufw_blocks_samba, with the +# rule pattern parameterized. rc 0 = ufw active but no matching rule (traffic +# blocked) · rc 1 = ufw absent/inactive OR a matching rule exists (not blocking). +# Callers own all message text. +share_ufw_blocks_ports() { + command -v ufw >/dev/null 2>&1 || return 1 + local st + st="$(sudo ufw status 2>/dev/null)" || return 1 + grep -q "^Status: active" <<<"$st" || return 1 + if grep -qiE "$1" <<<"$st"; then + return 1 + fi + return 0 +} + +# ── Advisory remediation offer ───────────────────────────────── +# On a terminal: confirm "<desc>. Fix it now?" (default No) and run/spawn the +# command, reporting the outcome. Without a terminal: print desc + manual +# command as a hint-only warning. ALWAYS rc 0 — purely advisory; aborting on a +# declined offer stays the caller's confirm/err decision. +share_offer_fix() { + local desc="$1"; shift + if [ -t 0 ]; then + echo >&2 + if confirm "$desc. Fix it now?" n; then + if run "$@"; then + ok "Fixed: $*" + else + warn "Fix command failed: $*" + fi + else + warn "Declined — run manually: $*" + fi + else + warn "$desc — run manually: $*" + fi + return 0 +} + +# ── Remote NFS export enumeration ────────────────────────────── +# stdout: export paths, one per row (column 1 of the showmount table, header +# skipped). rc 0 ok · rc 1 unavailable (showmount missing / timeout / RPC +# failure / no exports) with the reason warned on stderr. +share_nfs_exports() { + local host="$1" out rows + if ! command -v showmount >/dev/null 2>&1; then + printf '[!] showmount not found (install nfs-common) — cannot list exports from %s\n' "$host" >&2 + return 1 + fi + if ! out="$(timeout 5 showmount -e "$host" 2>&1)"; then + printf "[!] could not list exports from %s (server down, RPC/firewall blocked, or timeout)\n" "$host" >&2 + return 1 + fi + rows="$(awk 'NF > 0 && $1 !~ /^Export/ {print $1}' <<<"$out")" + if [ -z "$rows" ]; then + printf "[!] no exports visible on %s\n" "$host" >&2 + return 1 + fi + printf '%s\n' "$rows" +} + +# ── Remote SMB share enumeration ─────────────────────────────── +# stdout: Disk share names, one per line (-g parse; IPC$/printer `*$` names +# dropped). Sets SMB_AUTH_USER="" at entry, and to the account that ended up +# authenticating, so callers can reuse it for the actual mount: +# * user argument given → authenticate immediately (password prompted, +# travels via the PASSWD environment, never argv); no guest attempt. +# * no user argument → guest query first; on ACCESS_DENIED / +# LOGON_FAILURE a Samba user + password are asked once (TTY required) +# and the query retries. +# rc 0 ok · rc 1 unavailable / failed-after-retry (reason warned on stderr). +share_smb_shares() { + local host="$1" user="${2:-}" out names pw u + local -r GPARSE='BEGIN { FS = "|" } $1 == "Disk" && $2 != "" && $2 != "IPC$" && $2 !~ /\$$/ { print $2 }' + SMB_AUTH_USER="" + if ! command -v smbclient >/dev/null 2>&1; then + printf '[!] smbclient not found — cannot enumerate shares (install the smbclient package)\n' >&2 + return 1 + fi + + if [ -n "$user" ]; then + if ! [ -t 0 ]; then + printf '[!] Samba login for %s needs a terminal (password prompt)\n' "$host" >&2 + return 1 + fi + read -rsp "Samba password for $user: " pw || { echo >&2; return 1; } + echo >&2 + if out="$(PASSWD="$pw" smbclient -L "//${host}/" -g -t 5 -U "$user" 2>&1)"; then + SMB_AUTH_USER="$user" + else + printf '[!] share enumeration failed for %s@%s (wrong user/password?)\n' "$user" "$host" >&2 + return 1 + fi + else + if ! out="$(smbclient -L "//${host}/" -N -g -t 5 2>&1)"; then + case "$out" in + *ACCESS_DENIED*|*LOGON_FAILURE*|*NOT_GRANTED*) + if ! [ -t 0 ]; then + printf '[!] %s requires authentication — rerun interactively (menu) to enter a Samba user\n' "$host" >&2 + return 1 + fi + printf '[!] %s rejected guest access — a Samba login is required\n' "$host" >&2 + u="$(share_ask_value "Samba user for ${host}")" || return 1 + read -rsp "Samba password for $u: " pw || { echo >&2; return 1; } + echo >&2 + if ! out="$(PASSWD="$pw" smbclient -L "//${host}/" -g -t 5 -U "$u" 2>&1)"; then + printf '[!] share enumeration failed for %s@%s after retry\n' "$u" "$host" >&2 + return 1 + fi + SMB_AUTH_USER="$u" + ;; + *) + printf '[!] could not enumerate shares on %s: %s\n' "$host" "$(head -1 <<<"$out")" >&2 + return 1 + ;; + esac + fi + fi + + names="$(printf '%s\n' "$out" | awk "$GPARSE")" + if [ -z "$names" ]; then + printf '[!] no Disk shares visible on %s\n' "$host" >&2 + return 1 + fi + printf '%s\n' "$names" +} + +# ── USB Redirector listings ──────────────────────────────────── +# Both emit blank-line-split records as "ID|display-line" rows; rc 0 parsed ≥1 +# record · rc 1 unparsable/down — callers fall back to the raw listing plus a +# manual ID entry (= today's UX). +share_usb_records() { # internal helper: stdin = raw listing, $1 = ID-line regex + awk -v idre="$1" ' + BEGIN { RS = "" } + { + n = split($0, L, "\n") + id = ""; desc = ""; fb = "" + for (i = 1; i <= n; i++) { + line = L[i] + if (line ~ idre && id == "") { + id = line + sub(/^[^:]*:[ \t]*/, "", id) + } else if (line ~ /^Description:/) { + desc = line + sub(/^Description:[ \t]*/, "", desc) + } else if (fb == "") { + fb = line + } + } + if (id != "") print id "|" (desc != "" ? desc : fb) + }' +} + +share_usb_devices() { + local raw recs + if ! command -v usbsrv >/dev/null 2>&1; then + printf '[!] usbsrv not found — install the USB Redirector server first\n' >&2 + return 1 + fi + if ! raw="$(usbsrv -list-devices 2>&1)"; then + printf '[!] usbsrv -list-devices failed — is the USB Redirector server running?\n' >&2 + return 1 + fi + recs="$(printf '%s\n' "$raw" | share_usb_records '^ID:')" + if [ -z "$recs" ]; then + printf '[!] no USB device records could be parsed from the server listing\n' >&2 + return 1 + fi + printf '%s\n' "$recs" +} + +share_usb_clients() { + local raw recs + if ! command -v usbsrv >/dev/null 2>&1; then + printf '[!] usbsrv not found — install the USB Redirector server first\n' >&2 + return 1 + fi + if ! raw="$(usbsrv -list-clients 2>&1)"; then + printf '[!] usbsrv -list-clients failed — is the USB Redirector server running?\n' >&2 + return 1 + fi + recs="$(printf '%s\n' "$raw" | share_usb_records '^Client ID:')" + if [ -z "$recs" ]; then + printf '[!] no connected clients could be parsed from the server listing\n' >&2 + return 1 + fi + printf '%s\n' "$recs" +} + +# ── Share-folder candidates ──────────────────────────────────── +# stdout: candidate paths, one per line — writable real-filesystem findmnt +# targets (pseudo-fs denylist and read-only mounts excluded) ∪ immediate +# directories under /mnt,/srv,/media,/export. LC_ALL=C sorted, deduplicated; +# entries that are mountpoints carry a "(mounted <fstype>)" annotation. +# rc 0 always; an empty list is allowed (caller falls back to manual entry). +share_folder_candidates() { + local -A seen=() + local line fs tgt opts entry child r path fst + local -a cands=() + + # findmnt targets: split TARGET FSTYPE OPTIONS (TARGET may hold escaped + # spaces, OPTIONS/FSTYPE never do) — pseudo-fs denylist + ro exclusion. + while IFS=$'\t' read -r fs tgt opts; do + [ -n "$tgt" ] || continue + case "$fs" in + proc | sysfs | devtmpfs | devpts | tmpfs | cgroup | cgroup2 | squashfs | \ + overlay | mqueue | hugetlbfs | debugfs | tracefs | configfs | fusectl | \ + securityfs | pstore | efivarfs | bpf | ramfs | nsfs | binfmt_misc | autofs | \ + iso9660 | swap | fuse.* | cgroupfs) continue ;; + esac + case ",$opts," in *,ro,*) continue ;; esac + share_path_probe -w "$tgt" || continue + cands+=("${tgt}|${fs}") + done < <(timeout 5 findmnt -rn -o TARGET,FSTYPE,OPTIONS 2>/dev/null | + awk '{ opts=$NF; fstype=$(NF-1); tgt=substr($0, 1, length($0)-length(opts)-length(fstype)-1); sub(/[ \t]+$/, "", tgt); print fstype "\t" tgt "\t" opts }') + + # immediate directories under the conventional share roots + for r in /mnt /srv /media /export; do + [ -d "$r" ] || continue + for child in "$r"/*; do + share_path_probe -d "$child" || continue + cands+=("${child}|") + done + done + + # annotate mountpoints, prefer annotated duplicates, sort byte-order + for line in "${cands[@]}"; do + path="${line%%|*}" + fst="${line#*|}" + if [ -n "$fst" ]; then + seen["$path"]="${path} (mounted ${fst})" + elif [ -z "${seen[$path]:-}" ]; then + seen["$path"]="$path" + fi + done + for path in "${!seen[@]}"; do + printf '%s\n' "${seen[$path]}" + done | LC_ALL=C sort -u +} diff --git a/preinstall.sh b/preinstall.sh index cacbf43..658a3b8 100755 --- a/preinstall.sh +++ b/preinstall.sh @@ -31,7 +31,7 @@ PACKAGES=( net-tools iputils-ping traceroute tcpdump nmap openssh-client openssh-server ufw fail2ban nfs-common nfs-kernel-server - samba cifs-utils + samba cifs-utils smbclient hostapd dnsmasq iptables iw ca-certificates gnupg lsb-release lm-sensors smartmontools nvme-cli hdparm