hooks: add permission filtering, argv-prepend;

hooks can be restricted to users with certain permissions, for example
`--xm aw,notify-send` will only `notify-send` if user has write-access

the user's list of permissions are now also included in the json
that is passed to the hook if enabled; `--xm aw,j,notify-send`

will now also stop parsing flags when encountering a blank value,
allowing to specify any initial arguments to the command:
`--xm aw,j,,notify-send,hey` would run `notify-send` with `hey`
as its first argument, and the json would be the 2nd argument,
similarly `--xm ,notify-send,hey` when no flags specified

this is somewhat explained in `--help-hooks`, but
additional related features are planned in the near future
and will all be better documented when the dust settles
This commit is contained in:
ed 2024-07-16 04:45:02 +00:00
parent 84e8e1ddfb
commit d749683d48
9 changed files with 121 additions and 27 deletions

View file

@ -634,12 +634,12 @@ def get_sects():
\033[36mxban\033[35m executes CMD if someone gets banned \033[36mxban\033[35m executes CMD if someone gets banned
\033[0m \033[0m
can be defined as --args or volflags; for example \033[36m can be defined as --args or volflags; for example \033[36m
--xau notify-send --xau foo.py
-v .::r:c,xau=notify-send -v .::r:c,xau=bar.py
\033[0m \033[0m
commands specified as --args are appended to volflags; hooks specified as commandline --args are appended to volflags;
each --arg and volflag can be specified multiple times, each commandline --arg and volflag can be specified multiple times,
each command will execute in order unless one returns non-zero each hook will execute in order unless one returns non-zero
optionally prefix the command with comma-sep. flags similar to -mtp: optionally prefix the command with comma-sep. flags similar to -mtp:
@ -650,6 +650,10 @@ def get_sects():
\033[36mtN\033[35m sets an N sec timeout before the command is abandoned \033[36mtN\033[35m sets an N sec timeout before the command is abandoned
\033[36miN\033[35m xiu only: volume must be idle for N sec (default = 5) \033[36miN\033[35m xiu only: volume must be idle for N sec (default = 5)
\033[36mar\033[35m only run hook if user has read-access
\033[36marw\033[35m only run hook if user has read-write-access
\033[36marwmd\033[35m ...and so on... (doesn't work for xiu or xban)
\033[36mkt\033[35m kills the entire process tree on timeout (default), \033[36mkt\033[35m kills the entire process tree on timeout (default),
\033[36mkm\033[35m kills just the main process \033[36mkm\033[35m kills just the main process
\033[36mkn\033[35m lets it continue running until copyparty is terminated \033[36mkn\033[35m lets it continue running until copyparty is terminated
@ -659,6 +663,21 @@ def get_sects():
\033[36mc2\033[35m show only stdout \033[36mc2\033[35m show only stdout
\033[36mc3\033[35m mute all process otput \033[36mc3\033[35m mute all process otput
\033[0m \033[0m
examples:
\033[36m--xm some.py\033[35m runs \033[33msome.py msgtxt\033[35m on each 📟 message;
\033[33mmsgtxt\033[35m is the message that was written into the web-ui
\033[36m--xm j,some.py\033[35m runs \033[33msome.py jsontext\033[35m on each 📟 message;
\033[33mjsontext\033[35m is the message info (ip, user, ..., msg-text)
\033[36m--xm aw,j,some.py\033[35m requires user to have write-access
\033[36m--xm aw,,notify-send,hey,--\033[35m shows an OS alert on linux;
the \033[33m,,\033[35m stops copyparty from reading the rest as flags and
the \033[33m--\033[35m stops notify-send from reading the message as args
and the alert will be "hey" followed by the messagetext
\033[0m
each hook is executed once for each event, except for \033[36mxiu\033[0m each hook is executed once for each event, except for \033[36mxiu\033[0m
which builds up a backlog of uploads, running the hook just once which builds up a backlog of uploads, running the hook just once
as soon as the volume has been idle for iN seconds (5 by default) as soon as the volume has been idle for iN seconds (5 by default)
@ -685,7 +704,10 @@ def get_sects():
\033[36mstash\033[35m dumps the data to file and returns length + checksum \033[36mstash\033[35m dumps the data to file and returns length + checksum
\033[36msave,get\033[35m dumps to file and returns the page like a GET \033[36msave,get\033[35m dumps to file and returns the page like a GET
\033[36mprint,get\033[35m prints the data in the log and returns GET \033[36mprint,get\033[35m prints the data in the log and returns GET
(leave out the ",get" to return an error instead) (leave out the ",get" to return an error instead)\033[0m
note that the \033[35m--xm\033[0m hook will only run if \033[35m--urlform\033[0m
is either \033[36mprint\033[0m or the default \033[36mprint,get\033[0m
""" """
), ),
], ],

View file

@ -477,6 +477,13 @@ class VFS(object):
) )
# skip uhtml because it's rarely needed # skip uhtml because it's rarely needed
def get_perms(self, vpath: str, uname: str) -> str:
zbl = self.can_access(vpath, uname)
ret = "".join(ch for ch, ok in zip("rwmdgGa.", zbl) if ok)
if "rwmd" in ret and "a." in ret:
ret += "A"
return ret
def get( def get(
self, self,
vpath: str, vpath: str,

View file

@ -470,9 +470,10 @@ class FtpHandler(FTPHandler):
None, None,
xbu, xbu,
ap, ap,
vfs.canonical(rem), vp,
"", "",
self.uname, self.uname,
self.hub.asrv.vfs.get_perms(vp, self.uname),
0, 0,
0, 0,
self.cli_ip, self.cli_ip,

View file

@ -699,6 +699,7 @@ class HttpCli(object):
self.vpath, self.vpath,
self.host, self.host,
self.uname, self.uname,
"",
time.time(), time.time(),
0, 0,
self.ip, self.ip,
@ -1635,6 +1636,7 @@ class HttpCli(object):
self.vpath, self.vpath,
self.host, self.host,
self.uname, self.uname,
self.asrv.vfs.get_perms(self.vpath, self.uname),
time.time(), time.time(),
len(buf), len(buf),
self.ip, self.ip,
@ -1784,6 +1786,7 @@ class HttpCli(object):
self.vpath, self.vpath,
self.host, self.host,
self.uname, self.uname,
self.asrv.vfs.get_perms(self.vpath, self.uname),
at, at,
remains, remains,
self.ip, self.ip,
@ -1874,6 +1877,7 @@ class HttpCli(object):
self.vpath, self.vpath,
self.host, self.host,
self.uname, self.uname,
self.asrv.vfs.get_perms(self.vpath, self.uname),
mt, mt,
post_sz, post_sz,
self.ip, self.ip,
@ -2556,6 +2560,7 @@ class HttpCli(object):
self.vpath, self.vpath,
self.host, self.host,
self.uname, self.uname,
self.asrv.vfs.get_perms(self.vpath, self.uname),
at, at,
0, 0,
self.ip, self.ip,
@ -2619,6 +2624,7 @@ class HttpCli(object):
self.vpath, self.vpath,
self.host, self.host,
self.uname, self.uname,
self.asrv.vfs.get_perms(self.vpath, self.uname),
at, at,
sz, sz,
self.ip, self.ip,
@ -2863,6 +2869,7 @@ class HttpCli(object):
self.vpath, self.vpath,
self.host, self.host,
self.uname, self.uname,
self.asrv.vfs.get_perms(self.vpath, self.uname),
time.time(), time.time(),
0, 0,
self.ip, self.ip,
@ -2901,6 +2908,7 @@ class HttpCli(object):
self.vpath, self.vpath,
self.host, self.host,
self.uname, self.uname,
self.asrv.vfs.get_perms(self.vpath, self.uname),
new_lastmod, new_lastmod,
sz, sz,
self.ip, self.ip,

View file

@ -240,7 +240,7 @@ class SMB(object):
xbu = vfs.flags.get("xbu") xbu = vfs.flags.get("xbu")
if xbu and not runhook( if xbu and not runhook(
self.nlog, xbu, ap, vpath, "", "", 0, 0, "1.7.6.2", 0, "" self.nlog, xbu, ap, vpath, "", "", "", 0, 0, "1.7.6.2", 0, ""
): ):
yeet("blocked by xbu server config: " + vpath) yeet("blocked by xbu server config: " + vpath)

View file

@ -328,7 +328,7 @@ class Tftpd(object):
xbu = vfs.flags.get("xbu") xbu = vfs.flags.get("xbu")
if xbu and not runhook( if xbu and not runhook(
self.nlog, xbu, ap, vpath, "", "", 0, 0, "8.3.8.7", 0, "" self.nlog, xbu, ap, vpath, "", "", "", 0, 0, "8.3.8.7", 0, ""
): ):
yeet("blocked by xbu server config: " + vpath) yeet("blocked by xbu server config: " + vpath)

View file

@ -2770,6 +2770,7 @@ class Up2k(object):
job["vtop"], job["vtop"],
job["host"], job["host"],
job["user"], job["user"],
self.asrv.vfs.get_perms(job["vtop"], job["user"]),
job["lmod"], job["lmod"],
job["size"], job["size"],
job["addr"], job["addr"],
@ -3297,6 +3298,7 @@ class Up2k(object):
djoin(vtop, rd, fn), djoin(vtop, rd, fn),
host, host,
usr, usr,
self.asrv.vfs.get_perms(djoin(vtop, rd, fn), usr),
int(ts), int(ts),
sz, sz,
ip, ip,
@ -3496,6 +3498,7 @@ class Up2k(object):
vpath, vpath,
"", "",
uname, uname,
self.asrv.vfs.get_perms(vpath, uname),
stl.st_mtime, stl.st_mtime,
st.st_size, st.st_size,
ip, ip,
@ -3529,6 +3532,7 @@ class Up2k(object):
vpath, vpath,
"", "",
uname, uname,
self.asrv.vfs.get_perms(vpath, uname),
stl.st_mtime, stl.st_mtime,
st.st_size, st.st_size,
ip, ip,
@ -3661,7 +3665,18 @@ class Up2k(object):
xar = dvn.flags.get("xar") xar = dvn.flags.get("xar")
if xbr: if xbr:
if not runhook( if not runhook(
self.log, xbr, sabs, svp, "", uname, stl.st_mtime, st.st_size, "", 0, "" self.log,
xbr,
sabs,
svp,
"",
uname,
self.asrv.vfs.get_perms(svp, uname),
stl.st_mtime,
st.st_size,
"",
0,
"",
): ):
t = "move blocked by xbr server config: {}".format(svp) t = "move blocked by xbr server config: {}".format(svp)
self.log(t, 1) self.log(t, 1)
@ -3686,7 +3701,20 @@ class Up2k(object):
self.rescan_cond.notify_all() self.rescan_cond.notify_all()
if xar: if xar:
runhook(self.log, xar, dabs, dvp, "", uname, 0, 0, "", 0, "") runhook(
self.log,
xar,
dabs,
dvp,
"",
uname,
self.asrv.vfs.get_perms(dvp, uname),
0,
0,
"",
0,
"",
)
return "k" return "k"
@ -3785,7 +3813,20 @@ class Up2k(object):
wunlink(self.log, sabs, svn.flags) wunlink(self.log, sabs, svn.flags)
if xar: if xar:
runhook(self.log, xar, dabs, dvp, "", uname, 0, 0, "", 0, "") runhook(
self.log,
xar,
dabs,
dvp,
"",
uname,
self.asrv.vfs.get_perms(dvp, uname),
0,
0,
"",
0,
"",
)
return "k" return "k"
@ -4074,6 +4115,7 @@ class Up2k(object):
vp_chk, vp_chk,
job["host"], job["host"],
job["user"], job["user"],
self.asrv.vfs.get_perms(vp_chk, job["user"]),
int(job["lmod"]), int(job["lmod"]),
job["size"], job["size"],
job["addr"], job["addr"],

View file

@ -2992,7 +2992,8 @@ def retchk(
def _parsehook( def _parsehook(
log: Optional["NamedLogger"], cmd: str log: Optional["NamedLogger"], cmd: str
) -> tuple[bool, bool, bool, float, dict[str, Any], str]: ) -> tuple[str, bool, bool, bool, float, dict[str, Any], list[str]]:
areq = ""
chk = False chk = False
fork = False fork = False
jtxt = False jtxt = False
@ -3017,8 +3018,12 @@ def _parsehook(
cap = int(arg[1:]) # 0=none 1=stdout 2=stderr 3=both cap = int(arg[1:]) # 0=none 1=stdout 2=stderr 3=both
elif arg.startswith("k"): elif arg.startswith("k"):
kill = arg[1:] # [t]ree [m]ain [n]one kill = arg[1:] # [t]ree [m]ain [n]one
elif arg.startswith("a"):
areq = arg[1:] # required perms
elif arg.startswith("i"): elif arg.startswith("i"):
pass pass
elif not arg:
break
else: else:
t = "hook: invalid flag {} in {}" t = "hook: invalid flag {} in {}"
(log or print)(t.format(arg, ocmd)) (log or print)(t.format(arg, ocmd))
@ -3045,9 +3050,11 @@ def _parsehook(
"capture": cap, "capture": cap,
} }
cmd = os.path.expandvars(os.path.expanduser(cmd)) argv = cmd.split(",") if "," in cmd else [cmd]
return chk, fork, jtxt, wait, sp_ka, cmd argv[0] = os.path.expandvars(os.path.expanduser(argv[0]))
return areq, chk, fork, jtxt, wait, sp_ka, argv
def runihook( def runihook(
@ -3056,10 +3063,9 @@ def runihook(
vol: "VFS", vol: "VFS",
ups: list[tuple[str, int, int, str, str, str, int]], ups: list[tuple[str, int, int, str, str, str, int]],
) -> bool: ) -> bool:
ocmd = cmd _, chk, fork, jtxt, wait, sp_ka, acmd = _parsehook(log, cmd)
chk, fork, jtxt, wait, sp_ka, cmd = _parsehook(log, cmd) bcmd = [sfsenc(x) for x in acmd]
bcmd = [sfsenc(cmd)] if acmd[0].endswith(".py"):
if cmd.endswith(".py"):
bcmd = [sfsenc(pybin)] + bcmd bcmd = [sfsenc(pybin)] + bcmd
vps = [vjoin(*list(s3dec(x[3], x[4]))) for x in ups] vps = [vjoin(*list(s3dec(x[3], x[4]))) for x in ups]
@ -3084,7 +3090,7 @@ def runihook(
t0 = time.time() t0 = time.time()
if fork: if fork:
Daemon(runcmd, ocmd, [bcmd], ka=sp_ka) Daemon(runcmd, cmd, bcmd, ka=sp_ka)
else: else:
rc, v, err = runcmd(bcmd, **sp_ka) # type: ignore rc, v, err = runcmd(bcmd, **sp_ka) # type: ignore
if chk and rc: if chk and rc:
@ -3105,14 +3111,20 @@ def _runhook(
vp: str, vp: str,
host: str, host: str,
uname: str, uname: str,
perms: str,
mt: float, mt: float,
sz: int, sz: int,
ip: str, ip: str,
at: float, at: float,
txt: str, txt: str,
) -> bool: ) -> bool:
ocmd = cmd areq, chk, fork, jtxt, wait, sp_ka, acmd = _parsehook(log, cmd)
chk, fork, jtxt, wait, sp_ka, cmd = _parsehook(log, cmd) if areq:
for ch in areq:
if ch not in perms:
t = "user %s not allowed to run hook %s; need perms %s, have %s"
log(t % (uname, cmd, areq, perms))
return True # fallthrough to next hook
if jtxt: if jtxt:
ja = { ja = {
"ap": ap, "ap": ap,
@ -3123,21 +3135,22 @@ def _runhook(
"at": at or time.time(), "at": at or time.time(),
"host": host, "host": host,
"user": uname, "user": uname,
"perms": perms,
"txt": txt, "txt": txt,
} }
arg = json.dumps(ja) arg = json.dumps(ja)
else: else:
arg = txt or ap arg = txt or ap
acmd = [cmd, arg] acmd += [arg]
if cmd.endswith(".py"): if acmd[0].endswith(".py"):
acmd = [pybin] + acmd acmd = [pybin] + acmd
bcmd = [fsenc(x) if x == ap else sfsenc(x) for x in acmd] bcmd = [fsenc(x) if x == ap else sfsenc(x) for x in acmd]
t0 = time.time() t0 = time.time()
if fork: if fork:
Daemon(runcmd, ocmd, [bcmd], ka=sp_ka) Daemon(runcmd, cmd, [bcmd], ka=sp_ka)
else: else:
rc, v, err = runcmd(bcmd, **sp_ka) # type: ignore rc, v, err = runcmd(bcmd, **sp_ka) # type: ignore
if chk and rc: if chk and rc:
@ -3158,6 +3171,7 @@ def runhook(
vp: str, vp: str,
host: str, host: str,
uname: str, uname: str,
perms: str,
mt: float, mt: float,
sz: int, sz: int,
ip: str, ip: str,
@ -3167,7 +3181,7 @@ def runhook(
vp = vp.replace("\\", "/") vp = vp.replace("\\", "/")
for cmd in cmds: for cmd in cmds:
try: try:
if not _runhook(log, cmd, ap, vp, host, uname, mt, sz, ip, at, txt): if not _runhook(log, cmd, ap, vp, host, uname, perms, mt, sz, ip, at, txt):
return False return False
except Exception as ex: except Exception as ex:
(log or print)("hook: {}".format(ex)) (log or print)("hook: {}".format(ex))

View file

@ -1064,7 +1064,7 @@ ebi('ops').innerHTML = (
'<a href="#" data-perm="write" data-dest="bup" tt="' + L.ot_bup + '">🎈</a>' + '<a href="#" data-perm="write" data-dest="bup" tt="' + L.ot_bup + '">🎈</a>' +
'<a href="#" data-perm="write" data-dest="mkdir" tt="' + L.ot_mkdir + '">📂</a>' + '<a href="#" data-perm="write" data-dest="mkdir" tt="' + L.ot_mkdir + '">📂</a>' +
'<a href="#" data-perm="read write" data-dest="new_md" tt="' + L.ot_md + '">📝</a>' + '<a href="#" data-perm="read write" data-dest="new_md" tt="' + L.ot_md + '">📝</a>' +
'<a href="#" data-perm="write" data-dest="msg" tt="' + L.ot_msg + '">📟</a>' + '<a href="#" data-dest="msg" tt="' + L.ot_msg + '">📟</a>' +
'<a href="#" data-dest="player" tt="' + L.ot_mp + '">🎺</a>' + '<a href="#" data-dest="player" tt="' + L.ot_mp + '">🎺</a>' +
'<a href="#" data-dest="cfg" tt="' + L.ot_cfg + '">⚙️</a>' + '<a href="#" data-dest="cfg" tt="' + L.ot_cfg + '">⚙️</a>' +
(IE ? '<span id="noie">' + L.ot_noie + '</span>' : '') + (IE ? '<span id="noie">' + L.ot_noie + '</span>' : '') +