Files
6e096fa286 Add real preview deployments and strengthen core boundaries (#184)
* fix(security): honour the failed-login time window (#180)

check_failed_logins computed a cutoff from since_hours and never used
it, so the Security -> Events "Failed login attempts (24h)" card and
its alert counted every failure still in /var/log/auth.log or
/var/log/secure: up to a week of SSH attempts under the usual weekly
log rotation.

Each matching line is now dated from its stamp, either RFC 3339
(rsyslog's high-precision format) or the traditional BSD syslog stamp
(no year, so a December line read in January is placed in the previous
year), and lines older than the cutoff are skipped. A line whose stamp
is not recognised is still counted rather than hidden. The log paths
move to SecurityService.AUTH_LOGS so the tests can point at a temp
file.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: separate backend startup phases and guard ordering

* refactor: route file operations through capability-checked adapters

* chore: bump version to 1.11.25 [skip ci]

* refactor: split File Manager state and views with regression guards

* test: enforce core boundaries and document extension rules

* test: cover File Manager tree races and listing failures

* fix: preserve File Manager deep links on initial mount

* refactor: keep File Manager download operations in the action hook

* fix: start agent maintenance only in the guarded runtime phase

* test: capture nginx rendering contract before extraction

* refactor: separate nginx configuration rendering from host operations

* test: enforce pure nginx configuration boundaries

* fix: require provisioned runtime before advertising previews

* feat: expose value-free effective environment provenance through app access boundary

* feat: add effective environment inspector with browser regression checks

* feat: provision isolated generic previews with retryable resource cleanup

* feat: queue preview automation and preserve cleanup ownership through app deletion

* feat: add pinned preview deployment controls and queued lifecycle feedback

* fix: preserve container port semantics for previews of slot deployments

* fix: preserve search focus and ignore stale list responses

* feat: run approved assistant deployments through persistent recipe operations

* test: run search and assistant deployment regressions in CI

* fix: refresh preview API and controller boundary inventories

* docs: refresh controller migration count after preview extraction

* fix: bind file editor previews to their storage target

* chore: bump version to 1.11.26 [skip ci]

---------

Co-authored-by: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-10-24 15:31:39 -04:00

222 lines
9.4 KiB
Python

"""Pure vhost rendering. Inputs are values; output is text or a validation error."""
import re
from dataclasses import dataclass
from typing import Dict, List, Optional
from . import templates
from .validation import _validate_domain, _validate_path
@dataclass(frozen=True)
class RenderSettings:
"""Template values, isolated from the service's filesystem/process methods."""
DOCKER_SITE_TEMPLATE: str = templates.DOCKER_SITE_TEMPLATE
IMMUTABLE_ASSET_HEADERS: str = templates.IMMUTABLE_ASSET_HEADERS
IMMUTABLE_ASSET_PATTERN: str = templates.IMMUTABLE_ASSET_PATTERN
MICROCACHE_FASTCGI_BLOCK: str = templates.MICROCACHE_FASTCGI_BLOCK
MICROCACHE_PROXY_BLOCK: str = templates.MICROCACHE_PROXY_BLOCK
MICROCACHE_SKIP_BLOCK: str = templates.MICROCACHE_SKIP_BLOCK
MICROCACHE_TTL_DEFAULT: int = templates.MICROCACHE_TTL_DEFAULT
MICROCACHE_TTL_MAX: int = templates.MICROCACHE_TTL_MAX
PHP_SITE_TEMPLATE: str = templates.PHP_SITE_TEMPLATE
PYTHON_SITE_TEMPLATE: str = templates.PYTHON_SITE_TEMPLATE
REMOTE_UPSTREAM_TEMPLATE: str = templates.REMOTE_UPSTREAM_TEMPLATE
SSL_BLOCK: str = templates.SSL_BLOCK
SSL_REDIRECT_TEMPLATE: str = templates.SSL_REDIRECT_TEMPLATE
STATIC_SITE_TEMPLATE: str = templates.STATIC_SITE_TEMPLATE
WORDPRESS_PROTECTION_BLOCK: str = templates.WORDPRESS_PROTECTION_BLOCK
DEFAULT_SETTINGS = RenderSettings()
def render_site_config(name: str, app_type: str, domains: List[str],
root_path: str = None, port: int = None,
php_version: str = '8.2',
ssl_cert: str = None, ssl_key: str = None,
upstream: str = None,
micro_cache: bool = False,
micro_cache_ttl: Optional[int] = None,
immutable_assets: bool = False,
wordpress_protection: bool = False, *,
settings: RenderSettings = DEFAULT_SETTINGS) -> Dict:
"""Render the vhost config for a site to a string — no side effects.
This is the exact template pipeline :meth:`create_site` writes to disk;
it is exposed separately so callers (e.g. drift detection) can compute
the expected on-disk content without touching the filesystem.
Returns ``{'success': True, 'config': str}`` or
``{'success': False, 'error': msg}``.
"""
if not domains:
return {'success': False, 'error': 'At least one domain is required'}
# Validate all domains
for domain in domains:
if domain == '_':
return {'success': False, 'error': 'Wildcard server_name "_" is reserved for the ServerKit panel'}
if not _validate_domain(domain):
return {'success': False, 'error': f'Invalid domain name: {domain}'}
# Validate root_path if provided
if root_path and not _validate_path(root_path):
return {'success': False, 'error': f'Invalid root path: {root_path}'}
domains_str = ' '.join(domains)
# Select template based on app type
if app_type in ['php', 'wordpress']:
config = settings.PHP_SITE_TEMPLATE.format(
name=name,
domains=domains_str,
root_path=root_path,
php_version=php_version
)
elif app_type in ['flask', 'django', 'python']:
if not port:
return {'success': False, 'error': 'Python services need a port'}
config = settings.PYTHON_SITE_TEMPLATE.format(
name=name,
domains=domains_str,
root_path=root_path,
port=port
)
elif app_type == 'docker':
if not port:
return {'success': False, 'error': 'Docker services need a port'}
config = settings.DOCKER_SITE_TEMPLATE.format(
name=name,
domains=domains_str,
port=port
)
elif app_type == 'remote':
if not upstream:
return {'success': False, 'error': 'Services on remote servers need an upstream (host:port)'}
config = settings.REMOTE_UPSTREAM_TEMPLATE.format(
name=name,
domains=domains_str,
upstream=upstream,
)
elif app_type == 'static':
config = settings.STATIC_SITE_TEMPLATE.format(
name=name,
domains=domains_str,
root_path=root_path
)
else:
return {'success': False, 'error': f'Unknown service type: {app_type}'}
if immutable_assets:
config = _with_immutable_assets(config, app_type, settings=settings)
if micro_cache:
# Inject before the SSL wrap so the redirect server block (which
# also carries a server_name line) never receives cache directives.
config = _with_micro_cache(config, app_type, micro_cache_ttl, settings=settings)
if wordpress_protection:
if app_type != 'docker' or not port:
return {
'success': False,
'error': 'WordPress protection requires a Docker proxy port',
}
config = _with_wordpress_protection(config, port, settings=settings)
if ssl_cert and ssl_key:
config = _with_ssl(config, domains_str, ssl_cert, ssl_key, settings=settings)
return {'success': True, 'config': config}
def _with_wordpress_protection(config: str, port: int, *,
settings: RenderSettings = DEFAULT_SETTINGS) -> str:
"""Add exact-match throttling locations before the catch-all proxy."""
anchor = ' location / {\n'
if anchor not in config:
return config
block = settings.WORDPRESS_PROTECTION_BLOCK.format(port=port)
return config.replace(anchor, block + anchor, 1)
def _with_immutable_assets(config: str, app_type: str, *,
settings: RenderSettings = DEFAULT_SETTINGS) -> str:
"""Add a regex location for fingerprinted assets to a proxied vhost:
the same proxying as ``location /`` plus a one-year immutable cache.
Appended after ``location /`` (a matching regex location wins over the
``/`` prefix wherever it sits) so the micro-cache injection still
anchors on the real ``location /``."""
if (app_type or '').lower() not in ('flask', 'django', 'python', 'docker', 'remote'):
return config
match = re.search(r'^ location / \{\n(.*?)^ \}\n', config, re.MULTILINE | re.DOTALL)
if not match:
return config
block = ('\n # ServerKit: fingerprinted assets never change\n'
f' location ~* "{settings.IMMUTABLE_ASSET_PATTERN}" {{\n'
+ match.group(1) + settings.IMMUTABLE_ASSET_HEADERS + ' }\n')
return config[:match.end()] + block + config[match.end():]
def _with_micro_cache(config: str, app_type: str,
ttl: Optional[int] = None, *,
settings: RenderSettings = DEFAULT_SETTINGS) -> str:
"""Inject the shared-zone micro-cache directives into a rendered vhost.
fastcgi_cache for PHP-FPM-served sites, proxy_cache for reverse-proxied
ones; static sites are a no-op (files already come straight off disk).
Scheme-agnostic — nothing here depends on HTTP vs HTTPS. The referenced
zones are declared once in conf.d by :meth:`ensure_cache_zone`.
"""
t = (app_type or '').lower()
ttl = clamp_micro_cache_ttl(ttl, settings=settings)
if t in ('php', 'wordpress'):
anchor = ' include fastcgi_params;\n'
if anchor not in config:
return config
config = config.replace(
anchor, anchor + settings.MICROCACHE_FASTCGI_BLOCK.format(ttl=ttl), 1)
elif t in ('flask', 'django', 'python', 'docker', 'remote'):
# proxy_cache_bypass may only be declared once per location, so
# fold $sk_skip_cache into the template's existing $http_upgrade
# bypass instead of adding a duplicate directive.
anchor = ' proxy_cache_bypass $http_upgrade;\n'
if anchor not in config:
return config
config = config.replace(
anchor,
' proxy_cache_bypass $sk_skip_cache $http_upgrade;\n'
+ settings.MICROCACHE_PROXY_BLOCK.format(ttl=ttl),
1,
)
else:
return config
# Server-level $sk_skip_cache logic, right after server_name.
match = re.search(r'^( server_name [^;]+;\n)', config, re.MULTILINE)
if match:
config = config.replace(
match.group(1), match.group(1) + '\n' + settings.MICROCACHE_SKIP_BLOCK, 1)
return config
def clamp_micro_cache_ttl(ttl, *, settings: RenderSettings = DEFAULT_SETTINGS) -> int:
"""A stored TTL as the seconds the vhost uses: unset → the 10s
default, anything else clamped to 1..MICROCACHE_TTL_MAX."""
if ttl in (None, ''):
return settings.MICROCACHE_TTL_DEFAULT
return max(1, min(int(ttl), settings.MICROCACHE_TTL_MAX))
def _with_ssl(http_config: str, domains: str, ssl_cert: str, ssl_key: str, *,
settings: RenderSettings = DEFAULT_SETTINGS) -> str:
"""Turn an HTTP server block into HTTPS: swap the :80 listens for the TLS
block (so the existing server serves 443) and prepend an HTTP->HTTPS
redirect. Idempotent because create_site regenerates the whole file."""
ssl_block = settings.SSL_BLOCK.format(ssl_cert=ssl_cert, ssl_key=ssl_key)
https_config = http_config.replace(
' listen 80;\n listen [::]:80;',
ssl_block.strip('\n'),
1,
)
redirect = settings.SSL_REDIRECT_TEMPLATE.format(domains=domains)
return redirect + '\n' + https_config