wasm-cli 1.1.0__tar.gz → 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {wasm_cli-1.1.0/src/wasm_cli.egg-info → wasm_cli-1.2.0}/PKG-INFO +1 -1
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/pyproject.toml +1 -1
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/setup.py +1 -1
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/__init__.py +1 -1
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/web.py +99 -134
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/webapp.py +5 -11
- wasm_cli-1.2.0/src/wasm/core/net.py +252 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/registry.py +24 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/monitor/process_monitor.py +10 -1
- wasm_cli-1.2.0/src/wasm/web/events.py +214 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/jobs.py +20 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/server.py +125 -39
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/app.css +357 -10
- wasm_cli-1.2.0/src/wasm/web/static/panel.js +548 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/_macros.html +34 -6
- wasm_cli-1.2.0/src/wasm/web/templates/base.html +169 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/login.html +26 -11
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/activity.html +7 -6
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/app.html +21 -17
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/backups.html +4 -1
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/certificates.html +12 -5
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/dashboard.html +8 -8
- wasm_cli-1.2.0/src/wasm/web/templates/pages/deploy.html +123 -0
- wasm_cli-1.2.0/src/wasm/web/templates/pages/failure.html +31 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/resources.html +13 -3
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/settings.html +2 -2
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/views/context.py +8 -1
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/views/resources.py +146 -1
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/views/router.py +301 -2
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/websockets/router.py +43 -12
- {wasm_cli-1.1.0 → wasm_cli-1.2.0/src/wasm_cli.egg-info}/PKG-INFO +1 -1
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm_cli.egg-info/SOURCES.txt +4 -1
- wasm_cli-1.1.0/src/wasm/web/static/panel.js +0 -233
- wasm_cli-1.1.0/src/wasm/web/static/vendor/alpine.min.js +0 -5
- wasm_cli-1.1.0/src/wasm/web/templates/base.html +0 -124
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/LICENSE +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/MANIFEST.in +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/README.md +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/docs/MONITOR.md +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/docs/OBS_SETUP.md +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/docs/assets/logo.png +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/docs/assets/logo_bg.png +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/docs/superpowers/specs/2026-08-12-wasm-panel-design-direction.md +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/docs/superpowers/specs/2026-08-12-wasm-v1-refactor-design.md +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/man/wasm.1 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/setup.cfg +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/__main__.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/__init__.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/app.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/__init__.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/backup.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/cert.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/config.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/db.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/env.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/health.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/monitor.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/service.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/setup.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/site.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/store.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/commands/version.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/interactive.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/cli/prompts.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/completions/README.md +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/completions/_wasm +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/completions/wasm.bash +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/completions/wasm.fish +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/__init__.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/app_state.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/config.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/dependencies.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/exceptions.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/fs.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/logger.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/runner.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/store.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/update_checker.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/core/utils.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/__init__.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/auto.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/base.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/docker_compose.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/__init__.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/env_manager.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/health.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/nginx_config.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/package_manager.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/path_resolver.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/preflight.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/prisma.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/registration.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/summary.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/turbo.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/helpers/workspace.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/interface.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/monorepo.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/nextjs.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/nodejs.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/pipeline.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/python.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/static.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/deployers/vite.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/main.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/__init__.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/apache_manager.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/backup_manager.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/backup_scheduler.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/base_manager.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/cert_manager.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/database/__init__.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/database/base.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/database/mongodb.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/database/mysql.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/database/postgres.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/database/redis.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/database/registry.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/nginx_manager.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/service_manager.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/source_manager.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/managers/webserver.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/monitor/__init__.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/monitor/email_notifier.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/monitor/metrics.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/monitor/models.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/monitor/observation_store.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/monitor/signals.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/__init__.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/apache/proxy.conf.j2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/apache/static.conf.j2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/nginx/advanced.conf.j2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/nginx/monorepo.conf.j2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/nginx/proxy.conf.j2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/nginx/static.conf.j2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/systemd/_escape.j2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/systemd/app.service.j2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/systemd/backup-service.j2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/systemd/backup-timer.j2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/templates/systemd/docker-compose.service.j2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/validators/__init__.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/validators/domain.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/validators/environment.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/validators/names.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/validators/port.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/validators/source.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/validators/ssh.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/__init__.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/__init__.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/apps.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/auth.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/backups.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/certs.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/config.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/databases.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/deps.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/jobs.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/monitor.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/router.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/services.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/sites.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/api/system.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/auth.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/logo.png +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/logo.webp +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-sans-condensed-latin-500-normal.woff2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-sans-condensed-latin-600-normal.woff2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/htmx.min.js +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/uplot.min.css +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/uplot.min.js +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/xterm.css +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/static/vendor/xterm.js +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/fragments/machine.html +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/templates/pages/missing.html +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/views/__init__.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/views/machine.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/views/rendering.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm/web/websockets/__init__.py +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm_cli.egg-info/dependency_links.txt +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm_cli.egg-info/entry_points.txt +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm_cli.egg-info/requires.txt +0 -0
- {wasm_cli-1.1.0 → wasm_cli-1.2.0}/src/wasm_cli.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "wasm-cli"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.2.0"
|
|
8
8
|
description = "Web App System Management - Deploy and manage web applications on Linux servers"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "WASM-NCSAL" }
|
|
@@ -9,7 +9,7 @@ from setuptools import setup, find_packages
|
|
|
9
9
|
|
|
10
10
|
setup(
|
|
11
11
|
name="wasm-cli",
|
|
12
|
-
version="1.
|
|
12
|
+
version="1.2.0",
|
|
13
13
|
description="Web App System Management - Deploy and manage web applications on Linux servers",
|
|
14
14
|
author="Yago López Prado",
|
|
15
15
|
author_email="yago.lopez.adeje@gmail.com",
|
|
@@ -15,7 +15,7 @@ from importlib.metadata import version as _installed_version
|
|
|
15
15
|
#: as an OBS build directory. The single source of truth is the ``version``
|
|
16
16
|
#: field of pyproject.toml; ``scripts/release.py`` keeps this literal and the
|
|
17
17
|
#: distribution packaging files in step with it.
|
|
18
|
-
_FALLBACK_VERSION = "1.
|
|
18
|
+
_FALLBACK_VERSION = "1.2.0"
|
|
19
19
|
|
|
20
20
|
try:
|
|
21
21
|
__version__ = _installed_version("wasm-cli")
|
|
@@ -40,7 +40,6 @@ Two structural notes about the Click migration:
|
|
|
40
40
|
from __future__ import annotations
|
|
41
41
|
|
|
42
42
|
import importlib.util
|
|
43
|
-
import ipaddress
|
|
44
43
|
import os
|
|
45
44
|
import signal
|
|
46
45
|
import socket
|
|
@@ -59,6 +58,13 @@ from wasm.cli.app import Context, enable_dry_run, pass_context
|
|
|
59
58
|
from wasm.core.exceptions import SecurityError, WASMError
|
|
60
59
|
from wasm.core.fs import get_fs
|
|
61
60
|
from wasm.core.logger import Logger, set_colors_disabled
|
|
61
|
+
from wasm.core.net import (
|
|
62
|
+
host_addresses,
|
|
63
|
+
is_loopback_host,
|
|
64
|
+
loopback_access_lines,
|
|
65
|
+
normalize_host,
|
|
66
|
+
strip_brackets,
|
|
67
|
+
)
|
|
62
68
|
from wasm.core.runner import get_runner
|
|
63
69
|
|
|
64
70
|
if TYPE_CHECKING:
|
|
@@ -68,23 +74,18 @@ if TYPE_CHECKING:
|
|
|
68
74
|
PID_FILE = Path("/var/run/wasm-web.pid")
|
|
69
75
|
PID_FILE_USER = Path.home() / ".wasm" / "web.pid"
|
|
70
76
|
|
|
71
|
-
#: The address a socket is given when it should answer on every interface. It
|
|
72
|
-
#: is named here to be recognised and refused, never bound by default.
|
|
73
|
-
ALL_INTERFACES = "0.0.0.0" # noqa: S104
|
|
74
|
-
|
|
75
|
-
#: Spellings of "every interface" that no resolver accepts, so they cannot be
|
|
76
|
-
#: classified by looking them up. The empty string is the one that mattered: a
|
|
77
|
-
#: version of this module kept a set of loopback *strings* with "" in it, and
|
|
78
|
-
#: ``wasm web start --host ""`` walked past the refusal below and bound the
|
|
79
|
-
#: root panel to every interface in cleartext.
|
|
80
|
-
WILDCARD_SPELLINGS = frozenset({"", "*"})
|
|
81
|
-
|
|
82
77
|
#: Package installation is slow on a cold cache but must not hang a session.
|
|
83
78
|
INSTALL_TIMEOUT = 900
|
|
84
79
|
|
|
85
80
|
#: Seconds between stopping and starting again, so the port is released.
|
|
86
81
|
RESTART_PAUSE = 1
|
|
87
82
|
|
|
83
|
+
#: How far past a taken port to look for a free one to name. The panel is not
|
|
84
|
+
#: moved automatically - see :func:`_start` - but the port offered instead has
|
|
85
|
+
#: to be one that will actually work, and the search has to finish while the
|
|
86
|
+
#: operator is still reading the error.
|
|
87
|
+
PORT_SUGGESTION_SPAN = 20
|
|
88
|
+
|
|
88
89
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
89
90
|
|
|
90
91
|
|
|
@@ -208,110 +209,6 @@ def _exit(code: int) -> NoReturn:
|
|
|
208
209
|
# How the panel is exposed
|
|
209
210
|
# ---------------------------------------------------------------------------
|
|
210
211
|
|
|
211
|
-
#: Address family alias, for the helpers below.
|
|
212
|
-
IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def _strip_brackets(host: str) -> str:
|
|
216
|
-
"""
|
|
217
|
-
Remove the brackets an IPv6 literal is often written with.
|
|
218
|
-
|
|
219
|
-
Args:
|
|
220
|
-
host: The spelling the operator typed.
|
|
221
|
-
|
|
222
|
-
Returns:
|
|
223
|
-
The spelling without a surrounding ``[]`` pair.
|
|
224
|
-
"""
|
|
225
|
-
candidate = host.strip()
|
|
226
|
-
if len(candidate) > 1 and candidate.startswith("[") and candidate.endswith("]"):
|
|
227
|
-
return candidate[1:-1]
|
|
228
|
-
return candidate
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def _host_addresses(host: str) -> tuple[IPAddress, ...]:
|
|
232
|
-
"""
|
|
233
|
-
Return every address a host spelling would end up bound to.
|
|
234
|
-
|
|
235
|
-
Comparing host strings is what made this decision wrong: "" , "*", "0",
|
|
236
|
-
"0.0.0.0", "::" and a name in ``/etc/hosts`` pointing at 0.0.0.0 are six
|
|
237
|
-
spellings of the same socket, and a set of known-good strings misses five
|
|
238
|
-
of them. The resolver is asked instead, and its answer is classified with
|
|
239
|
-
:mod:`ipaddress`.
|
|
240
|
-
|
|
241
|
-
Args:
|
|
242
|
-
host: The spelling the operator typed.
|
|
243
|
-
|
|
244
|
-
Returns:
|
|
245
|
-
The addresses, empty when the spelling cannot be resolved. Empty means
|
|
246
|
-
"unknown", and every caller treats unknown as exposed.
|
|
247
|
-
"""
|
|
248
|
-
candidate = _strip_brackets(host)
|
|
249
|
-
if candidate in WILDCARD_SPELLINGS:
|
|
250
|
-
return (ipaddress.ip_address(ALL_INTERFACES),)
|
|
251
|
-
|
|
252
|
-
try:
|
|
253
|
-
return (ipaddress.ip_address(candidate),)
|
|
254
|
-
except ValueError:
|
|
255
|
-
pass
|
|
256
|
-
|
|
257
|
-
try:
|
|
258
|
-
# A resolution covers names and also the inet_aton spellings ipaddress
|
|
259
|
-
# refuses but a socket accepts, such as "0" (INADDR_ANY) and "127.1".
|
|
260
|
-
infos = socket.getaddrinfo(candidate, None, type=socket.SOCK_STREAM)
|
|
261
|
-
except (OSError, UnicodeError, ValueError):
|
|
262
|
-
return ()
|
|
263
|
-
|
|
264
|
-
addresses = []
|
|
265
|
-
for info in infos:
|
|
266
|
-
# A link-local sockaddr carries a %scope suffix that ipaddress refuses.
|
|
267
|
-
text = str(info[4][0]).split("%", 1)[0]
|
|
268
|
-
try:
|
|
269
|
-
addresses.append(ipaddress.ip_address(text))
|
|
270
|
-
except ValueError:
|
|
271
|
-
return ()
|
|
272
|
-
return tuple(addresses)
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
def _is_loopback_host(host: str) -> bool:
|
|
276
|
-
"""
|
|
277
|
-
Report whether only this machine could reach a panel bound to a host.
|
|
278
|
-
|
|
279
|
-
Args:
|
|
280
|
-
host: The spelling the operator typed.
|
|
281
|
-
|
|
282
|
-
Returns:
|
|
283
|
-
True only when the spelling is known to resolve to loopback addresses
|
|
284
|
-
and nothing else. A spelling that cannot be resolved is not loopback,
|
|
285
|
-
because guessing in the other direction publishes a root shell.
|
|
286
|
-
"""
|
|
287
|
-
addresses = _host_addresses(host)
|
|
288
|
-
return bool(addresses) and all(address.is_loopback for address in addresses)
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
def _normalize_host(host: str) -> str:
|
|
292
|
-
"""
|
|
293
|
-
Canonicalise a host spelling so what is checked is what is reported.
|
|
294
|
-
|
|
295
|
-
Args:
|
|
296
|
-
host: The spelling the operator typed.
|
|
297
|
-
|
|
298
|
-
Returns:
|
|
299
|
-
The canonical spelling. Every way of writing "every interface" becomes
|
|
300
|
-
the address it binds, so the refusal below names a real address instead
|
|
301
|
-
of quoting an empty string back at the operator.
|
|
302
|
-
"""
|
|
303
|
-
candidate = _strip_brackets(host)
|
|
304
|
-
addresses = _host_addresses(candidate)
|
|
305
|
-
if not addresses:
|
|
306
|
-
return candidate
|
|
307
|
-
if candidate in WILDCARD_SPELLINGS or all(address.is_unspecified for address in addresses):
|
|
308
|
-
return str(addresses[0])
|
|
309
|
-
try:
|
|
310
|
-
return str(ipaddress.ip_address(candidate))
|
|
311
|
-
except ValueError:
|
|
312
|
-
# A name: binding it is the resolver's business, not this module's.
|
|
313
|
-
return candidate
|
|
314
|
-
|
|
315
212
|
|
|
316
213
|
@dataclass(frozen=True)
|
|
317
214
|
class StartOptions:
|
|
@@ -422,11 +319,11 @@ def _build_security_config(options: StartOptions) -> SecurityConfig:
|
|
|
422
319
|
|
|
423
320
|
# Normalised first, so the exposure decision, the message and the address
|
|
424
321
|
# that is finally bound all talk about the same thing.
|
|
425
|
-
host =
|
|
322
|
+
host = normalize_host(options.host)
|
|
426
323
|
port = options.port
|
|
427
324
|
whitelist = list(options.allow_ip)
|
|
428
325
|
proxies = list(options.trusted_proxy)
|
|
429
|
-
local_only =
|
|
326
|
+
local_only = is_loopback_host(host)
|
|
430
327
|
|
|
431
328
|
if options.require_https and not (options.tls_cert and options.tls_key):
|
|
432
329
|
raise SecurityError(
|
|
@@ -443,7 +340,7 @@ def _build_security_config(options: StartOptions) -> SecurityConfig:
|
|
|
443
340
|
unresolved = (
|
|
444
341
|
f"WASM could not resolve {host!r}, so it cannot show that only this machine "
|
|
445
342
|
"would reach the panel, and treats it as exposed.\n"
|
|
446
|
-
if not
|
|
343
|
+
if not host_addresses(host)
|
|
447
344
|
else ""
|
|
448
345
|
)
|
|
449
346
|
raise SecurityError(
|
|
@@ -723,12 +620,12 @@ def _port_in_use(host: str, port: int) -> bool:
|
|
|
723
620
|
Returns:
|
|
724
621
|
True when binding would fail because something holds the address.
|
|
725
622
|
"""
|
|
726
|
-
family = socket.AF_INET6 if ":" in
|
|
623
|
+
family = socket.AF_INET6 if ":" in strip_brackets(host) else socket.AF_INET
|
|
727
624
|
probe = socket.socket(family, socket.SOCK_STREAM)
|
|
728
625
|
try:
|
|
729
626
|
# Deliberately not SO_REUSEADDR: with it the bind succeeds against a
|
|
730
627
|
# socket in TIME_WAIT, which is the case this is meant to catch.
|
|
731
|
-
probe.bind((
|
|
628
|
+
probe.bind((strip_brackets(host), port))
|
|
732
629
|
except OSError:
|
|
733
630
|
return True
|
|
734
631
|
except (ValueError, socket.gaierror):
|
|
@@ -740,6 +637,84 @@ def _port_in_use(host: str, port: int) -> bool:
|
|
|
740
637
|
return False
|
|
741
638
|
|
|
742
639
|
|
|
640
|
+
def _first_free_port(host: str, port: int) -> int | None:
|
|
641
|
+
"""
|
|
642
|
+
Find a port to offer in place of one that is taken.
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
host: Address the panel would bind.
|
|
646
|
+
port: The port that turned out to be taken.
|
|
647
|
+
|
|
648
|
+
Returns:
|
|
649
|
+
The first free port above it, or None when the whole search span is
|
|
650
|
+
held. Naming a port without checking it is what made the old suggestion
|
|
651
|
+
useless: it was the requested port plus one, and on a machine busy
|
|
652
|
+
enough to hit this at all that is frequently taken too.
|
|
653
|
+
"""
|
|
654
|
+
for candidate in range(port + 1, min(port + 1 + PORT_SUGGESTION_SPAN, 65536)):
|
|
655
|
+
if not _port_in_use(host, candidate):
|
|
656
|
+
return candidate
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _report_taken_port(host: str, port: int, logger: Logger) -> None:
|
|
661
|
+
"""
|
|
662
|
+
Explain a taken port, including why the panel does not move itself.
|
|
663
|
+
|
|
664
|
+
Moving to the next free port would be the obliging thing to do and it is
|
|
665
|
+
the wrong thing to do. The port is an address other things point at - an
|
|
666
|
+
SSH tunnel, a reverse proxy, a bookmark, a script - so a panel that lands
|
|
667
|
+
somewhere new whenever the old place is busy cannot be automated. It would
|
|
668
|
+
also paper over the case this check exists to catch: a second panel, still
|
|
669
|
+
running, still holding a root session.
|
|
670
|
+
|
|
671
|
+
None of that is obvious from the outside, so it is said rather than
|
|
672
|
+
implied.
|
|
673
|
+
|
|
674
|
+
Args:
|
|
675
|
+
host: Address the panel would have bound.
|
|
676
|
+
port: The port something else holds.
|
|
677
|
+
logger: Logger for the report.
|
|
678
|
+
"""
|
|
679
|
+
logger.error(f"Something is already listening on {host}:{port}")
|
|
680
|
+
logger.info("WASM does not move the panel to another port on its own:")
|
|
681
|
+
logger.info(" the port is what your SSH tunnel, proxy and bookmarks point at,")
|
|
682
|
+
logger.info(" and moving it would hide a panel that is still running here.")
|
|
683
|
+
logger.info("If it is a panel this machine has forgotten about:")
|
|
684
|
+
logger.info(" wasm web stop")
|
|
685
|
+
logger.info("To see what holds the port:")
|
|
686
|
+
logger.info(f" ss -ltnp 'sport = :{port}'")
|
|
687
|
+
|
|
688
|
+
free = _first_free_port(host, port)
|
|
689
|
+
if free is None:
|
|
690
|
+
logger.info(
|
|
691
|
+
f"Nothing up to {port + PORT_SUGGESTION_SPAN} is free either. "
|
|
692
|
+
"Choose a port with --port."
|
|
693
|
+
)
|
|
694
|
+
return
|
|
695
|
+
logger.info(f"Or serve on {free}, which is free right now:")
|
|
696
|
+
logger.info(f" wasm web start --port {free}")
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _report_daemon_started(config: SecurityConfig, pid: int, logger: Logger) -> None:
|
|
700
|
+
"""
|
|
701
|
+
Report a backgrounded panel, and how to reach it.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
config: The configuration it was started with.
|
|
705
|
+
pid: Process id of the panel.
|
|
706
|
+
logger: Logger for the report.
|
|
707
|
+
"""
|
|
708
|
+
scheme = "https" if config.require_https else "http"
|
|
709
|
+
|
|
710
|
+
logger.success(f"Web server started in background (PID: {pid})")
|
|
711
|
+
logger.info(f"Server running at {scheme}://{config.host}:{config.port}")
|
|
712
|
+
for line in loopback_access_lines(config.host, config.port, scheme=scheme):
|
|
713
|
+
logger.info(line)
|
|
714
|
+
logger.info("Use 'wasm web status' to check status")
|
|
715
|
+
logger.info("Use 'wasm web stop' to stop the server")
|
|
716
|
+
|
|
717
|
+
|
|
743
718
|
def _start(options: StartOptions, verbose: bool, *, dry_run: bool = False) -> int:
|
|
744
719
|
"""
|
|
745
720
|
Start the panel, refusing an unsafe exposure first.
|
|
@@ -811,20 +786,14 @@ def _start(options: StartOptions, verbose: bool, *, dry_run: bool = False) -> in
|
|
|
811
786
|
# bare OSError, leaving an operator holding a credential for a server that
|
|
812
787
|
# never started.
|
|
813
788
|
if not dry_run and _port_in_use(host, config.port):
|
|
814
|
-
|
|
815
|
-
logger.info("If it is a panel this machine has forgotten about:")
|
|
816
|
-
logger.info(" wasm web stop")
|
|
817
|
-
logger.info("To see what holds the port:")
|
|
818
|
-
logger.info(f" ss -ltnp 'sport = :{config.port}'")
|
|
819
|
-
logger.info("Or serve somewhere else:")
|
|
820
|
-
logger.info(f" wasm web start --port {config.port + 1}")
|
|
789
|
+
_report_taken_port(host, config.port, logger)
|
|
821
790
|
return 1
|
|
822
791
|
|
|
823
792
|
if config.ip_whitelist:
|
|
824
793
|
logger.info(f"Only these clients may connect: {', '.join(config.ip_whitelist)}")
|
|
825
794
|
if config.trusted_proxies:
|
|
826
795
|
logger.info(f"Forwarding headers believed from: {', '.join(config.trusted_proxies)}")
|
|
827
|
-
elif
|
|
796
|
+
elif is_loopback_host(host) and not config.require_https:
|
|
828
797
|
logger.warning(
|
|
829
798
|
"Serving plain HTTP on loopback. Behind a TLS proxy, pass --trusted-proxy "
|
|
830
799
|
"so the session cookie is issued with the Secure flag."
|
|
@@ -882,17 +851,13 @@ def _start_daemon(config: SecurityConfig, verbose: bool) -> int:
|
|
|
882
851
|
Exit code.
|
|
883
852
|
"""
|
|
884
853
|
logger = Logger(verbose=verbose)
|
|
885
|
-
scheme = "https" if config.require_https else "http"
|
|
886
854
|
|
|
887
855
|
# Fork process
|
|
888
856
|
pid = os.fork()
|
|
889
857
|
|
|
890
858
|
if pid > 0:
|
|
891
859
|
# Parent process
|
|
892
|
-
|
|
893
|
-
logger.info(f"Server running at {scheme}://{config.host}:{config.port}")
|
|
894
|
-
logger.info("Use 'wasm web status' to check status")
|
|
895
|
-
logger.info("Use 'wasm web stop' to stop the server")
|
|
860
|
+
_report_daemon_started(config, pid, logger)
|
|
896
861
|
return 0
|
|
897
862
|
|
|
898
863
|
# Child process
|
|
@@ -47,6 +47,7 @@ from wasm.core.utils import domain_to_app_name, remove_directory
|
|
|
47
47
|
from wasm.deployers import detect_app_type, get_deployer
|
|
48
48
|
from wasm.deployers.docker_compose import DockerComposeDeployer
|
|
49
49
|
from wasm.deployers.monorepo import MonorepoDeployer
|
|
50
|
+
from wasm.deployers.registry import available_types
|
|
50
51
|
from wasm.managers.apache_manager import ApacheManager
|
|
51
52
|
from wasm.managers.backup_manager import RollbackManager
|
|
52
53
|
from wasm.managers.cert_manager import CertManager
|
|
@@ -68,17 +69,10 @@ _FOLLOW_TIMEOUT = 86400
|
|
|
68
69
|
#: Docker Compose pulls images and rebuilds; it needs room.
|
|
69
70
|
_COMPOSE_TIMEOUT = 1800
|
|
70
71
|
|
|
71
|
-
#: Application types ``create`` accepts, in the order they are offered.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
"vite",
|
|
76
|
-
"python",
|
|
77
|
-
"static",
|
|
78
|
-
"monorepo",
|
|
79
|
-
"docker-compose",
|
|
80
|
-
"auto",
|
|
81
|
-
]
|
|
72
|
+
#: Application types ``create`` accepts, in the order they are offered. Read
|
|
73
|
+
#: from the registry rather than typed out here: a hand-written copy is a copy
|
|
74
|
+
#: that goes stale the first time a deployer is added, and it did.
|
|
75
|
+
APP_TYPES = [entry["type"] for entry in available_types()]
|
|
82
76
|
|
|
83
77
|
#: Node package managers ``create`` and ``update`` accept.
|
|
84
78
|
PACKAGE_MANAGERS = ["npm", "pnpm", "bun", "auto"]
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# Copyright (c) 2024-2026 Yago Lopez Prado
|
|
2
|
+
# Licensed under WASM-NCSAL 1.0 (Commercial use prohibited)
|
|
3
|
+
# https://github.com/Perkybeet/wasm/blob/main/LICENSE
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
How a listening address is classified, and how an operator reaches it.
|
|
7
|
+
|
|
8
|
+
One question is asked of this module twice. The CLI asks whether an address is
|
|
9
|
+
reachable from another machine, because that decides whether it will serve a
|
|
10
|
+
root panel on it at all. The startup banner asks the same thing, because an
|
|
11
|
+
address only this machine can open has to come with instructions for opening it
|
|
12
|
+
from somewhere else. Two callers, one classification: a security decision that
|
|
13
|
+
is made in two places is a security decision that drifts, and this one is
|
|
14
|
+
subtle enough that the second copy would have been the wrong one - "", ``*``,
|
|
15
|
+
``0``, ``0.0.0.0``, ``::`` and a name resolving to any of them are six
|
|
16
|
+
spellings of the same socket.
|
|
17
|
+
|
|
18
|
+
The instructions matter as much as the classification. A panel started over SSH
|
|
19
|
+
on a server with no desktop printed ``Server: http://127.0.0.1:8080`` and
|
|
20
|
+
stopped there, which is an address that, from the machine the operator is
|
|
21
|
+
actually sitting at, does not exist.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import getpass
|
|
27
|
+
import ipaddress
|
|
28
|
+
import os
|
|
29
|
+
import socket
|
|
30
|
+
|
|
31
|
+
#: The address a socket is given when it should answer on every interface. It
|
|
32
|
+
#: is named here to be recognised and refused, never bound by default.
|
|
33
|
+
ALL_INTERFACES = "0.0.0.0" # noqa: S104
|
|
34
|
+
|
|
35
|
+
#: Spellings of "every interface" that no resolver accepts, so they cannot be
|
|
36
|
+
#: classified by looking them up. The empty string is the one that mattered: a
|
|
37
|
+
#: version of this check kept a set of loopback *strings* with "" in it, and
|
|
38
|
+
#: ``wasm web start --host ""`` walked past the refusal and bound the root
|
|
39
|
+
#: panel to every interface in cleartext.
|
|
40
|
+
WILDCARD_SPELLINGS = frozenset({"", "*"})
|
|
41
|
+
|
|
42
|
+
#: Shown in place of a login name when the passwd database has no entry for
|
|
43
|
+
#: this uid, which happens in containers. The command stays copy-editable.
|
|
44
|
+
UNKNOWN_USER = "user"
|
|
45
|
+
|
|
46
|
+
#: Address family alias, for the helpers below.
|
|
47
|
+
IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def strip_brackets(host: str) -> str:
|
|
51
|
+
"""
|
|
52
|
+
Remove the brackets an IPv6 literal is often written with.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
host: The spelling the operator typed.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The spelling without a surrounding ``[]`` pair.
|
|
59
|
+
"""
|
|
60
|
+
candidate = host.strip()
|
|
61
|
+
if len(candidate) > 1 and candidate.startswith("[") and candidate.endswith("]"):
|
|
62
|
+
return candidate[1:-1]
|
|
63
|
+
return candidate
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def host_addresses(host: str) -> tuple[IPAddress, ...]:
|
|
67
|
+
"""
|
|
68
|
+
Return every address a host spelling would end up bound to.
|
|
69
|
+
|
|
70
|
+
Comparing host strings is what made this decision wrong: "", ``*``, ``0``,
|
|
71
|
+
``0.0.0.0``, ``::`` and a name in ``/etc/hosts`` pointing at 0.0.0.0 are six
|
|
72
|
+
spellings of the same socket, and a set of known-good strings misses five of
|
|
73
|
+
them. The resolver is asked instead, and its answer is classified with
|
|
74
|
+
:mod:`ipaddress`.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
host: The spelling the operator typed.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The addresses, empty when the spelling cannot be resolved. Empty means
|
|
81
|
+
"unknown", and every caller treats unknown as exposed.
|
|
82
|
+
"""
|
|
83
|
+
candidate = strip_brackets(host)
|
|
84
|
+
if candidate in WILDCARD_SPELLINGS:
|
|
85
|
+
return (ipaddress.ip_address(ALL_INTERFACES),)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
return (ipaddress.ip_address(candidate),)
|
|
89
|
+
except ValueError:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
# A resolution covers names and also the inet_aton spellings ipaddress
|
|
94
|
+
# refuses but a socket accepts, such as "0" (INADDR_ANY) and "127.1".
|
|
95
|
+
infos = socket.getaddrinfo(candidate, None, type=socket.SOCK_STREAM)
|
|
96
|
+
except (OSError, UnicodeError, ValueError):
|
|
97
|
+
return ()
|
|
98
|
+
|
|
99
|
+
addresses = []
|
|
100
|
+
for info in infos:
|
|
101
|
+
# A link-local sockaddr carries a %scope suffix that ipaddress refuses.
|
|
102
|
+
text = str(info[4][0]).split("%", 1)[0]
|
|
103
|
+
try:
|
|
104
|
+
addresses.append(ipaddress.ip_address(text))
|
|
105
|
+
except ValueError:
|
|
106
|
+
return ()
|
|
107
|
+
return tuple(addresses)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_loopback_host(host: str) -> bool:
|
|
111
|
+
"""
|
|
112
|
+
Report whether only this machine could reach a service bound to a host.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
host: The spelling the operator typed.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
True only when the spelling is known to resolve to loopback addresses
|
|
119
|
+
and nothing else. A spelling that cannot be resolved is not loopback,
|
|
120
|
+
because guessing in the other direction publishes a root shell.
|
|
121
|
+
"""
|
|
122
|
+
addresses = host_addresses(host)
|
|
123
|
+
return bool(addresses) and all(address.is_loopback for address in addresses)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def normalize_host(host: str) -> str:
|
|
127
|
+
"""
|
|
128
|
+
Canonicalise a host spelling so what is checked is what is reported.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
host: The spelling the operator typed.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
The canonical spelling. Every way of writing "every interface" becomes
|
|
135
|
+
the address it binds, so a refusal names a real address instead of
|
|
136
|
+
quoting an empty string back at the operator.
|
|
137
|
+
"""
|
|
138
|
+
candidate = strip_brackets(host)
|
|
139
|
+
addresses = host_addresses(candidate)
|
|
140
|
+
if not addresses:
|
|
141
|
+
return candidate
|
|
142
|
+
if candidate in WILDCARD_SPELLINGS or all(address.is_unspecified for address in addresses):
|
|
143
|
+
return str(addresses[0])
|
|
144
|
+
try:
|
|
145
|
+
return str(ipaddress.ip_address(candidate))
|
|
146
|
+
except ValueError:
|
|
147
|
+
# A name: binding it is the resolver's business, not this module's.
|
|
148
|
+
return candidate
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def local_address() -> str:
|
|
152
|
+
"""
|
|
153
|
+
Best-effort address of this machine, for a banner.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The outbound interface address, or 127.0.0.1 when it cannot be found.
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
160
|
+
# A UDP connect sends nothing; it only asks the routing table which
|
|
161
|
+
# source address a packet to that destination would leave from.
|
|
162
|
+
sock.connect(("8.8.8.8", 80))
|
|
163
|
+
return str(sock.getsockname()[0])
|
|
164
|
+
except OSError:
|
|
165
|
+
return "127.0.0.1"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def server_address() -> str:
|
|
169
|
+
"""
|
|
170
|
+
Name this server the way the operator's own machine would reach it.
|
|
171
|
+
|
|
172
|
+
``SSH_CONNECTION`` is preferred over the outbound interface because it holds
|
|
173
|
+
the address this very session arrived on. Behind NAT, on a host with several
|
|
174
|
+
interfaces, or on a VPS with a separate private network, that is the address
|
|
175
|
+
that works and the outbound one is a guess.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
An address or a placeholder; never raises, because a banner is not worth
|
|
179
|
+
an exception.
|
|
180
|
+
"""
|
|
181
|
+
parts = os.environ.get("SSH_CONNECTION", "").split()
|
|
182
|
+
if len(parts) >= 4:
|
|
183
|
+
return parts[2]
|
|
184
|
+
return local_address()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _current_user() -> str:
|
|
188
|
+
"""
|
|
189
|
+
Return the login name to put in an SSH command.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
The current user, or a placeholder when there is no passwd entry.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
return getpass.getuser()
|
|
196
|
+
except (KeyError, OSError):
|
|
197
|
+
return UNKNOWN_USER
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def ssh_target() -> str:
|
|
201
|
+
"""
|
|
202
|
+
Build the ``user@host`` an operator would SSH to.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
The target, ready to paste into an ssh command line.
|
|
206
|
+
"""
|
|
207
|
+
return f"{_current_user()}@{server_address()}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def loopback_access_lines(host: str, port: int, scheme: str = "http") -> tuple[str, ...]:
|
|
211
|
+
"""
|
|
212
|
+
Explain how to open a service that only answers on this machine.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
host: The address the service is bound to.
|
|
216
|
+
port: The port it listens on.
|
|
217
|
+
scheme: ``http`` or ``https``, matching what it serves.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Lines for a banner, empty when the address is reachable from another
|
|
221
|
+
machine and needs no forwarding. Callers print them as they are.
|
|
222
|
+
"""
|
|
223
|
+
if not is_loopback_host(host):
|
|
224
|
+
return ()
|
|
225
|
+
|
|
226
|
+
literal = normalize_host(host)
|
|
227
|
+
# ssh -L splits its argument on colons, so an IPv6 literal has to be
|
|
228
|
+
# bracketed or the address is read as a port and a host.
|
|
229
|
+
forwarded = f"[{literal}]" if ":" in literal else literal
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
"This address answers on the server itself and nowhere else.",
|
|
233
|
+
"To open it from your own machine, forward the port over SSH:",
|
|
234
|
+
f" ssh -L {port}:{forwarded}:{port} {ssh_target()}",
|
|
235
|
+
f"then browse to {scheme}://localhost:{port} there.",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
__all__ = [
|
|
240
|
+
"ALL_INTERFACES",
|
|
241
|
+
"UNKNOWN_USER",
|
|
242
|
+
"WILDCARD_SPELLINGS",
|
|
243
|
+
"IPAddress",
|
|
244
|
+
"host_addresses",
|
|
245
|
+
"is_loopback_host",
|
|
246
|
+
"local_address",
|
|
247
|
+
"loopback_access_lines",
|
|
248
|
+
"normalize_host",
|
|
249
|
+
"server_address",
|
|
250
|
+
"ssh_target",
|
|
251
|
+
"strip_brackets",
|
|
252
|
+
]
|
|
@@ -159,6 +159,30 @@ def detect_app_type(path: Path, verbose: bool = False) -> str | None:
|
|
|
159
159
|
return DeployerRegistry.detect(path, verbose=verbose)
|
|
160
160
|
|
|
161
161
|
|
|
162
|
+
def available_types() -> list[dict[str, str]]:
|
|
163
|
+
"""
|
|
164
|
+
List the application types anything in the product may offer.
|
|
165
|
+
|
|
166
|
+
The registry is the only place that knows what can be deployed, so it is
|
|
167
|
+
the only place that should answer this. The CLI used to carry a
|
|
168
|
+
hand-written list of eight strings and the panel had no list at all, which
|
|
169
|
+
is how a deployer could be added and reach neither front door.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
One mapping per type, with ``type`` and ``name``, ordered with the
|
|
173
|
+
detectors first and ``auto`` last: picking a specific type is the
|
|
174
|
+
deliberate choice, and offering "let WASM decide" at the top invites
|
|
175
|
+
the operator to skip a decision they usually know the answer to.
|
|
176
|
+
"""
|
|
177
|
+
_import_deployers()
|
|
178
|
+
|
|
179
|
+
types = [
|
|
180
|
+
{"type": str(entry["type"]), "name": str(entry["name"])}
|
|
181
|
+
for entry in DeployerRegistry.list_deployers()
|
|
182
|
+
]
|
|
183
|
+
return sorted(types, key=lambda entry: (entry["type"] == "auto", entry["name"].lower()))
|
|
184
|
+
|
|
185
|
+
|
|
162
186
|
def _import_deployers() -> None:
|
|
163
187
|
"""
|
|
164
188
|
Import every deployer module so that registration has happened.
|