feat: share suite — interactive menus for usb/nfs/smb server+client on share-lib domain layer
This commit is contained in:
@@ -16,9 +16,9 @@ CRITICAL: real guidance lives in DOC/. When you encounter a reference below, use
|
||||
|
||||
- **Tool model:** `bin/pos-<category>-<command>`, or **category-less** `bin/pos-<cmd>` 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: <cat> <cmd> — <desc>` 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/<sha>` or `ci-fail/<sha>` (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/<sha>` or `ci-fail/<sha>` (pushes only — query with `scripts/ci-status.sh [--wait] [<sha>]`; 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 <bin> || 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 <bin> || 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/<tool>.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: <name>` + `# 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 <name> --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 *`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+273
-68
@@ -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 <server:export> <local-dir> Persistent mount via a systemd .mount unit
|
||||
(ordered after network-online.target)
|
||||
unpersist <local-dir> 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 <server:export> <local-dir>"
|
||||
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 <local-dir>"
|
||||
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 <server:export> <local-dir>"
|
||||
validate_share "$what"
|
||||
validate_dir "$where"
|
||||
|
||||
unit="$(systemd-escape --path --suffix=mount "$where")"
|
||||
unit_file="/etc/systemd/system/${unit}"
|
||||
|
||||
sudo mkdir -p "$where"
|
||||
cat <<UNIT | sudo tee "$unit_file" >/dev/null
|
||||
write_mount_unit() { # <unit_file> <what> <where>
|
||||
cat <<UNIT | sudo tee "$1" >/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() { # <what> <where> (dry-run preview)
|
||||
cat <<UNIT
|
||||
[Unit]
|
||||
Description=NFS mount of ${1} at ${2}
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Mount]
|
||||
What=${1}
|
||||
Where=${2}
|
||||
Type=nfs
|
||||
Options=defaults,_netdev,rw,noatime
|
||||
UNIT
|
||||
}
|
||||
|
||||
cmd_persist() {
|
||||
local what="$1" where="$2"
|
||||
validate_share "$what"
|
||||
validate_dir "$where"
|
||||
|
||||
unit="$(systemd-escape --path --suffix=mount "$where")"
|
||||
unit_file="${UNIT_DIR}/${unit}"
|
||||
|
||||
if [ -f "$unit_file" ]; then
|
||||
if [ -t 0 ]; then
|
||||
# FLAGGED DELTA: interactive overwrite now confirms first.
|
||||
confirm "Unit ${unit} already exists — overwrite?" n ||
|
||||
{ warn "Aborted — ${unit_file} left untouched"; return 1; }
|
||||
else
|
||||
warn "Overwriting existing unit ${unit}"
|
||||
fi
|
||||
fi
|
||||
|
||||
sudo mkdir -p "$where"
|
||||
if [ "${DRY_RUN:-0}" -eq 1 ]; then
|
||||
log "(dry-run) would write ${unit_file}:"
|
||||
show_mount_unit "$what" "$where"
|
||||
else
|
||||
write_mount_unit "$unit_file" "$what" "$where"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now "$unit"
|
||||
log "Persistent NFS mount: ${what} → ${where} (${unit})"
|
||||
notify_send "NFS mount persisted: ${what} → ${where}"
|
||||
|
||||
# Verify the export actually mounted; roll back the unit if not.
|
||||
local i mounted=1
|
||||
for i in 1 2 3 4 5; do
|
||||
if findmnt -r -n -o TARGET -t nfs,nfs4 2>/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() { # <host> — 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 <server:export> <local-dir>"
|
||||
cmd_mount "$2" "$3"
|
||||
;;
|
||||
|
||||
unmount)
|
||||
[ $# -ge 2 ] || err "Usage: pos share nfs-client unmount <local-dir>"
|
||||
cmd_unmount "$2"
|
||||
;;
|
||||
|
||||
list)
|
||||
cmd_list
|
||||
;;
|
||||
|
||||
persist)
|
||||
[ $# -ge 3 ] || err "Usage: pos share nfs-client persist <server:export> <local-dir>"
|
||||
cmd_persist "$2" "$3"
|
||||
;;
|
||||
|
||||
unpersist)
|
||||
where="${2:-}"
|
||||
[ -n "$where" ] || err "Usage: pos share nfs-client unpersist <local-dir>"
|
||||
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 <local-dir>"
|
||||
cmd_unpersist "$2"
|
||||
;;
|
||||
esac
|
||||
|
||||
+184
-73
@@ -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 <<EOF
|
||||
Usage: pos share nfs-server <command> [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 <path> [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 <path> [client]"
|
||||
cmd_share "$2" "${3:-*(rw,sync,no_subtree_check)}"
|
||||
;;
|
||||
|
||||
unshare)
|
||||
path="${2:-}"
|
||||
[ -n "$path" ] || err "Usage: pos share nfs-server unshare <path>"
|
||||
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 <path>"
|
||||
cmd_unshare "$2"
|
||||
;;
|
||||
list) cmd_list ;;
|
||||
reload) cmd_reload ;;
|
||||
enable) cmd_enable ;;
|
||||
disable) cmd_disable ;;
|
||||
esac
|
||||
|
||||
+320
-184
@@ -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 <//server/share> <local-dir> [user]"
|
||||
validate_share "$what"
|
||||
validate_dir "$where"
|
||||
split_share "$what"
|
||||
cmd_mount() { # <//server/share> <local-dir> [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 <local-dir>"
|
||||
validate_dir "$where"
|
||||
cmd_unmount() { # <local-dir>
|
||||
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 <//server/share> <local-dir> [user]"
|
||||
validate_share "$what"
|
||||
validate_dir "$where"
|
||||
split_share "$what"
|
||||
cmd_persist() { # <//server/share> <local-dir> [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 <<UNIT | sudo tee "$unit_file" >/dev/null
|
||||
cat <<UNIT | sudo tee "$unit_file" >/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 <<UNIT | sudo tee "$auto_file" >/dev/null
|
||||
cat <<UNIT | sudo tee "$auto_file" >/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-dir>
|
||||
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 <//server/share> <local-dir> [user]"
|
||||
cmd_mount "$2" "$3" "${4:-}"
|
||||
;;
|
||||
|
||||
unmount)
|
||||
[ $# -ge 2 ] || err "Usage: pos share smb-client unmount <local-dir>"
|
||||
cmd_unmount "$2"
|
||||
;;
|
||||
|
||||
list)
|
||||
cmd_list
|
||||
;;
|
||||
|
||||
persist)
|
||||
[ $# -ge 3 ] || err "Usage: pos share smb-client persist <//server/share> <local-dir> [user]"
|
||||
cmd_persist "$2" "$3" "${4:-}"
|
||||
;;
|
||||
|
||||
unpersist)
|
||||
where="${2:-}"
|
||||
[ -n "$where" ] || err "Usage: pos share smb-client unpersist <local-dir>"
|
||||
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 <local-dir>"
|
||||
cmd_unpersist "$2"
|
||||
;;
|
||||
esac
|
||||
|
||||
+284
-154
@@ -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 <path> [name] [--read-only|--guest|--users u1,u2]"
|
||||
case "$path" in
|
||||
-*) err "Usage: pos share smb-server share <path> [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 <path> [name] [--read-only|--guest|--users u1,u2]"
|
||||
case "$path" in
|
||||
-*) err "Usage: pos share smb-server share <path> [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 <user>"
|
||||
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 <user>"
|
||||
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 <path> [name] [--read-only|--guest|--users u1,u2]"
|
||||
cmd_share "${@:2}"
|
||||
;;
|
||||
|
||||
unshare)
|
||||
name="${2:-}"
|
||||
[ -n "$name" ] || err "Usage: pos share smb-server unshare <name>"
|
||||
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 <name>"
|
||||
cmd_unshare "$2"
|
||||
;;
|
||||
|
||||
list)
|
||||
list_shares
|
||||
;;
|
||||
list) cmd_list ;;
|
||||
|
||||
adduser)
|
||||
user="${2:-}"
|
||||
[ -n "$user" ] || err "Usage: pos share smb-server adduser <user>"
|
||||
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 <user>"
|
||||
cmd_adduser "$2"
|
||||
;;
|
||||
|
||||
deluser)
|
||||
user="${2:-}"
|
||||
[ -n "$user" ] || err "Usage: pos share smb-server deluser <user>"
|
||||
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 <user>"
|
||||
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
|
||||
|
||||
+147
-3
@@ -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 <<EOF
|
||||
@@ -188,18 +189,161 @@ cmd_port() {
|
||||
command -v usbsrv &>/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() { # <prompt> <records-cmd...> — 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 "$@" ;;
|
||||
|
||||
@@ -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 <title> <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
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user