rocketdoo 3.3.0__tar.gz → 3.4.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.
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/LICENSE +2 -2
- {rocketdoo-3.3.0/rocketdoo.egg-info → rocketdoo-3.4.0}/PKG-INFO +26 -7
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/README.md +23 -4
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/pyproject.toml +1 -1
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui/server.py +65 -1
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui/static/index.html +33 -8
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui_cli.py +20 -6
- {rocketdoo-3.3.0 → rocketdoo-3.4.0/rocketdoo.egg-info}/PKG-INFO +26 -7
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_gui_api.py +157 -3
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_secrets_hardening.py +44 -15
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/MANIFEST.in +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/__init__.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/cli.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/__init__.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/compose.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/deploy/__init__.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/deploy/base.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/deploy/config_manager.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/deploy/module_packager.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/deploy/odoo_sh.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/deploy/vps.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/edition_setup.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/generator.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/gitignore_manager.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/gitman_config.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/instance/__init__.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/instance/config_manager.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/instance/deployer_docker.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/instance/deployer_native.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/instance/secrets_store.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/instance/ssh_utils.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/models/__init__.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/models/deploy.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/models/instance.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/models/profiles.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/models/project.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/module_scanner.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/odoo_db.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/port_validation.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/ssh_manager.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/core/utils.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/deploy_cli.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/docker_cli.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui/__init__.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui/api/__init__.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui/api/deploy_ops.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui/api/docker_ops.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui/api/gitman.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui/api/instances.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui/api/mail.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui/api/modules.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui/api/odoo.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui/api/pack_ops.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui/api/project.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui/api/setup.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui/api/traefik_ops.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/gui/api/workspace.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/init_project.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/instance_cli.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/mail_cli.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/pack_environment.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/project_info.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/scaffold.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/.devcontainer/devcontainer.json +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/.devcontainer/docker-compose.yaml +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/.gitignore.jinja +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/.vscode/launch.json +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/.vscode/launch.json.jinja +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/.vscode/settings.json +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/Dockerfile.jinja +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/addons/.gitkeep +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/config/odoo.conf +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/config/odoo.conf.jinja +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/deploy/.deployignore +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/deploy/deploy.yaml +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/deploy/deploy.yaml.jinja +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/docker-compose.yaml.jinja +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/install_dependencies.sh +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/instance/Dockerfile.jinja +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/instance/docker-compose.jinja +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/instance/odoo.conf.jinja +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/odoo_pg_pass +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/README.md +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo15-ce.yaml +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo15-ee.yaml +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo16-ce.yaml +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo16-ee.yaml +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo17-ce.yaml +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo17-ee.yaml +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo18-ce.yaml +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo18-ee.yaml +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo19-ce.yaml +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo19-ee.yaml +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/traefik_cli.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/unpack_environment.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo/welcome.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo.egg-info/SOURCES.txt +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo.egg-info/dependency_links.txt +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo.egg-info/entry_points.txt +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo.egg-info/requires.txt +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/rocketdoo.egg-info/top_level.txt +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/setup.cfg +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_compose.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_deploy_config_manager.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_e2e_scaffold.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_edition_setup.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_gitignore_manager.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_imports.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_mail_cli.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_models.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_module_scanner.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_odoo_db.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_pack_environment.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_port_validation.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_profiles.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_ssh_manager.py +0 -0
- {rocketdoo-3.3.0 → rocketdoo-3.4.0}/tests/test_templates.py +0 -0
|
@@ -57,7 +57,7 @@ If the Library as you received it specifies that a proxy can decide whether futu
|
|
|
57
57
|
<div RKD as ROCKETDOO V3=""></div>
|
|
58
58
|
|
|
59
59
|
Licencia: LGPL-3.0+
|
|
60
|
-
Versión: "3.
|
|
60
|
+
Versión: "3.4.0"
|
|
61
61
|
Autor: Horacio Montaño
|
|
62
|
-
Fecha:
|
|
62
|
+
Fecha: 16/09/2026
|
|
63
63
|
Descripción: Framework to development Odoo
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rocketdoo
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.4.0
|
|
4
4
|
Summary: Framework for creating Odoo development environments with Docker and custom templates.
|
|
5
5
|
Author-email: Horacio Montaño <horaciomontano@hdmsoft.com.ar>
|
|
6
6
|
License: GNU LESSER GENERAL PUBLIC LICENSE
|
|
@@ -62,9 +62,9 @@ License: GNU LESSER GENERAL PUBLIC LICENSE
|
|
|
62
62
|
<div RKD as ROCKETDOO V3=""></div>
|
|
63
63
|
|
|
64
64
|
Licencia: LGPL-3.0+
|
|
65
|
-
Versión: "3.
|
|
65
|
+
Versión: "3.4.0"
|
|
66
66
|
Autor: Horacio Montaño
|
|
67
|
-
Fecha:
|
|
67
|
+
Fecha: 16/09/2026
|
|
68
68
|
Descripción: Framework to development Odoo
|
|
69
69
|
Requires-Python: >=3.10
|
|
70
70
|
Description-Content-Type: text/markdown
|
|
@@ -97,7 +97,7 @@ Odoo Development Framework
|
|
|
97
97
|
- "Horacio Montaño"
|
|
98
98
|
|
|
99
99
|
## Version:
|
|
100
|
-
- "3.
|
|
100
|
+
- "3.4.0"
|
|
101
101
|
|
|
102
102
|
----------------------------------------------------------------------------------------------------------------------------------------------------------
|
|
103
103
|
|
|
@@ -532,12 +532,19 @@ rkd instance status # Show configured environments
|
|
|
532
532
|
Rocketdoo v3 includes a professional web-based management interface, accessible from your browser just like Odoo — no installation of additional tools required.
|
|
533
533
|
|
|
534
534
|
```bash
|
|
535
|
-
rkd gui # Launch GUI
|
|
536
|
-
rkd gui --open # Also open the browser automatically
|
|
535
|
+
rkd gui # Launch GUI, prints a tokenized URL to open
|
|
536
|
+
rkd gui --open # Also open that same URL in the browser automatically
|
|
537
537
|
rkd gui --port 9090 # Use a custom port
|
|
538
538
|
rkd gui --cwd /my/proj # Point to a specific project directory
|
|
539
539
|
```
|
|
540
540
|
|
|
541
|
+
`rkd gui` prints a URL of the form `http://127.0.0.1:8070/?token=<session-token>` —
|
|
542
|
+
use that exact URL, not a bare `http://localhost:8070`. Every API and WebSocket
|
|
543
|
+
call requires this session token (generated fresh on each `rkd gui` start), so
|
|
544
|
+
opening the GUI without it shows a message asking you to use the printed URL
|
|
545
|
+
instead of the project's data. `--open` already opens the correct, tokenized
|
|
546
|
+
URL for you.
|
|
547
|
+
|
|
541
548
|
The GUI provides a complete visual interface for all Rocketdoo v3 features:
|
|
542
549
|
|
|
543
550
|
| Section | What you can do |
|
|
@@ -555,7 +562,10 @@ Additional interface features:
|
|
|
555
562
|
- Real-time log streaming via WebSocket
|
|
556
563
|
- Runs entirely from the installed package — no Node.js or build step required
|
|
557
564
|
|
|
558
|
-
> The GUI server runs locally and is bound to `127.0.0.1` by default, so it is only accessible
|
|
565
|
+
> The GUI server runs locally and is bound to `127.0.0.1` by default, so it is only accessible
|
|
566
|
+
> from your own machine. The session token adds a second layer on top of that: even another
|
|
567
|
+
> process running as your own user on the same machine cannot call the API without it. See
|
|
568
|
+
> [SECURITY.md](SECURITY.md) for the full threat model.
|
|
559
569
|
|
|
560
570
|
---
|
|
561
571
|
|
|
@@ -615,6 +625,15 @@ deploy — no manual step needed.
|
|
|
615
625
|
|
|
616
626
|
---
|
|
617
627
|
|
|
628
|
+
### Security
|
|
629
|
+
|
|
630
|
+
Rocketdoo is a local, single-user development tool, not a hardened multi-user
|
|
631
|
+
service. See [SECURITY.md](SECURITY.md) for the threat model: what the GUI
|
|
632
|
+
exposes, where credentials live on disk, and the residual risks that are
|
|
633
|
+
accepted rather than solved.
|
|
634
|
+
|
|
635
|
+
---
|
|
636
|
+
|
|
618
637
|
### Technical Support
|
|
619
638
|
|
|
620
639
|
- If you have any questions or issues with our development environment, you can contact us and submit your inquiry or support ticket by clicking on the link below.
|
|
@@ -15,7 +15,7 @@ Odoo Development Framework
|
|
|
15
15
|
- "Horacio Montaño"
|
|
16
16
|
|
|
17
17
|
## Version:
|
|
18
|
-
- "3.
|
|
18
|
+
- "3.4.0"
|
|
19
19
|
|
|
20
20
|
----------------------------------------------------------------------------------------------------------------------------------------------------------
|
|
21
21
|
|
|
@@ -450,12 +450,19 @@ rkd instance status # Show configured environments
|
|
|
450
450
|
Rocketdoo v3 includes a professional web-based management interface, accessible from your browser just like Odoo — no installation of additional tools required.
|
|
451
451
|
|
|
452
452
|
```bash
|
|
453
|
-
rkd gui # Launch GUI
|
|
454
|
-
rkd gui --open # Also open the browser automatically
|
|
453
|
+
rkd gui # Launch GUI, prints a tokenized URL to open
|
|
454
|
+
rkd gui --open # Also open that same URL in the browser automatically
|
|
455
455
|
rkd gui --port 9090 # Use a custom port
|
|
456
456
|
rkd gui --cwd /my/proj # Point to a specific project directory
|
|
457
457
|
```
|
|
458
458
|
|
|
459
|
+
`rkd gui` prints a URL of the form `http://127.0.0.1:8070/?token=<session-token>` —
|
|
460
|
+
use that exact URL, not a bare `http://localhost:8070`. Every API and WebSocket
|
|
461
|
+
call requires this session token (generated fresh on each `rkd gui` start), so
|
|
462
|
+
opening the GUI without it shows a message asking you to use the printed URL
|
|
463
|
+
instead of the project's data. `--open` already opens the correct, tokenized
|
|
464
|
+
URL for you.
|
|
465
|
+
|
|
459
466
|
The GUI provides a complete visual interface for all Rocketdoo v3 features:
|
|
460
467
|
|
|
461
468
|
| Section | What you can do |
|
|
@@ -473,7 +480,10 @@ Additional interface features:
|
|
|
473
480
|
- Real-time log streaming via WebSocket
|
|
474
481
|
- Runs entirely from the installed package — no Node.js or build step required
|
|
475
482
|
|
|
476
|
-
> The GUI server runs locally and is bound to `127.0.0.1` by default, so it is only accessible
|
|
483
|
+
> The GUI server runs locally and is bound to `127.0.0.1` by default, so it is only accessible
|
|
484
|
+
> from your own machine. The session token adds a second layer on top of that: even another
|
|
485
|
+
> process running as your own user on the same machine cannot call the API without it. See
|
|
486
|
+
> [SECURITY.md](SECURITY.md) for the full threat model.
|
|
477
487
|
|
|
478
488
|
---
|
|
479
489
|
|
|
@@ -533,6 +543,15 @@ deploy — no manual step needed.
|
|
|
533
543
|
|
|
534
544
|
---
|
|
535
545
|
|
|
546
|
+
### Security
|
|
547
|
+
|
|
548
|
+
Rocketdoo is a local, single-user development tool, not a hardened multi-user
|
|
549
|
+
service. See [SECURITY.md](SECURITY.md) for the threat model: what the GUI
|
|
550
|
+
exposes, where credentials live on disk, and the residual risks that are
|
|
551
|
+
accepted rather than solved.
|
|
552
|
+
|
|
553
|
+
---
|
|
554
|
+
|
|
536
555
|
### Technical Support
|
|
537
556
|
|
|
538
557
|
- If you have any questions or issues with our development environment, you can contact us and submit your inquiry or support ticket by clicking on the link below.
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import secrets
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
|
|
4
5
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
5
6
|
from fastapi.middleware.cors import CORSMiddleware
|
|
6
7
|
from fastapi.responses import FileResponse, JSONResponse
|
|
8
|
+
from starlette.datastructures import Headers, QueryParams
|
|
9
|
+
from starlette.routing import get_route_path
|
|
7
10
|
|
|
8
11
|
from rocketdoo import __version__
|
|
9
12
|
|
|
@@ -62,6 +65,55 @@ async def _stream_process(websocket: WebSocket, cmd: list[str], timeout: float =
|
|
|
62
65
|
pass
|
|
63
66
|
|
|
64
67
|
|
|
68
|
+
class TokenAuthMiddleware:
|
|
69
|
+
"""Require the session token on every /api and /ws request.
|
|
70
|
+
|
|
71
|
+
CORS only constrains browsers; any local process can reach 127.0.0.1 and
|
|
72
|
+
drive Docker or the filesystem through this API. The token raises that bar
|
|
73
|
+
to "processes that can read the terminal running rkd gui". ASGI middleware
|
|
74
|
+
is used instead of a FastAPI dependency because the 3 websocket routes are
|
|
75
|
+
plain ASGI routes on `app`, outside the router, and a dependency cannot
|
|
76
|
+
reach them; a `BaseHTTPMiddleware` cannot either, since it never sees the
|
|
77
|
+
`websocket` scope.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, app, token: str):
|
|
81
|
+
self.app = app
|
|
82
|
+
self.token = token
|
|
83
|
+
|
|
84
|
+
async def __call__(self, scope, receive, send):
|
|
85
|
+
if scope["type"] in ("http", "websocket") and self._protected(scope):
|
|
86
|
+
if not self._authorized(scope):
|
|
87
|
+
await self._reject(scope, receive, send)
|
|
88
|
+
return
|
|
89
|
+
await self.app(scope, receive, send)
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _protected(scope) -> bool:
|
|
93
|
+
# get_route_path, not scope["path"]: the router strips root_path before
|
|
94
|
+
# matching, so mounting this app under a prefix would make the two
|
|
95
|
+
# diverge and every protected route would answer unauthenticated.
|
|
96
|
+
path = get_route_path(scope)
|
|
97
|
+
return (path.startswith("/api/") or path.startswith("/ws/")) and scope.get("method") != "OPTIONS"
|
|
98
|
+
|
|
99
|
+
def _authorized(self, scope) -> bool:
|
|
100
|
+
provided = Headers(scope=scope).get("x-rkd-token") or QueryParams(scope["query_string"]).get("token")
|
|
101
|
+
# isascii() first: compare_digest raises TypeError on non-ASCII strings,
|
|
102
|
+
# and the value is entirely attacker-controlled. Without this a crafted
|
|
103
|
+
# header answers 500 with a traceback instead of 401.
|
|
104
|
+
if not provided or not provided.isascii():
|
|
105
|
+
return False
|
|
106
|
+
return secrets.compare_digest(provided, self.token)
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
async def _reject(scope, receive, send):
|
|
110
|
+
if scope["type"] == "websocket":
|
|
111
|
+
await send({"type": "websocket.close", "code": 1008})
|
|
112
|
+
else:
|
|
113
|
+
response = JSONResponse({"detail": "Unauthorized"}, status_code=401)
|
|
114
|
+
await response(scope, receive, send)
|
|
115
|
+
|
|
116
|
+
|
|
65
117
|
def local_origins(host: str = "127.0.0.1", port: int = DEFAULT_PORT) -> list[str]:
|
|
66
118
|
"""Browser origins allowed to call this API.
|
|
67
119
|
|
|
@@ -79,14 +131,26 @@ def local_origins(host: str = "127.0.0.1", port: int = DEFAULT_PORT) -> list[str
|
|
|
79
131
|
return origins
|
|
80
132
|
|
|
81
133
|
|
|
82
|
-
def create_app(host: str = "127.0.0.1", port: int = DEFAULT_PORT) -> FastAPI:
|
|
134
|
+
def create_app(host: str = "127.0.0.1", port: int = DEFAULT_PORT, token: str | None = None) -> FastAPI:
|
|
83
135
|
app = FastAPI(
|
|
84
136
|
title="Rocketdoo GUI",
|
|
85
137
|
version=__version__,
|
|
86
138
|
docs_url="/api/docs",
|
|
139
|
+
# Under /api so the token middleware covers it: on its default path it
|
|
140
|
+
# served the full route inventory to anyone on the host.
|
|
141
|
+
openapi_url="/api/openapi.json",
|
|
87
142
|
redoc_url=None,
|
|
88
143
|
)
|
|
89
144
|
|
|
145
|
+
# Generated even if the caller passes nothing: there is no way to end up
|
|
146
|
+
# with an unprotected API by omission.
|
|
147
|
+
app.state.rkd_token = token or secrets.token_urlsafe(32)
|
|
148
|
+
|
|
149
|
+
# Added before CORSMiddleware so it ends up as the inner layer: CORS stays
|
|
150
|
+
# the outermost middleware and keeps handling preflight and headers
|
|
151
|
+
# exactly as before, unaffected by the token check.
|
|
152
|
+
app.add_middleware(TokenAuthMiddleware, token=app.state.rkd_token)
|
|
153
|
+
|
|
90
154
|
# Not allow_origins=["*"]: with allow_credentials=True Starlette echoes the
|
|
91
155
|
# caller's Origin back, so any site the user visited while `rkd gui` was
|
|
92
156
|
# running could read /api/workspace and POST /api/docker/down.
|
|
@@ -161,11 +161,28 @@ html.light ::-webkit-scrollbar-thumb:hover { background: #4A6080; }
|
|
|
161
161
|
<script>
|
|
162
162
|
const { createApp, ref, computed, onMounted, onActivated, onUnmounted, watch, nextTick } = Vue
|
|
163
163
|
|
|
164
|
+
/* ── Session token ───────────────────────────────────────────────────────── */
|
|
165
|
+
// `rkd gui` prints the URL with `?token=...`; kept in the URL (not stripped
|
|
166
|
+
// after reading it) so a page refresh or a duplicated tab keeps working.
|
|
167
|
+
const TOKEN = new URLSearchParams(location.search).get('token') || ''
|
|
168
|
+
const authError = ref(!TOKEN)
|
|
169
|
+
|
|
170
|
+
function wsUrl(path) {
|
|
171
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
|
172
|
+
const sep = path.includes('?') ? '&' : '?'
|
|
173
|
+
return `${proto}://${location.host}${path}${sep}token=${TOKEN}`
|
|
174
|
+
}
|
|
175
|
+
|
|
164
176
|
/* ── API ─────────────────────────────────────────────────────────────────── */
|
|
165
177
|
const api = {
|
|
166
|
-
async get(p) {
|
|
178
|
+
async get(p) {
|
|
179
|
+
const r = await fetch('/api'+p, { headers: { 'X-RKD-Token': TOKEN } })
|
|
180
|
+
if (r.status === 401) authError.value = true
|
|
181
|
+
return r.json()
|
|
182
|
+
},
|
|
167
183
|
async post(p, b={}) {
|
|
168
|
-
const r = await fetch('/api'+p, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(b) })
|
|
184
|
+
const r = await fetch('/api'+p, { method:'POST', headers:{'Content-Type':'application/json','X-RKD-Token':TOKEN}, body: JSON.stringify(b) })
|
|
185
|
+
if (r.status === 401) authError.value = true
|
|
169
186
|
return r.json()
|
|
170
187
|
}
|
|
171
188
|
}
|
|
@@ -265,8 +282,7 @@ const LogViewer = {
|
|
|
265
282
|
pending = []
|
|
266
283
|
lines.value = []
|
|
267
284
|
live.value = true
|
|
268
|
-
|
|
269
|
-
ws = new WebSocket(`${proto}://${location.host}/ws/logs/${props.name}`)
|
|
285
|
+
ws = new WebSocket(wsUrl(`/ws/logs/${props.name}`))
|
|
270
286
|
ws.onmessage = e => {
|
|
271
287
|
pending.push(e.data)
|
|
272
288
|
if (!raf) raf = requestAnimationFrame(flush)
|
|
@@ -335,8 +351,7 @@ const DockerTerminal = {
|
|
|
335
351
|
}
|
|
336
352
|
|
|
337
353
|
onMounted(() => {
|
|
338
|
-
|
|
339
|
-
ws = new WebSocket(`${proto}://${location.host}${props.url}`)
|
|
354
|
+
ws = new WebSocket(wsUrl(props.url))
|
|
340
355
|
ws.onmessage = e => {
|
|
341
356
|
const txt = e.data
|
|
342
357
|
if (txt.startsWith('\x00exit:')) {
|
|
@@ -2298,10 +2313,20 @@ const App = {
|
|
|
2298
2313
|
if (!darkMode.value) document.documentElement.classList.add('light')
|
|
2299
2314
|
init()
|
|
2300
2315
|
})
|
|
2301
|
-
return { view, nav, notifs, I, viewMap, currentPath, darkMode, toggleTheme, rkdVersion }
|
|
2316
|
+
return { view, nav, notifs, I, viewMap, currentPath, darkMode, toggleTheme, rkdVersion, authError }
|
|
2302
2317
|
},
|
|
2303
2318
|
template: `
|
|
2304
|
-
<div
|
|
2319
|
+
<div v-if="authError" style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;">
|
|
2320
|
+
<div class="card" style="max-width:460px;text-align:center;">
|
|
2321
|
+
<span v-html="I.warn" style="width:32px;height:32px;color:#FCD34D;display:inline-block;margin-bottom:12px;"></span>
|
|
2322
|
+
<h2 style="font-size:16px;margin-bottom:8px;">Session token required</h2>
|
|
2323
|
+
<p style="color:#8CA0C4;font-size:13px;line-height:1.6;">
|
|
2324
|
+
This page has no valid session token. Open the URL printed by <code>rkd gui</code>
|
|
2325
|
+
in the terminal it is running from — it ends with <code>?token=...</code>.
|
|
2326
|
+
</p>
|
|
2327
|
+
</div>
|
|
2328
|
+
</div>
|
|
2329
|
+
<div v-else class="layout">
|
|
2305
2330
|
<!-- Sidebar -->
|
|
2306
2331
|
<aside class="sidebar">
|
|
2307
2332
|
<div class="logo-area">
|
|
@@ -35,14 +35,15 @@ def gui_command(port, host, auto_open, cwd):
|
|
|
35
35
|
|
|
36
36
|
from rocketdoo.gui.server import create_app
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
# Built first so the panel below can show the real, tokenized URL.
|
|
39
|
+
app = create_app(host=host, port=port)
|
|
40
|
+
url = f"http://{host}:{port}/?token={app.state.rkd_token}"
|
|
41
|
+
browser_note = "opening automatically" if auto_open else "not opened (--open to launch one here)"
|
|
40
42
|
|
|
41
43
|
console.print()
|
|
42
44
|
console.print(
|
|
43
45
|
Panel(
|
|
44
46
|
f"[bold white]Rocketdoo GUI[/bold white] [dim]v{__version__}[/dim]\n\n"
|
|
45
|
-
f" [dim]URL:[/dim] [bold cyan]{url}[/bold cyan]\n"
|
|
46
47
|
f" [dim]Browser:[/dim] {browser_note}\n"
|
|
47
48
|
f" [dim]Stop:[/dim] [bold]Ctrl+C[/bold] (this terminal stays busy while the server runs)",
|
|
48
49
|
title="[bold blue]RKD GUI[/bold blue]",
|
|
@@ -51,9 +52,23 @@ def gui_command(port, host, auto_open, cwd):
|
|
|
51
52
|
padding=(1, 2),
|
|
52
53
|
)
|
|
53
54
|
)
|
|
55
|
+
# Outside the panel and unwrapped: the tokenised URL is long, and inside a
|
|
56
|
+
# bordered box Rich splits it across lines, which breaks copy-paste — the
|
|
57
|
+
# one thing the user has to do with it.
|
|
58
|
+
console.print("\n Open this URL (it carries the session token):\n")
|
|
59
|
+
console.print(f" [bold cyan]{url}[/bold cyan]", soft_wrap=True, highlight=False)
|
|
54
60
|
console.print()
|
|
55
61
|
|
|
56
62
|
if auto_open:
|
|
63
|
+
# The tokenised URL becomes an argument of the browser helper process
|
|
64
|
+
# (webbrowser resolves to Popen(["xdg-open", url]) on Linux), so it is
|
|
65
|
+
# briefly visible in `ps aux` to any local user. Same channel #142
|
|
66
|
+
# closed for VPS passwords, here as an accepted trade-off of an opt-in
|
|
67
|
+
# flag: documented in SECURITY.md rather than silently accepted.
|
|
68
|
+
console.print("[dim] note: --open passes the tokenised URL to the browser helper,[/dim]")
|
|
69
|
+
console.print("[dim] which makes it briefly visible in the process list.[/dim]")
|
|
70
|
+
console.print()
|
|
71
|
+
|
|
57
72
|
import threading
|
|
58
73
|
import time
|
|
59
74
|
import webbrowser
|
|
@@ -64,7 +79,6 @@ def gui_command(port, host, auto_open, cwd):
|
|
|
64
79
|
|
|
65
80
|
threading.Thread(target=_open, daemon=True).start()
|
|
66
81
|
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
app = create_app(host=host, port=port)
|
|
82
|
+
# log_level="error" also keeps the token out of an access log: the query
|
|
83
|
+
# string would otherwise be recorded on every request.
|
|
70
84
|
uvicorn.run(app, host=host, port=port, log_level="error")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rocketdoo
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.4.0
|
|
4
4
|
Summary: Framework for creating Odoo development environments with Docker and custom templates.
|
|
5
5
|
Author-email: Horacio Montaño <horaciomontano@hdmsoft.com.ar>
|
|
6
6
|
License: GNU LESSER GENERAL PUBLIC LICENSE
|
|
@@ -62,9 +62,9 @@ License: GNU LESSER GENERAL PUBLIC LICENSE
|
|
|
62
62
|
<div RKD as ROCKETDOO V3=""></div>
|
|
63
63
|
|
|
64
64
|
Licencia: LGPL-3.0+
|
|
65
|
-
Versión: "3.
|
|
65
|
+
Versión: "3.4.0"
|
|
66
66
|
Autor: Horacio Montaño
|
|
67
|
-
Fecha:
|
|
67
|
+
Fecha: 16/09/2026
|
|
68
68
|
Descripción: Framework to development Odoo
|
|
69
69
|
Requires-Python: >=3.10
|
|
70
70
|
Description-Content-Type: text/markdown
|
|
@@ -97,7 +97,7 @@ Odoo Development Framework
|
|
|
97
97
|
- "Horacio Montaño"
|
|
98
98
|
|
|
99
99
|
## Version:
|
|
100
|
-
- "3.
|
|
100
|
+
- "3.4.0"
|
|
101
101
|
|
|
102
102
|
----------------------------------------------------------------------------------------------------------------------------------------------------------
|
|
103
103
|
|
|
@@ -532,12 +532,19 @@ rkd instance status # Show configured environments
|
|
|
532
532
|
Rocketdoo v3 includes a professional web-based management interface, accessible from your browser just like Odoo — no installation of additional tools required.
|
|
533
533
|
|
|
534
534
|
```bash
|
|
535
|
-
rkd gui # Launch GUI
|
|
536
|
-
rkd gui --open # Also open the browser automatically
|
|
535
|
+
rkd gui # Launch GUI, prints a tokenized URL to open
|
|
536
|
+
rkd gui --open # Also open that same URL in the browser automatically
|
|
537
537
|
rkd gui --port 9090 # Use a custom port
|
|
538
538
|
rkd gui --cwd /my/proj # Point to a specific project directory
|
|
539
539
|
```
|
|
540
540
|
|
|
541
|
+
`rkd gui` prints a URL of the form `http://127.0.0.1:8070/?token=<session-token>` —
|
|
542
|
+
use that exact URL, not a bare `http://localhost:8070`. Every API and WebSocket
|
|
543
|
+
call requires this session token (generated fresh on each `rkd gui` start), so
|
|
544
|
+
opening the GUI without it shows a message asking you to use the printed URL
|
|
545
|
+
instead of the project's data. `--open` already opens the correct, tokenized
|
|
546
|
+
URL for you.
|
|
547
|
+
|
|
541
548
|
The GUI provides a complete visual interface for all Rocketdoo v3 features:
|
|
542
549
|
|
|
543
550
|
| Section | What you can do |
|
|
@@ -555,7 +562,10 @@ Additional interface features:
|
|
|
555
562
|
- Real-time log streaming via WebSocket
|
|
556
563
|
- Runs entirely from the installed package — no Node.js or build step required
|
|
557
564
|
|
|
558
|
-
> The GUI server runs locally and is bound to `127.0.0.1` by default, so it is only accessible
|
|
565
|
+
> The GUI server runs locally and is bound to `127.0.0.1` by default, so it is only accessible
|
|
566
|
+
> from your own machine. The session token adds a second layer on top of that: even another
|
|
567
|
+
> process running as your own user on the same machine cannot call the API without it. See
|
|
568
|
+
> [SECURITY.md](SECURITY.md) for the full threat model.
|
|
559
569
|
|
|
560
570
|
---
|
|
561
571
|
|
|
@@ -615,6 +625,15 @@ deploy — no manual step needed.
|
|
|
615
625
|
|
|
616
626
|
---
|
|
617
627
|
|
|
628
|
+
### Security
|
|
629
|
+
|
|
630
|
+
Rocketdoo is a local, single-user development tool, not a hardened multi-user
|
|
631
|
+
service. See [SECURITY.md](SECURITY.md) for the threat model: what the GUI
|
|
632
|
+
exposes, where credentials live on disk, and the residual risks that are
|
|
633
|
+
accepted rather than solved.
|
|
634
|
+
|
|
635
|
+
---
|
|
636
|
+
|
|
618
637
|
### Technical Support
|
|
619
638
|
|
|
620
639
|
- If you have any questions or issues with our development environment, you can contact us and submit your inquiry or support ticket by clicking on the link below.
|
|
@@ -17,13 +17,20 @@ import pytest
|
|
|
17
17
|
|
|
18
18
|
fastapi_testclient = pytest.importorskip("fastapi.testclient")
|
|
19
19
|
|
|
20
|
+
from starlette.websockets import WebSocketDisconnect # noqa: E402
|
|
21
|
+
|
|
20
22
|
from rocketdoo.gui.server import create_app # noqa: E402
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
@pytest.fixture
|
|
24
26
|
def client(project_dir):
|
|
25
|
-
"""A TestClient whose working directory is an empty project dir.
|
|
26
|
-
|
|
27
|
+
"""A TestClient whose working directory is an empty project dir.
|
|
28
|
+
|
|
29
|
+
Sends the app's own session token by default: these tests exercise the
|
|
30
|
+
endpoints, not the token gate itself (that is #142's T11).
|
|
31
|
+
"""
|
|
32
|
+
app = create_app()
|
|
33
|
+
return fastapi_testclient.TestClient(app, headers={"X-RKD-Token": app.state.rkd_token})
|
|
27
34
|
|
|
28
35
|
|
|
29
36
|
GET_ENDPOINTS = [
|
|
@@ -421,7 +428,8 @@ def test_the_app_honours_the_port_it_was_created_with():
|
|
|
421
428
|
"""`rkd gui --port N` passes N through, so CORS matches the real URL."""
|
|
422
429
|
from rocketdoo.gui.server import create_app
|
|
423
430
|
|
|
424
|
-
|
|
431
|
+
app = create_app(port=9090)
|
|
432
|
+
client = fastapi_testclient.TestClient(app, headers={"X-RKD-Token": app.state.rkd_token})
|
|
425
433
|
response = client.get("/api/workspace", headers={"Origin": "http://localhost:9090"})
|
|
426
434
|
assert response.headers.get("access-control-allow-origin") == "http://localhost:9090"
|
|
427
435
|
|
|
@@ -503,3 +511,149 @@ class TestHealthReportsTheRealVersion:
|
|
|
503
511
|
|
|
504
512
|
source = (Path(rocketdoo.__path__[0]) / "gui" / "server.py").read_text()
|
|
505
513
|
assert not re.findall(r'"\d+\.\d+\.\d+"', source)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
class TestSessionToken:
|
|
517
|
+
"""The token gate itself (#142's T11): T8-T10 only made `client` send it.
|
|
518
|
+
|
|
519
|
+
`no_token_client` deliberately carries no default header, so each test
|
|
520
|
+
controls exactly what credential (if any) travels with the request.
|
|
521
|
+
"""
|
|
522
|
+
|
|
523
|
+
@pytest.fixture
|
|
524
|
+
def app(self, project_dir):
|
|
525
|
+
return create_app()
|
|
526
|
+
|
|
527
|
+
@pytest.fixture
|
|
528
|
+
def no_token_client(self, app):
|
|
529
|
+
return fastapi_testclient.TestClient(app)
|
|
530
|
+
|
|
531
|
+
def test_rest_without_token_is_rejected(self, no_token_client):
|
|
532
|
+
assert no_token_client.get("/api/project").status_code == 401
|
|
533
|
+
|
|
534
|
+
def test_rest_with_an_invalid_token_is_rejected(self, no_token_client):
|
|
535
|
+
response = no_token_client.get("/api/project", headers={"X-RKD-Token": "not-the-token"})
|
|
536
|
+
assert response.status_code == 401
|
|
537
|
+
|
|
538
|
+
def test_rest_with_the_valid_header_token_is_accepted(self, app, no_token_client):
|
|
539
|
+
headers = {"X-RKD-Token": app.state.rkd_token}
|
|
540
|
+
assert no_token_client.get("/api/project", headers=headers).status_code == 200
|
|
541
|
+
|
|
542
|
+
def test_rest_with_the_valid_query_token_is_accepted(self, app, no_token_client):
|
|
543
|
+
response = no_token_client.get(f"/api/project?token={app.state.rkd_token}")
|
|
544
|
+
assert response.status_code == 200
|
|
545
|
+
|
|
546
|
+
def test_post_endpoints_are_gated_too(self, no_token_client):
|
|
547
|
+
assert no_token_client.post("/api/mail/off").status_code == 401
|
|
548
|
+
|
|
549
|
+
@pytest.mark.parametrize("path", ["/", "/health", "/some/spa/route"])
|
|
550
|
+
def test_public_paths_need_no_token(self, no_token_client, path):
|
|
551
|
+
assert no_token_client.get(path).status_code == 200
|
|
552
|
+
|
|
553
|
+
@pytest.mark.parametrize(
|
|
554
|
+
"ws_path",
|
|
555
|
+
["/ws/docker/bogus", "/ws/logs/some-container", "/ws/odoo/update?module=m&db=d"],
|
|
556
|
+
)
|
|
557
|
+
def test_every_websocket_route_rejects_a_missing_token(self, no_token_client, ws_path):
|
|
558
|
+
"""Rejected in the middleware, before the handler runs — no Docker involved."""
|
|
559
|
+
with pytest.raises(WebSocketDisconnect):
|
|
560
|
+
with no_token_client.websocket_connect(ws_path):
|
|
561
|
+
pass
|
|
562
|
+
|
|
563
|
+
def test_websocket_rejects_an_invalid_token(self, no_token_client):
|
|
564
|
+
with pytest.raises(WebSocketDisconnect):
|
|
565
|
+
with no_token_client.websocket_connect("/ws/docker/bogus?token=not-the-token"):
|
|
566
|
+
pass
|
|
567
|
+
|
|
568
|
+
def test_websocket_connects_with_the_valid_token(self, app, no_token_client):
|
|
569
|
+
url = f"/ws/docker/bogus?token={app.state.rkd_token}"
|
|
570
|
+
with no_token_client.websocket_connect(url) as websocket:
|
|
571
|
+
assert websocket.receive_text() == "[error] Unknown action: bogus"
|
|
572
|
+
|
|
573
|
+
def test_two_apps_never_share_a_token(self, project_dir):
|
|
574
|
+
assert create_app().state.rkd_token != create_app().state.rkd_token
|
|
575
|
+
|
|
576
|
+
def test_the_generated_token_is_not_a_short_guess(self, app):
|
|
577
|
+
assert len(app.state.rkd_token) >= 32
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
class TestTokenMiddlewareEdges:
|
|
581
|
+
"""Ways the middleware could answer without a token, found in review."""
|
|
582
|
+
|
|
583
|
+
@pytest.fixture
|
|
584
|
+
def untokened(self, project_dir):
|
|
585
|
+
"""A client that sends no token, unlike the module-wide `client`."""
|
|
586
|
+
from rocketdoo.gui.server import create_app
|
|
587
|
+
|
|
588
|
+
return fastapi_testclient.TestClient(create_app())
|
|
589
|
+
|
|
590
|
+
def _probe(self, app, path, headers=None, query=b"", root_path=""):
|
|
591
|
+
"""Drive the app over raw ASGI: httpx rejects non-ASCII headers itself."""
|
|
592
|
+
import asyncio
|
|
593
|
+
|
|
594
|
+
scope = {
|
|
595
|
+
"type": "http",
|
|
596
|
+
"method": "GET",
|
|
597
|
+
"path": path,
|
|
598
|
+
"raw_path": path.encode(),
|
|
599
|
+
"query_string": query,
|
|
600
|
+
"headers": headers or [],
|
|
601
|
+
"root_path": root_path,
|
|
602
|
+
"app": app,
|
|
603
|
+
"scheme": "http",
|
|
604
|
+
"server": ("test", 80),
|
|
605
|
+
"client": ("c", 1),
|
|
606
|
+
"http_version": "1.1",
|
|
607
|
+
}
|
|
608
|
+
seen = {}
|
|
609
|
+
|
|
610
|
+
async def receive():
|
|
611
|
+
return {"type": "http.request", "body": b"", "more_body": False}
|
|
612
|
+
|
|
613
|
+
async def send(message):
|
|
614
|
+
if message["type"] == "http.response.start":
|
|
615
|
+
seen["status"] = message["status"]
|
|
616
|
+
|
|
617
|
+
asyncio.run(app(scope, receive, send))
|
|
618
|
+
return seen.get("status")
|
|
619
|
+
|
|
620
|
+
def test_a_non_ascii_token_is_rejected_not_a_crash(self):
|
|
621
|
+
"""compare_digest raises TypeError on non-ASCII, and the value is attacker-controlled."""
|
|
622
|
+
from rocketdoo.gui.server import create_app
|
|
623
|
+
|
|
624
|
+
app = create_app()
|
|
625
|
+
status = self._probe(app, "/api/project", [(b"x-rkd-token", "tokén".encode("latin-1"))])
|
|
626
|
+
|
|
627
|
+
assert status == 401, "a crafted header must answer 401, not 500"
|
|
628
|
+
|
|
629
|
+
def test_a_non_ascii_token_in_the_query_is_rejected(self):
|
|
630
|
+
from rocketdoo.gui.server import create_app
|
|
631
|
+
|
|
632
|
+
app = create_app()
|
|
633
|
+
status = self._probe(app, "/api/project", query="token=tokén".encode("latin-1"))
|
|
634
|
+
|
|
635
|
+
assert status == 401
|
|
636
|
+
|
|
637
|
+
def test_a_mounted_app_still_requires_the_token(self):
|
|
638
|
+
"""The router strips root_path before matching; the guard must too.
|
|
639
|
+
|
|
640
|
+
Reading scope["path"] instead made every route answer unauthenticated
|
|
641
|
+
once the app was mounted under a prefix — failing open, silently.
|
|
642
|
+
"""
|
|
643
|
+
from rocketdoo.gui.server import create_app
|
|
644
|
+
|
|
645
|
+
app = create_app()
|
|
646
|
+
status = self._probe(app, "/prefix/api/project", root_path="/prefix")
|
|
647
|
+
|
|
648
|
+
assert status == 401
|
|
649
|
+
|
|
650
|
+
def test_the_route_inventory_is_not_public(self, untokened):
|
|
651
|
+
"""openapi_url defaulted outside /api and served all 47 paths."""
|
|
652
|
+
response = untokened.get("/openapi.json")
|
|
653
|
+
|
|
654
|
+
assert "text/html" in response.headers.get("content-type", "")
|
|
655
|
+
assert "/api/instances/deploy" not in response.text
|
|
656
|
+
|
|
657
|
+
def test_the_schema_under_api_needs_the_token(self, untokened, client):
|
|
658
|
+
assert untokened.get("/api/openapi.json").status_code == 401
|
|
659
|
+
assert client.get("/api/openapi.json").status_code == 200
|
|
@@ -393,26 +393,55 @@ ROCKETDOO_ROOT = Path(__file__).resolve().parent.parent / "rocketdoo"
|
|
|
393
393
|
# self._sshpass_password and INSTANCE_STAGE_PASSWORD must all match).
|
|
394
394
|
_SENSITIVE_NAME_RE = re.compile(r"password|passwd|secret|token", re.IGNORECASE)
|
|
395
395
|
|
|
396
|
-
#
|
|
397
|
-
#
|
|
398
|
-
#
|
|
399
|
-
|
|
400
|
-
|
|
396
|
+
# Process-launching methods from stdlib modules. Matched by (module, method)
|
|
397
|
+
# rather than by method name alone: a bare-name match on "run" or "call" also
|
|
398
|
+
# fires on unrelated calls like `uvicorn.run(...)`, which is not a sink.
|
|
399
|
+
_STDLIB_SINK_MODULES = {"subprocess", "os", "asyncio"}
|
|
400
|
+
|
|
401
|
+
# Names common enough to collide with unrelated calls (uvicorn.run, a local
|
|
402
|
+
# call()), so they only count when qualified by one of the modules above.
|
|
403
|
+
_QUALIFIED_SINK_METHODS = {"run", "call", "check_call", "system", "popen"}
|
|
404
|
+
|
|
405
|
+
# Names that belong to no other API worth worrying about: matched bare, so a
|
|
406
|
+
# `from asyncio import create_subprocess_exec` still counts.
|
|
407
|
+
_BARE_SINK_METHODS = {
|
|
401
408
|
"Popen",
|
|
402
|
-
"
|
|
403
|
-
"check_call",
|
|
404
|
-
"check_output", # subprocess.*
|
|
405
|
-
"system",
|
|
406
|
-
"popen", # os.*
|
|
409
|
+
"check_output",
|
|
407
410
|
"create_subprocess_exec",
|
|
408
|
-
"create_subprocess_shell",
|
|
411
|
+
"create_subprocess_shell",
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
# Local helpers that build the ssh/rsync argv passed to a sink, or launch one
|
|
415
|
+
# directly on `self`. Their names are specific enough to match bare, unlike
|
|
416
|
+
# the generic stdlib methods above.
|
|
417
|
+
_LOCAL_SINK_NAMES = {
|
|
409
418
|
"_ssh",
|
|
410
419
|
"_run_ssh_command",
|
|
411
|
-
"_rsync",
|
|
420
|
+
"_rsync",
|
|
412
421
|
"build_ssh_cmd",
|
|
413
|
-
"build_rsync_cmd",
|
|
422
|
+
"build_rsync_cmd",
|
|
414
423
|
}
|
|
415
424
|
|
|
425
|
+
|
|
426
|
+
def _is_sink_call(node: ast.Call) -> bool:
|
|
427
|
+
"""Whether this call launches a process.
|
|
428
|
+
|
|
429
|
+
Known gap: a qualified sink reached under another name -- `from
|
|
430
|
+
subprocess import run` with a bare `run(...)`, or `import subprocess as
|
|
431
|
+
sp` with `sp.run(...)` -- is not matched, because requiring a known
|
|
432
|
+
module qualifier is what keeps uvicorn.run from producing a false
|
|
433
|
+
positive. Nothing in the package does either; if that changes, add the
|
|
434
|
+
name to _BARE_SINK_METHODS or the alias to _STDLIB_SINK_MODULES.
|
|
435
|
+
"""
|
|
436
|
+
name = _called_name(node.func)
|
|
437
|
+
if name in _LOCAL_SINK_NAMES or name in _BARE_SINK_METHODS:
|
|
438
|
+
return True
|
|
439
|
+
if name not in _QUALIFIED_SINK_METHODS or not isinstance(node.func, ast.Attribute):
|
|
440
|
+
return False
|
|
441
|
+
base = node.func.value
|
|
442
|
+
return isinstance(base, ast.Name) and base.id in _STDLIB_SINK_MODULES
|
|
443
|
+
|
|
444
|
+
|
|
416
445
|
# The only two channels a secret is allowed to travel through once it is
|
|
417
446
|
# about to reach a sink: subprocess's own `input=`/`env=` keywords, and the
|
|
418
447
|
# first argument of sshpass_wrap() (which moves it into `env["SSHPASS"]`).
|
|
@@ -484,7 +513,7 @@ def _walk_pruned(node: ast.AST, safe_ids: set[int]):
|
|
|
484
513
|
|
|
485
514
|
|
|
486
515
|
def _violations_in_function(func: ast.AST) -> list[tuple[int, str]]:
|
|
487
|
-
if not any(isinstance(n, ast.Call) and
|
|
516
|
+
if not any(isinstance(n, ast.Call) and _is_sink_call(n) for n in ast.walk(func)):
|
|
488
517
|
return []
|
|
489
518
|
|
|
490
519
|
safe_ids = _safe_node_ids(func)
|
|
@@ -528,7 +557,7 @@ def test_no_secret_reaches_subprocess_argv_construction():
|
|
|
528
557
|
- the secret read through a subscript of a literal key
|
|
529
558
|
(`conn["password"]`) or renamed first (`pw = self.password`)
|
|
530
559
|
- the command built in a helper that does not launch anything itself
|
|
531
|
-
- a sink reached under a name not in
|
|
560
|
+
- a sink reached under a name not in _QUALIFIED_SINK_METHODS/_LOCAL_SINK_NAMES
|
|
532
561
|
|
|
533
562
|
Closing those needs real dataflow analysis, which is a bigger machine than
|
|
534
563
|
the bug it guards. Reviewing a diff that touches a deploy path is still the
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|