fix(config): secure-by-default loopback bind and startup-error logging

- Default `host: 127.0.0.1` in config.example.yaml; require explicit
  api_tokens or `allow_lan_without_auth: true` before binding LAN.
- Mirror pre-uvicorn fatal errors to startup-errors.log in the config
  dir so silent boot failures via wscript/pythonw are diagnosable.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 03:11:08 +03:00
parent 527f3d0aa4
commit 0cf49deac0
2 changed files with 30 additions and 14 deletions
+10 -3
View File
@@ -1,7 +1,13 @@
# Media Server Configuration # Media Server Configuration
# Copy this file to config.yaml and customize as needed. # Copy this file to config.yaml and customize as needed.
# By default, authentication is DISABLED (no tokens = open access). #
# To enable auth, uncomment and configure the api_tokens section below. # Secure-by-default: the server binds to loopback (127.0.0.1) only and refuses
# to bind a non-loopback address with no tokens configured.
#
# To expose on the LAN you must do ONE of:
# 1. Configure api_tokens below AND change host to "0.0.0.0", OR
# 2. Set `allow_lan_without_auth: true` (LAN-open, no auth — insecure on
# hostile networks, only acceptable on a trusted home LAN).
# API Tokens - Multiple tokens with friendly labels # API Tokens - Multiple tokens with friendly labels
# This allows you to identify which client is making requests in the logs # This allows you to identify which client is making requests in the logs
@@ -11,8 +17,9 @@
# web_ui: "your-web-ui-token-here" # web_ui: "your-web-ui-token-here"
# Server settings # Server settings
host: "0.0.0.0" host: "127.0.0.1"
port: 8765 port: 8765
# allow_lan_without_auth: true # uncomment + change host to 0.0.0.0 for LAN-open mode
# Custom scripts # Custom scripts
scripts: scripts:
+20 -11
View File
@@ -320,44 +320,53 @@ def main():
print("\nAuthentication is DISABLED (no tokens configured)") print("\nAuthentication is DISABLED (no tokens configured)")
return return
# Stderr is invisible when launched via wscript / pythonw (Start Menu shortcut,
# autostart). Mirror pre-uvicorn failures to a file in the config dir so the
# next silent boot failure is diagnosable.
def _fatal(msg: str, exit_code: int = 1) -> None:
print(msg, file=sys.stderr)
try:
log_path = get_config_dir() / "startup-errors.log"
from datetime import datetime
with open(log_path, "a", encoding="utf-8") as f:
f.write(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}\n")
except OSError:
pass
sys.exit(exit_code)
# First-run bootstrap: if no config has ever been written, generate one # First-run bootstrap: if no config has ever been written, generate one
# with a random token instead of starting in the insecure "no-auth" mode. # with a random token instead of starting in the insecure "no-auth" mode.
config_path = get_config_dir() / "config.yaml" config_path = get_config_dir() / "config.yaml"
if not config_path.exists() and not settings.api_tokens: if not config_path.exists() and not settings.api_tokens:
try: try:
generate_default_config(config_path) generate_default_config(config_path)
print( _fatal(
f"\nFirst run: generated default config at {config_path}.\n" f"\nFirst run: generated default config at {config_path}.\n"
"Run --show-token to retrieve the API token, then restart.", "Run --show-token to retrieve the API token, then restart.",
file=sys.stderr, exit_code=0,
) )
sys.exit(0)
except OSError as e: except OSError as e:
print(f"WARNING: could not bootstrap config: {e}", file=sys.stderr) print(f"WARNING: could not bootstrap config: {e}", file=sys.stderr)
# Refuse to bind a non-loopback address with no tokens, unless explicitly opted in. # Refuse to bind a non-loopback address with no tokens, unless explicitly opted in.
non_loopback = args.host not in ("127.0.0.1", "localhost", "::1") non_loopback = args.host not in ("127.0.0.1", "localhost", "::1")
if non_loopback and not settings.api_tokens and not settings.allow_lan_without_auth: if non_loopback and not settings.api_tokens and not settings.allow_lan_without_auth:
print( _fatal(
"ERROR: refusing to bind a non-loopback address with no api_tokens configured.\n" "ERROR: refusing to bind a non-loopback address with no api_tokens configured.\n"
"Either set api_tokens in config.yaml, bind to 127.0.0.1," "Either set api_tokens in config.yaml, bind to 127.0.0.1,"
" or set allow_lan_without_auth: true in config.yaml to override.", " or set allow_lan_without_auth: true in config.yaml to override."
file=sys.stderr,
) )
sys.exit(1)
# Check if port is available before starting # Check if port is available before starting
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try: try:
sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port)) sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port))
except OSError: except OSError:
print( _fatal(
f"ERROR: Port {args.port} is already in use. " f"ERROR: Port {args.port} is already in use. "
f"Another instance of Media Server may be running.\n" f"Another instance of Media Server may be running.\n"
f"Stop the other process or use --port to pick a different port.", f"Stop the other process or use --port to pick a different port."
file=sys.stderr,
) )
sys.exit(1)
from .tray import PYSTRAY_AVAILABLE, TrayManager from .tray import PYSTRAY_AVAILABLE, TrayManager