init: sunshine-moonlight-accept module
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user