#!/usr/bin/env python3 """BakarServer subscription client. Python 3.9+, standard library only.""" import argparse import getpass import hashlib import json import os from pathlib import Path import re import stat import sys import tempfile import urllib.error import urllib.parse import urllib.request VERSION = '1.0.0' API_URL = 'https://api.bakarserver.com/index.php/v1/client/status' MANIFEST_URL = 'https://mirror.bakarserver.com/releases/stable.json' CONFIG = Path.home() / '.config' / 'bakarserver' / 'client.json' class ClientError(Exception): pass class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *args, **kwargs): return None def fetch(url, token=None, limit=262144): parsed=urllib.parse.urlsplit(url) if parsed.scheme!='https' or parsed.hostname not in ('api.bakarserver.com','mirror.bakarserver.com') or parsed.port not in (None,443) or parsed.username or parsed.password or parsed.fragment: raise ClientError('Untrusted download or API address.') headers={'Accept':'application/json' if token else '*/*','User-Agent':'BakarServerClient/'+VERSION} data=None if token: if url!=API_URL:raise ClientError('Tokens can only be sent to the subscription API.') headers['Authorization']='Bearer '+token headers['Content-Type']='application/json' data=b'{}' try: with urllib.request.build_opener(NoRedirect).open(urllib.request.Request(url,data=data,headers=headers),timeout=15) as r: if r.status!=200:raise ClientError('Unexpected server response.') body=r.read(limit+1) except urllib.error.HTTPError as e: if e.code==401:raise ClientError('Token invalid or revoked. Create a new token in your subscription page.') from None raise ClientError('Server request failed (HTTP %s). No authorization granted.'%e.code) from None except (urllib.error.URLError,TimeoutError,OSError): raise ClientError('Unable to verify the HTTPS connection. No authorization granted.') from None if len(body)>limit:raise ClientError('Response exceeds the allowed size.') return body def atomic_write(path, data, mode): path=Path(path) if path.is_symlink():raise ClientError('Refusing to replace a symbolic link.') path.parent.mkdir(parents=True,exist_ok=True,mode=0o700) fd,tmp=tempfile.mkstemp(prefix='.'+path.name+'.',dir=str(path.parent)) try: os.fchmod(fd,mode) with os.fdopen(fd,'wb') as f: f.write(data);f.flush();os.fsync(f.fileno()) os.replace(tmp,path) finally: if os.path.exists(tmp):os.unlink(tmp) def valid_token(token): return isinstance(token,str) and re.fullmatch(r'bkc1_[a-f0-9]{64}',token) is not None def load_token(path): path=Path(path) if path.is_symlink():raise ClientError('Refusing a symbolic-link credential file.') try: s=path.stat() if not stat.S_ISREG(s.st_mode) or s.st_mode&0o077 or (hasattr(os,'getuid') and s.st_uid!=os.getuid()):raise ClientError('Credential file must belong to you and have permissions 0600.') if s.st_size>4096:raise ClientError('Invalid credential file.') token=json.loads(path.read_text(encoding='utf8')).get('token') except (OSError,ValueError,AttributeError):raise ClientError('Run enroll to configure a client token.') from None if not valid_token(token):raise ClientError('Invalid credential file. Run enroll again.') return token def status(token, transport=fetch): try:r=json.loads(transport(API_URL,token=token)) except (ValueError,TypeError):raise ClientError('Invalid subscription response.') from None if not isinstance(r,dict) or r.get('service')!='BakarServer Client' or type(r.get('authorized')) is not bool or r.get('scope')!='BakarServer subscription record' or r.get('vendor_license_validation')!='not_performed':raise ClientError('Invalid subscription response.') if r['authorized'] and (r.get('status')!='Active' or r.get('ip_matches') is not True):raise ClientError('Inconsistent subscription authorization.') return r def version_tuple(value): if not isinstance(value,str) or not re.fullmatch(r'[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}',value):raise ClientError('Invalid release version.') return tuple(int(n) for n in value.split('.')) def check_update(transport=fetch): try:m=json.loads(transport(MANIFEST_URL,limit=32768)) except (ValueError,TypeError):raise ClientError('Invalid release manifest.') from None if not isinstance(m,dict) or m.get('product')!='bakar-client' or m.get('schema')!=1:raise ClientError('Invalid release manifest.') newer=version_tuple(m.get('version'))>version_tuple(VERSION) p=m.get('client',{}) expected='https://mirror.bakarserver.com/releases/v'+m['version']+'/bakar-client.py' if p.get('url')!=expected or not re.fullmatch(r'[a-f0-9]{64}',str(p.get('sha256',''))) or type(p.get('bytes')) is not int or not 1<=p['bytes']<=1048576:raise ClientError('Invalid client download metadata.') return m,newer def update_script(target, manifest, transport=fetch): if version_tuple(manifest['version'])<=version_tuple(VERSION):raise ClientError('Downgrade or same-version replacement refused.') p=manifest['client'];data=transport(p['url'],limit=1048576) if len(data)!=p['bytes'] or hashlib.sha256(data).hexdigest()!=p['sha256']:raise ClientError('Download checksum mismatch. Installed client was not changed.') try: compile(data,str(target),'exec') except (SyntaxError,ValueError):raise ClientError('Downloaded client is not valid Python for this runtime.') from None marker=("VERSION = '"+manifest['version']+"'").encode() if marker not in data:raise ClientError('Downloaded version does not match the release.') target=Path(target) if not target.is_file() or target.is_symlink():raise ClientError('Installed client path is invalid.') atomic_write(target,data,0o755) def main(argv=None): p=argparse.ArgumentParser(description='Check your BakarServer subscription. This client does not install or activate vendor software.') p.add_argument('--version',action='version',version=VERSION) p.add_argument('--config',type=Path,default=CONFIG,help='Private token configuration path (default: ~/.config/bakarserver/client.json)') commands=p.add_subparsers(dest='command',required=True) commands.add_parser('enroll',help='Read a subscription token at a hidden prompt') commands.add_parser('status',help='Check current subscription and outgoing server IP') u=commands.add_parser('update',help='Check the release manifest; only --apply installs a newer client') u.add_argument('--apply',action='store_true') f=commands.add_parser('forget',help='Remove the local token; revoke it separately in the portal') f.add_argument('--yes',action='store_true') args=p.parse_args(argv) try: if args.command=='enroll': if not sys.stdin.isatty():raise ClientError('Enrollment requires an interactive terminal; tokens are never accepted in command arguments.') token=getpass.getpass('Subscription token (hidden): ').strip() if not valid_token(token):raise ClientError('Invalid token format.') r=status(token) atomic_write(args.config,json.dumps({'token':token,'entitlement_id':r.get('entitlement_id')}).encode()+b'\n',0o600) print('Client enrolled. Subscription: %s. Enrollment does not activate the product.'%r['status']) if not r.get('ip_matches'):print('The outgoing public IP does not match the subscription IP. Request the correct IP through the portal.') elif args.command=='status': r=status(load_token(args.config));print(json.dumps(r,indent=2)) return 0 if r['authorized'] else 3 elif args.command=='forget': if not args.yes:raise ClientError('Use forget --yes to remove the local token. Revoke it in the portal to disable other copies.') if args.config.is_symlink():raise ClientError('Refusing a symbolic-link credential file.') if args.config.exists():args.config.unlink() print('Local token removed. Portal revocation is separate.') elif args.command=='update': m,newer=check_update() print('Installed %s; latest %s.'%(VERSION,m['version'])) if newer and args.apply: update_script(Path(__file__).absolute(),m);print('Verified client update installed. Run --version to confirm.') elif newer:print('Run update --apply to install the verified client release.') else:print('No newer client release.') return 0 except (ClientError,OSError) as e: print('BakarServer: '+str(e),file=sys.stderr);return 2 if __name__=='__main__': sys.exit(main())