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
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()
|
|
|