diff --git a/README.md b/README.md index a6a301c0..b7f263b2 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ turn almost any device into a file server with resumable uploads/downloads using * [event hooks](#event-hooks) - trigger a program on uploads, renames etc ([examples](./bin/hooks/)) * [upload events](#upload-events) - the older, more powerful approach ([examples](./bin/mtag/)) * [handlers](#handlers) - redefine behavior with plugins ([examples](./bin/handlers/)) + * [ip auth](#ip-auth) - autologin based on IP range (CIDR) * [identity providers](#identity-providers) - replace copyparty passwords with oauth and such * [user-changeable passwords](#user-changeable-passwords) - if permitted, users can change their own passwords * [using the cloud as storage](#using-the-cloud-as-storage) - connecting to an aws s3 bucket and similar @@ -1432,6 +1433,22 @@ redefine behavior with plugins ([examples](./bin/handlers/)) replace 404 and 403 errors with something completely different (that's it for now) +## ip auth + +autologin based on IP range (CIDR) , using the global-option `--ipu` + +for example, if everyone with an IP that starts with `192.168.123` should automatically log in as the user `spartacus`, then you can either specify `--ipu=192.168.123.0/24=spartacus` as a commandline option, or put this in a config file: + +```yaml +[global] + ipu: 192.168.123.0/24=spartacus +``` + +repeat the option to map additional subnets + +**be careful with this one!** if you have a reverseproxy, then you definitely want to make sure you have [real-ip](#real-ip) configured correctly, and it's probably a good idea to nullmap the reverseproxy's IP just in case; so if your reverseproxy is sending requests from `172.24.27.9` then that would be `--ipu=172.24.27.9/32=` + + ## identity providers replace copyparty passwords with oauth and such diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 4df48dcf..02f9efe6 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1087,6 +1087,7 @@ def add_auth(ap): ap2.add_argument("--ses-db", metavar="PATH", type=u, default=ses_db, help="where to store the sessions database (if you run multiple copyparty instances, make sure they use different DBs)") ap2.add_argument("--ses-len", metavar="CHARS", type=int, default=20, help="session key length; default is 120 bits ((20//4)*4*6)") ap2.add_argument("--no-ses", action="store_true", help="disable sessions; use plaintext passwords in cookies") + ap2.add_argument("--ipu", metavar="CIDR=USR", type=u, action="append", help="users with IP matching \033[33mCIDR\033[0m are auto-authenticated as username \033[33mUSR\033[0m; example: [\033[32m172.16.24.0/24=dave]") def add_chpw(ap): diff --git a/copyparty/ftpd.py b/copyparty/ftpd.py index c94c6cc6..2cbe93a9 100644 --- a/copyparty/ftpd.py +++ b/copyparty/ftpd.py @@ -76,6 +76,7 @@ class FtpAuth(DummyAuthorizer): else: raise AuthenticationFailed("banned") + args = self.hub.args asrv = self.hub.asrv uname = "*" if username != "anonymous": @@ -86,6 +87,9 @@ class FtpAuth(DummyAuthorizer): uname = zs break + if args.ipu and uname == "*": + uname = args.ipu_iu[args.ipu_nm.map(ip)] + if not uname or not (asrv.vfs.aread.get(uname) or asrv.vfs.awrite.get(uname)): g = self.hub.gpwd if g.lim: diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index a9f9f5bd..772e593f 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -589,6 +589,9 @@ class HttpCli(object): or "*" ) + if self.args.ipu and self.uname == "*": + self.uname = self.conn.ipu_iu[self.conn.ipu_nm.map(self.ip)] + self.rvol = self.asrv.vfs.aread[self.uname] self.wvol = self.asrv.vfs.awrite[self.uname] self.avol = self.asrv.vfs.aadmin[self.uname] diff --git a/copyparty/httpconn.py b/copyparty/httpconn.py index 1d76001e..66b1adc7 100644 --- a/copyparty/httpconn.py +++ b/copyparty/httpconn.py @@ -59,6 +59,8 @@ class HttpConn(object): self.asrv: AuthSrv = hsrv.asrv # mypy404 self.u2fh: Util.FHC = hsrv.u2fh # mypy404 self.pipes: Util.CachedDict = hsrv.pipes # mypy404 + self.ipu_iu: Optional[dict[str, str]] = hsrv.ipu_iu + self.ipu_nm: Optional[NetMap] = hsrv.ipu_nm self.ipa_nm: Optional[NetMap] = hsrv.ipa_nm self.xff_nm: Optional[NetMap] = hsrv.xff_nm self.xff_lan: NetMap = hsrv.xff_lan # type: ignore diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py index c17390d2..128a9b97 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -69,6 +69,7 @@ from .util import ( build_netmap, has_resource, ipnorm, + load_ipu, load_resource, min_ex, shut_socket, @@ -175,6 +176,11 @@ class HttpSrv(object): self.j2 = {x: env.get_template(x + ".html") for x in jn} self.prism = has_resource(self.E, "web/deps/prism.js.gz") + if self.args.ipu: + self.ipu_iu, self.ipu_nm = load_ipu(self.log, self.args.ipu) + else: + self.ipu_iu = self.ipu_nm = None + self.ipa_nm = build_netmap(self.args.ipa) self.xff_nm = build_netmap(self.args.xff_src) self.xff_lan = build_netmap("lan") diff --git a/copyparty/svchub.py b/copyparty/svchub.py index d63d7f3d..719e4507 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -60,6 +60,7 @@ from .util import ( alltrace, ansi_re, build_netmap, + load_ipu, min_ex, mp, odfusion, @@ -221,6 +222,11 @@ class SvcHub(object): noch.update([x for x in zsl if x]) args.chpw_no = noch + if args.ipu: + iu, nm = load_ipu(self.log, args.ipu) + setattr(args, "ipu_iu", iu) + setattr(args, "ipu_nm", nm) + if not self.args.no_ses: self.setup_session_db() diff --git a/copyparty/util.py b/copyparty/util.py index e14b3732..5d6a47b4 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -665,11 +665,15 @@ class HLog(logging.Handler): class NetMap(object): - def __init__(self, ips: list[str], cidrs: list[str], keep_lo=False) -> None: + def __init__( + self, ips: list[str], cidrs: list[str], keep_lo=False, strict_cidr=False + ) -> None: """ ips: list of plain ipv4/ipv6 IPs, not cidr cidrs: list of cidr-notation IPs (ip/prefix) """ + self.mutex = threading.Lock() + if "::" in ips: ips = [x for x in ips if x != "::"] + list( [x.split("/")[0] for x in cidrs if ":" in x] @@ -696,7 +700,7 @@ class NetMap(object): bip = socket.inet_pton(fam, ip.split("/")[0]) self.bip.append(bip) self.b2sip[bip] = ip.split("/")[0] - self.b2net[bip] = (IPv6Network if v6 else IPv4Network)(ip, False) + self.b2net[bip] = (IPv6Network if v6 else IPv4Network)(ip, strict_cidr) self.bip.sort(reverse=True) @@ -707,8 +711,10 @@ class NetMap(object): try: return self.cache[ip] except: - pass + with self.mutex: + return self._map(ip) + def _map(self, ip: str) -> str: v6 = ":" in ip ci = IPv6Address(ip) if v6 else IPv4Address(ip) bip = next((x for x in self.bip if ci in self.b2net[x]), None) @@ -2678,6 +2684,31 @@ def build_netmap(csv: str): return NetMap(ips, cidrs, True) +def load_ipu(log: "RootLogger", ipus: list[str]) -> tuple[dict[str, str], NetMap]: + ip_u = {"": "*"} + cidr_u = {} + for ipu in ipus: + try: + cidr, uname = ipu.split("=") + cip, csz = cidr.split("/") + except: + t = "\n invalid value %r for argument --ipu; must be CIDR=UNAME (192.168.0.0/16=amelia)" + raise Exception(t % (ipu,)) + uname2 = cidr_u.get(cidr) + if uname2 is not None: + t = "\n invalid value %r for argument --ipu; cidr %s already mapped to %r" + raise Exception(t % (ipu, cidr, uname2)) + cidr_u[cidr] = uname + ip_u[cip] = uname + try: + nm = NetMap(["::"], list(cidr_u.keys()), True, True) + except Exception as ex: + t = "failed to translate --ipu into netmap, probably due to invalid config: %r" + log("root", t % (ex,), 1) + raise + return ip_u, nm + + def yieldfile(fn: str, bufsz: int) -> Generator[bytes, None, None]: readsz = min(bufsz, 128 * 1024) with open(fsenc(fn), "rb", bufsz) as f: diff --git a/tests/util.py b/tests/util.py index 5719fd3e..a1adb1e7 100644 --- a/tests/util.py +++ b/tests/util.py @@ -128,7 +128,7 @@ class Cfg(Namespace): ex = "dedup dotpart dotsrch hook_v no_dhash no_fastboot no_fpool no_htp no_rescan no_sendfile no_ses no_snap no_up_list no_voldump re_dhash plain_ip" ka.update(**{k: True for k in ex.split()}) - ex = "ah_cli ah_gen css_browser hist js_browser js_other mime mimes no_forget no_hash no_idx nonsus_urls og_tpl og_ua" + ex = "ah_cli ah_gen css_browser hist ipu js_browser js_other mime mimes no_forget no_hash no_idx nonsus_urls og_tpl og_ua" ka.update(**{k: None for k in ex.split()}) ex = "hash_mt safe_dedup srch_time u2abort u2j u2sz"