diff --git a/clanModules/moonlight.nix b/clanModules/moonlight.nix index 67267fb6..37b442d2 100644 --- a/clanModules/moonlight.nix +++ b/clanModules/moonlight.nix @@ -1,4 +1,84 @@ -{ pkgs, ... }: { +{ pkgs, config, ... }: +let + ms-accept = pkgs.callPackage ../pkgs/moonlight-sunshine-accept { }; + defaultPort = 48011; +in +{ hardware.opengl.enable = true; - environment.systemPackages = [ pkgs.moonlight-qt ]; + environment.systemPackages = [ + pkgs.moonlight-qt + pkgs.libnotify + ms-accept + ]; + + systemd.tmpfiles.rules = [ + "d '/var/lib/moonlight' 0770 'user' 'users' - -" + "C '/var/lib/moonlight/moonlight.cert' 0644 'user' 'users' - ${config.clanCore.secrets.moonlight.secrets."moonlight.cert".path or ""}" + "C '/var/lib/moonlight/moonlight.key' 0644 'user' 'users' - ${config.clanCore.secrets.moonlight.secrets."moonlight.key".path or ""}" + ]; + + systemd.user.services.init-moonlight = { + enable = false; + description = "Initializes moonlight"; + wantedBy = [ "graphical-session.target" ]; + script = '' + ${ms-accept}/bin/moonlight-sunshine-accept moonlight init-config --key /var/lib/moonlight/moonlight.key --cert /var/lib/moonlight/moonlight.cert + ''; + serviceConfig = { + user = "user"; + Type = "oneshot"; + WorkingDirectory = "/home/user/"; + RunTimeDirectory = "moonlight"; + TimeoutSec = "infinity"; + Restart = "on-failure"; + RemainAfterExit = true; + ReadOnlyPaths = [ + "/var/lib/moonlight/moonlight.key" + "/var/lib/moonlight/moonlight.cert" + ]; + }; + + }; + + systemd.user.services.moonlight-join = { + description = "Join sunshine hosts"; + script = '' + ${ms-accept}/bin/moonlight-sunshine-accept moonlight join --port ${builtins.toString defaultPort} --cert '${config.clanCore.secrets.moonlight.facts."moonlight.cert".value or ""}' --host fd2e:25da:6035:c98f:cd99:93e0:b9b8:9ca1''; + serviceConfig = { + Type = "oneshot"; + TimeoutSec = "infinity"; + Restart = "on-failure"; + ReadOnlyPaths = [ + "/var/lib/moonlight/moonlight.key" + "/var/lib/moonlight/moonlight.cert" + ]; + }; + }; + systemd.user.timers.moonlight-join = { + description = "Join sunshine hosts"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnUnitActiveSec = "5min"; + OnBootSec = "0min"; + Persistent = true; + Unit = "moonlight-join.service"; + }; + + }; + + clanCore.secrets.moonlight = { + secrets."moonlight.key" = { }; + secrets."moonlight.cert" = { }; + facts."moonlight.cert" = { }; + generator.path = [ + pkgs.coreutils + ms-accept + ]; + generator.script = '' + moonlight-sunshine-accept moonlight init + mv credentials/cakey.pem "$secrets"/moonlight.key + cp credentials/cacert.pem "$secrets"/moonlight.cert + mv credentials/cacert.pem "$facts"/moonlight.cert + ''; + }; } diff --git a/clanModules/sunshine.nix b/clanModules/sunshine.nix index 6558e47d..c91a4789 100644 --- a/clanModules/sunshine.nix +++ b/clanModules/sunshine.nix @@ -1,4 +1,16 @@ -{ pkgs, config, ... }: +{ pkgs, config, lib, ... }: +let + ms-accept = pkgs.callPackage ../pkgs/moonlight-sunshine-accept { }; + sunshineConfiguration = pkgs.writeText "sunshine.conf" '' + address_family = both + channels = 5 + pkey = /var/lib/sunshine/sunshine.key + cert = /var/lib/sunshine/sunshine.cert + file_state = /var/lib/sunshine/state.json + credentials_file = /var/lib/sunshine/credentials.json + ''; + listenPort = 48011; +in { networking.firewall = { allowedTCPPorts = [ @@ -6,6 +18,7 @@ 47989 47990 48010 + 48011 ]; allowedUDPPorts = [ @@ -29,17 +42,28 @@ to = 48010; } ]; + networking.firewall.interfaces."zt+".allowedTCPPorts = [ + 47984 + 47989 + 47990 + 48010 + listenPort + ]; + networking.firewall.interfaces."zt+".allowedUDPPortRanges = [ + { + from = 47998; + to = 48010; + } + ]; + environment.systemPackages = [ + ms-accept pkgs.sunshine pkgs.avahi # Convenience script, until we find a better UX (pkgs.writers.writeDashBin "sun" '' - ${pkgs.sunshine}/bin/sunshine -1 ${ - pkgs.writeText "sunshine.conf" '' - address_family = both - '' - } "$@" + ${pkgs.sunshine}/bin/sunshine -0 ${sunshineConfiguration} "$@" '') # Create a dummy account, for easier setup, # don't use this account in actual production yet. @@ -51,32 +75,118 @@ # Required to simulate input boot.kernelModules = [ "uinput" ]; - security.rtkit.enable = true; - - # services.udev.extraRules = '' - # KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess" - # ''; services.udev.extraRules = '' - KERNEL=="uinput", GROUP="input", MODE="0660" OPTIONS+="static_node=uinput" + KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess" ''; - security.wrappers.sunshine = { - owner = "root"; - group = "root"; - capabilities = "cap_sys_admin+p"; - source = "${pkgs.sunshine}/bin/sunshine"; + security = { + rtkit.enable = true; + wrappers.sunshine = { + owner = "root"; + group = "root"; + capabilities = "cap_sys_admin+p"; + source = "${pkgs.sunshine}/bin/sunshine"; + }; }; + systemd.tmpfiles.rules = [ + "d '/var/lib/sunshine' 0770 'user' 'users' - -" + "C '/var/lib/sunshine/sunshine.cert' 0644 'user' 'users' - ${config.clanCore.secrets.sunshine.secrets."sunshine.cert".path or ""}" + "C '/var/lib/sunshine/sunshine.key' 0644 'user' 'users' - ${config.clanCore.secrets.sunshine.secrets."sunshine.key".path or ""}" + ]; + + hardware.opengl.enable = true; + systemd.user.services.sunshine = { - description = "sunshine"; - wantedBy = [ "graphical-session.target" ]; - environment = { - DISPLAY = ":0"; - }; + enable = true; + description = "Sunshine self-hosted game stream host for Moonlight"; + startLimitBurst = 5; + startLimitIntervalSec = 500; + script = "/run/current-system/sw/bin/env /run/wrappers/bin/sunshine ${sunshineConfiguration}"; serviceConfig = { - ExecStart = "${config.security.wrapperDir}/sunshine"; + Restart = "on-failure"; + RestartSec = "5s"; + ReadWritePaths = [ + "/var/lib/sunshine" + ]; + ReadOnlyPaths = [ + (config.clanCore.secrets.sunshine.secrets."sunshine.key".path or "") + (config.clanCore.secrets.sunshine.secrets."sunshine.cert".path or "") + ]; }; + wantedBy = [ "graphical-session.target" ]; + partOf = [ "graphical-session.target" ]; + wants = [ "graphical-session.target" ]; + after = [ + "sunshine-init-state.service" + "sunshine-init-credentials.service" + ]; + }; + + systemd.user.services.sunshine-init-state = { + enable = true; + description = "Sunshine self-hosted game stream host for Moonlight"; + startLimitBurst = 5; + startLimitIntervalSec = 500; + script = '' + ${ms-accept}/bin/moonlight-sunshine-accept sunshine init-state --uuid ${config.clanCore.secrets.sunshine.facts.sunshine-uuid.value or null} --state-file /var/lib/sunshine/state.json + ''; + serviceConfig = { + Restart = "on-failure"; + RestartSec = "5s"; + Type = "oneshot"; + ReadWritePaths = [ + "/var/lib/sunshine" + ]; + }; + wantedBy = [ "graphical-session.target" ]; + }; + + systemd.user.services.sunshine-init-credentials = { + enable = true; + description = "Sunshine self-hosted game stream host for Moonlight"; + startLimitBurst = 5; + startLimitIntervalSec = 500; + script = '' + ${lib.getExe pkgs.sunshine} --creds sunshine sunshine + ''; + serviceConfig = { + Restart = "on-failure"; + RestartSec = "5s"; + Type = "oneshot"; + ReadWritePaths = [ + "/var/lib/sunshine" + ]; + }; + wantedBy = [ "graphical-session.target" ]; + }; + + systemd.user.services.sunshine-listener = { + enable = true; + description = "Sunshine self-hosted game stream host for Moonlight"; + startLimitBurst = 5; + startLimitIntervalSec = 500; + script = '' + ${ms-accept}/bin/moonlight-sunshine-accept sunshine listen --port ${builtins.toString listenPort} --uuid ${config.clanCore.secrets.sunshine.facts.sunshine-uuid.value or null} --state /var/lib/sunshine/state.json --cert '${config.clanCore.secrets.sunshine.facts."sunshine.cert".value or null}' + ''; + serviceConfig = { + # ExecStart = lib.concatStringsSep " " (lib.flatten + # [ + # (lib.getExe ms-accept) "sunshine" "listen" + # "--port" (builtins.toString listenPort) + # "--uuid" (config.clanCore.secrets.sunshine.facts."sunshine-uuid".value or "") + # "--state" "/var/lib/sunshine/state.json" + # "--cert" (config.clanCore.secrets.sunshine.facts."sunshine.cert".value or "") + # ] + # ); + Restart = "on-failure"; + RestartSec = 5; + ReadWritePaths = [ + "/var/lib/sunshine" + ]; + }; + wantedBy = [ "graphical-session.target" ]; }; # xdg.configFile."sunshine/apps.json".text = builtins.toJSON { @@ -93,17 +203,21 @@ # ]; # }; - services = { - avahi = { - enable = true; - reflector = true; - nssmdns = true; - publish = { - enable = true; - addresses = true; - userServices = true; - workstation = true; - }; - }; + clanCore.secrets.sunshine = { + secrets."sunshine.key" = { }; + secrets."sunshine.cert" = { }; + facts."sunshine-uuid" = { }; + facts."sunshine.cert" = { }; + generator.path = [ + pkgs.coreutils + ms-accept + ]; + generator.script = '' + moonlight-sunshine-accept sunshine init + mv credentials/cakey.pem "$secrets"/sunshine.key + cp credentials/cacert.pem "$secrets"/sunshine.cert + mv credentials/cacert.pem "$facts"/sunshine.cert + mv uuid "$facts"/sunshine-uuid + ''; }; } diff --git a/pkgs/flake-module.nix b/pkgs/flake-module.nix index 771b4ee2..7d44417c 100644 --- a/pkgs/flake-module.nix +++ b/pkgs/flake-module.nix @@ -10,6 +10,7 @@ tea-create-pr = pkgs.callPackage ./tea-create-pr { }; zerotier-members = pkgs.callPackage ./zerotier-members { }; zt-tcp-relay = pkgs.callPackage ./zt-tcp-relay { }; + moonlight-sunshine-accept = pkgs.callPackage ./moonlight-sunshine-accept { }; merge-after-ci = pkgs.callPackage ./merge-after-ci { inherit (config.packages) tea-create-pr; }; diff --git a/pkgs/moonlight-sunshine-accept/default.nix b/pkgs/moonlight-sunshine-accept/default.nix new file mode 100644 index 00000000..4244e6e8 --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/default.nix @@ -0,0 +1,39 @@ +{ lib +, python3Packages +, makeDesktopItem +, copyDesktopItems +}: +let + desktop-file = makeDesktopItem { + name = "org.clan.moonlight-sunset-accept"; + exec = "moonlight-sunshine-accept moonlight join %u"; + desktopName = "moonlight-handler"; + startupWMClass = "moonlight-handler"; + mimeTypes = [ "x-scheme-handler/moonlight" ]; + }; + +in +python3Packages.buildPythonApplication { + name = "moonlight-sunshine-accept"; + + src = ./.; + + format = "pyproject"; + + propagatedBuildInputs = [ python3Packages.cryptography ]; + nativeBuildInputs = [ + python3Packages.setuptools + copyDesktopItems + ]; + + desktopItems = [ + desktop-file + ]; + + meta = with lib; { + description = "Moonlight Sunshine Bridge"; + license = licenses.mit; + maintainers = with maintainers; [ a-kenji ]; + mainProgram = "moonlight-sunshine-accept"; + }; +} diff --git a/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/__init__.py b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/__init__.py new file mode 100644 index 00000000..e05a45ab --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/__init__.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python + +import argparse + +from . import moonlight, sunshine + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="moonlight-sunshine-accept", + description="Manage moonlight machines", + ) + subparsers = parser.add_subparsers() + + parser_sunshine = subparsers.add_parser( + "sunshine", + aliases=["sun"], + description="Sunshine configuration", + help="Sunshine configuration", + ) + sunshine.register_parser(parser_sunshine) + + parser_moonlight = subparsers.add_parser( + "moonlight", + aliases=["moon"], + description="Moonlight configuration", + help="Moonlight configuration", + ) + moonlight.register_parser(parser_moonlight) + + args = parser.parse_args() + + if not hasattr(args, "func"): + parser.print_help() + else: + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/__main__.py b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/__main__.py new file mode 100644 index 00000000..e69de29b diff --git a/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/__init__.py b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/__init__.py new file mode 100644 index 00000000..6524a4d8 --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/__init__.py @@ -0,0 +1,37 @@ +import argparse + +from .init_certificates import register_initialization_parser +from .init_config import register_config_initialization_parser +from .join import register_join_parser + + +def register_parser(parser: argparse.ArgumentParser) -> None: + subparser = parser.add_subparsers( + title="command", + description="the command to run", + help="the command to run", + required=True, + ) + + initialization_parser = subparser.add_parser( + "init", + aliases=["i"], + description="Initialize the moonlight credentials", + help="Initialize the moonlight credentials", + ) + register_initialization_parser(initialization_parser) + + config_initialization_parser = subparser.add_parser( + "init-config", + description="Initialize the moonlight configuration", + help="Initialize the moonlight configuration", + ) + register_config_initialization_parser(config_initialization_parser) + + join_parser = subparser.add_parser( + "join", + aliases=["j"], + description="Join a sunshine host", + help="Join a sunshine host", + ) + register_join_parser(join_parser) diff --git a/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/init_certificates.py b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/init_certificates.py new file mode 100644 index 00000000..dae2ba1b --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/init_certificates.py @@ -0,0 +1,76 @@ +import argparse +import os +from datetime import datetime, timedelta +from pathlib import Path + +from cryptography import hazmat, x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + + +def generate_private_key() -> rsa.RSAPrivateKey: + private_key = rsa.generate_private_key( + public_exponent=65537, key_size=2048, backend=hazmat.backends.default_backend() + ) + return private_key + + +def generate_certificate(private_key: rsa.RSAPrivateKey) -> bytes: + subject = issuer = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, "NVIDIA GameStream Client"), + ] + ) + cert_builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.utcnow()) + .not_valid_after(datetime.utcnow() + timedelta(days=365 * 20)) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("localhost")]), + critical=False, + ) + .sign(private_key, hashes.SHA256(), default_backend()) + ) + pem_certificate = cert_builder.public_bytes(serialization.Encoding.PEM) + return pem_certificate + + +def private_key_to_pem(private_key: rsa.RSAPrivateKey) -> bytes: + pem_private_key = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + # format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + return pem_private_key + + +def init_credentials() -> (str, str): + private_key = generate_private_key() + certificate = generate_certificate(private_key) + private_key_pem = private_key_to_pem(private_key) + return certificate, private_key_pem + + +def write_credentials(_args: argparse.Namespace) -> None: + pem_certificate, pem_private_key = init_credentials() + credentials_path = os.getcwd() + "credentials" + Path(credentials_path).mkdir(parents=True, exist_ok=True) + + cacaert_path = os.path.join(credentials_path, "cacert.pem") + with open(cacaert_path, mode="wb") as file: + file.write(pem_certificate) + cakey_path = os.path.join(credentials_path, "cakey.pem") + with open(cakey_path, mode="wb") as file: + file.write(pem_private_key) + print("Finished writing moonlight credentials") + + +def register_initialization_parser(parser: argparse.ArgumentParser) -> None: + parser.set_defaults(func=write_credentials) diff --git a/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/init_config.py b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/init_config.py new file mode 100644 index 00000000..71d19f22 --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/init_config.py @@ -0,0 +1,22 @@ +import argparse + +from .state import init_state + + +def read_file(file_path: str) -> str: + with open(file_path) as file: + return file.read() + + +def init_config(args: argparse.Namespace) -> None: + key = read_file(args.key) + certificate = read_file(args.certificate) + + init_state(certificate, key) + print("Finished initializing moonlight state.") + + +def register_config_initialization_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--certificate") + parser.add_argument("--key") + parser.set_defaults(func=init_config) diff --git a/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/join.py b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/join.py new file mode 100644 index 00000000..f048bb56 --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/join.py @@ -0,0 +1,131 @@ +import argparse +import base64 +import json +import socket + +from .run import MoonlightPairing +from .state import add_sunshine_host, gen_pin, get_moonlight_certificate +from .uri import parse_moonlight_uri + + +def send_join_request(host: str, port: int, cert: str) -> bool: + tries = 0 + max_tries = 3 + response = False + for tries in range(max_tries): + response = send_join_request_api(host, port) + if response: + return response + if send_join_request_native(host, port, cert): + return True + + return False + + +# This is the preferred join method, but sunshines pin mechanism +# seems to be somewhat brittle in repeated testing, retry then fallback to native +def send_join_request_api(host: str, port: int) -> bool: + moonlight = MoonlightPairing() + # is_paired = moonlight.check(host) + is_paired = False + if is_paired: + print(f"Moonlight is already paired with this host: {host}") + return True + pin = gen_pin() + moonlight.init_pairing(host, pin) + moonlight.wait_until_started() + + with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as s: + s.connect((host, port)) + json_body = {"type": "api", "pin": pin} + json_body = json.dumps(json_body) + request = ( + f"POST / HTTP/1.1\r\n" + f"Content-Type: application/json\r\n" + f"Content-Length: {len(json_body)}\r\n" + f"Connection: close\r\n\r\n" + f"{json_body}" + ) + try: + s.sendall(request.encode("utf-8")) + response = s.recv(16384).decode("utf-8") + print(response) + body = response.split("\n")[-1] + print(body) + moonlight.terminate() + return True + except Exception as e: + print(f"An error occurred: {e}") + moonlight.terminate() + return False + + +def send_join_request_native(host: str, port: int, cert: str) -> bool: + # This is the hardcoded UUID for the moonlight client + uuid = "123456789ABCD" + with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as s: + s.connect((host, port)) + encoded_cert = base64.urlsafe_b64encode(cert.encode("utf-8")).decode("utf-8") + json_body = {"type": "native", "uuid": uuid, "cert": encoded_cert} + json_body = json.dumps(json_body) + request = ( + f"POST / HTTP/1.1\r\n" + f"Content-Type: application/json\r\n" + f"Content-Length: {len(json_body)}\r\n" + f"Connection: close\r\n\r\n" + f"{json_body}" + ) + try: + s.sendall(request.encode("utf-8")) + response = s.recv(16384).decode("utf-8") + print(response) + lines = response.split("\n") + body = "\n".join(lines[2:])[2:] + print(body) + return body + except Exception as e: + print(f"An error occurred: {e}") + # TODO: fix + try: + print(f"response: {response}") + data = json.loads(response) + print(f"Data: {data}") + print(f"Host uuid: {data['uuid']}") + print(f"Host certificate: {data['cert']}") + print("Joining sunshine host") + cert = data["cert"] + cert = base64.urlsafe_b64decode(cert).decode("utf-8") + uuid = data["uuid"] + hostname = data["hostname"] + add_sunshine_host(hostname, host, cert, uuid) + except json.JSONDecodeError as e: + print(f"Failed to decode JSON: {e}") + pos = e.pos + print(f"Failed to decode JSON: unexpected character {response[pos]}") + + +def join(args: argparse.Namespace) -> None: + if args.url: + (host, port) = parse_moonlight_uri(args.url) + if port is None: + port = 48011 + else: + port = args.port + host = args.host + + print(f"Host: {host}, port: {port}") + # TODO: If cert is not provided parse from config + # cert = args.cert + cert = get_moonlight_certificate() + if send_join_request(host, port, cert): + print(f"Successfully joined sunshine host: {host}") + else: + print(f"Failed to join sunshine host: {host}") + + +def register_join_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument("url", nargs="?") + parser.add_argument("--port", type=int, default=48011) + parser.add_argument("--host") + parser.add_argument("--cert") + parser.set_defaults(func=join) diff --git a/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/run.py b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/run.py new file mode 100644 index 00000000..20a76117 --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/run.py @@ -0,0 +1,56 @@ +import subprocess +import sys +import threading + + +class MoonlightPairing: + def __init__(self) -> "MoonlightPairing": + self.process = None + self.output = "" + self.found = threading.Event() + + def init_pairing(self, host: str, pin: str) -> bool: + args = ["moonlight", "pair", host, "--pin", pin] + print("Trying to pair") + try: + self.process = subprocess.Popen( + args, stderr=subprocess.PIPE, stdout=subprocess.PIPE + ) + print("Pairing initiated") + thread = threading.Thread( + target=self.stream_output, + args=('Latest supported GFE server: "99.99.99.99"',), + ) + thread.start() + return True + except Exception as e: + print( + "Error occurred while starting the process: ", str(e), file=sys.stderr + ) + return False + + def check(self, host: str) -> bool: + try: + result = subprocess.run( + ["moonlight", "list", "localhost", host], check=True + ) + return result.returncode == 0 + except subprocess.CalledProcessError: + return False + + def terminate(self) -> None: + if self.process: + self.process.terminate() + self.process.wait() + + def stream_output(self, target_string: str) -> None: + for line in iter(self.process.stdout.readline, b""): + line = line.decode() + self.output += line + if target_string in line: + self.found.set() + break + + def wait_until_started(self) -> None: + self.found.wait() + print("Started up.") diff --git a/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/state.py b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/state.py new file mode 100644 index 00000000..3aff35d0 --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/state.py @@ -0,0 +1,148 @@ +import os +import random +import string +from configparser import ConfigParser, DuplicateSectionError, NoOptionError + + +def moonlight_config_dir() -> str: + return os.path.join( + os.path.expanduser("~"), ".config", "Moonlight Game Streaming Project" + ) + + +def moonlight_state_file() -> str: + return os.path.join(moonlight_config_dir(), "Moonlight.conf") + + +def load_state() -> ConfigParser | None: + try: + with open(moonlight_state_file()) as file: + config = ConfigParser() + config.read_file(file) + print(config.sections()) + return config + except FileNotFoundError: + print("Sunshine state file not found.") + return None + + +# prepare the string for the config file +# this is how qt handles byte arrays +def convert_string_to_bytearray(data: str) -> str: + byte_array = '"@ByteArray(' + byte_array += data.replace("\n", "\\n") + byte_array += ')"' + return byte_array + + +def convert_bytearray_to_string(byte_array: str) -> str: + if byte_array.startswith('"@ByteArray(') and byte_array.endswith(')"'): + byte_array = byte_array[12:-2] + return byte_array.replace("\\n", "\n") + + +# this must be created before moonlight is first run +def init_state(certificate: str, key: str) -> None: + print("Initializing moonlight state.") + os.makedirs(moonlight_config_dir(), exist_ok=True) + print("Initialized moonlight config directory.") + + print("Writing moonlight state file.") + # write the initial bootstrap config file + with open(moonlight_state_file(), "w") as file: + config = ConfigParser() + # bytearray ojbects are not supported by ConfigParser, + # so we need to adjust them ourselves + config.add_section("General") + config.set("General", "certificate", convert_string_to_bytearray(certificate)) + config.set("General", "key", convert_string_to_bytearray(key)) + config.set("General", "latestsupportedversion-v1", "99.99.99.99") + config.add_section("gcmapping") + config.set("gcmapping", "size", "0") + + config.write(file) + + +def write_state(data: ConfigParser) -> bool: + with open(moonlight_state_file(), "w") as file: + data.write(file) + + +def add_sunshine_host_to_parser( + config: ConfigParser, hostname: str, manual_host: str, certificate: str, uuid: str +) -> bool: + try: + config.add_section("hosts") + except DuplicateSectionError: + pass + + # amount of hosts + try: + no_hosts = int(config.get("hosts", "size")) + except NoOptionError: + no_hosts = 0 + + new_host = no_hosts + 1 + + config.set("hosts", f"{new_host}\srvcert", convert_string_to_bytearray(certificate)) + config.set("hosts", "size", str(new_host)) + config.set("hosts", f"{new_host}\\uuid", uuid) + config.set("hosts", f"{new_host}\hostname", hostname) + config.set("hosts", f"{new_host}\\nvidiasv", "false") + config.set("hosts", f"{new_host}\customname", "false") + config.set("hosts", f"{new_host}\manualaddress", manual_host) + config.set("hosts", f"{new_host}\manualport", "47989") + config.set("hosts", f"{new_host}\\remoteport", "0") + config.set("hosts", f"{new_host}\\remoteaddress", "") + config.set("hosts", f"{new_host}\localaddress", "") + config.set("hosts", f"{new_host}\localport", "0") + config.set("hosts", f"{new_host}\ipv6port", "0") + config.set("hosts", f"{new_host}\ipv6address", "") + config.set( + "hosts", f"{new_host}\mac", convert_string_to_bytearray("\\xceop\\x8d\\xfc{") + ) + add_app(config, "Desktop", new_host, 1, 881448767) + add_app(config, "Low Res Desktop", new_host, 2, 303580669) + add_app(config, "Steam Big Picture", new_host, 3, 1093255277) + + print(config.items("hosts")) + return True + + +# set default apps for the host for now +# TODO: do this dynamically +def add_app( + config: ConfigParser, name: str, host_id: int, app_id: int, app_no: int +) -> None: + identifier = f"{host_id}\\apps\{app_id}\\" + config.set("hosts", f"{identifier}appcollector", "false") + config.set("hosts", f"{identifier}directlaunch", "false") + config.set("hosts", f"{identifier}hdr", "false") + config.set("hosts", f"{identifier}hidden", "false") + config.set("hosts", f"{identifier}id", f"{app_no}") + config.set("hosts", f"{identifier}name", f"{name}") + + +def get_moonlight_certificate() -> str: + config = load_state() + if config is None: + raise FileNotFoundError("Moonlight state file not found.") + certificate = config.get("General", "certificate") + certificate = convert_bytearray_to_string(certificate) + return certificate + + +def gen_pin() -> str: + return "".join(random.choice(string.digits) for _ in range(4)) + + +def add_sunshine_host( + hostname: str, manual_host: str, certificate: str, uuid: str +) -> bool: + config = load_state() + if config is None: + return False + hostname = "test" + add_sunshine_host_to_parser(config, hostname, manual_host, certificate, uuid) + write_state(config) + return True diff --git a/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/uri.py b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/uri.py new file mode 100644 index 00000000..2b2e8193 --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/moonlight/uri.py @@ -0,0 +1,16 @@ +from urllib.parse import urlparse + + +def parse_moonlight_uri(uri: str) -> (str, str): + print(uri) + if uri.startswith("moonlight:"): + # Fixes a bug where moonlight:// is not parsed correctly + uri = uri[10:] + uri = "moonlight://" + uri + print(uri) + parsed = urlparse(uri) + if parsed.scheme != "moonlight": + raise ValueError(f"Invalid moonlight URI: {uri}") + hostname = parsed.hostname + port = parsed.port + return (hostname, port) diff --git a/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/__init__.py b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/__init__.py new file mode 100644 index 00000000..88872b0a --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/__init__.py @@ -0,0 +1,63 @@ +import argparse + +from .init_certificates import register_initialization_parser +from .init_state import register_state_initialization_parser +from .listen import register_socket_listener + + +def register_parser(parser: argparse.ArgumentParser) -> None: + subparser = parser.add_subparsers( + title="command", + description="the command to run", + help="the command to run", + required=True, + ) + + subparser.add_parser( + "generate", + # title="command", + aliases=["gen"], + description="Generate a shareable link", + help="Generate a shareable link", + ) + # TODO: add a timeout for the link + # generate.add_argument( + # "--timeout", + # default="10", + # ) + # copy = subparsers.add_parser("copy", description="Copy the link to the clipboard") + + initialization_parser = subparser.add_parser( + "init", + aliases=["i"], + description="Initialize the sunshine credentials", + help="Initialize the sunshine credentials", + ) + register_initialization_parser(initialization_parser) + + state_initialization_parser = subparser.add_parser( + "init-state", + description="Initialize the sunshine state file", + help="Initialize the sunshine state file", + ) + register_state_initialization_parser(state_initialization_parser) + + listen_parser = subparser.add_parser( + "listen", + description="Listen for incoming connections", + help="Listen for incoming connections", + ) + register_socket_listener(listen_parser) + + # TODO: Add a machine directly <- useful when using dependent secrets + # sunshine_add = subparser.add_parser( + # "add", + # aliases=["a"], + # description="Add a new moonlight machine to sunshine", + # help="Add a new moonlight machine to sunshine", + # ) + # sunshine_add.add_argument("--url", type=str, help="URL of the moonlight machine") + # sunshine_add.add_argument( + # "--cert", type=str, help="Certificate of the moonlight machine" + # ) + # sunshine_add.add_argument("--uuid", type=str, help="UUID of the moonlight machine") diff --git a/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/api.py b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/api.py new file mode 100644 index 00000000..7969802b --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/api.py @@ -0,0 +1,64 @@ +import base64 +import http.client +import json + + +def get_context() -> http.client.ssl.SSLContext: + # context = http.client.ssl.create_default_context() + # # context.load_cert_chain( + # # certfile="/var/lib/sunshine/sunshine.cert", keyfile="/var/lib/sunshine/sunshine.key" + # # ) + # context.load_cert_chain( + # certfile="/home/kenji/.config/sunshine/credentials/cacert.pem", + # keyfile="/home/kenji/.config/sunshine/credentials/cakey.pem", + # ) + return http.client.ssl._create_unverified_context() + + +def pair(pin: str) -> str: + conn = http.client.HTTPSConnection("localhost", 47990, context=get_context()) + + # TODO: dynamic username and password + user_and_pass = base64.b64encode(b"sunshine:sunshine").decode("ascii") + headers = { + "Content-Type": "application/json", + "Authorization": "Basic %s" % user_and_pass, + } + + # Define the parameters + params = json.dumps({"pin": f"{pin}"}) + + # Make the request + conn.request("POST", "/api/pin", params, headers) + + # Get and print the response + res = conn.getresponse() + data = res.read() + + print(data.decode("utf-8")) + return data.decode("utf-8") + + +def restart() -> None: + # Define the connection + conn = http.client.HTTPSConnection( + "localhost", 47990, context=http.client.ssl._create_unverified_context() + ) + user_and_pass = base64.b64encode(b"sunshine:sunshine").decode("ascii") + headers = { + "Content-Type": "application/json", + "Authorization": "Basic %s" % user_and_pass, + } + + # Define the parameters + params = {} + + # Make the request + conn.request("POST", "/api/restart", params, headers) + + # Get and print the response + # There wont be a response, because it is restarted + res = conn.getresponse() + data = res.read() + + print(data.decode("utf-8")) diff --git a/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/config.py b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/config.py new file mode 100644 index 00000000..d4dc10eb --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/config.py @@ -0,0 +1,48 @@ +import configparser +import os + +# address_family = both +# channels = 5 +# pkey = /var/lib/sunshine/sunshine.key +# cert = /var/lib/sunshine/sunshine.cert +# file_state = /var/lib/sunshine/state.json +# credentials_file = /var/lib/sunshine/credentials.json + +PSEUDO_SECTION = "DEFAULT" + + +class Config: + _instance = None + + def __new__(cls, config_location: str | None = None) -> "Config": + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance.config = configparser.ConfigParser() + config = config_location or cls._instance.default_sunshine_config_file() + cls._instance._config_location = config + with open(config) as f: + config_string = f"[{PSEUDO_SECTION}]\n" + f.read() + print(config_string) + cls._instance.config.read_string(config_string) + return cls._instance + + def config_location(self) -> str: + return self._config_location + + def default_sunshine_config_dir(self) -> str: + return os.path.join(os.path.expanduser("~"), ".config", "sunshine") + + def default_sunshine_config_file(self) -> str: + return os.path.join(self.default_sunshine_config_dir(), "sunshine.conf") + + def get_private_key(self) -> str: + return self.config.get(PSEUDO_SECTION, "pkey") + + def get_certificate(self) -> str: + return self.config.get(PSEUDO_SECTION, "cert") + + def get_state_file(self) -> str: + return self.config.get(PSEUDO_SECTION, "file_state") + + def get_credentials_file(self) -> str: + return self.config.get(PSEUDO_SECTION, "credentials_file") diff --git a/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/init_certificates.py b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/init_certificates.py new file mode 100644 index 00000000..65ae34f2 --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/init_certificates.py @@ -0,0 +1,78 @@ +import argparse +import datetime +import uuid +from pathlib import Path + +from cryptography import hazmat, x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + + +def generate_private_key() -> rsa.RSAPrivateKey: + private_key = rsa.generate_private_key( + public_exponent=65537, key_size=2048, backend=hazmat.backends.default_backend() + ) + return private_key + + +def generate_certificate(private_key: rsa.RSAPrivateKey) -> bytes: + subject = issuer = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, "Sunshine Gamestream Host"), + ] + ) + cert_builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(private_key.public_key()) + .serial_number(61093384576940497812448570031200738505731293357) + .not_valid_before(datetime.datetime(2024, 2, 27)) + .not_valid_after(datetime.datetime(2044, 2, 22)) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("localhost")]), + critical=False, + ) + .sign(private_key, hashes.SHA256(), default_backend()) + ) + pem_certificate = cert_builder.public_bytes(serialization.Encoding.PEM) + return pem_certificate + + +def private_key_to_pem(private_key: rsa.RSAPrivateKey) -> bytes: + pem_private_key = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + return pem_private_key + + +def init_credentials() -> (str, str): + private_key = generate_private_key() + certificate = generate_certificate(private_key) + private_key_pem = private_key_to_pem(private_key) + return certificate, private_key_pem + + +def uniqueid() -> str: + return str(uuid.uuid4()).upper() + + +def write_credentials(_args: argparse.Namespace) -> None: + print("Writing sunshine credentials") + pem_certificate, pem_private_key = init_credentials() + Path("credentials").mkdir(parents=True, exist_ok=True) + with open("credentials/cacert.pem", mode="wb") as file: + file.write(pem_certificate) + with open("credentials/cakey.pem", mode="wb") as file: + file.write(pem_private_key) + print("Generating sunshine UUID") + with open("uuid", mode="w") as file: + file.write(uniqueid()) + + +def register_initialization_parser(parser: argparse.ArgumentParser) -> None: + parser.set_defaults(func=write_credentials) diff --git a/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/init_state.py b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/init_state.py new file mode 100644 index 00000000..95f2028b --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/init_state.py @@ -0,0 +1,16 @@ +import argparse + +from .state import init_state + + +def init_state_file(args: argparse.Namespace) -> None: + uuid = args.uuid + state_file = args.state_file + init_state(uuid, state_file) + print("Finished initializing sunshine state file.") + + +def register_state_initialization_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--uuid") + parser.add_argument("--state-file") + parser.set_defaults(func=init_state_file) diff --git a/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/listen.py b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/listen.py new file mode 100644 index 00000000..f6f57a8e --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/listen.py @@ -0,0 +1,93 @@ +import argparse +import base64 +import json +import socket +import traceback + +from .api import pair +from .state import default_sunshine_state_file + + +# listen on a specific port for information from the moonlight side +def listen(port: int, cert: str, uuid: str, state_file: str) -> bool: + host = "" + # Create a socket object with dual-stack support + server_socket = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) + # Enable dual-stack support + server_socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + # Bind the socket to the host and port + server_socket.bind((host, port)) + # Listen for incoming connections (accept up to 5) + server_socket.listen(5) + + while True: + # Accept incoming connection + client_socket, addr = server_socket.accept() + + print(f"Connection accepted from {addr}") + + # Receive data from the client + + data = client_socket.recv(16384) + + try: + request = data.decode("utf-8") + body = request.split("\n")[-1] + print(body) + body = json.loads(f"{body}") + print(body) + + pair_type = body.get("type", "") + + if pair_type == "api": + print("Api request") + status = pair(body.get("pin", "")) + status = json.dumps(status) + response = f"HTTP/1.1 200 OK\r\nContent-Type:application/json\r\n\r\{status}\r\n" + client_socket.sendall(response.encode("utf-8")) + + if pair_type == "native": + pass + + # url = unquote(data_str.split()[1]) + # rec_uuid = parse_qs(urlparse(url).query).get("uuid", [""])[0] + # rec_cert = parse_qs(urlparse(url).query).get("cert", [""])[0] + # decoded_cert = base64.urlsafe_b64decode(rec_cert).decode("utf-8") + # print(f"Received URL: {url}") + # print(f"Extracted UUID: {rec_uuid}") + # print(f"Extracted Cert: {decoded_cert}") + encoded_cert = base64.urlsafe_b64encode(cert.encode("utf-8")).decode( + "utf-8" + ) + json_body = {} + json_body["uuid"] = uuid + json_body["cert"] = encoded_cert + json_body["hostname"] = socket.gethostname() + json_body = json.dumps(json_body) + response = f"HTTP/1.1 200 OK\r\nContent-Type:application/json\r\n\r\{json_body}\r\n" + client_socket.sendall(response.encode("utf-8")) + # add_moonlight_client(decoded_cert, state_file, rec_uuid) + + except UnicodeDecodeError: + print(f"UnicodeDecodeError: Cannot decode byte {data[8]}") + traceback.print_exc() + + client_socket.close() + + +def init_listener(args: argparse.Namespace) -> None: + port = args.port + cert = args.cert + uuid = args.uuid + state = args.state + listen(port, cert, uuid, state) + + +def register_socket_listener(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--port", default=48011, type=int) + parser.add_argument("--cert") + parser.add_argument("--uuid") + parser.add_argument("--state", default=default_sunshine_state_file()) + # TODO: auto accept + # parser.add_argument("--auto-accept") + parser.set_defaults(func=init_listener) diff --git a/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/state.py b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/state.py new file mode 100644 index 00000000..0be4dc80 --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/moonlight_sunshine_accept/sunshine/state.py @@ -0,0 +1,67 @@ +import json +import os +from typing import Any + + +def default_sunshine_config_dir() -> str: + return os.path.join(os.path.expanduser("~"), ".config", "sunshine") + + +def default_sunshine_state_file() -> str: + return os.path.join(default_sunshine_config_dir(), "sunshine_state.json") + + +def load_state(sunshine_state_path: str) -> dict[str, Any] | None: + sunshine_state_path = sunshine_state_path or default_sunshine_state_file() + print(f"Loading sunshine state from {sunshine_state_path}") + try: + with open(sunshine_state_path) as file: + config = file.read() + return config + except FileNotFoundError: + print("Sunshine state file not found.") + return None + + +# this needs to be created before sunshine is first run +def init_state(uuid: str, sunshine_state_path: str) -> None: + print("Initializing sunshine state.") + + data = {} + data["root"] = {} + data["root"]["uniqueid"] = uuid + data["root"]["devices"] = [] + + # write the initial bootstrap config file + write_state(data, sunshine_state_path) + + +def write_state(data: dict[str, Any], sunshine_state_path: str) -> None: + sunshine_state_path = sunshine_state_path or default_sunshine_state_file() + with open(sunshine_state_path, "w") as file: + json.dump(data, file, indent=4) + + +# this is used by moonlight-qt +def pseudo_uuid() -> str: + return "0123456789ABCDEF" + + +# TODO: finish this function +def add_moonlight_client(certificate: str, sunshine_state_path: str, uuid: str) -> None: + print("Adding moonlight client to sunshine state.") + state = load_state(sunshine_state_path) + if state: + state = json.loads(state) + + if not state["root"]["devices"]: + state["root"]["devices"].append( + {"uniqueid": pseudo_uuid(), "certs": [certificate]} + ) + write_state(state, sunshine_state_path) + if certificate not in state["root"]["devices"][0]["certs"]: + state["root"]["devices"][0]["certs"].append(certificate) + state["root"]["devices"][0]["uniqueid"] = pseudo_uuid() + write_state(state, sunshine_state_path) + else: + print("Moonlight certificate already added.") diff --git a/pkgs/moonlight-sunshine-accept/pyproject.toml b/pkgs/moonlight-sunshine-accept/pyproject.toml new file mode 100644 index 00000000..43a98214 --- /dev/null +++ b/pkgs/moonlight-sunshine-accept/pyproject.toml @@ -0,0 +1,9 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "moonlight-sunshine-accept" +description = "Moonlight Sunshine Bridge" +dynamic = ["version"] +scripts = { moonlight-sunshine-accept = "moonlight_sunshine_accept:main" }