Quickly deploy Syncplay server
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

291 lines
12 KiB

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Syncplay Bootstrap using to convert redesigned parameter fields into arguments
that are non-intrusive to Syncplay Server. It supports command line arguments,
environment variables, JSON / YAML / TOML configuration input, and processes
them according to priority.
The command line parameters of Syncplay server are not convenient for container
startup, especially for scenarios that require specified file, which can easily
confuse people who use docker. Through this adapter, you will no longer need to
create files and specify paths, but directly configure it through the command
line or other methods.
Docs: https://syncplay.pl/guide/server/
https://man.archlinux.org/man/extra/syncplay/syncplay-server.1
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Docker Arguments ┃ Official Arguments ┃
┠───────────────────────────────╂───────────────────────────────────────┨
┃ --config [FILE] ┃ / ┃
┠───────────────────────────────╂───────────────────────────────────────┨
┃ --port [PORT] ┃ PASS-THROUGH ┃
┃ --password [PASSWD] ┃ ┃
┃ --isolate-rooms ┃ ┃
┃ --disable-chat ┃ ┃
┃ --disable-ready ┃ ┃
┠───────────────────────────────╂───────────────────────────────────────┨
┃ --motd [MESSAGE] ┃ --motd-file [FILE] ┃
┃ --salt [TEXT] & --random-salt ┃ --salt [TEXT] ┃
┃ --enable-stats ┃ --stats-db-file [FILE] ┃
┃ --enable-tls ┃ --tls [PATH] ┃
┃ --persistent ┃ --rooms-db-file [FILE] ┃
┃ --max-username [NUM] ┃ --max-username-length [NUM] ┃
┃ --max-chat-message [NUM] ┃ --max-chat-message-length [NUM] ┃
┃ --permanent-rooms [ROOM ...] ┃ --permanent-rooms-file [FILE] ┃
┃ --listen-ipv4 [ADDR] ┃ --ipv4-only & --interface-ipv4 [ADDR] ┃
┃ --listen-ipv6 [ADDR] ┃ --ipv6-only & --interface-ipv6 [ADDR] ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
"""
import os
import sys
import json
import yaml
import tomllib
import argparse
import platform
import syncplay
from types import GenericAlias
from typing import Any, TypedDict, NotRequired
class SyncplayOptions(TypedDict):
config: NotRequired[str] # special option for loading
port: NotRequired[int]
password: NotRequired[str]
motd: NotRequired[str]
salt: NotRequired[str]
random_salt: NotRequired[bool] # bool options must be True when existed
isolate_rooms: NotRequired[bool]
disable_chat: NotRequired[bool]
disable_ready: NotRequired[bool]
enable_stats: NotRequired[bool]
enable_tls: NotRequired[bool]
persistent: NotRequired[bool]
max_username: NotRequired[int]
max_chat_message: NotRequired[int]
permanent_rooms: NotRequired[list[str]]
listen_ipv4: NotRequired[str]
listen_ipv6: NotRequired[str]
DESC = {
'config': ('FILE', 'configure file path'),
'port': ('PORT', 'listen port of syncplay server'),
'password': ('PASSWD', 'authentication of syncplay server'),
'motd': ('MESSAGE', 'welcome text after the user enters the room'),
'salt': ('TEXT', 'string used to secure passwords'),
'random_salt': (None, 'use a randomly generated salt value'),
'isolate_rooms': (None, 'room isolation enabled'),
'disable_chat': (None, 'disables the chat feature'),
'disable_ready': (None, 'disables the readiness indicator feature'),
'enable_stats': (None, 'enable syncplay server statistics'),
'enable_tls': (None, 'enable tls support of syncplay server'),
'persistent': (None, 'enables room persistence'),
'max_username': ('NUM', 'maximum length of usernames'),
'max_chat_message': ('NUM', 'maximum length of chat messages'),
'permanent_rooms': ('ROOM', 'permanent rooms of syncplay server'),
'listen_ipv4': ('ADDR', 'listening address of ipv4'),
'listen_ipv6': ('ADDR', 'listening address of ipv6'),
}
ARG_OPTS: dict[str, dict] = {} # for loading cli arguments
ENV_OPTS: dict[str, type] = {} # for loading env variables
CFG_OPTS: dict[str, tuple[type, bool]] = {} # for loading configure file
def debug_msg(prefix: str, message: Any) -> None:
""" Output debug message. """
if os.environ.get('DEBUG', '').upper() in ['ON', 'TRUE']:
print(f'\033[33m{prefix}\033[0m -> \033[90m{message}\033[0m', file=sys.stderr)
def init_opts() -> None:
""" Build syncplay formatting options. """
for name, field in SyncplayOptions.__annotations__.items():
field_t, is_list = field.__args__[0], False
if type(field_t) is GenericAlias:
field_t, is_list = field_t.__args__[0], True # list[T] -> T
ENV_OPTS[name] = field_t
CFG_OPTS[name] = (field_t, is_list)
ARG_OPTS[name] = {'type': field_t, 'metavar': DESC[name][0], 'help': DESC[name][1]}
if is_list:
ENV_OPTS.pop(name) # not supported in env
ARG_OPTS[name]['nargs'] = '*' # multiple values
if field_t is bool:
ARG_OPTS[name]['action'] = 'store_true'
[ARG_OPTS[name].pop(x) for x in ('type', 'metavar')]
debug_msg('ENV_OPTS', ENV_OPTS)
debug_msg('CFG_OPTS', CFG_OPTS)
debug_msg('ARG_OPTS', ARG_OPTS)
def load_from_env() -> SyncplayOptions:
""" Load syncplay options from environment variables. """
options: SyncplayOptions = {}
for name, field_t in ENV_OPTS.items():
if name.upper() in os.environ:
value = os.environ[name.upper()]
if field_t is str:
options[name] = value
elif field_t is int:
options[name] = int(value)
elif field_t is bool:
options[name] = value.upper() in ['ON', 'TRUE']
debug_msg('Environment variables', os.environ)
return options
def load_from_args() -> SyncplayOptions:
""" Load syncplay options from command line arguments. """
def __version_msg() -> str:
python_ver = f'{platform.python_implementation()} {platform.python_version()}'
return (f'{parser.description} v{syncplay.version} '
f'({syncplay.milestone} {syncplay.release_number}) '
f'[{python_ver} {platform.machine()}]')
def __build_args(opt: str) -> list[str]:
match opt := opt.replace('_', '-'):
case 'port': return ['-p', f'--{opt}']
case 'motd': return ['-m', f'--{opt}']
case _: return [f'--{opt}']
parser = argparse.ArgumentParser(description='Syncplay Docker Bootstrap')
parser.add_argument('-v', '--version', action='version', version=__version_msg())
for name, opts in ARG_OPTS.items():
parser.add_argument(*__build_args(name), **opts)
args = parser.parse_args(sys.argv[1:])
debug_msg('Command line arguments', args)
options: SyncplayOptions = {}
for arg, value in vars(args).items():
if value is None or value is False:
continue
options[arg] = value
return options
def load_from_config(path: str) -> SyncplayOptions:
""" Load syncplay options from configure file. """
def __load_file() -> dict[str, Any]:
if not os.path.exists(path):
return {}
content = open(path).read()
if path.endswith('.json'):
return json.loads(content)
elif path.endswith('.toml'):
return tomllib.loads(content)
else:
return yaml.safe_load(content) # assume YAML format
assert type(config := __load_file()) is dict
debug_msg('Configure content', config)
options: SyncplayOptions = {}
for key, (field_t, is_list) in CFG_OPTS.items():
value = config.get(key.replace('_', '-'), None)
if value is not None:
if is_list:
assert type(value) is list
assert all(type(x) is field_t for x in value)
else:
assert type(value) is field_t
options[key] = value
return options
def load_opts() -> SyncplayOptions:
""" Combine syncplay options from multiple source. """
env_opts = load_from_env()
cli_opts = load_from_args()
cfg_opts = load_from_config((env_opts | cli_opts).get('config', 'config.yml'))
debug_msg('Environment options', env_opts)
debug_msg('Command line options', cli_opts)
debug_msg('Configure file options', cfg_opts)
final_opts: SyncplayOptions = {}
for opt, value in (env_opts | cfg_opts | cli_opts).items():
if type(value) is not bool or value:
final_opts[opt] = value
debug_msg('Bootstrap final options', final_opts)
return final_opts
def sp_convert(opts: SyncplayOptions) -> list[str]:
""" Construct the startup arguments for syncplay server. """
def __temp_file(file: str, content: str) -> str:
""" Create and save content to temporary files. """
file = os.path.join(temp_dir, file)
with open(file, 'w', encoding='utf-8') as fp:
fp.write(content)
return file
temp_dir = os.environ.get('TEMP_DIR', '/tmp/')
work_dir = os.environ.get('WORK_DIR', '/data/')
cert_dir = os.environ.get('CERT_DIR', '/certs/')
args = ['--port', f'{opts.get('port', 8999)}']
if 'password' in opts:
args += ['--password', opts['password']]
if 'motd' in opts:
args += ['--motd-file', __temp_file('motd.data', opts['motd'])]
salt = opts.get('salt', None if 'random_salt' in opts else '')
if salt is not None:
args += ['--salt', salt] # using random salt without this option
for opt in ['isolate_rooms', 'disable_chat', 'disable_ready']:
if opt in opts:
args.append(f'--{opt}'.replace('_', '-'))
if 'enable_stats' in opts:
args += ['--stats-db-file', os.path.join(work_dir, 'stats.db')]
if 'enable_tls' in opts:
args += ['--tls', cert_dir]
if 'persistent' in opts:
args += ['--rooms-db-file', os.path.join(work_dir, 'rooms.db')]
if 'max_username' in opts:
args += ['--max-username-length', str(opts['max_username'])]
if 'max_chat_message' in opts:
args += ['--max-chat-message-length', str(opts['max_chat_message'])]
if 'permanent_rooms' in opts:
rooms = '\n'.join(opts['permanent_rooms'])
args += ['--permanent-rooms-file', __temp_file('rooms.list', rooms)]
if 'listen_ipv4' in opts and 'listen_ipv6' in opts: # dual stack
args += ['--interface-ipv4', opts['listen_ipv4']]
args += ['--interface-ipv6', opts['listen_ipv6']]
elif 'listen_ipv4' in opts:
args += ['--ipv4-only', '--interface-ipv4', opts['listen_ipv4']]
elif 'listen_ipv6' in opts:
args += ['--ipv6-only', '--interface-ipv6', opts['listen_ipv6']]
return args
def boot() -> None:
init_opts()
sys.argv = ['syncplay'] + sp_convert(load_opts())
debug_msg('Syncplay startup arguments', sys.argv)
from syncplay import ep_server
sys.exit(ep_server.main())
if __name__ == '__main__':
boot()