Complete private gateway and local website publishing UAT
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local frps admission plugin. Reload enrollments for every request.
|
||||
|
||||
The enrollment file is private operator configuration, never a public catalogue.
|
||||
Transport must require TLS; the node pins the gateway CA. No bearer value is
|
||||
logged. An unavailable/malformed policy rejects requests, including heartbeats.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
OPS = {'Login', 'NewProxy', 'Ping', 'NewWorkConn', 'NewUserConn'}
|
||||
NAME = re.compile(r'[a-z0-9][a-z0-9-]{0,47}\Z')
|
||||
DOMAIN = re.compile(r'(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}\Z')
|
||||
|
||||
|
||||
def authorize(op, content, enrollments):
|
||||
if op not in OPS or not isinstance(content, dict):
|
||||
return False
|
||||
user = content if op == 'Login' else content.get('user')
|
||||
if not isinstance(user, dict):
|
||||
return False
|
||||
name = user.get('user')
|
||||
if not isinstance(name, str) or not NAME.fullmatch(name):
|
||||
return False
|
||||
entry = enrollments.get(name)
|
||||
if not isinstance(entry, dict) or entry.get('enabled') is not True:
|
||||
return False
|
||||
metas = user.get('metas', {})
|
||||
token = metas.get('enrollment_token') if isinstance(metas, dict) else None
|
||||
expected = entry.get('token_sha256')
|
||||
if not isinstance(token, str) or not 32 <= len(token) <= 256:
|
||||
return False
|
||||
if not isinstance(expected, str) or not re.fullmatch('[a-f0-9]{64}', expected):
|
||||
return False
|
||||
if not hmac.compare_digest(hashlib.sha256(token.encode()).hexdigest(), expected):
|
||||
return False
|
||||
domains = entry.get('domains')
|
||||
if not isinstance(domains, list) or not domains or len(domains) > 32:
|
||||
return False
|
||||
if any(not isinstance(d, str) or not DOMAIN.fullmatch(d) for d in domains):
|
||||
return False
|
||||
if op in {'NewProxy', 'NewUserConn'}:
|
||||
# frpc prefixes proxy names with its configured user.
|
||||
proxy = content.get('proxy_name', '')
|
||||
if not isinstance(proxy, str) or not proxy.startswith(name + '.'):
|
||||
return False
|
||||
if not NAME.fullmatch(proxy[len(name) + 1:]):
|
||||
return False
|
||||
if content.get('proxy_type') != 'https':
|
||||
return False
|
||||
if op == 'NewProxy':
|
||||
requested = content.get('custom_domains')
|
||||
if not isinstance(requested, list) or len(requested) != 1 or requested[0] not in domains:
|
||||
return False
|
||||
# No arbitrary TCP ports, wildcard subdomains, shared groups or routing
|
||||
# rewrites. TLS terminates on the node; gateway only forwards SNI.
|
||||
if any(content.get(k) for k in ('remote_port', 'subdomain', 'group', 'group_key', 'locations', 'host_header_rewrite', 'headers', 'http_user', 'http_pwd', 'multiplexer')):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
accepted = False
|
||||
try:
|
||||
self.connection.settimeout(3)
|
||||
url = urlsplit(self.path)
|
||||
query = parse_qs(url.query, strict_parsing=True)
|
||||
size = int(self.headers.get('Content-Length', '0'))
|
||||
if url.path != '/handler' or query.get('version') != ['0.1.0'] or len(query.get('op', [])) != 1 or not 0 < size <= 65536:
|
||||
raise ValueError('Invalid request')
|
||||
if self.headers.get('Transfer-Encoding'):
|
||||
raise ValueError('Streaming request unsupported')
|
||||
config = self.server.policy_path
|
||||
if config.stat().st_mode & 0o077:
|
||||
raise ValueError('Enrollment file must be private')
|
||||
raw = config.read_bytes()
|
||||
if len(raw) > 1024 * 1024:
|
||||
raise ValueError('Oversized policy')
|
||||
enrollments = json.loads(raw)
|
||||
request = json.loads(self.rfile.read(size))
|
||||
accepted = authorize(query['op'][0], request['content'], enrollments)
|
||||
except (OSError, ValueError, TypeError, KeyError, AttributeError):
|
||||
pass
|
||||
body = json.dumps({'reject': not accepted, 'unchange': True, 'reject_reason': '' if accepted else 'Enrollment or route is not authorized'}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--enrollments', type=Path, required=True)
|
||||
parser.add_argument('--port', type=int, default=17700)
|
||||
args = parser.parse_args()
|
||||
server = ThreadingHTTPServer(('127.0.0.1', args.port), Handler)
|
||||
server.policy_path = args.enrollments
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user