feat: add weblink playlists, SSH player deployment, Caddy HTTPS, build player page

- Content model: add url column + is_weblink() for web page content items
- Player model: add deployment tracking fields (status, timestamps, message)
- Content blueprint: add add_weblink and add_weblink_to_playlist routes;
  weblinks auto-deleted when removed from playlist
- Players blueprint: add SSH deploy mode in add_player; weblink-aware playlist
- API blueprint: weblink URL served directly in playlist response;
  add /api/deploy/test-ssh and /api/deploy/player endpoints
- Admin blueprint: add build-player page (clone from Gitea, write base config);
  replace nginx status card with Caddy status on HTTPS config page
- caddy_manager: rewritten to generate proper HTTPS/internal-CA Caddyfiles
  and reload Caddy via admin API (/load) for live config updates
- ssh_deploy, background_tasks, player_build: new utils for SSH deployment
- background_tasks: push Flask app context into background thread so DB
  updates after deployment complete correctly
- ssh_deploy: robust install script detection with passwordless sudo injection
  (uses SSH credentials, cleaned up after install)
- Dockerfile: add git, sshpass, openssh-client, rsync
- docker-compose: switch nginx to Caddy on ports 80/443; add port 5000 for dev
- Templates: add_player deploy mode UI, weblink form in playlist/upload pages,
  build_player admin page, Caddy status on HTTPS config page
- Migrations: add_url_to_content, add_deployment_fields_to_player
- app.py: call db.create_all() on startup for schema bootstrap
- config.py: add PLAYER_CODE_DIR and PLAYER_REPO_URL settings
This commit is contained in:
2026-07-13 16:46:19 +03:00
parent ae3b82862d
commit 2af04e1db3
24 changed files with 2571 additions and 248 deletions
+101
View File
@@ -0,0 +1,101 @@
"""Background task execution for long-running operations."""
import threading
import logging
from typing import Callable, Any, Dict
logger = logging.getLogger(__name__)
def run_background_task(task_func: Callable, *args, **kwargs) -> threading.Thread:
"""Run a function in a background thread, with a Flask app context pushed."""
from flask import current_app
# Capture the app instance now (in the request context) so the thread can use it
app = current_app._get_current_object()
def wrapper():
with app.app_context():
try:
logger.info(f"Starting background task: {task_func.__name__}")
task_func(*args, **kwargs)
logger.info(f"Completed background task: {task_func.__name__}")
except Exception as e:
logger.error(f"Background task failed ({task_func.__name__}): {str(e)}", exc_info=True)
thread = threading.Thread(target=wrapper, daemon=True)
thread.start()
return thread
def background_player_deployment(
hostname: str,
username: str,
password: str,
player_name: str,
player_id: int,
port: int = 22,
server_url: str = None,
server_api_key: str = None,
player_hostname: str = None,
quickconnect_code: str = None,
orientation: str = 'Landscape',
verify_ssl: bool = False
) -> None:
"""
Deploy player code to host in background.
Args:
hostname: SSH hostname/IP
username: SSH username
password: SSH password
player_name: Player name
player_id: Player database ID
port: SSH port
server_url: DigiServer URL for player
server_api_key: API key for player
player_hostname: Player screen identity used for auth (Player.hostname)
quickconnect_code: Quick connect code used for auth
orientation: Player orientation (Landscape/Portrait)
verify_ssl: Whether the player should verify the server TLS certificate
"""
from app.utils.ssh_deploy import deploy_player_to_host
from app.models import Player
from app.extensions import db
from app.utils.logger import log_action
try:
# Execute deployment
result = deploy_player_to_host(
hostname=hostname,
username=username,
password=password,
player_name=player_name,
port=port,
server_url=server_url,
server_api_key=server_api_key,
player_hostname=player_hostname,
quickconnect_code=quickconnect_code,
orientation=orientation,
verify_ssl=verify_ssl
)
# Update player with deployment status
from datetime import datetime
player = Player.query.get(player_id)
if player:
player.last_deployment_at = datetime.utcnow()
if result.get('success'):
player.deployment_status = 'deployed'
player.last_deployment_status = 'success'
player.last_deployment_message = result.get('message', 'Deployment successful')
log_action('info', f'Background deployment completed for player "{player_name}": {result["message"]}')
else:
player.deployment_status = 'failed'
player.last_deployment_status = 'failed'
player.last_deployment_message = result.get('error', result.get('message', 'Deployment failed'))
log_action('error', f'Background deployment failed for player "{player_name}": {result.get("error", result.get("message"))}')
db.session.commit()
except Exception as e:
logger.error(f"Background deployment error for player '{player_name}': {str(e)}", exc_info=True)
log_action('error', f'Background deployment error for player "{player_name}": {str(e)}')
+116 -2
View File
@@ -3,12 +3,126 @@ import os
from typing import Optional
from app.models.https_config import HTTPSConfig
# Shared reverse-proxy snippet used in every Caddy site block
_PROXY_SNIPPET = """\
reverse_proxy digiserver-app:5000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
transport http {
read_timeout 300s
write_timeout 300s
}
}
request_body {
max_size 2GB
}
encode gzip
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
}
log {
output file /var/log/caddy/access.log
}
"""
class CaddyConfigGenerator:
"""Generate Caddyfile configuration based on HTTPSConfig."""
@staticmethod
def generate_caddyfile(config: Optional[HTTPSConfig] = None) -> str:
def generate_caddyfile(config: Optional['HTTPSConfig'] = None) -> str:
"""Generate a complete Caddyfile.
Behaviour:
- HTTPS disabled / no domain → HTTP-only on port 80 (initial deploy mode).
- HTTPS enabled + real domain → Caddy auto-provisions a Let's Encrypt cert
for that domain; HTTP redirects to HTTPS automatically.
- HTTPS enabled + IP only (no domain) → TLS with Caddy's internal CA
(self-signed, trusted within the Docker network).
"""
if config is None:
config = HTTPSConfig.get_config()
email = (config.email or "admin@localhost") if config else "admin@localhost"
https_enabled = config.https_enabled if config else False
domain = (config.domain or "").strip() if config else ""
ip_address = (config.ip_address or "").strip() if config else ""
global_block = f"""{{\n admin 0.0.0.0:2019\n email {email}\n}}\n\n"""
if https_enabled and domain:
# Caddy handles Let's Encrypt + HTTP→HTTPS redirect automatically
# when a plain hostname (no scheme) is used.
caddyfile = global_block
caddyfile += f"{domain} {{\n{_PROXY_SNIPPET}}}\n"
# Also accept requests on the raw IP (HTTP only, no cert needed)
if ip_address:
caddyfile += f"\nhttp://{ip_address} {{\n{_PROXY_SNIPPET}}}\n"
elif https_enabled and ip_address:
# No public domain — use Caddy's internal CA (self-signed)
caddyfile = global_block
caddyfile += f"https://{ip_address} {{\n tls internal\n{_PROXY_SNIPPET}}}\n"
caddyfile += f"\nhttp://{ip_address} {{\n redir https://{ip_address}{{uri}} 301\n}}\n"
else:
# HTTP-only fallback (first deploy, before HTTPS is configured)
caddyfile = "{\n admin 0.0.0.0:2019\n}\n\n"
caddyfile += f":80 {{\n{_PROXY_SNIPPET}}}\n"
return caddyfile
@staticmethod
def write_caddyfile(caddyfile_content: str,
path: str = '/etc/caddy/Caddyfile') -> bool:
"""Write Caddyfile to disk.
The default path is /etc/caddy/Caddyfile — the standard location inside
the caddy:2-alpine container when a volume is mounted there.
"""
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w') as f:
f.write(caddyfile_content)
return True
except Exception as e:
print(f"Error writing Caddyfile: {str(e)}")
return False
@staticmethod
def reload_caddy() -> bool:
"""Push the current Caddyfile to Caddy via its admin API (/load).
Caddy applies the new config live without dropping connections.
"""
try:
import urllib.request
caddyfile_path = '/etc/caddy/Caddyfile'
if not os.path.exists(caddyfile_path):
print(f"Caddyfile not found at {caddyfile_path}")
return False
with open(caddyfile_path, 'rb') as f:
caddyfile_bytes = f.read()
req = urllib.request.Request(
'http://caddy:2019/load',
data=caddyfile_bytes,
headers={'Content-Type': 'text/caddyfile'},
method='POST',
)
response = urllib.request.urlopen(req, timeout=10)
return response.status == 200
except Exception as e:
print(f"Caddy reload error: {str(e)}")
return False
"""Generate complete Caddyfile content.
Args:
+223
View File
@@ -0,0 +1,223 @@
"""Utilities for building/staging the player files on the server.
Admins use the "Build player files" admin page to:
* clone/refresh the player source code from a git repository into a local
staged directory (``PLAYER_CODE_DIR``), and
* write a base ``config/app_config.json`` so the staged code already knows how
to reach this server.
The SSH deployment flow then ships this staged directory to player devices, so
the version admins build here is exactly what gets deployed.
"""
import os
import json
import shutil
import subprocess
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from app.utils.ssh_deploy import generate_app_config
logger = logging.getLogger(__name__)
# Metadata file name stored in the Flask instance folder.
BUILD_META_FILENAME = 'player_build.json'
def _run_git(args, cwd=None, timeout=300) -> subprocess.CompletedProcess:
return subprocess.run(
['git'] + args,
cwd=cwd,
capture_output=True,
text=True,
timeout=timeout,
)
def get_short_head(player_code_dir: str) -> str:
"""Return the short git commit of the staged code, or 'unknown'."""
try:
result = _run_git(['-C', player_code_dir, 'rev-parse', '--short', 'HEAD'], timeout=10)
if result.returncode == 0:
return result.stdout.strip()
except Exception:
pass
return 'unknown'
def build_player_files(player_code_dir: str, repo_url: str, branch: str = 'main') -> Dict[str, Any]:
"""Clone or refresh the player source into ``player_code_dir``.
If the directory is already a git checkout of ``repo_url`` it is updated in
place (fetch + hard reset to the chosen branch). Otherwise it is cloned
fresh (an existing non-git directory is replaced).
Returns a dict: ``success`` (bool), ``message`` (str), ``version`` (str),
``branch`` (str).
"""
branch = (branch or 'main').strip()
repo_url = (repo_url or '').strip()
if not repo_url:
return {'success': False, 'message': 'Repository URL is required.', 'version': None, 'branch': branch}
try:
git_dir = os.path.join(player_code_dir, '.git')
is_git_repo = os.path.isdir(git_dir)
if is_git_repo:
# Update existing checkout in place.
fetch = _run_git(['-C', player_code_dir, 'fetch', '--prune', 'origin'])
if fetch.returncode != 0:
return {
'success': False,
'message': f'git fetch failed: {fetch.stderr.strip() or fetch.stdout.strip()}',
'version': get_short_head(player_code_dir),
'branch': branch,
}
# Point origin at the requested URL in case it changed.
_run_git(['-C', player_code_dir, 'remote', 'set-url', 'origin', repo_url])
checkout = _run_git(['-C', player_code_dir, 'checkout', branch])
if checkout.returncode != 0:
return {
'success': False,
'message': f'git checkout {branch} failed: {checkout.stderr.strip()}',
'version': get_short_head(player_code_dir),
'branch': branch,
}
reset = _run_git(['-C', player_code_dir, 'reset', '--hard', f'origin/{branch}'])
if reset.returncode != 0:
return {
'success': False,
'message': f'git reset failed: {reset.stderr.strip()}',
'version': get_short_head(player_code_dir),
'branch': branch,
}
action = 'Updated'
else:
# Fresh clone. Replace any existing (non-git) directory.
parent = os.path.dirname(player_code_dir.rstrip('/'))
os.makedirs(parent, exist_ok=True)
if os.path.exists(player_code_dir):
shutil.rmtree(player_code_dir)
clone = _run_git(['clone', '--branch', branch, repo_url, player_code_dir])
if clone.returncode != 0:
return {
'success': False,
'message': f'git clone failed: {clone.stderr.strip() or clone.stdout.strip()}',
'version': None,
'branch': branch,
}
action = 'Cloned'
version = get_short_head(player_code_dir)
logger.info('%s player code from %s (%s) -> %s', action, repo_url, branch, version)
return {
'success': True,
'message': f'{action} player code from {branch} (version {version}).',
'version': version,
'branch': branch,
}
except subprocess.TimeoutExpired:
return {'success': False, 'message': 'Git operation timed out.', 'version': None, 'branch': branch}
except Exception as e:
logger.exception('build_player_files failed')
return {'success': False, 'message': f'Build failed: {str(e)}', 'version': None, 'branch': branch}
def write_base_config(
player_code_dir: str,
server_ip: str,
port: str,
use_https: bool = True,
verify_ssl: bool = False,
orientation: str = 'Landscape',
max_resolution: str = '1920x1080',
) -> Dict[str, Any]:
"""Write a base ``config/app_config.json`` into the staged player code.
``screen_name`` and ``quickconnect_key`` are left blank on purpose: they are
per-player and get filled in by the SSH deploy step for each device.
"""
try:
config_dir = os.path.join(player_code_dir, 'config')
os.makedirs(config_dir, exist_ok=True)
content = generate_app_config(
server_ip=server_ip,
port=str(port),
screen_name='',
quickconnect_code='',
orientation=orientation,
use_https=use_https,
verify_ssl=verify_ssl,
max_resolution=max_resolution,
)
config_path = os.path.join(config_dir, 'app_config.json')
with open(config_path, 'w', encoding='utf-8') as f:
f.write(content)
logger.info('Wrote base player config -> %s', config_path)
return {'success': True, 'message': 'Base player config written.', 'path': config_path}
except Exception as e:
logger.exception('write_base_config failed')
return {'success': False, 'message': f'Failed to write config: {str(e)}'}
def load_build_settings(meta_path: str) -> Optional[Dict[str, Any]]:
"""Load saved build settings from ``meta_path`` (or None if absent/invalid)."""
try:
if os.path.isfile(meta_path):
with open(meta_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.warning('Could not read build settings: %s', e)
return None
def save_build_settings(meta_path: str, data: Dict[str, Any]) -> bool:
"""Persist build settings to ``meta_path``."""
try:
os.makedirs(os.path.dirname(meta_path), exist_ok=True)
with open(meta_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
return True
except Exception as e:
logger.warning('Could not save build settings: %s', e)
return False
def get_player_server_settings(meta_path: str) -> Optional[Dict[str, Any]]:
"""Return the saved server address settings for deployment, if available.
Returns a dict with ``server_ip``, ``port`` (str), ``use_https`` (bool) and
``verify_ssl`` (bool), or None when no usable build settings are saved.
"""
settings = load_build_settings(meta_path)
if not settings:
return None
server_ip = (settings.get('server_ip') or '').strip()
if not server_ip:
return None
return {
'server_ip': server_ip,
'port': str(settings.get('port') or ('443' if settings.get('use_https', True) else '80')),
'use_https': bool(settings.get('use_https', True)),
'verify_ssl': bool(settings.get('verify_ssl', False)),
}
def make_build_record(repo_url, branch, server_ip, port, use_https, verify_ssl,
orientation, max_resolution, version, built_by) -> Dict[str, Any]:
"""Assemble the metadata record to persist after a build."""
return {
'repo_url': repo_url,
'branch': branch,
'server_ip': server_ip,
'port': str(port),
'use_https': bool(use_https),
'verify_ssl': bool(verify_ssl),
'orientation': orientation,
'max_resolution': max_resolution,
'built_version': version,
'built_at': datetime.utcnow().isoformat(timespec='seconds') + 'Z',
'built_by': built_by,
}
+52
View File
@@ -0,0 +1,52 @@
"""
Portal SSO middleware for DigiServer v2.
When the umbrella nginx verifies the portal JWT it sets two headers:
X-Auth-Username — the portal username
X-Auth-Role — 'admin' or 'user'
This before_request handler reads those headers and auto-logs in the
corresponding local DigiServer user, creating them on first access if
needed. The local session is then maintained normally by Flask-Login.
"""
import secrets
from flask import request
from flask_login import login_user, current_user
def init_portal_sso(app):
"""Register the SSO before_request handler on the given Flask app."""
@app.before_request
def _portal_sso():
if current_user.is_authenticated:
return
username = request.headers.get('X-Auth-Username', '').strip()
if not username:
return
role = request.headers.get('X-Auth-Role', 'user').strip()
user = _get_or_create_user(username, role)
if user:
login_user(user, remember=False)
def _get_or_create_user(username, role):
from app.models.user import User
from app.extensions import db, bcrypt
try:
user = User.query.filter_by(username=username).first()
if not user:
hashed_pw = bcrypt.generate_password_hash(secrets.token_hex(32)).decode('utf-8')
user = User(
username=username,
password=hashed_pw,
role='admin' if role == 'admin' else 'user',
)
db.session.add(user)
db.session.commit()
return user
except Exception:
return None
+26
View File
@@ -0,0 +1,26 @@
"""
ScriptNameFix WSGI middleware.
When nginx strips the path prefix before forwarding to a Flask app it also
sets the X-Script-Name header (e.g. /digiserver). This middleware reads
that header and sets SCRIPT_NAME in the WSGI environ so that Flask's
url_for() generates absolute URLs with the correct prefix.
Usage in the app factory:
from app.utils.script_name_fix import ScriptNameFix
app.wsgi_app = ScriptNameFix(app.wsgi_app)
"""
class ScriptNameFix:
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
script_name = environ.get('HTTP_X_SCRIPT_NAME', '').rstrip('/')
if script_name:
environ['SCRIPT_NAME'] = script_name
path_info = environ.get('PATH_INFO', '/')
if path_info.startswith(script_name):
environ['PATH_INFO'] = path_info[len(script_name):] or '/'
return self.app(environ, start_response)
+753
View File
@@ -0,0 +1,753 @@
"""SSH deployment utilities for player provisioning."""
import subprocess
import logging
import os
import json
from typing import Tuple, Dict, Any, Optional
from datetime import datetime
logger = logging.getLogger(__name__)
# Pre-staged player code location in container
LOCAL_PLAYER_CODE_DIR = '/app/data/player'
def get_local_player_code_status(player_code_dir: Optional[str] = None) -> Dict[str, Any]:
"""
Check status of pre-staged player code.
Args:
player_code_dir: Optional override for the staged code path. Defaults to
``LOCAL_PLAYER_CODE_DIR`` (the container location).
Returns:
Dict with availability, version, and path info
"""
code_dir = player_code_dir or LOCAL_PLAYER_CODE_DIR
try:
if not os.path.isdir(code_dir):
return {
'available': False,
'reason': 'Directory not found',
'path': code_dir
}
# Check if git repository
git_dir = os.path.join(code_dir, '.git')
if not os.path.isdir(git_dir):
return {
'available': False,
'reason': 'Not a git repository',
'path': code_dir
}
# Get current git version
try:
result = subprocess.run(
['git', '-C', code_dir, 'rev-parse', '--short', 'HEAD'],
capture_output=True,
text=True,
timeout=5
)
version = result.stdout.strip() if result.returncode == 0 else 'unknown'
except:
version = 'unknown'
# Get directory size
try:
result = subprocess.run(
['du', '-sh', code_dir],
capture_output=True,
text=True,
timeout=5
)
size = result.stdout.split()[0] if result.returncode == 0 else 'unknown'
except:
size = 'unknown'
return {
'available': True,
'path': code_dir,
'version': version,
'size': size,
'updated': os.path.getmtime(git_dir),
'reason': 'Pre-staged code ready for deployment'
}
except Exception as e:
logger.warning(f'Error checking player code status: {str(e)}')
return {
'available': False,
'reason': f'Status check failed: {str(e)}',
'path': code_dir
}
def test_ssh_connection(hostname: str, username: str, password: str, port: int = 22) -> Dict[str, Any]:
"""
Test SSH connection to a remote host.
Args:
hostname: Target hostname or IP
username: SSH username
password: SSH password
port: SSH port (default 22)
Returns:
Dict with status, message, and timestamp
"""
try:
# Use sshpass to test connection without interactive prompt
cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-o', 'ConnectTimeout=10',
'-p', str(port),
f'{username}@{hostname}',
'echo "SSH connection successful"'
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=15
)
if result.returncode == 0:
return {
'success': True,
'message': f'SSH connection successful to {hostname}',
'timestamp': datetime.now().isoformat(),
'output': result.stdout.strip()
}
else:
error_msg = result.stderr.strip() or result.stdout.strip()
return {
'success': False,
'message': f'SSH connection failed: {error_msg}',
'timestamp': datetime.now().isoformat(),
'error': error_msg
}
except subprocess.TimeoutExpired:
return {
'success': False,
'message': f'SSH connection timeout to {hostname}',
'timestamp': datetime.now().isoformat(),
'error': 'Connection timeout (10s)'
}
except Exception as e:
logger.error(f'SSH test error: {str(e)}')
return {
'success': False,
'message': f'SSH connection error: {str(e)}',
'timestamp': datetime.now().isoformat(),
'error': str(e)
}
def generate_player_config(
player_name: str,
server_url: str,
api_key: str,
player_id: str = None,
location: str = None
) -> str:
"""
Generate player configuration JSON for connecting to DigiServer.
Args:
player_name: Name of the player
server_url: DigiServer base URL (e.g., http://localhost/digiserver)
api_key: API authentication key
player_id: Optional player ID (defaults to player_name)
location: Optional player location/description
Returns:
JSON configuration string
"""
config = {
"player": {
"name": player_name,
"id": player_id or player_name,
"location": location or "",
"version": "2.0"
},
"server": {
"url": server_url,
"api_endpoint": f"{server_url}/api",
"authentication": {
"type": "api_key",
"key": api_key
},
"endpoints": {
"playlists": f"{server_url}/api/playlists",
"content": f"{server_url}/api/content",
"schedule": f"{server_url}/api/schedule",
"heartbeat": f"{server_url}/api/player/heartbeat",
"logs": f"{server_url}/api/player/logs"
}
},
"playback": {
"audio_enabled": True,
"video_enabled": True,
"max_resolution": "4K",
"refresh_interval": 60,
"rotation": "0"
},
"networking": {
"timeout": 30,
"retry_count": 3,
"retry_delay": 5
}
}
return json.dumps(config, indent=2)
def detect_server_ip() -> Optional[str]:
"""Best-effort detection of this server's primary LAN IP address.
Opens a UDP socket toward a public address (no packets are actually sent)
and reads the local socket address, which resolves to the IP of the
interface used for outbound traffic. Returns None on failure.
"""
import socket
s = None
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
if ip and not ip.startswith('127.'):
return ip
except Exception:
pass
finally:
if s is not None:
try:
s.close()
except Exception:
pass
# Fallback via hostname resolution.
try:
ip = socket.gethostbyname(socket.gethostname())
if ip and not ip.startswith('127.'):
return ip
except Exception:
pass
return None
def parse_server_address(server_url: str) -> Dict[str, Any]:
"""Derive the values the player needs from a DigiServer URL.
The player's config/app_config.json stores server_ip + port + use_https and
builds requests as ``{scheme}://{server_ip}:{port}/api/...`` (it does NOT use
any URL path prefix such as ``/digiserver``). This helper extracts the host,
port and scheme from a server URL and drops any path component.
Args:
server_url: e.g. ``https://signage.example.com/digiserver`` or
``http://192.168.0.50:8080``
Returns:
Dict with ``server_ip`` (str), ``port`` (str) and ``use_https`` (bool).
"""
from urllib.parse import urlparse
parsed = urlparse(server_url if '://' in (server_url or '') else f'//{server_url}')
use_https = (parsed.scheme or 'https') == 'https'
host = parsed.hostname or ''
port = parsed.port
if port is None:
port = 443 if use_https else 80
return {'server_ip': host, 'port': str(port), 'use_https': use_https}
def generate_app_config(
server_ip: str,
port: str,
screen_name: str,
quickconnect_code: str,
orientation: str = 'Landscape',
use_https: bool = True,
verify_ssl: bool = False,
max_resolution: str = '1920x1080',
) -> str:
"""Generate the config/app_config.json the player actually reads.
This is what binds a deployed player to the real server and its assigned
playlist: the player authenticates with ``screen_name`` + ``quickconnect_code``
and the server returns the playlist assigned to that player.
Args:
server_ip: Server IP or domain the player should contact.
port: Server port as a string.
screen_name: Player hostname / screen identity (matches Player.hostname).
quickconnect_code: Quick connect code (matches Player.quickconnect_code).
orientation: Landscape or Portrait.
use_https: Whether the player should use HTTPS.
verify_ssl: Whether the player should verify the TLS certificate.
max_resolution: Maximum playback resolution.
Returns:
JSON configuration string.
"""
config = {
'server_ip': server_ip,
'port': str(port),
'screen_name': screen_name,
'quickconnect_key': quickconnect_code,
'orientation': orientation or 'Landscape',
'touch': 'True',
'max_resolution': max_resolution,
'edit_feature_enabled': True,
'use_https': bool(use_https),
'verify_ssl': bool(verify_ssl),
}
return json.dumps(config, indent=2)
def deploy_player_to_host(
hostname: str,
username: str,
password: str,
player_name: str,
repo_url: str = 'https://gitea.moto-adv.com/ske087/Kiwy-Signage.git',
deploy_path: str = None, # Default: /home/[user]/kiwy-signage
port: int = 22,
server_url: str = None, # DigiServer URL for player to connect to
server_api_key: str = None, # API key for player authentication
player_hostname: str = None, # Player screen identity (Player.hostname)
quickconnect_code: str = None, # Player quick connect code
orientation: str = 'Landscape', # Player orientation
verify_ssl: bool = False, # Whether the player should verify TLS
) -> Dict[str, Any]:
"""
Deploy player code to remote host.
Args:
hostname: Target hostname or IP
username: SSH username
password: SSH password
player_name: Name for the player instance
repo_url: Git repository URL
deploy_path: Path where to deploy on remote host (default: /home/[user]/kiwy-signage)
port: SSH port (default 22)
server_url: DigiServer URL for player connection
server_api_key: API key for player authentication
player_hostname: Player screen identity used for auth (Player.hostname)
quickconnect_code: Quick connect code used for auth (Player.quickconnect_code)
orientation: Player orientation (Landscape/Portrait)
verify_ssl: Whether the player should verify the server TLS certificate
Returns:
Dict with deployment status and output
"""
# Set default deployment path to user's home directory
if deploy_path is None:
deploy_path = f'/home/{username}/kiwy-signage'
try:
# Step 1: Verify host accessibility
test_result = test_ssh_connection(hostname, username, password, port)
if not test_result['success']:
return {
'success': False,
'message': 'Cannot deploy: SSH connection failed',
'timestamp': datetime.now().isoformat(),
'error': test_result['message'],
'steps': []
}
steps = [
{
'step': 'SSH Connection Test',
'status': 'completed',
'message': 'SSH connection successful',
'timestamp': datetime.now().isoformat()
}
]
# Step 2: Create deployment directory
try:
mkdir_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'mkdir -p {deploy_path}'
]
result = subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=30)
steps.append({
'step': 'Create Deploy Directory',
'status': 'completed' if result.returncode == 0 else 'failed',
'message': f'Directory {deploy_path} created',
'timestamp': datetime.now().isoformat()
})
except Exception as e:
steps.append({
'step': 'Create Deploy Directory',
'status': 'failed',
'message': f'Failed: {str(e)}',
'timestamp': datetime.now().isoformat()
})
return {
'success': False,
'message': f'Deployment failed at step: Create Deploy Directory',
'timestamp': datetime.now().isoformat(),
'error': str(e),
'steps': steps
}
# Step 3: Deploy code (use local if available, otherwise clone from git)
try:
code_status = get_local_player_code_status()
if code_status['available']:
# Use pre-staged player code via rsync
logger.info(f'Using pre-staged player code (version: {code_status.get("version", "unknown")})')
rsync_cmd = [
'sshpass', '-p', password,
'rsync', '-avz',
'--delete',
'-e', f'ssh -o StrictHostKeyChecking=no -p {port}',
f'{LOCAL_PLAYER_CODE_DIR}/',
f'{username}@{hostname}:{deploy_path}/'
]
result = subprocess.run(rsync_cmd, capture_output=True, text=True, timeout=300)
steps.append({
'step': 'Deploy Code',
'status': 'completed' if result.returncode == 0 else 'failed',
'message': f'Code deployed via rsync (version: {code_status.get("version", "local")})',
'timestamp': datetime.now().isoformat()
})
if result.returncode != 0:
logger.warning(f'Rsync failed, falling back to git clone: {result.stderr}')
# Fall back to git clone
raise Exception('Rsync failed, retrying with git')
else:
# No local code, clone from repository
logger.info(f'No pre-staged code available ({code_status.get("reason", "unknown")}), cloning from repository')
raise Exception('Local code not available')
except Exception as rsync_error:
# Fallback: Clone or pull repository
try:
logger.info(f'Deploying via git: {rsync_error}')
# Check if repo already exists
check_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'[ -d {deploy_path}/.git ]'
]
result = subprocess.run(check_cmd, capture_output=True, text=True, timeout=10)
if result.returncode == 0:
# Repo exists, pull latest
git_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'cd {deploy_path} && git pull origin main 2>&1'
]
git_msg = 'Pull latest code'
else:
# Clone repository
git_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'git clone {repo_url} {deploy_path} 2>&1'
]
git_msg = 'Clone repository'
result = subprocess.run(git_cmd, capture_output=True, text=True, timeout=120)
steps.append({
'step': 'Deploy Code',
'status': 'completed' if result.returncode == 0 else 'failed',
'message': f'{git_msg}: {result.stdout.split(chr(10))[0][:100]}',
'timestamp': datetime.now().isoformat()
})
if result.returncode != 0:
return {
'success': False,
'message': f'Deployment failed at step: Deploy Code',
'timestamp': datetime.now().isoformat(),
'error': result.stderr or result.stdout,
'steps': steps
}
except Exception as e:
steps.append({
'step': 'Deploy Code',
'status': 'failed',
'message': f'Failed: {str(e)}',
'timestamp': datetime.now().isoformat()
})
return {
'success': False,
'message': f'Deployment failed at step: Deploy Code',
'timestamp': datetime.now().isoformat(),
'error': str(e),
'steps': steps
}
# Step 3.5: Generate player configuration (config/app_config.json)
# This is the file the player actually reads to learn the server address
# and its screen identity. Authenticating with that identity is what binds
# the player to its assigned playlist on the real server.
install_env_prefix = ''
try:
screen_name = player_hostname or player_name
if server_url and screen_name and quickconnect_code:
addr = parse_server_address(server_url)
app_config_content = generate_app_config(
server_ip=addr['server_ip'],
port=addr['port'],
screen_name=screen_name,
quickconnect_code=quickconnect_code,
orientation=orientation or 'Landscape',
use_https=addr['use_https'],
verify_ssl=verify_ssl,
)
# Build an env prefix so install.sh's configure_player() also runs
# (single, consistent configuration path on the player side).
import shlex
env_pairs = {
'KIWY_SERVER_IP': addr['server_ip'],
'KIWY_PORT': addr['port'],
'KIWY_SCREEN_NAME': screen_name,
'KIWY_QUICKCONNECT': quickconnect_code,
'KIWY_ORIENTATION': orientation or 'Landscape',
'KIWY_USE_HTTPS': 'true' if addr['use_https'] else 'false',
'KIWY_VERIFY_SSL': 'true' if verify_ssl else 'false',
}
install_env_prefix = ' '.join(
f'{k}={shlex.quote(str(v))}' for k, v in env_pairs.items()
) + ' '
# Write config/app_config.json directly (robust even if install.sh
# is missing or fails), and clear any stale baked-in auth.
remote_cmd = (
f'mkdir -p {deploy_path}/config && '
f"cat > {deploy_path}/config/app_config.json << 'EOF'\n"
f'{app_config_content}\n'
f'EOF\n'
f'rm -f {deploy_path}/player_auth.json {deploy_path}/src/player_auth.json 2>/dev/null || true'
)
write_config_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
remote_cmd
]
result = subprocess.run(write_config_cmd, capture_output=True, text=True, timeout=30)
steps.append({
'step': 'Configure Player',
'status': 'completed' if result.returncode == 0 else 'warning',
'message': (
f'Wrote config/app_config.json '
f'(server {addr["server_ip"]}:{addr["port"]}, screen {screen_name})'
),
'timestamp': datetime.now().isoformat()
})
else:
steps.append({
'step': 'Configure Player',
'status': 'skipped',
'message': 'Missing server_url / player hostname / quickconnect; player not auto-configured',
'timestamp': datetime.now().isoformat()
})
except Exception as e:
logger.warning(f'Failed to create player config: {str(e)}')
steps.append({
'step': 'Configure Player',
'status': 'warning',
'message': f'Failed to write player config: {str(e)}',
'timestamp': datetime.now().isoformat()
})
# Step 4: Run installation script
# Before running the install script, grant the SSH user temporary
# passwordless sudo so that any 'sudo apt-get / pip install' calls inside
# install.sh don't hang waiting for an interactive password prompt.
# The sudoers entry is removed automatically after the script finishes.
try:
sudoers_file = f'/etc/sudoers.d/kiwy_deploy_{username}'
setup_sudo_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
(
f'echo {password!r} | sudo -S bash -c '
f'"echo \\"{username} ALL=(ALL) NOPASSWD:ALL\\" '
f'> {sudoers_file} && chmod 440 {sudoers_file}" 2>&1'
)
]
sudo_result = subprocess.run(setup_sudo_cmd, capture_output=True, text=True, timeout=15)
sudo_configured = sudo_result.returncode == 0
if not sudo_configured:
logger.warning(f'Could not configure passwordless sudo: {sudo_result.stderr[:200]}')
except Exception as e:
sudo_configured = False
logger.warning(f'Passwordless sudo setup failed: {str(e)}')
try:
# Build a shell one-liner: run the first install script found.
# Priority: install.sh > setup.sh > install_player.sh > any *.sh except start.sh
run_install_cmd = (
f'cd {deploy_path} && '
f'INSTALL_SCRIPT="" && '
f'for s in install.sh setup.sh install_player.sh; do '
f' if [ -f "$s" ]; then INSTALL_SCRIPT="$s"; break; fi; '
f'done && '
f'if [ -z "$INSTALL_SCRIPT" ]; then '
f' INSTALL_SCRIPT=$(ls *.sh 2>/dev/null | grep -v "^start.sh$" | head -1); '
f'fi && '
f'if [ -n "$INSTALL_SCRIPT" ]; then '
f' chmod +x "$INSTALL_SCRIPT" && '
f' echo "Running $INSTALL_SCRIPT" && '
f' {install_env_prefix}bash "$INSTALL_SCRIPT" 2>&1; '
f' echo "Exit code: $?"; '
f'else '
f' echo "No install script found"; '
f'fi'
)
install_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
run_install_cmd
]
result = subprocess.run(install_cmd, capture_output=True, text=True, timeout=600)
output = (result.stdout or '').strip()
if 'No install script found' in output:
steps.append({
'step': 'Run Installation Script',
'status': 'skipped',
'message': 'No install script found in deploy directory',
'timestamp': datetime.now().isoformat()
})
else:
script_line = next((l for l in output.splitlines() if l.startswith('Running ')), '')
script_name = script_line.replace('Running ', '').strip() or 'install script'
steps.append({
'step': 'Run Installation Script',
'status': 'completed' if result.returncode == 0 else 'completed_with_warnings',
'message': f'Executed {script_name} (exit {result.returncode})',
'timestamp': datetime.now().isoformat()
})
if result.returncode != 0:
logger.warning(f'Install script exited {result.returncode}: {result.stderr[:200]}')
else:
logger.info(f'Install script completed successfully on {hostname}')
except subprocess.TimeoutExpired:
steps.append({
'step': 'Run Installation Script',
'status': 'completed_with_warnings',
'message': 'Install script timed out after 600s — it may still be running on the device',
'timestamp': datetime.now().isoformat()
})
except Exception as e:
steps.append({
'step': 'Run Installation Script',
'status': 'error',
'message': f'Error running installation: {str(e)}',
'timestamp': datetime.now().isoformat()
})
logger.error(f'Installation script error: {str(e)}')
finally:
# Always clean up the temporary sudoers entry
if sudo_configured:
try:
cleanup_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'sudo rm -f {sudoers_file} 2>/dev/null || true'
]
subprocess.run(cleanup_cmd, capture_output=True, text=True, timeout=10)
except Exception:
pass
# Step 5: Start player service (execute start.sh)
try:
# Check if start.sh exists
check_start = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'[ -f {deploy_path}/start.sh ]'
]
start_check = subprocess.run(check_start, capture_output=True, text=True, timeout=10)
if start_check.returncode == 0:
# Make sure start.sh is executable and run it
start_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'cd {deploy_path} && chmod +x start.sh && bash start.sh 2>&1'
]
result = subprocess.run(start_cmd, capture_output=True, text=True, timeout=300)
# Capture first line of output for feedback
output_msg = result.stdout.split('\n')[0][:100] if result.stdout else 'Started'
steps.append({
'step': 'Start Player Service',
'status': 'completed' if result.returncode == 0 else 'completed_with_warnings',
'message': f'Player service started: {output_msg}',
'timestamp': datetime.now().isoformat()
})
logger.info(f'Player service started on {hostname} at {deploy_path}')
else:
steps.append({
'step': 'Start Player Service',
'status': 'warning',
'message': 'start.sh not found - player may require manual startup',
'timestamp': datetime.now().isoformat()
})
logger.warning(f'start.sh not found at {deploy_path}/start.sh on {hostname}')
except Exception as e:
steps.append({
'step': 'Start Player Service',
'status': 'error',
'message': f'Error starting player service: {str(e)}',
'timestamp': datetime.now().isoformat()
})
logger.error(f'Failed to start player service: {str(e)}')
return {
'success': True,
'message': f'Player "{player_name}" deployed successfully to {hostname}',
'timestamp': datetime.now().isoformat(),
'deploy_path': deploy_path,
'steps': steps
}
except Exception as e:
logger.error(f'Deployment error: {str(e)}')
return {
'success': False,
'message': f'Unexpected deployment error: {str(e)}',
'timestamp': datetime.now().isoformat(),
'error': str(e),
'steps': []
}