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[0m
can be defined as --args or volflags; for example \033[36m
--xau notify-send
-v .::r:c,xau=notify-send
--xau foo.py
-v .::r:c,xau=bar.py
\033[0m
commands specified as --args are appended to volflags;
each --arg and volflag can be specified multiple times,
each command will execute in order unless one returns non-zero
hooks specified as commandline --args are appended to volflags;
each commandline --arg and volflag can be specified multiple times,
each hook will execute in order unless one returns non-zero
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[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[36mkm\033[35m kills just the main process
\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[36mc3\033[35m mute all process otput
\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
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)
@ -685,7 +704,10 @@ def get_sects():
\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[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
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(
self,
vpath: str,

View file

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

View file

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

View file

@ -240,7 +240,7 @@ class SMB(object):
xbu = vfs.flags.get("xbu")
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)

View file

@ -328,7 +328,7 @@ class Tftpd(object):
xbu = vfs.flags.get("xbu")
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)

View file

@ -2770,6 +2770,7 @@ class Up2k(object):
job["vtop"],
job["host"],
job["user"],
self.asrv.vfs.get_perms(job["vtop"], job["user"]),
job["lmod"],
job["size"],
job["addr"],
@ -3297,6 +3298,7 @@ class Up2k(object):
djoin(vtop, rd, fn),
host,
usr,
self.asrv.vfs.get_perms(djoin(vtop, rd, fn), usr),
int(ts),
sz,
ip,
@ -3496,6 +3498,7 @@ class Up2k(object):
vpath,
"",
uname,
self.asrv.vfs.get_perms(vpath, uname),
stl.st_mtime,
st.st_size,
ip,
@ -3529,6 +3532,7 @@ class Up2k(object):
vpath,
"",
uname,
self.asrv.vfs.get_perms(vpath, uname),
stl.st_mtime,
st.st_size,
ip,
@ -3661,7 +3665,18 @@ class Up2k(object):
xar = dvn.flags.get("xar")
if xbr:
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)
self.log(t, 1)
@ -3686,7 +3701,20 @@ class Up2k(object):
self.rescan_cond.notify_all()
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"
@ -3785,7 +3813,20 @@ class Up2k(object):
wunlink(self.log, sabs, svn.flags)
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"
@ -4074,6 +4115,7 @@ class Up2k(object):
vp_chk,
job["host"],
job["user"],
self.asrv.vfs.get_perms(vp_chk, job["user"]),
int(job["lmod"]),
job["size"],
job["addr"],

View file

@ -2992,7 +2992,8 @@ def retchk(
def _parsehook(
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
fork = False
jtxt = False
@ -3017,8 +3018,12 @@ def _parsehook(
cap = int(arg[1:]) # 0=none 1=stdout 2=stderr 3=both
elif arg.startswith("k"):
kill = arg[1:] # [t]ree [m]ain [n]one
elif arg.startswith("a"):
areq = arg[1:] # required perms
elif arg.startswith("i"):
pass
elif not arg:
break
else:
t = "hook: invalid flag {} in {}"
(log or print)(t.format(arg, ocmd))
@ -3045,9 +3050,11 @@ def _parsehook(
"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(
@ -3056,10 +3063,9 @@ def runihook(
vol: "VFS",
ups: list[tuple[str, int, int, str, str, str, int]],
) -> bool:
ocmd = cmd
chk, fork, jtxt, wait, sp_ka, cmd = _parsehook(log, cmd)
bcmd = [sfsenc(cmd)]
if cmd.endswith(".py"):
_, chk, fork, jtxt, wait, sp_ka, acmd = _parsehook(log, cmd)
bcmd = [sfsenc(x) for x in acmd]
if acmd[0].endswith(".py"):
bcmd = [sfsenc(pybin)] + bcmd
vps = [vjoin(*list(s3dec(x[3], x[4]))) for x in ups]
@ -3084,7 +3090,7 @@ def runihook(
t0 = time.time()
if fork:
Daemon(runcmd, ocmd, [bcmd], ka=sp_ka)
Daemon(runcmd, cmd, bcmd, ka=sp_ka)
else:
rc, v, err = runcmd(bcmd, **sp_ka) # type: ignore
if chk and rc:
@ -3105,14 +3111,20 @@ def _runhook(
vp: str,
host: str,
uname: str,
perms: str,
mt: float,
sz: int,
ip: str,
at: float,
txt: str,
) -> bool:
ocmd = cmd
chk, fork, jtxt, wait, sp_ka, cmd = _parsehook(log, cmd)
areq, chk, fork, jtxt, wait, sp_ka, acmd = _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:
ja = {
"ap": ap,
@ -3123,21 +3135,22 @@ def _runhook(
"at": at or time.time(),
"host": host,
"user": uname,
"perms": perms,
"txt": txt,
}
arg = json.dumps(ja)
else:
arg = txt or ap
acmd = [cmd, arg]
if cmd.endswith(".py"):
acmd += [arg]
if acmd[0].endswith(".py"):
acmd = [pybin] + acmd
bcmd = [fsenc(x) if x == ap else sfsenc(x) for x in acmd]
t0 = time.time()
if fork:
Daemon(runcmd, ocmd, [bcmd], ka=sp_ka)
Daemon(runcmd, cmd, [bcmd], ka=sp_ka)
else:
rc, v, err = runcmd(bcmd, **sp_ka) # type: ignore
if chk and rc:
@ -3158,6 +3171,7 @@ def runhook(
vp: str,
host: str,
uname: str,
perms: str,
mt: float,
sz: int,
ip: str,
@ -3167,7 +3181,7 @@ def runhook(
vp = vp.replace("\\", "/")
for cmd in cmds:
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
except Exception as 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="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="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="cfg" tt="' + L.ot_cfg + '">⚙️</a>' +
(IE ? '<span id="noie">' + L.ot_noie + '</span>' : '') +