92 lines
4.8 KiB
Python
92 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Initialize one private control runtime and optionally its own user service."""
|
|
import argparse
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import secrets
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--repo', required=True)
|
|
parser.add_argument('--runtime', required=True)
|
|
parser.add_argument('--bind', required=True)
|
|
parser.add_argument('--port', type=int, default=8796)
|
|
parser.add_argument('--hostname', required=True)
|
|
parser.add_argument('--install-user-service', action='store_true')
|
|
parser.add_argument('--start', action='store_true')
|
|
args = parser.parse_args()
|
|
if os.name != 'posix':
|
|
raise ValueError('Service setup requires the authorized Linux service account')
|
|
address = ipaddress.IPv4Address(args.bind)
|
|
if not any(address in ipaddress.ip_network(net) for net in ['10.0.0.0/8','172.16.0.0/12','192.168.0.0/16','127.0.0.0/8']):
|
|
raise ValueError('Explicit LAN or loopback IPv4 required')
|
|
if not 1024 <= args.port <= 65535:
|
|
raise ValueError('Unprivileged port required')
|
|
if any(c.isspace() for c in args.hostname) or any(c in args.hostname for c in '/:@'):
|
|
raise ValueError('Invalid hostname')
|
|
repo, runtime = Path(args.repo).resolve(), Path(args.runtime).expanduser().resolve()
|
|
if not (repo/'agent_control/server.py').is_file():
|
|
raise ValueError('Control service source is missing from this repository')
|
|
if not runtime.is_relative_to(Path.home()):
|
|
raise ValueError('Runtime must be within the service account home')
|
|
runtime.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
info = runtime.stat()
|
|
if info.st_uid != os.getuid() or info.st_mode & 0o077:
|
|
raise ValueError('Runtime must be owned by current account with mode 700')
|
|
token = runtime/'access-token'
|
|
if token.is_symlink():
|
|
raise ValueError('Token cannot be a symlink')
|
|
if not token.exists():
|
|
fd = os.open(str(token), os.O_CREAT|os.O_EXCL|os.O_WRONLY, 0o600)
|
|
with os.fdopen(fd,'w') as stream:
|
|
stream.write(secrets.token_urlsafe(48))
|
|
config_path = runtime/'config.json'
|
|
config = {'bind':args.bind,'port':args.port,'runtime_root':str(runtime),'token_file':str(token),
|
|
'allowed_hosts':[args.hostname+':'+str(args.port)],'metrics':{},
|
|
'title':'Agent Workbench','primary_session_id':'human-progress'}
|
|
if config_path.is_symlink():
|
|
raise ValueError('Config cannot be a symlink')
|
|
if config_path.exists():
|
|
old = json.loads(config_path.read_text())
|
|
if any(old.get(k) != config[k] for k in ['bind','port','runtime_root','token_file']):
|
|
raise ValueError('Existing runtime configuration differs; inspect it before explicit changes')
|
|
else:
|
|
fd = os.open(str(config_path), os.O_CREAT|os.O_EXCL|os.O_WRONLY, 0o600)
|
|
with os.fdopen(fd,'w') as stream:
|
|
json.dump(config,stream,ensure_ascii=False,indent=2)
|
|
if args.start and not args.install_user_service:
|
|
raise ValueError('--start requires --install-user-service')
|
|
if args.install_user_service:
|
|
# This is a user-owned unit, never a system service and never sudo.
|
|
units = Path.home()/'.config/systemd/user'
|
|
units.mkdir(parents=True,exist_ok=True)
|
|
unit = units/'agent-workbench.service'
|
|
# systemd accepts double-quoted paths, independently of shell quoting.
|
|
quote = lambda value: '"'+str(value).replace('\\','\\\\').replace('"','\\"').replace('%','%%')+'"'
|
|
if '\n' in str(repo) or '\r' in str(repo):
|
|
raise ValueError('Invalid repository path')
|
|
content = ('[Unit]\nDescription=Agent Workbench\nAfter=network.target\n\n'
|
|
'[Service]\nType=simple\nWorkingDirectory='+str(repo).replace('%','%%')+'\nExecStart='+quote(sys.executable)+
|
|
' -m agent_control.server --config '+quote(config_path)+
|
|
'\nRestart=on-failure\nRestartSec=5\nUMask=0077\nNoNewPrivileges=true\n\n'
|
|
'[Install]\nWantedBy=default.target\n')
|
|
previous = content.replace('WorkingDirectory='+str(repo).replace('%','%%'), 'WorkingDirectory='+quote(repo))
|
|
if unit.exists() and unit.read_text() not in (content,previous):
|
|
raise ValueError('Existing user unit differs; inspect before replacement')
|
|
unit.write_text(content)
|
|
subprocess.run(['systemctl','--user','daemon-reload'],check=True)
|
|
if args.start:
|
|
subprocess.run(['systemctl','--user','enable','--now',unit.name],check=True)
|
|
print(json.dumps({'config':str(config_path),'token_file':str(token),'url':'http://'+args.hostname+':'+str(args.port),
|
|
'user_service_started':bool(args.start)},ensure_ascii=False))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|