#!/usr/bin/env python3
"""Excessive Code connector for a Linux server.

The server dials out to Excessive Code; Excessive Code never connects in and holds no SSH
key, password or root access here. You choose one grant at setup:

  sudo python3 fairystack-connector.py setup '<code>' --manage stig.service
      Only start, stop, restart, status and logs of that one existing service.
      No shell: these five words map to fixed systemctl/journalctl calls.
  sudo python3 fairystack-connector.py setup '<code>' --workspace
      Shell commands in /srv/fairystack, nothing else writable, /home and /root hidden.

Both run as the unprivileged user `fairystack` in a sandboxed systemd service. The
grant lives on this server (/etc/fairystack-connector/grant, the unit and
/etc/sudoers.d/fairystack-connector); Excessive Code cannot change it. This file does not
update itself. Revoke with Disconnect on the Connect page or
`sudo systemctl disable --now fairystack-connector`.
"""
import json
import os
import pwd
import re
import signal
import socket
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request

USER, ROOT, UNIT = 'fairystack', '/srv/fairystack', 'fairystack-connector'
PROGRAM = '/usr/local/lib/fairystack-connector.py'
CONNECTION = '/etc/fairystack-connector/connection'
GRANT = '/etc/fairystack-connector/grant'
SUDOERS = '/etc/sudoers.d/fairystack-connector'
UNIT_NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9@._-]{0,200}\.service')
OUTPUT_LIMIT = 130000
REVOKED = 3

SERVICE = f'''[Unit]
Description=Excessive Code connector
After=network-online.target
Wants=network-online.target

[Service]
User={USER}
Group={USER}
WorkingDirectory={ROOT}
Environment=HOME={ROOT}
ExecStart=/usr/bin/python3 -I {PROGRAM} run
LoadCredential=connection:{CONNECTION}
Restart=always
RestartSec=5
RestartPreventExitStatus={REVOKED}
# Everything outside ReadWritePaths is read-only; /home and /root are hidden.
ReadWritePaths={ROOT}{{sudo_state}}
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ProtectControlGroups=yes
{{privilege}}

[Install]
WantedBy=multi-user.target
'''


def lifecycle(unit):
    """The only argv a --manage grant can run; sudoers allows exactly these."""
    systemctl, journalctl = '/usr/bin/systemctl', '/usr/bin/journalctl'
    return {'status': [systemctl, 'show', unit, '-p', 'ActiveState,SubState,UnitFileState,NRestarts,ActiveEnterTimestamp'],
            'start': ['sudo', '-n', systemctl, 'enable', '--now', unit],
            'stop': ['sudo', '-n', systemctl, 'disable', '--now', unit],
            'restart': ['sudo', '-n', systemctl, 'restart', unit],
            'logs': ['sudo', '-n', journalctl, '-u', unit, '-n', '200', '--no-pager', '-o', 'short-iso']}


def setup(code, grant):
    if os.geteuid() != 0:
        sys.exit('Run setup with sudo.')
    manage = grant[1] if grant[:1] == ['--manage'] and len(grant) == 2 else None
    if grant != ['--workspace'] and not (manage and UNIT_NAME.fullmatch(manage)):
        sys.exit('Choose one grant: --manage <name>.service or --workspace.')
    if manage and subprocess.run(['systemctl', 'cat', manage], capture_output=True, timeout=30).returncode:
        sys.exit(f'{manage} is not an installed service. Install and test it first.')
    origin, _, token = code.strip().partition('|')
    if not re.fullmatch(r'https://[a-z0-9.-]+', origin) or not re.fullmatch(r'fs_mac_[A-Za-z0-9_-]{20,93}', token):
        sys.exit('That is not an Excessive Code connection code. Copy it again from the Connect page.')
    try:
        pwd.getpwnam(USER)
    except KeyError:
        subprocess.run(['useradd', '--system', '--home-dir', ROOT, '--shell', '/usr/sbin/nologin', USER], check=True, timeout=30)
    subprocess.run(['install', '-d', '-o', USER, '-g', USER, '-m', '750', ROOT], check=True, timeout=30)
    subprocess.run(['install', '-o', 'root', '-g', 'root', '-m', '755', os.path.abspath(__file__), PROGRAM], check=True, timeout=30)
    os.makedirs(os.path.dirname(CONNECTION), exist_ok=True)
    os.chmod(os.path.dirname(CONNECTION), 0o755)
    descriptor = os.open(CONNECTION, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(descriptor, 'w') as handle:
        handle.write(f'{origin}\n{token}\n')
    if manage:
        rules = ''.join(f'{USER} ALL=(root) NOPASSWD: {" ".join(argv[2:])}\n'
                        for argv in lifecycle(manage).values() if argv[0] == 'sudo')
        with open(SUDOERS + '.new', 'w') as handle:
            handle.write(rules)
        os.chmod(SUDOERS + '.new', 0o440)
        subprocess.run(['visudo', '-cqf', SUDOERS + '.new'], check=True, timeout=30)
        os.replace(SUDOERS + '.new', SUDOERS)
    elif os.path.exists(SUDOERS):
        os.remove(SUDOERS)
    with open(GRANT, 'w') as handle:
        handle.write(f'manage {manage}\n' if manage else 'workspace\n')
    os.chmod(GRANT, 0o644)
    # sudo is setuid, so a --manage unit omits settings that imply NoNewPrivileges; its
    # sudoers file allows only the exact lifecycle calls.
    privilege = '' if manage else ('NoNewPrivileges=yes\nRestrictSUIDSGID=yes\nCapabilityBoundingSet=\n'
                                   'PrivateDevices=yes\nProtectKernelTunables=yes\nProtectKernelModules=yes\n')
    with open(f'/etc/systemd/system/{UNIT}.service', 'w') as handle:
        handle.write(SERVICE.format(privilege=privilege, sudo_state=' -/run/sudo' if manage else ''))
    subprocess.run(['systemctl', 'daemon-reload'], check=True, timeout=30)
    subprocess.run(['systemctl', 'enable', UNIT], check=True, timeout=30)
    subprocess.run(['systemctl', 'restart', UNIT], check=True, timeout=30)
    scope = f'start, stop, restart, status and logs of {manage}' if manage else f'commands as {USER} in {ROOT}'
    print(f'Connected {socket.gethostname()} to {origin}. Granted: {scope}.')


def call(origin, token, path, body):
    request = urllib.request.Request(f'{origin}/api/companions/device/{path}', data=json.dumps(body).encode(),
                                     headers={'X-FairyStack-Agent-Token': token, 'Content-Type': 'application/json'})
    try:
        with urllib.request.urlopen(request, timeout=10) as response:
            return json.load(response)
    except urllib.error.HTTPError as exc:
        if exc.code in (401, 403):
            print('Access expired or was revoked. Pair again from the Connect page.', file=sys.stderr)
            sys.exit(REVOKED)
        raise


class Command:
    """One shell command in its own process group; output keeps the newest bytes."""

    def __init__(self, job, argv, directory, seconds):
        self.id, self.output, self.timed_out = job['id'], bytearray(), False
        self.deadline = time.monotonic() + seconds
        self.process = subprocess.Popen(argv, cwd=directory, stdin=subprocess.DEVNULL,
                                        stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
        threading.Thread(target=self.read, daemon=True).start()

    def read(self):
        for chunk in iter(lambda: self.process.stdout.read1(65536), b''):
            self.output += chunk
            del self.output[:-OUTPUT_LIMIT]

    def text(self):
        return bytes(self.output).decode('utf-8', 'replace').encode()[-OUTPUT_LIMIT:].decode('utf-8', 'ignore')

    def stop(self):
        try:
            os.killpg(self.process.pid, signal.SIGKILL)
        except ProcessLookupError:
            pass

    def result(self):
        code = self.process.poll()
        if code is None and time.monotonic() > self.deadline:
            self.timed_out = True
            self.stop()
            code = self.process.wait()
        if code is None:
            return None
        state = 'completed' if code == 0 else 'timed_out' if self.timed_out else 'failed'
        report = {'id': self.id, 'state': state, 'output': self.text(), 'exit_code': code}
        if code:
            report['error'] = 'Local command deadline elapsed.' if self.timed_out else f'Command exited with status {code}.'
        return report


def start(job, server_time, manage):
    root = os.path.realpath(os.getcwd())
    if not re.fullmatch(r'[a-f0-9]{24}', str(job.get('id'))):
        raise ValueError('invalid command id')
    directory = os.path.realpath(os.path.join(root, job['cwd']))
    seconds = min(1800, job['deadline'] - server_time - 2)
    argv = lifecycle(manage).get(job['command'].strip()) if manage else ['/bin/bash', '-lc', job['command']]
    if not argv:
        return None, {'id': job['id'], 'state': 'failed', 'output': '',
                      'error': f'This server grants only status, start, stop, restart and logs of {manage}.'}
    if os.path.commonpath([root, directory]) != root or not os.path.isdir(directory) or seconds <= 0:
        return None, {'id': job['id'], 'state': 'failed', 'output': '',
                      'error': 'Working folder is unavailable or command deadline elapsed.'}
    return Command(job, argv, directory, seconds), None


def run():
    with open(os.path.join(os.environ['CREDENTIALS_DIRECTORY'], 'connection')) as handle:
        origin, token = handle.read().split()
    with open(GRANT) as handle:
        grant = handle.read().split()
    if grant != ['workspace'] and not (len(grant) == 2 and grant[0] == 'manage' and UNIT_NAME.fullmatch(grant[1])):
        sys.exit(f'{GRANT} is invalid. Run setup again.')
    manage = grant[1] if grant[0] == 'manage' else None
    root, name = os.path.realpath(os.getcwd()), socket.gethostname()[:80]
    active, pending, last_contact = None, None, time.monotonic()
    while True:
        try:
            if pending:
                call(origin, token, 'report', pending)
                pending = None
            elif active:
                pending = active.result()
                if pending:
                    active = None
                    continue
                if call(origin, token, 'report', {'id': active.id, 'state': 'running', 'output': active.text()}).get('stop'):
                    active.stop()
            else:
                job = call(origin, token, 'poll', {'root': root, 'name': name}).get('command')
                if job:
                    active, pending = start(job, time.time(), manage)
            last_contact = time.monotonic()
        except (OSError, ValueError) as exc:
            print(f'Excessive Code unreachable: {exc}', file=sys.stderr)
            # The server fails a silent command after 25 s; never keep running one it gave up on.
            if time.monotonic() - last_contact > 20:
                if active:
                    active.stop()
                active, pending = None, None
        time.sleep(2)


if __name__ == '__main__':
    if sys.argv[1:2] == ['setup'] and len(sys.argv) >= 3:
        setup(sys.argv[2], sys.argv[3:])
    elif sys.argv[1:] == ['run']:
        run()
    else:
        sys.exit(__doc__)
