tmux-party: share a tmux session with people on the same UNIX host. Single-file POSIX shell (party) with a filesystem + tmux server-access trust model. See README.md.
1324 lines
53 KiB
Bash
1324 lines
53 KiB
Bash
#!/bin/sh
|
|
# party: share a tmux session with the people you already work with.
|
|
#
|
|
# Single-file POSIX shell.
|
|
#
|
|
# Project by veg & kol3rby for the UNIX Social Club <https://club.unix.rocks>
|
|
|
|
set -eu
|
|
|
|
PARTY_VERSION=0.1.0
|
|
|
|
# Overridable defaults
|
|
# ====================
|
|
|
|
# $USER may be unset in stripped environments (cron, systemd unit shells,
|
|
# `env -i` invocations). Every cmd_* path references it, so guarantee it
|
|
# now via id -un rather than crashing later under set -eu.
|
|
: "${USER:=$(id -un)}"
|
|
: "${TMUX_PARTY_GROUP:=party}"
|
|
: "${PARTY_SOCKET_DIR:=/tmp}"
|
|
: "${PARTY_TMUX:=tmux}"
|
|
|
|
# Usage
|
|
# =====
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: party <subcommand> [args]
|
|
|
|
Hosting:
|
|
host [name] [--group <g>]
|
|
Start a party on a dedicated tmux server.
|
|
close Tear down the party you host.
|
|
|
|
Joining:
|
|
join [name] [--passive] Join a party. Without name: auto-pick or numbered prompt.
|
|
--passive lands in the host's session (mirrored view).
|
|
leave Detach from the party you joined.
|
|
role [active|passive|switch]
|
|
Flip your clients between guest session and host session.
|
|
|
|
Moderation (host only):
|
|
invite <user> [-r] Add user to allowlist (read/write; -r for read-only).
|
|
voice <user> Promote user to read/write (alias: rw).
|
|
mute <user> Demote user to read-only (alias: ro).
|
|
kick <user> Revoke invite + disconnect + clean up guest session.
|
|
detach <user> Disconnect user; keep them on the allowlist.
|
|
|
|
Info:
|
|
list List discoverable parties on this host.
|
|
who [--short] Show invited and attached users for the active party.
|
|
status Show the caller's own party state.
|
|
--help This message.
|
|
--version Print version.
|
|
EOF
|
|
}
|
|
|
|
# Library mode
|
|
# ============
|
|
|
|
# Sourced from tests with __PARTY_LIB_ONLY=1 to expose helpers without
|
|
# triggering dispatch. The marker MUST be checked before the dispatch
|
|
# block at the bottom of the file.
|
|
|
|
# Helpers
|
|
# =======
|
|
|
|
# Validate a party name. Accepts [A-Za-z0-9._-]{1,63}. Reject empty,
|
|
# paths, shell metas, and overlong names: the name lands in a file
|
|
# path component, a tmux session name, and shell command lines.
|
|
validate_party_name() {
|
|
name="$1"
|
|
case "$name" in
|
|
'' ) echo "party: name must not be empty" >&2; return 2 ;;
|
|
esac
|
|
# Length cap: 63 keeps the full /tmp/party-${USER}-${name} comfortably
|
|
# below the 108-char sun_path limit for typical USERs.
|
|
if [ "${#name}" -gt 63 ]; then
|
|
echo "party: name '$name' too long (max 63 chars)" >&2
|
|
return 2
|
|
fi
|
|
case "$name" in
|
|
*[!A-Za-z0-9._-]* )
|
|
echo "party: name '$name' has invalid characters" >&2
|
|
echo " allowed: letters, digits, '.', '_', '-'" >&2
|
|
return 2 ;;
|
|
esac
|
|
# Reject ".", "..", and any leading-dot variant we don't want.
|
|
case "$name" in
|
|
.|.. )
|
|
echo "party: name '$name' is reserved" >&2
|
|
return 2 ;;
|
|
esac
|
|
return 0
|
|
}
|
|
|
|
# UTC ISO 8601, portable across BSD/GNU date.
|
|
iso_now() {
|
|
date -u '+%Y-%m-%dT%H:%M:%SZ'
|
|
}
|
|
|
|
# Boolean: does the named OS user exist?
|
|
user_exists() {
|
|
id -u "$1" >/dev/null 2>&1
|
|
}
|
|
|
|
# Boolean: is the named user in TMUX_PARTY_GROUP?
|
|
user_in_party_group() {
|
|
user="$1"
|
|
id -nG "$user" 2>/dev/null | tr ' ' '\n' | grep -qx "$TMUX_PARTY_GROUP"
|
|
}
|
|
|
|
# Soft assertion: caller is in TMUX_PARTY_GROUP. Fails loudly with
|
|
# remediation instructions when not. Used by every subcommand that
|
|
# touches the roster or socket.
|
|
require_caller_in_group() {
|
|
if ! user_in_party_group "$USER"; then
|
|
cat >&2 <<EOF
|
|
party: you ($USER) are not in the '$TMUX_PARTY_GROUP' group.
|
|
|
|
One-time setup (as root), your OS may differ:
|
|
Linux: groupadd $TMUX_PARTY_GROUP && usermod -aG $TMUX_PARTY_GROUP $USER
|
|
FreeBSD: pw groupadd $TMUX_PARTY_GROUP && pw groupmod $TMUX_PARTY_GROUP -m $USER
|
|
OpenBSD: groupadd $TMUX_PARTY_GROUP && usermod -G $TMUX_PARTY_GROUP $USER
|
|
illumos: groupadd $TMUX_PARTY_GROUP && usermod -G $TMUX_PARTY_GROUP $USER
|
|
Then log out and back in.
|
|
EOF
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# Roster paths
|
|
# ------------
|
|
|
|
# Path separators are ':' rather than '-' because party names may contain
|
|
# '-' (validate_party_name allows it) and we don't want USER-foo-bar to
|
|
# collide with USER:foo-bar / USER-foo:bar etc. POSIX usernames cannot
|
|
# contain ':' and validate_party_name rejects it, so the separator is
|
|
# unambiguous.
|
|
|
|
# Per-party private directory. Created at host time, removed at close.
|
|
# Holds the tmux socket, the notify helper, and the roster record. The
|
|
# directory's group + mode IS the discovery perimeter: members of
|
|
# $TMUX_PARTY_GROUP can traverse and read; non-members get EACCES at
|
|
# the dir, so the roster inside is invisible to them. No shared
|
|
# install directory: each party is a self-contained perimeter.
|
|
party_dir_path() {
|
|
printf '%s/party-%s:%s.d\n' "$PARTY_SOCKET_DIR" "$1" "$2"
|
|
}
|
|
|
|
roster_record_path() {
|
|
printf '%s/roster\n' "$(party_dir_path "$1" "$2")"
|
|
}
|
|
|
|
socket_path() {
|
|
printf '%s/sock\n' "$(party_dir_path "$1" "$2")"
|
|
}
|
|
|
|
# Roster I/O
|
|
# ----------
|
|
#
|
|
# Records are flat shell-sourceable key=value files:
|
|
#
|
|
# HOST_USER=veg
|
|
# PARTY_NAME=debug-the-deploy
|
|
# SOCKET=/tmp/party-veg:debug-the-deploy.d/sock
|
|
# SERVER_PID=12345
|
|
# GROUP=party
|
|
# CREATED=2026-04-26T18:42:00Z
|
|
#
|
|
# Schema: keys MUST be from the set above. Values are single-line, no
|
|
# spaces or special chars (we control all writes).
|
|
|
|
# Atomic write: temp file then rename.
|
|
# Usage: roster_write <path> <KEY1> <VAL1> <KEY2> <VAL2> ...
|
|
roster_write() {
|
|
rec="$1"; shift
|
|
tmp="${rec}.tmp.$$"
|
|
: > "$tmp"
|
|
while [ $# -ge 2 ]; do
|
|
printf '%s=%s\n' "$1" "$2" >> "$tmp"
|
|
shift 2
|
|
done
|
|
chmod 0640 "$tmp" 2>/dev/null || true
|
|
mv "$tmp" "$rec"
|
|
}
|
|
|
|
# Read a roster record, exporting RR_<KEY> into the calling shell.
|
|
#
|
|
# Trust model: security-relevant fields (RR_HOST_USER, RR_PARTY_NAME,
|
|
# RR_SOCKET) come from the filesystem, the dir basename and the dir's
|
|
# owner, never from the file's content. The roster file itself supplies
|
|
# advisory metadata (RR_SERVER_PID, RR_GROUP, RR_CREATED) which is fine
|
|
# to display but not to act on.
|
|
#
|
|
# This anchors trust in unforgeable primitives: mkdir(2) and bind(2) set
|
|
# the owner to the calling euid, and `chown` requires root. A group
|
|
# member who plants a forged dir cannot fake its ownership, cannot
|
|
# redirect RR_SOCKET (always `$d/sock`), and cannot forge a sock
|
|
# elsewhere (we reject `$d` and `$d/sock` symlinks; we require both
|
|
# inodes to be owned by the user named in the basename, which catches
|
|
# hardlinks pointing at sibling-party sockets).
|
|
#
|
|
# What we deliberately don't try to defend against: a group member
|
|
# hosting a real party that appears in discovery (that's just hosting),
|
|
# and TOCTOU swaps inside an attacker's own dir between roster_read and
|
|
# the subsequent tmux call (window is short; damage cap is "any tmux op
|
|
# the caller was already authorized to perform").
|
|
#
|
|
# That damage cap holds because the swap only reaches discovery paths
|
|
# (join/leave/status), which act on the caller's own clients/guest
|
|
# session, never on someone else's server. The host-only moderation
|
|
# paths (kick/detach/voice/mute) can't be redirected at all: they route
|
|
# through resolve_authoritative_party, which gates on [ -O "$d" ], so the
|
|
# dir is the caller's own (mode 0750, group r-x, no group write) under a
|
|
# sticky parent, a group member can neither replace its sock nor rename
|
|
# the dir, so there is no socket to swap out from under the tmux call.
|
|
roster_read() {
|
|
rec="$1"
|
|
[ -f "$rec" ] || return 1
|
|
|
|
d="${rec%/roster}"
|
|
[ -L "$d" ] && return 1
|
|
[ -L "$d/sock" ] && return 1
|
|
[ -e "$d/sock" ] || return 1
|
|
|
|
base="${d##*/}"
|
|
case "$base" in
|
|
party-*:*.d) ;;
|
|
*) return 1 ;;
|
|
esac
|
|
rest="${base#party-}"
|
|
rest="${rest%.d}"
|
|
RR_HOST_USER="${rest%%:*}"
|
|
RR_PARTY_NAME="${rest#*:}"
|
|
[ -n "$RR_HOST_USER" ] || return 1
|
|
[ -n "$RR_PARTY_NAME" ] || return 1
|
|
|
|
# Pattern parity with cmd_host. The basename glob (party-*:*.d) is
|
|
# permissive, names like '.' (basename party-USER:..d) or 'a b'
|
|
# match the glob but validate_party_name rejects them. Run the
|
|
# canonical validator so the invariant cmd_host enforces on write
|
|
# is also checked on read.
|
|
validate_party_name "$RR_PARTY_NAME" 2>/dev/null || return 1
|
|
|
|
# Both the dir and its sock must be owned by the user named in the
|
|
# basename. -prune halts find's descent so we examine just the path
|
|
# itself. find errors (unknown user, missing path) suppress to no
|
|
# output → reject.
|
|
[ -n "$(find "$d" -prune -user "$RR_HOST_USER" 2>/dev/null)" ] || return 1
|
|
[ -n "$(find "$d/sock" -prune -user "$RR_HOST_USER" 2>/dev/null)" ] || return 1
|
|
|
|
# Mode hardening for cross-user discovery. cmd_host writes the
|
|
# per-party dir as mode 0750, no other-bits set. A non-member
|
|
# outside TMUX_PARTY_GROUP cannot read or traverse such a dir, so
|
|
# anything surfacing in roster_list with other-readable bits is
|
|
# suspect: a local user planted a world-readable
|
|
# party-attacker:foo.d under a sticky parent (e.g. /tmp) to inject
|
|
# a fake party into our `party list` / `party join` flow. Reject
|
|
# so the spoof never reaches a tmux command. POSIX find lacks "/"
|
|
# or "+" perm operators (BSD/GNU extensions), so test each
|
|
# other-bit alone.
|
|
[ -n "$(find "$d" -prune -perm -0001 2>/dev/null)" ] && return 1 # o+x
|
|
[ -n "$(find "$d" -prune -perm -0002 2>/dev/null)" ] && return 1 # o+w
|
|
[ -n "$(find "$d" -prune -perm -0004 2>/dev/null)" ] && return 1 # o+r
|
|
|
|
RR_SOCKET="$d/sock"
|
|
RR_SERVER_PID='' RR_GROUP='' RR_CREATED=''
|
|
while IFS='=' read -r k v; do
|
|
# shellcheck disable=SC2034 # RR_CREATED is part of the public schema
|
|
case "$k" in
|
|
SERVER_PID) RR_SERVER_PID=$v ;;
|
|
GROUP) RR_GROUP=$v ;;
|
|
CREATED) RR_CREATED=$v ;;
|
|
'' | \#*) ;;
|
|
esac
|
|
done < "$rec"
|
|
return 0
|
|
}
|
|
|
|
# List full roster record paths discoverable to the caller. The glob
|
|
# walks every per-party private dir under PARTY_SOCKET_DIR; entries
|
|
# whose group the caller isn't in fail the inner [ -f ] check (EACCES
|
|
# on the dir) and are silently skipped.
|
|
roster_list() {
|
|
[ -d "$PARTY_SOCKET_DIR" ] || return 0
|
|
# No ownership/symlink gate here: roster_list is the cross-user
|
|
# discovery primitive (party list, party join, party leave, status),
|
|
# so it must surface dirs hosted by other group members. Host-only
|
|
# walkers (resolve_authoritative_party, no-arg cmd_close) apply
|
|
# their own owner+symlink filter before trusting HOST_USER.
|
|
for d in "$PARTY_SOCKET_DIR"/party-*:*.d; do
|
|
[ -d "$d" ] || continue
|
|
rec="$d/roster"
|
|
[ -f "$rec" ] || continue
|
|
printf '%s\n' "$rec"
|
|
done
|
|
}
|
|
|
|
|
|
# Liveness
|
|
# --------
|
|
|
|
# Liveness check: the socket is a live tmux server we can speak the tmux
|
|
# protocol to. `tmux -S list-clients` exits 0 only against a real tmux
|
|
# server bound to that exact socket path, which simultaneously rules out
|
|
# stale roster pointers (server gone), AF_UNIX impostors (a same-group
|
|
# user planting a hand-rolled listener via `nc -lU`), and PID-reuse
|
|
# leftovers, none of those answer the tmux handshake. Stronger than any
|
|
# PID check, and it works regardless of who owns the server process.
|
|
#
|
|
# Earlier revisions also gated on `kill -0 $pid` as a cheap pre-filter.
|
|
# That broke cross-user discovery on every non-Linux POSIX target: per
|
|
# POSIX, kill(2) signal 0 may return EPERM when the caller lacks
|
|
# send-permission, and illumos, all the BSDs, and macOS honor that.
|
|
# Linux is the outlier that returns 0 for "exists, even if not
|
|
# signalable," and Linux ABI environments (LX-branded zones) inherit
|
|
# that behavior, which is why the bug hid in the test matrix until a
|
|
# native illumos run surfaced it. The PID arg is kept in the function
|
|
# signature for caller compatibility and as a numeric-validation guard,
|
|
# but is no longer probed. Local var names are prefixed to avoid
|
|
# clobbering caller-side `pid` / `sock` (POSIX sh has no real locals).
|
|
is_party_alive() {
|
|
_pa_pid="$1"
|
|
_pa_sock="$2"
|
|
case "$_pa_pid" in
|
|
'' | *[!0-9]* ) return 1 ;;
|
|
esac
|
|
"$PARTY_TMUX" -S "$_pa_sock" list-clients >/dev/null 2>&1
|
|
}
|
|
|
|
# FS perms
|
|
# --------
|
|
#
|
|
# The OS group is the perimeter, tmux's server-access allowlist is the
|
|
# per-user filter. Both are universal across the supported platforms
|
|
# (Linux, FreeBSD, NetBSD, OpenBSD, illumos, Solaris, macOS), so the
|
|
# mechanism is one liner: chgrp + chmod. No ACL syscalls invoked.
|
|
#
|
|
# ACL-shadowing on filesystems with NFSv4-style extended ACLs (macOS
|
|
# HFS+/APFS, FreeBSD/illumos ZFS): inherited ACEs on PARTY_SOCKET_DIR
|
|
# can override the mode bits we set here, either widening access (an
|
|
# inherited `everyone@:rx` survives our chmod 0750 because mode bits
|
|
# don't displace ACEs) or narrowing it (a `deny everyone:` blocks the
|
|
# group members our chgrp tried to admit). We deliberately do NOT
|
|
# invoke `getfacl` / `getextattr` / `ls -le` to verify effective
|
|
# access. Two reasons:
|
|
#
|
|
# 1. Re-introducing per-platform ACL plumbing would unwind the
|
|
# simplification this redesign committed to: one universal
|
|
# mechanism, no OS branching in the trust path.
|
|
#
|
|
# 2. The dual gate keeps the FS layer from being the sole
|
|
# authorization check. tmux server-access is the authoritative
|
|
# perimeter on every platform; the FS gate is defense in depth.
|
|
# An ACL-shadowed FS gate is a *weakening of depth*, not a
|
|
# privilege escalation: a user who wins the FS-gate race still
|
|
# has to be on the auth-list to attach, and the auth-list is
|
|
# only edited by the host.
|
|
#
|
|
# Treat the FS gate as best-effort discoverability and traversal
|
|
# control. If you operate on a filesystem with restrictive inherited
|
|
# ACLs that chmod 0750 can't override, document it in your deployment
|
|
# notes, server-access still keeps strangers out, but advertised
|
|
# group members may need ACL-level access to traverse PARTY_SOCKET_DIR.
|
|
# See README §Trust model for the user-facing version of this caveat.
|
|
# WONTFIX, by design.
|
|
|
|
apply_party_perms_file() {
|
|
p="$1"
|
|
chgrp "$TMUX_PARTY_GROUP" "$p"
|
|
chmod g+rw "$p"
|
|
}
|
|
|
|
# Refuse to operate when PARTY_SOCKET_DIR is somewhere an attacker could
|
|
# rename our per-party directory between mkdir and use. Trustworthy is
|
|
# either: sticky (mode +t, like /tmp), or owned by the caller and neither
|
|
# group- nor world-writable. Default /tmp passes by virtue of the sticky
|
|
# bit. Group-writable parents are rejected because the parent's group
|
|
# may differ from TMUX_PARTY_GROUP, in that case its members would be
|
|
# outside our trust perimeter yet able to rename or unlink our per-party
|
|
# directory.
|
|
validate_socket_dir_parent() {
|
|
p="$PARTY_SOCKET_DIR"
|
|
if [ ! -d "$p" ]; then
|
|
echo "party: PARTY_SOCKET_DIR $p does not exist" >&2
|
|
return 1
|
|
fi
|
|
if [ -k "$p" ]; then
|
|
# Sticky bit only protects entries against non-owners; the parent's
|
|
# OWNER can still rename or unlink our per-party dir. Accept sticky
|
|
# only if the parent is owned by us or by root.
|
|
[ -O "$p" ] && return 0
|
|
# shellcheck disable=SC2012 # ls -ldn is portable; find -printf is not
|
|
p_uid=$(ls -ldn "$p" 2>/dev/null | awk '{print $3; exit}')
|
|
[ "$p_uid" = 0 ] && return 0
|
|
fi
|
|
# Two separate -perm checks: POSIX/BSD find uses `+` for "any of",
|
|
# GNU find uses `/`, but both accept `-mode` ("all of these bits set"),
|
|
# so we ask the question twice instead of OR-ing in one expression.
|
|
if [ -O "$p" ] \
|
|
&& [ -z "$(find "$p" -prune -perm -0002 2>/dev/null)" ] \
|
|
&& [ -z "$(find "$p" -prune -perm -0020 2>/dev/null)" ]; then
|
|
return 0
|
|
fi
|
|
cat >&2 <<EOF
|
|
party: refusing to operate in PARTY_SOCKET_DIR=$p
|
|
Parent must be either:
|
|
(a) sticky (mode +t, like /tmp) AND owned by you ($USER) or root, or
|
|
(b) owned by you ($USER) AND not group/world-writable.
|
|
Set PARTY_SOCKET_DIR to a directory meeting one of these.
|
|
EOF
|
|
return 1
|
|
}
|
|
|
|
# Default name when user runs `party host` with no args. Random hex
|
|
# suffix; no hostname involvement (parties are scoped to the host the
|
|
# script runs on, so the user already knows where they are).
|
|
compute_default_party_name() {
|
|
# 3 random bytes as hex. The `|| ...` can't catch a read failure on
|
|
# a pipeline (its exit status is tr's, not od's), so test the result
|
|
# and fall back to the PID when /dev/urandom gave us nothing.
|
|
rand=$(od -An -N3 -tx1 /dev/urandom 2>/dev/null | tr -d ' ')
|
|
[ -n "$rand" ] || rand=$$
|
|
printf 'party-%s\n' "$rand"
|
|
}
|
|
|
|
|
|
# After spawning the party server, configure status line and hooks.
|
|
# All commands run against -S "$sock". Every literal name embedded
|
|
# is shell-quoted to survive tmux's parser.
|
|
tmux_party_setup_server() {
|
|
sock="$1" name="$2"
|
|
|
|
# Write a tiny notification helper next to the socket. The leading dot
|
|
# keeps it out of typical `ls` listings. The per-party private dir
|
|
# hosts at most one socket so a fixed name is safe.
|
|
notify_script="${sock%/*}/.party-notify"
|
|
cat > "$notify_script" <<'EOF'
|
|
#!/bin/sh
|
|
# Auto-generated by party. Fan a display-message out to every client
|
|
# of the party server. Args: $1 = socket, $2 = client_user, $3 = "joined"|"left"
|
|
sock="$1"; user="$2"; verb="$3"
|
|
tmux -S "$sock" list-clients -F '#{client_name}' 2>/dev/null \
|
|
| while read -r c; do
|
|
tmux -S "$sock" display-message -c "$c" "$user $verb the party" 2>/dev/null || :
|
|
done
|
|
EOF
|
|
chmod 0755 "$notify_script"
|
|
# Match the rest of the per-party dir's group ownership so guests'
|
|
# debugging (`ls -l` inside a party dir they can traverse) doesn't
|
|
# surface this one file as the odd `:other` outlier. The script is
|
|
# invoked by tmux run-shell as the host, so guests don't need
|
|
# execute on it; setting the group is purely for consistency.
|
|
chgrp "$TMUX_PARTY_GROUP" "$notify_script"
|
|
|
|
"$PARTY_TMUX" -S "$sock" set-option -s exit-empty on
|
|
"$PARTY_TMUX" -S "$sock" set-option -g status-right \
|
|
"party: #(party who --short --socket $sock 2>/dev/null)"
|
|
# Join/leave broadcast. Hook bodies fan a display-message out to
|
|
# every attached client of THIS server via the notify helper script.
|
|
# tmux substitutes #{client_user} before running the shell command.
|
|
"$PARTY_TMUX" -S "$sock" set-hook -g client-attached \
|
|
"run-shell '$notify_script $sock #{client_user} joined'"
|
|
"$PARTY_TMUX" -S "$sock" set-hook -g client-detached \
|
|
"run-shell '$notify_script $sock #{client_user} left'"
|
|
}
|
|
|
|
# Find the live roster record for a party named "$name". Searches across
|
|
# all hosts. On match, sets RR_*. Returns 0 on found, 1 on not found,
|
|
# 2 on ambiguous (same name from multiple hosts).
|
|
find_party_by_name() {
|
|
target="$1"
|
|
set --
|
|
for rec in $(roster_list); do
|
|
roster_read "$rec" || continue
|
|
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue
|
|
[ "$RR_PARTY_NAME" = "$target" ] && set -- "$@" "$rec"
|
|
done
|
|
case $# in
|
|
0) return 1 ;;
|
|
1) roster_read "$1"; return 0 ;;
|
|
*) echo "party: '$target' is ambiguous (multiple hosts running it)" >&2
|
|
return 2 ;;
|
|
esac
|
|
}
|
|
|
|
# Pick the single live party, or run a numbered prompt.
|
|
pick_live_party() {
|
|
set --
|
|
for rec in $(roster_list); do
|
|
roster_read "$rec" || continue
|
|
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue
|
|
set -- "$@" "$rec"
|
|
done
|
|
case $# in
|
|
0) echo "party: no parties found on this host." >&2; return 1 ;;
|
|
1) roster_read "$1"; return 0 ;;
|
|
esac
|
|
i=0
|
|
for r in "$@"; do
|
|
i=$((i+1))
|
|
roster_read "$r"
|
|
printf '%d) %s, %s\n' "$i" "$RR_HOST_USER" "$RR_PARTY_NAME" >&2
|
|
done
|
|
printf 'Choose: ' >&2
|
|
read -r pick
|
|
case "$pick" in ''|*[!0-9]*)
|
|
echo "party: invalid choice" >&2; return 1 ;;
|
|
esac
|
|
if [ "$pick" -lt 1 ] || [ "$pick" -gt $# ]; then
|
|
echo "party: out of range" >&2; return 1
|
|
fi
|
|
eval "roster_read \"\$$pick\""
|
|
}
|
|
|
|
# Subcommand dispatch
|
|
# ===================
|
|
|
|
cmd_host() {
|
|
name=
|
|
group_arg=
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--group)
|
|
[ $# -ge 2 ] || { echo "party host: --group needs an argument" >&2; exit 2; }
|
|
group_arg="$2"; shift 2 ;;
|
|
--group=*) group_arg="${1#--group=}"; shift ;;
|
|
-h|--help)
|
|
cat <<'EOF'
|
|
Usage: party host [name] [--group <name>]
|
|
name Party name (default: random).
|
|
--group <g> Group used to gate this party. The host must be a
|
|
member; only members can discover or join. Precedence:
|
|
--group flag > $TMUX_PARTY_GROUP env > default 'party'.
|
|
Pick any existing group (e.g. 'staff', 'users', 'wheel');
|
|
no install-time setup needed.
|
|
EOF
|
|
exit 0 ;;
|
|
-*)
|
|
echo "party host: unknown flag '$1'" >&2; exit 2 ;;
|
|
*)
|
|
if [ -z "$name" ]; then name="$1"; shift
|
|
else echo "party host: unexpected arg '$1'" >&2; exit 2; fi
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# Group precedence: --group flag > $TMUX_PARTY_GROUP env > default
|
|
# 'party' (set by the env-defaults block at the top of the file).
|
|
# The chosen group becomes the per-party perimeter: chgrp on the
|
|
# private dir, on the socket, and on the roster. No shared install
|
|
# directory, pick any existing group the host is in.
|
|
if [ -n "$group_arg" ]; then
|
|
TMUX_PARTY_GROUP="$group_arg"
|
|
export TMUX_PARTY_GROUP
|
|
fi
|
|
|
|
require_caller_in_group
|
|
validate_socket_dir_parent || exit 1
|
|
|
|
# A trustworthy parent isn't necessarily a discoverable parent. With
|
|
# mode 0700, hosting and inviting succeed but no other user can
|
|
# traverse PARTY_SOCKET_DIR to glob for parties, `party join` and
|
|
# `party list` will report not-found for everyone but the host.
|
|
# Warn so the user notices before sending invites. Octal 0050 = group
|
|
# r+x; 0005 = other r+x. Non-empty `find -prune -perm -<bits>` means
|
|
# those bits are set on the parent.
|
|
#
|
|
# Known blind spot (WONTFIX for now): we check that *some* group bits
|
|
# are set, not that the parent's group matches TMUX_PARTY_GROUP. With
|
|
# PARTY_SOCKET_DIR mode 0750 owned by user:wheel and TMUX_PARTY_GROUP=party,
|
|
# this branch is skipped (group r+x is set, for wheel members), but
|
|
# party-group members who aren't in wheel still can't traverse. The
|
|
# mismatch self-announces on the first failed `party join`, and a
|
|
# portable name→gid map is awkward in POSIX shell (no getent on macOS,
|
|
# stat flags split BSD/Linux). Default PARTY_SOCKET_DIR=/tmp (1777)
|
|
# never trips this; only custom dirs with restrictive group perms do.
|
|
if [ -z "$(find "$PARTY_SOCKET_DIR" -prune -perm -0050 2>/dev/null)" ] \
|
|
&& [ -z "$(find "$PARTY_SOCKET_DIR" -prune -perm -0005 2>/dev/null)" ]; then
|
|
cat >&2 <<EOF
|
|
party host: warning, PARTY_SOCKET_DIR=$PARTY_SOCKET_DIR is not readable
|
|
or searchable by group/other. Guests will not be able to discover this
|
|
party (party join / party list will report not-found). To make it
|
|
discoverable: chmod g+rx $PARTY_SOCKET_DIR and chgrp $TMUX_PARTY_GROUP
|
|
$PARTY_SOCKET_DIR, or use a parent like /tmp.
|
|
EOF
|
|
fi
|
|
|
|
[ -z "$name" ] && name=$(compute_default_party_name)
|
|
validate_party_name "$name" || exit $?
|
|
|
|
party_dir=$(party_dir_path "$USER" "$name")
|
|
sock="$party_dir/sock"
|
|
rec="$party_dir/roster"
|
|
|
|
# AF_UNIX sun_path is 108 bytes on Linux/illumos and 104 on the BSDs
|
|
# (incl. macOS). The validator caps the name at 63, but the per-party
|
|
# dir adds `party-USER:NAME.d/sock` overhead on top of PARTY_SOCKET_DIR:
|
|
# with a 32-byte USER and a long name this overflows. Bail before
|
|
# bind(2) returns ENAMETOOLONG via tmux. 100 leaves headroom on the
|
|
# tightest platform (104 - NUL - small buffer).
|
|
if [ "${#sock}" -gt 100 ]; then
|
|
cat >&2 <<EOF
|
|
party: socket path too long (${#sock} bytes; AF_UNIX sun_path max ~104).
|
|
$sock
|
|
Try a shorter party name, a shorter PARTY_SOCKET_DIR, or both.
|
|
EOF
|
|
exit 1
|
|
fi
|
|
|
|
# Pre-mkdir trust + liveness checks. We diagnose two cases here for a
|
|
# better error message; everything else (including stale leftovers
|
|
# from crashed prior runs) falls through to the mkdir lock below.
|
|
if [ -L "$party_dir" ] || { [ -e "$party_dir" ] && [ ! -O "$party_dir" ]; }; then
|
|
echo "party: $party_dir exists and is not a directory you own" >&2
|
|
exit 1
|
|
fi
|
|
if [ -d "$party_dir" ] && [ -f "$rec" ]; then
|
|
roster_read "$rec" 2>/dev/null || true
|
|
if is_party_alive "${RR_SERVER_PID:-}" "${RR_SOCKET:-}"; then
|
|
echo "party: '$name' is already running (pid $RR_SERVER_PID)" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# mkdir is the atomic acquisition primitive. POSIX mkdir(2) on an
|
|
# existing path returns EEXIST without modifying anything, so it
|
|
# serializes concurrent `party host <same-name>` cleanly: exactly one
|
|
# process owns the directory, the rest see EEXIST and bail.
|
|
#
|
|
# We deliberately do NOT auto-clean leftover dirs (e.g. from a prior
|
|
# SIGKILLed cmd_host that couldn't run its rollback trap). Auto-cleanup
|
|
# races with a concurrent in-flight setup: two processes both seeing a
|
|
# stale dir would both rm -rf it, and the loser's late rm -rf would
|
|
# wipe the winner's freshly-created replacement.
|
|
# The recovery path for genuine crash leftovers is a one-shot manual
|
|
# `rm -rf $party_dir` followed by retry; the error message says so.
|
|
if ! mkdir -m 0700 "$party_dir" 2>/dev/null; then
|
|
cat >&2 <<EOF
|
|
party: $party_dir already exists. Either another host is starting '$name'
|
|
right now, or a prior crashed attempt left this directory behind. If no
|
|
other host is in flight, remove the directory and retry:
|
|
rm -rf $party_dir
|
|
EOF
|
|
exit 1
|
|
fi
|
|
|
|
# Catches symlink-swap between mkdir and use on non-sticky parents.
|
|
# We own the dir at this point, so it's safe to rm -rf on failure.
|
|
if [ -L "$party_dir" ] || [ ! -O "$party_dir" ]; then
|
|
rm -rf "$party_dir"
|
|
echo "party: $party_dir failed post-mkdir trust check" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Arm rollback ONLY after mkdir succeeded. Earlier (before mkdir),
|
|
# arming the trap would let a concurrent host attempt that lost the
|
|
# mkdir race tear down the winner's directory and tmux server via
|
|
# set -e + EXIT trap. Now the trap fires only on resources this
|
|
# process actually created. Disarmed once roster_write commits.
|
|
trap '
|
|
"$PARTY_TMUX" -S "$sock" kill-server 2>/dev/null || true
|
|
rm -rf "$party_dir"
|
|
' EXIT INT TERM HUP
|
|
|
|
chgrp "$TMUX_PARTY_GROUP" "$party_dir"
|
|
chmod 0750 "$party_dir"
|
|
|
|
# The per-party dir is now group-traversable (0750), but the tmux
|
|
# socket from `new-session` below lands at 0600, tmux's server.c
|
|
# calls umask(S_IXUSR|S_IRWXG|S_IRWXO) before bind(2), so group/other
|
|
# bits are stripped regardless of our umask or process group. Group
|
|
# members can stat the socket but cannot connect() until
|
|
# apply_party_perms_file widens it AFTER server-access -aw runs.
|
|
"$PARTY_TMUX" -S "$sock" new-session -d -s "$name"
|
|
|
|
tmux_party_setup_server "$sock" "$name"
|
|
|
|
# Auth gate FIRST, server-access runs BEFORE we widen FS perms.
|
|
# This closes the connect-before-allowlist race: without this order,
|
|
# a group member could connect() the socket between chmod g+rw and
|
|
# server-access -a, and tmux would honor an in-flight connection
|
|
# that wasn't yet on the allowlist.
|
|
|
|
# Capability probe. server-access requires tmux ≥ 3.3; without it
|
|
# the auth gate doesn't exist and the FS gate alone would let any
|
|
# group member attach. README states the version requirement, but
|
|
# silently swallowing errors here would degrade the gate to nothing
|
|
# under version skew. Refuse to host
|
|
# rather than ship an unenforced auth layer; the rollback trap
|
|
# tears down the dedicated tmux server we just started.
|
|
if ! "$PARTY_TMUX" -S "$sock" server-access -l >/dev/null 2>&1; then
|
|
ver=$("$PARTY_TMUX" -V 2>/dev/null || echo "?")
|
|
echo "party: tmux ($ver) does not support 'server-access' (requires tmux ≥ 3.3)." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Add host to allowlist. tmux refuses to modify the server owner's
|
|
# own ACL entry ("owns the server, can't change access"), the owner
|
|
# has implicit write access, so that specific message is benign
|
|
# idempotency. Anything else is a real failure: rollback fires.
|
|
err=$("$PARTY_TMUX" -S "$sock" server-access -aw "$USER" 2>&1) || true
|
|
case "$err" in
|
|
'' | *"owns the server"* | *"can't change access"* ) ;;
|
|
*) echo "party: server-access -aw $USER failed: $err" >&2; exit 1 ;;
|
|
esac
|
|
|
|
# FS gate AFTER the access list is populated. The per-party private
|
|
# directory was already chgrp'd + chmod 0750 above, so only the
|
|
# socket itself needs widening.
|
|
apply_party_perms_file "$sock"
|
|
|
|
pid=$("$PARTY_TMUX" -S "$sock" display-message -p '#{pid}')
|
|
|
|
roster_write "$rec" \
|
|
HOST_USER "$USER" \
|
|
PARTY_NAME "$name" \
|
|
SOCKET "$sock" \
|
|
SERVER_PID "$pid" \
|
|
GROUP "$TMUX_PARTY_GROUP" \
|
|
CREATED "$(iso_now)"
|
|
|
|
# Party committed. Disarm rollback, from here on, a failure (e.g.
|
|
# chgrp on the roster) leaves a fully-formed party that the host can
|
|
# tear down explicitly with `party close`. Tearing it down on the
|
|
# caller's behalf for a stray permission warning would be worse.
|
|
trap - EXIT INT TERM HUP
|
|
|
|
# Roster: chgrp only. roster_write already left mode 0640 (g+r, no g+w).
|
|
# Guests read records to discover/join; only the host writes them. Keeping
|
|
# them non-group-writable means a tampered SOCKET=... can't redirect
|
|
# cmd_close's destructive cleanup at peer-supplied paths.
|
|
chgrp "$TMUX_PARTY_GROUP" "$rec"
|
|
|
|
if [ -z "${TMUX:-}" ] && [ -t 1 ]; then
|
|
echo "Party '$name' started. Attaching..."
|
|
exec "$PARTY_TMUX" -S "$sock" attach -t "$name"
|
|
fi
|
|
echo "Party '$name' started. Attach from a fresh shell with: party join $name"
|
|
}
|
|
|
|
cmd_close() {
|
|
name=
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-h|--help)
|
|
echo "Usage: party close [name]"; exit 0 ;;
|
|
-*) echo "party close: unknown flag '$1'" >&2; exit 2 ;;
|
|
*)
|
|
if [ -z "$name" ]; then name="$1"; shift
|
|
else echo "party close: unexpected arg '$1'" >&2; exit 2; fi
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [ -z "$name" ]; then
|
|
# No name → close the only party we host, if exactly one.
|
|
# Filter forged dirs (symlinks or non-caller-owned) before
|
|
# trusting HOST_USER so another local user can't seed a fake
|
|
# party-$USER:junk.d/roster and turn this into a "multiple
|
|
# parties" DoS.
|
|
set --
|
|
for rec in $(roster_list); do
|
|
d="${rec%/roster}"
|
|
[ -L "$d" ] && continue
|
|
[ -O "$d" ] || continue
|
|
roster_read "$rec" || continue
|
|
[ "$RR_HOST_USER" = "$USER" ] || continue
|
|
set -- "$@" "$RR_PARTY_NAME"
|
|
done
|
|
case $# in
|
|
0) echo "party close: you are not hosting any party." >&2; exit 1 ;;
|
|
1) name="$1" ;;
|
|
*) echo "party close: you host multiple parties; pass a name:" >&2
|
|
echo " $*" >&2; exit 1 ;;
|
|
esac
|
|
fi
|
|
|
|
# Parity with cmd_host's validate_party_name call: reject path
|
|
# separators, whitespace, shell metas, oversized input before path
|
|
# derivation.
|
|
# Hygiene, not the security gate; [ -O ] below is.
|
|
validate_party_name "$name" || exit $?
|
|
|
|
expected_dir=$(party_dir_path "$USER" "$name")
|
|
expected_sock="$expected_dir/sock"
|
|
rec="$expected_dir/roster"
|
|
|
|
# Refuse before any tmux work if the per-party dir isn't unambiguously
|
|
# ours. With a sticky parent (e.g. /tmp), another local user can
|
|
# pre-create party-$USER:$name.d as a symlink before we ever host;
|
|
# without this gate, kill-server would fire against a forged socket
|
|
# path and rm -rf would chase the symlink.
|
|
if [ -L "$expected_dir" ]; then
|
|
echo "party close: $expected_dir is a symlink; refusing." >&2
|
|
exit 1
|
|
fi
|
|
if [ ! -d "$expected_dir" ]; then
|
|
echo "party close: no party named '$name'." >&2; exit 1
|
|
fi
|
|
if [ ! -O "$expected_dir" ]; then
|
|
echo "party close: $expected_dir is not yours; refusing." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Trust anchor (deliberate): paths are derived
|
|
# from $USER + $name and gated on [ -L ]/[ -O ]. mkdir(2) sets owner
|
|
# to caller's euid; chown requires root; so a same-group attacker
|
|
# can't forge a dir that satisfies [ -O ] for our $USER. We do NOT
|
|
# consult the roster file here; file content is advisory in this
|
|
# codebase, never trusted for destructive operations.
|
|
# PARTY_SOCKET_DIR is caller-controlled env, not a privilege
|
|
# boundary, so we don't canonicalize it.
|
|
"$PARTY_TMUX" -S "$expected_sock" kill-server 2>/dev/null || true
|
|
rm -rf "$expected_dir"
|
|
echo "Party '$name' closed."
|
|
}
|
|
cmd_join() {
|
|
passive=0
|
|
name=
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--passive) passive=1; shift ;;
|
|
-h|--help)
|
|
cat <<'EOF'
|
|
Usage: party join [name] [--passive]
|
|
name Party to join. Auto-picks if exactly one is running.
|
|
--passive Attach read-only to the host's session (mirrored view)
|
|
rather than your own guest session. Switch later with
|
|
`party role` (note: role changes are session-level, not a
|
|
read-only boundary, see `party role --help`).
|
|
EOF
|
|
exit 0 ;;
|
|
-*) echo "party join: unknown flag '$1'" >&2; exit 2 ;;
|
|
*)
|
|
if [ -z "$name" ]; then name="$1"; shift
|
|
else echo "party join: unexpected arg '$1'" >&2; exit 2; fi
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [ -n "${TMUX:-}" ]; then
|
|
echo "party join: detach from your current tmux first." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ -n "$name" ]; then
|
|
# Capture rc explicitly: under set -e a bare `find_party_by_name`
|
|
# returning 1 or 2 would exit before the case ran, swallowing the
|
|
# error message.
|
|
find_party_by_name "$name" && rc=0 || rc=$?
|
|
case $rc in
|
|
0) ;;
|
|
1) echo "party: '$name' not found." >&2; exit 1 ;;
|
|
2) exit 1 ;; # find_party_by_name already printed the ambiguity hint
|
|
esac
|
|
else
|
|
pick_live_party || exit 1
|
|
fi
|
|
|
|
# Group gate. Check membership against the party's *recorded* group
|
|
# (RR_GROUP), the host may have hosted under --group <other>. The
|
|
# FS perimeter on the per-party dir already blocks non-group readers
|
|
# at roster_list; this preflight just turns that into a friendlier
|
|
# error than "party not found" when membership is the actual cause.
|
|
party_group="${RR_GROUP:-$TMUX_PARTY_GROUP}"
|
|
if ! id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$party_group"; then
|
|
cat >&2 <<EOF
|
|
party: '$RR_PARTY_NAME' uses group '$party_group'; you ($USER) are not a member.
|
|
|
|
Ask the host or your sysadmin to add you, then log out and back in.
|
|
EOF
|
|
exit 1
|
|
fi
|
|
|
|
# Auth gate. The joiner must already be on the access list (added
|
|
# by the host via `party invite`). Pre-flight here so the user gets
|
|
# a helpful message instead of an opaque tmux refusal.
|
|
if ! "$PARTY_TMUX" -S "$RR_SOCKET" server-access -l 2>/dev/null \
|
|
| awk '{print $1}' | grep -qx "$USER"; then
|
|
echo "party: '$RR_PARTY_NAME', ask $RR_HOST_USER to invite you." >&2
|
|
exit 13
|
|
fi
|
|
|
|
if [ "$passive" = 1 ]; then
|
|
# Mirror the host's pane focus, read-only at the tmux client
|
|
# layer. `-r` is the watcher boundary on the bootstrap path: a
|
|
# write-capable invitee who chose --passive cannot send keys
|
|
# into the host's panes regardless of their server-access ACL
|
|
# entry. Role switches done later via `party role` do NOT carry
|
|
# this flag (tmux switch-client cannot set read-only state),
|
|
# so role-switching is documented as session-level only, not
|
|
# a watcher boundary.
|
|
attach_cmd="$PARTY_TMUX -S $RR_SOCKET attach-session -r -t $RR_PARTY_NAME"
|
|
else
|
|
# Active: own session group with an independent window cursor.
|
|
# An active guest session survives a client detach, so create it
|
|
# only if it isn't already there, a plain new-session on re-join
|
|
# would hit "duplicate session" and abort under set -e.
|
|
#
|
|
# Reusing by name is an accepted insider risk, not a defended one:
|
|
# any write-capable invitee can pre-create a session called
|
|
# __party_guest_$USER (and even group it to the party), so on a
|
|
# shared server no session name is unforgeable. We trust invitees
|
|
# by design, that's the whole model. A read-only invitee (-r)
|
|
# can't create sessions, so it's not exposed. See README §Trust
|
|
# model.
|
|
guest="__party_guest_$USER"
|
|
"$PARTY_TMUX" -S "$RR_SOCKET" has-session -t "$guest" 2>/dev/null \
|
|
|| "$PARTY_TMUX" -S "$RR_SOCKET" new-session -d -t "$RR_PARTY_NAME" -s "$guest"
|
|
attach_cmd="$PARTY_TMUX -S $RR_SOCKET attach-session -t $guest"
|
|
fi
|
|
|
|
if [ "${PARTY_DRY_RUN:-0}" = 1 ]; then
|
|
echo "$attach_cmd"
|
|
return 0
|
|
fi
|
|
# shellcheck disable=SC2086
|
|
exec $attach_cmd
|
|
}
|
|
cmd_leave() {
|
|
case "${1:-}" in
|
|
-h|--help) echo "Usage: party leave"; exit 0 ;;
|
|
esac
|
|
|
|
# Walk every live party; for each one we have any presence on
|
|
# (either via __party_guest_$USER or as a client of the host's
|
|
# session in passive mode), kill our guest session and detach
|
|
# all our clients.
|
|
found=0
|
|
for rec in $(roster_list); do
|
|
roster_read "$rec" || continue
|
|
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue
|
|
attached=0
|
|
"$PARTY_TMUX" -S "$RR_SOCKET" has-session -t "__party_guest_$USER" 2>/dev/null \
|
|
&& attached=1
|
|
"$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_user}' 2>/dev/null \
|
|
| grep -qx "$USER" && attached=1
|
|
[ "$attached" = 1 ] || continue
|
|
_disconnect_user "$USER"
|
|
found=$((found+1))
|
|
done
|
|
|
|
[ "$found" -gt 0 ] || { echo "party: not joined to any party." >&2; exit 1; }
|
|
}
|
|
# Resolve the party the caller hosts. If exactly one matches, sets RR_*;
|
|
# if zero or multiple, error. Argument: optional party name to disambiguate.
|
|
resolve_authoritative_party() {
|
|
target="$1"
|
|
set --
|
|
for rec in $(roster_list); do
|
|
# Host-only trust: gate before reading the roster so a forged
|
|
# party-$USER:fake.d planted by another local user (symlink, or
|
|
# a real dir owned by them) cannot drive HOST_USER=$USER through
|
|
# this walker.
|
|
d="${rec%/roster}"
|
|
[ -L "$d" ] && continue
|
|
[ -O "$d" ] || continue
|
|
roster_read "$rec" || continue
|
|
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue
|
|
[ "$RR_HOST_USER" = "$USER" ] || continue
|
|
[ -z "$target" ] || [ "$target" = "$RR_PARTY_NAME" ] || continue
|
|
set -- "$@" "$rec"
|
|
done
|
|
case $# in
|
|
0) if [ -n "$target" ]; then
|
|
echo "party: '$target' not found, or you are not its host." >&2
|
|
else
|
|
echo "party: you do not host any active party." >&2
|
|
fi
|
|
return 1 ;;
|
|
1) roster_read "$1"; return 0 ;;
|
|
*) echo "party: multiple parties match, pass a name." >&2
|
|
return 1 ;;
|
|
esac
|
|
}
|
|
|
|
cmd_invite() {
|
|
user='' readonly=0 partyname=''
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-r|--read-only) readonly=1; shift ;;
|
|
--party)
|
|
[ $# -ge 2 ] || { echo "party invite: --party needs an argument" >&2; exit 2; }
|
|
partyname="$2"; shift 2 ;;
|
|
-h|--help)
|
|
echo "Usage: party invite <user> [-r] [--party name]"; exit 0 ;;
|
|
-*) echo "party invite: unknown flag '$1'" >&2; exit 2 ;;
|
|
*)
|
|
if [ -z "$user" ]; then user="$1"; shift
|
|
else echo "party invite: unexpected arg '$1'" >&2; exit 2; fi
|
|
;;
|
|
esac
|
|
done
|
|
|
|
[ -n "$user" ] || { echo "Usage: party invite <user>" >&2; exit 2; }
|
|
user_exists "$user" || { echo "party invite: no such user '$user'" >&2; exit 1; }
|
|
|
|
resolve_authoritative_party "$partyname" || exit 1
|
|
|
|
# The auth gate (server-access) accepts any user, but the FS gate
|
|
# (per-party dir mode 0750, group=$RR_GROUP) won't let a non-member
|
|
# traverse in. Without this warning, the host sees "Invited X" and
|
|
# the guest sees a write(1) ping, but `party join` reports not-found.
|
|
# Warn-and-proceed (rather than hard-fail) so a later usermod -aG
|
|
# doesn't require a re-invite.
|
|
invite_group="${RR_GROUP:-$TMUX_PARTY_GROUP}"
|
|
if ! id -nG "$user" 2>/dev/null | tr ' ' '\n' | grep -qx "$invite_group"; then
|
|
cat >&2 <<EOF
|
|
party invite: warning, '$user' is not in group '$invite_group'.
|
|
They will not be able to discover or join until added.
|
|
Run: sudo usermod -aG $invite_group $user (and have them relog).
|
|
EOF
|
|
fi
|
|
|
|
if [ "$readonly" = 1 ]; then
|
|
"$PARTY_TMUX" -S "$RR_SOCKET" server-access -ar "$user"
|
|
else
|
|
"$PARTY_TMUX" -S "$RR_SOCKET" server-access -aw "$user"
|
|
fi
|
|
|
|
# Best-effort guest-side ping. Ignore failure (write(1) may be absent).
|
|
write "$user" 2>/dev/null <<EOF || :
|
|
$USER invited you to a party named '$RR_PARTY_NAME'.
|
|
Run: party join $RR_PARTY_NAME
|
|
EOF
|
|
|
|
echo "Invited $user to '$RR_PARTY_NAME'."
|
|
}
|
|
|
|
# Toggle a user's read/write status. $1 = subcommand label (for usage
|
|
# and the success message), $2 = server-access flag (-w voice, -r mute),
|
|
# $3 = past-participle for the message, $4 = $user.
|
|
_set_user_access() {
|
|
label="$1" flag="$2" verb="$3" user="$4"
|
|
[ -n "$user" ] || { echo "Usage: party $label <user>" >&2; exit 2; }
|
|
resolve_authoritative_party "" || exit 1
|
|
"$PARTY_TMUX" -S "$RR_SOCKET" server-access "$flag" "$user"
|
|
echo "$verb $user."
|
|
}
|
|
|
|
cmd_voice() { _set_user_access voice -w Voiced "${1:-}"; }
|
|
cmd_mute() { _set_user_access mute -r Muted "${1:-}"; }
|
|
|
|
# Disconnect every client of $user on the resolved party socket and kill
|
|
# their guest session if present. Used by both cmd_kick and cmd_detach.
|
|
_disconnect_user() {
|
|
user="$1"
|
|
"$PARTY_TMUX" -S "$RR_SOCKET" kill-session -t "__party_guest_$user" 2>/dev/null || true
|
|
"$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_name} #{client_user}' 2>/dev/null \
|
|
| while read -r cname cuser; do
|
|
[ "$cuser" = "$user" ] || continue
|
|
"$PARTY_TMUX" -S "$RR_SOCKET" detach-client -t "$cname" 2>/dev/null || true
|
|
done
|
|
}
|
|
|
|
cmd_kick() {
|
|
user="${1:-}"
|
|
[ -n "$user" ] || { echo "Usage: party kick <user>" >&2; exit 2; }
|
|
resolve_authoritative_party "" || exit 1
|
|
"$PARTY_TMUX" -S "$RR_SOCKET" server-access -d "$user" 2>/dev/null || true
|
|
_disconnect_user "$user"
|
|
echo "Kicked $user."
|
|
}
|
|
|
|
cmd_detach() {
|
|
user="${1:-}"
|
|
[ -n "$user" ] || { echo "Usage: party detach <user>" >&2; exit 2; }
|
|
resolve_authoritative_party "" || exit 1
|
|
_disconnect_user "$user"
|
|
echo "Detached $user (still on allowlist)."
|
|
}
|
|
|
|
cmd_list() {
|
|
found=0
|
|
for rec in $(roster_list); do
|
|
roster_read "$rec" || continue
|
|
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue
|
|
# Output hygiene, NOT a confidentiality control. If inherited ACLs
|
|
# widened the per-party dir past its 0750 / group=RR_GROUP mode bits,
|
|
# roster_read above has ALREADY parsed host/name/group into vars,
|
|
# and a determined reader would just cat the roster file directly,
|
|
# never touching `party list`. So this re-check does not close the
|
|
# metadata leak; it only stops `party list` from DISPLAYING parties
|
|
# whose group the caller isn't in. Confidentiality still rides on
|
|
# the FS perimeter alone.
|
|
list_group="${RR_GROUP:-$TMUX_PARTY_GROUP}"
|
|
id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$list_group" || continue
|
|
# Stale records (dead PID, dead socket, planted daemon) are
|
|
# filtered out by is_party_alive. They're not auto-cleaned here:
|
|
# cmd_host's mkdir-as-lock refuses to take over leftover dirs
|
|
# (auto-cleanup races with concurrent in-flight hosts). Recovery
|
|
# for a genuine crash leftover is a one-shot
|
|
# manual `rm -rf` per the host-side error message.
|
|
attendees=$("$PARTY_TMUX" -S "$RR_SOCKET" list-clients 2>/dev/null | wc -l | tr -d ' ')
|
|
# Show the group only when it differs from the env/default, to
|
|
# keep the common case uncluttered.
|
|
group_tag=
|
|
if [ -n "$RR_GROUP" ] && [ "$RR_GROUP" != "$TMUX_PARTY_GROUP" ]; then
|
|
group_tag=" [group=$RR_GROUP]"
|
|
fi
|
|
printf '%-12s %-30s %s attendee(s)%s\n' \
|
|
"$RR_HOST_USER" "$RR_PARTY_NAME" "$attendees" "$group_tag"
|
|
found=$((found+1))
|
|
done
|
|
|
|
if [ "$found" -eq 0 ]; then
|
|
echo "no parties found on this host."
|
|
fi
|
|
}
|
|
|
|
cmd_who() {
|
|
short=0 partyname='' sockarg=''
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--short) short=1; shift ;;
|
|
--party)
|
|
[ $# -ge 2 ] || { echo "party who: --party needs an argument" >&2; exit 2; }
|
|
partyname="$2"; shift 2 ;;
|
|
--socket)
|
|
[ $# -ge 2 ] || { echo "party who: --socket needs an argument" >&2; exit 2; }
|
|
sockarg="$2"; shift 2 ;;
|
|
-h|--help)
|
|
echo "Usage: party who [--short] [--party name|--socket path]"
|
|
exit 0 ;;
|
|
-*) echo "party who: unknown flag '$1'" >&2; exit 2 ;;
|
|
*) partyname="$1"; shift ;;
|
|
esac
|
|
done
|
|
|
|
if [ -n "$sockarg" ]; then
|
|
# Status-line widget path: derive host/name from the per-party
|
|
# dir basename ${PARTY_SOCKET_DIR}/party-${USER}:${NAME}.d/sock.
|
|
sock="$sockarg"
|
|
parent="${sock%/*}"; parent="${parent##*/}"
|
|
case "$parent" in
|
|
party-*:*.d) ;;
|
|
*) echo "party who: '$sockarg' is not a party socket" >&2; exit 2 ;;
|
|
esac
|
|
stripped="${parent#party-}"; stripped="${stripped%.d}"
|
|
# Seed RR_HOST_USER / RR_PARTY_NAME from the basename before
|
|
# roster_read so the non-short output path never hits an unbound
|
|
# var under set -u when the roster file is missing or unreadable.
|
|
# roster_read overwrites these with the same values on success.
|
|
RR_HOST_USER="${stripped%%:*}"
|
|
RR_PARTY_NAME="${stripped#*:}"
|
|
rec=$(roster_record_path "$RR_HOST_USER" "$RR_PARTY_NAME")
|
|
roster_read "$rec" 2>/dev/null || true
|
|
elif [ -n "$partyname" ]; then
|
|
find_party_by_name "$partyname" || exit 1
|
|
else
|
|
resolve_authoritative_party "" || exit 1
|
|
fi
|
|
|
|
sock="${sock:-$RR_SOCKET}"
|
|
[ -S "$sock" ] || { echo "party who: socket gone" >&2; exit 1; }
|
|
|
|
invited=$("$PARTY_TMUX" -S "$sock" server-access -l 2>/dev/null \
|
|
| awk '{print $1}' | sort -u)
|
|
attached=$("$PARTY_TMUX" -S "$sock" list-clients -F '#{client_user}' 2>/dev/null \
|
|
| sort -u)
|
|
|
|
if [ "$short" = 1 ]; then
|
|
# shellcheck disable=SC2086 # word-split intentional
|
|
set -- $attached
|
|
nicks=$(printf '%s,' "$@" | sed 's/,$//')
|
|
printf 'party: %d (%s)\n' "$#" "$nicks"
|
|
return
|
|
fi
|
|
|
|
echo "Party '$RR_PARTY_NAME' (host: $RR_HOST_USER)"
|
|
echo "Invited:"
|
|
for u in $invited; do printf ' %s\n' "$u"; done
|
|
echo "Attached:"
|
|
if [ -z "$attached" ]; then echo " (none)"
|
|
else for u in $attached; do printf ' %s\n' "$u"; done; fi
|
|
}
|
|
|
|
cmd_status() {
|
|
case "${1:-}" in
|
|
-h|--help) echo "Usage: party status"; exit 0 ;;
|
|
esac
|
|
|
|
hosting='' joined=''
|
|
for rec in $(roster_list); do
|
|
roster_read "$rec" || continue
|
|
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue
|
|
if [ "$RR_HOST_USER" = "$USER" ]; then
|
|
hosting="$hosting $RR_PARTY_NAME"
|
|
fi
|
|
# Joined covers two cases. Passive: we have a client attached to
|
|
# the host's session, list-clients sees it. Active: we have our
|
|
# own __party_guest_$USER session, which survives a client detach
|
|
# (party leave will still find and clean it up). OR the two so a
|
|
# detached active guest still reports as joined.
|
|
if "$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_user}' \
|
|
2>/dev/null | grep -qx "$USER" \
|
|
|| "$PARTY_TMUX" -S "$RR_SOCKET" has-session \
|
|
-t "__party_guest_$USER" 2>/dev/null
|
|
then
|
|
joined="$joined $RR_PARTY_NAME"
|
|
fi
|
|
done
|
|
[ -n "$hosting$joined" ] || { echo "not in any party."; return 0; }
|
|
if [ -n "$hosting" ]; then echo "hosting:$hosting"; fi
|
|
if [ -n "$joined" ]; then echo "joined: $joined"; fi
|
|
}
|
|
|
|
# Locate the party socket where the calling user has at least one
|
|
# attached client. Sets RR_* on success. Used by cmd_role.
|
|
cmd_role() {
|
|
sub="${1:-}"
|
|
case "$sub" in
|
|
-h|--help)
|
|
cat <<'EOF'
|
|
Usage: party role [active|passive|switch]
|
|
(no arg) Print each of your client sessions and its role.
|
|
active Move all your clients to your own guest session.
|
|
passive Move all your clients to the host's session (mirrored view).
|
|
switch Per-client toggle between active and passive.
|
|
|
|
Note: role changes flip the session your client is attached to, but
|
|
do not change its read-only state. The watcher boundary is set at
|
|
attach time by `party join --passive` (or by `party invite -r`); it
|
|
can't be toggled later via `party role`.
|
|
EOF
|
|
exit 0 ;;
|
|
''|active|passive|switch) ;;
|
|
*) echo "party role: unknown role '$sub'" >&2; exit 2 ;;
|
|
esac
|
|
|
|
# Find the party we should target. When $TMUX is set the caller is
|
|
# currently inside a tmux session, its first comma-separated field
|
|
# is the active socket path, which uniquely identifies the party.
|
|
# Without that, fall back to "any party where the caller has a
|
|
# client" (correct when the caller is attached to exactly one).
|
|
sock=''
|
|
host_sess=''
|
|
if [ -n "${TMUX:-}" ]; then
|
|
tmux_sock="${TMUX%%,*}"
|
|
for rec in $(roster_list); do
|
|
roster_read "$rec" || continue
|
|
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue
|
|
[ "$RR_SOCKET" = "$tmux_sock" ] || continue
|
|
sock="$RR_SOCKET"; host_sess="$RR_PARTY_NAME"; break
|
|
done
|
|
fi
|
|
if [ -z "$sock" ]; then
|
|
for rec in $(roster_list); do
|
|
roster_read "$rec" || continue
|
|
is_party_alive "$RR_SERVER_PID" "$RR_SOCKET" || continue
|
|
"$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_user}' 2>/dev/null \
|
|
| grep -qx "$USER" || continue
|
|
sock="$RR_SOCKET"; host_sess="$RR_PARTY_NAME"; break
|
|
done
|
|
fi
|
|
[ -n "$sock" ] || { echo "party role: not attached to any party." >&2; exit 1; }
|
|
guest="__party_guest_$USER"
|
|
|
|
# active and switch may switch a client INTO the guest session; create
|
|
# it on demand. passive and the no-arg print path don't need it.
|
|
case "$sub" in
|
|
active|switch)
|
|
"$PARTY_TMUX" -S "$sock" has-session -t "$guest" 2>/dev/null \
|
|
|| "$PARTY_TMUX" -S "$sock" new-session -d -t "$host_sess" -s "$guest"
|
|
;;
|
|
esac
|
|
|
|
"$PARTY_TMUX" -S "$sock" list-clients \
|
|
-F '#{client_name} #{client_user} #{client_session}' 2>/dev/null \
|
|
| while read -r cname cuser csess; do
|
|
[ "$cuser" = "$USER" ] || continue
|
|
case "$sub" in
|
|
'')
|
|
case "$csess" in
|
|
"$guest") printf '%s: active\n' "$cname" ;;
|
|
"$host_sess") printf '%s: passive\n' "$cname" ;;
|
|
*) printf '%s: %s\n' "$cname" "$csess" ;;
|
|
esac ;;
|
|
active) target="$guest" ;;
|
|
passive) target="$host_sess" ;;
|
|
switch)
|
|
if [ "$csess" = "$host_sess" ]; then target="$guest"
|
|
else target="$host_sess"; fi ;;
|
|
esac
|
|
if [ -n "$sub" ]; then
|
|
"$PARTY_TMUX" -S "$sock" switch-client -c "$cname" -t "$target" 2>/dev/null || true
|
|
fi
|
|
done
|
|
}
|
|
|
|
dispatch() {
|
|
[ $# -eq 0 ] && { usage >&2; exit 2; }
|
|
sub="$1"; shift
|
|
case "$sub" in
|
|
-h|--help) usage; exit 0 ;;
|
|
--version) echo "party $PARTY_VERSION"; exit 0 ;;
|
|
host) cmd_host "$@" ;;
|
|
close) cmd_close "$@" ;;
|
|
join) cmd_join "$@" ;;
|
|
leave) cmd_leave "$@" ;;
|
|
invite) cmd_invite "$@" ;;
|
|
voice|rw) cmd_voice "$@" ;;
|
|
mute|ro) cmd_mute "$@" ;;
|
|
kick) cmd_kick "$@" ;;
|
|
detach) cmd_detach "$@" ;;
|
|
role) cmd_role "$@" ;;
|
|
list) cmd_list "$@" ;;
|
|
who) cmd_who "$@" ;;
|
|
status) cmd_status "$@" ;;
|
|
*)
|
|
echo "party: unknown subcommand '$sub'" >&2
|
|
usage >&2
|
|
exit 2 ;;
|
|
esac
|
|
}
|
|
|
|
# Entrypoint
|
|
# ==========
|
|
|
|
if [ "${__PARTY_LIB_ONLY:-0}" != "1" ]; then
|
|
dispatch "$@"
|
|
fi
|