rocketdoo 3.2.1__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.
Files changed (119) hide show
  1. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/LICENSE +2 -2
  2. {rocketdoo-3.2.1/rocketdoo.egg-info → rocketdoo-3.4.0}/PKG-INFO +30 -7
  3. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/README.md +27 -4
  4. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/pyproject.toml +1 -1
  5. rocketdoo-3.4.0/rocketdoo/core/odoo_db.py +191 -0
  6. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui/server.py +75 -4
  7. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui/static/index.html +33 -8
  8. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui_cli.py +20 -6
  9. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/mail_cli.py +268 -21
  10. {rocketdoo-3.2.1 → rocketdoo-3.4.0/rocketdoo.egg-info}/PKG-INFO +30 -7
  11. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo.egg-info/SOURCES.txt +1 -0
  12. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/tests/test_gui_api.py +198 -3
  13. rocketdoo-3.4.0/tests/test_mail_cli.py +617 -0
  14. rocketdoo-3.4.0/tests/test_odoo_db.py +325 -0
  15. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/tests/test_secrets_hardening.py +44 -15
  16. rocketdoo-3.2.1/rocketdoo/core/odoo_db.py +0 -118
  17. rocketdoo-3.2.1/tests/test_odoo_db.py +0 -112
  18. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/MANIFEST.in +0 -0
  19. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/__init__.py +0 -0
  20. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/cli.py +0 -0
  21. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/__init__.py +0 -0
  22. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/compose.py +0 -0
  23. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/deploy/__init__.py +0 -0
  24. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/deploy/base.py +0 -0
  25. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/deploy/config_manager.py +0 -0
  26. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/deploy/module_packager.py +0 -0
  27. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/deploy/odoo_sh.py +0 -0
  28. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/deploy/vps.py +0 -0
  29. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/edition_setup.py +0 -0
  30. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/generator.py +0 -0
  31. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/gitignore_manager.py +0 -0
  32. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/gitman_config.py +0 -0
  33. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/instance/__init__.py +0 -0
  34. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/instance/config_manager.py +0 -0
  35. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/instance/deployer_docker.py +0 -0
  36. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/instance/deployer_native.py +0 -0
  37. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/instance/secrets_store.py +0 -0
  38. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/instance/ssh_utils.py +0 -0
  39. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/models/__init__.py +0 -0
  40. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/models/deploy.py +0 -0
  41. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/models/instance.py +0 -0
  42. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/models/profiles.py +0 -0
  43. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/models/project.py +0 -0
  44. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/module_scanner.py +0 -0
  45. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/port_validation.py +0 -0
  46. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/ssh_manager.py +0 -0
  47. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/core/utils.py +0 -0
  48. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/deploy_cli.py +0 -0
  49. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/docker_cli.py +0 -0
  50. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui/__init__.py +0 -0
  51. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui/api/__init__.py +0 -0
  52. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui/api/deploy_ops.py +0 -0
  53. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui/api/docker_ops.py +0 -0
  54. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui/api/gitman.py +0 -0
  55. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui/api/instances.py +0 -0
  56. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui/api/mail.py +0 -0
  57. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui/api/modules.py +0 -0
  58. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui/api/odoo.py +0 -0
  59. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui/api/pack_ops.py +0 -0
  60. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui/api/project.py +0 -0
  61. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui/api/setup.py +0 -0
  62. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui/api/traefik_ops.py +0 -0
  63. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/gui/api/workspace.py +0 -0
  64. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/init_project.py +0 -0
  65. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/instance_cli.py +0 -0
  66. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/pack_environment.py +0 -0
  67. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/project_info.py +0 -0
  68. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/scaffold.py +0 -0
  69. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/.devcontainer/devcontainer.json +0 -0
  70. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/.devcontainer/docker-compose.yaml +0 -0
  71. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/.gitignore.jinja +0 -0
  72. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/.vscode/launch.json +0 -0
  73. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/.vscode/launch.json.jinja +0 -0
  74. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/.vscode/settings.json +0 -0
  75. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/Dockerfile.jinja +0 -0
  76. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/addons/.gitkeep +0 -0
  77. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/config/odoo.conf +0 -0
  78. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/config/odoo.conf.jinja +0 -0
  79. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/deploy/.deployignore +0 -0
  80. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/deploy/deploy.yaml +0 -0
  81. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/deploy/deploy.yaml.jinja +0 -0
  82. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/docker-compose.yaml.jinja +0 -0
  83. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/install_dependencies.sh +0 -0
  84. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/instance/Dockerfile.jinja +0 -0
  85. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/instance/docker-compose.jinja +0 -0
  86. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/instance/odoo.conf.jinja +0 -0
  87. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/odoo_pg_pass +0 -0
  88. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/README.md +0 -0
  89. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo15-ce.yaml +0 -0
  90. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo15-ee.yaml +0 -0
  91. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo16-ce.yaml +0 -0
  92. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo16-ee.yaml +0 -0
  93. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo17-ce.yaml +0 -0
  94. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo17-ee.yaml +0 -0
  95. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo18-ce.yaml +0 -0
  96. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo18-ee.yaml +0 -0
  97. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo19-ce.yaml +0 -0
  98. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/templates/profiles/odoo19-ee.yaml +0 -0
  99. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/traefik_cli.py +0 -0
  100. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/unpack_environment.py +0 -0
  101. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo/welcome.py +0 -0
  102. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo.egg-info/dependency_links.txt +0 -0
  103. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo.egg-info/entry_points.txt +0 -0
  104. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo.egg-info/requires.txt +0 -0
  105. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/rocketdoo.egg-info/top_level.txt +0 -0
  106. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/setup.cfg +0 -0
  107. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/tests/test_compose.py +0 -0
  108. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/tests/test_deploy_config_manager.py +0 -0
  109. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/tests/test_e2e_scaffold.py +0 -0
  110. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/tests/test_edition_setup.py +0 -0
  111. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/tests/test_gitignore_manager.py +0 -0
  112. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/tests/test_imports.py +0 -0
  113. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/tests/test_models.py +0 -0
  114. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/tests/test_module_scanner.py +0 -0
  115. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/tests/test_pack_environment.py +0 -0
  116. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/tests/test_port_validation.py +0 -0
  117. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/tests/test_profiles.py +0 -0
  118. {rocketdoo-3.2.1 → rocketdoo-3.4.0}/tests/test_ssh_manager.py +0 -0
  119. {rocketdoo-3.2.1 → 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.2.1"
60
+ Versión: "3.4.0"
61
61
  Autor: Horacio Montaño
62
- Fecha: 13/09/2026
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.2.1
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.2.1"
65
+ Versión: "3.4.0"
66
66
  Autor: Horacio Montaño
67
- Fecha: 13/09/2026
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.2.1"
100
+ - "3.4.0"
101
101
 
102
102
  ----------------------------------------------------------------------------------------------------------------------------------------------------------
103
103
 
@@ -472,6 +472,10 @@ rkd mail open # Open http://localhost:8025 in your browser
472
472
 
473
473
  Mailpit is toggled by commenting/uncommenting its service block in `docker-compose.yaml` using special markers (`# rkd:mailpit`). When enabled, Odoo's SMTP configuration is automatically updated to route emails through Mailpit.
474
474
 
475
+ `rkd mail on`/`off` also write a `Mailpit (rkd)` record straight into the project's `ir.mail_server` table (Odoo's technical settings, *Outgoing Mail Servers*), so emails are captured with no extra manual setup. `on` creates or reactivates that record with `sequence = 1`; `off` archives it (never deletes it), leaving any of your own mail servers untouched. With a single database this is automatic; with more than one, pass `--db NAME` to `on`, `off`, or `status` — with several databases and no `--db`, nothing is written or read and the command tells you to re-run with it. If the `db` container isn't reachable (project stopped, Docker not running), the rest of the command still runs and it reports the reason; re-running `rkd mail on` once the project is up fixes it.
476
+
477
+ `rkd mail status` shows this record's state (active, archived, not created yet, or not checked, with the reason) plus any other active `ir.mail_server` in the database. **A `sequence = 1` on the Mailpit record does not guarantee it captures every email**: Odoo's server selection filters by `from_filter` before it sorts by sequence, so a server you (or a restored production dump) already have, with a `from_filter` matching the sender, can still win even with a higher sequence number. `mail status` warns loudly when another active server has `sequence <= 1` (it can win the sequence tie-break outright), and with a softer note whenever any other server is active at all, since `from_filter` can override sequence regardless.
478
+
475
479
  ---
476
480
 
477
481
  #### `rkd traefik` — Traefik Reverse Proxy
@@ -528,12 +532,19 @@ rkd instance status # Show configured environments
528
532
  Rocketdoo v3 includes a professional web-based management interface, accessible from your browser just like Odoo — no installation of additional tools required.
529
533
 
530
534
  ```bash
531
- rkd gui # Launch GUI on http://localhost:8070
532
- 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
533
537
  rkd gui --port 9090 # Use a custom port
534
538
  rkd gui --cwd /my/proj # Point to a specific project directory
535
539
  ```
536
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
+
537
548
  The GUI provides a complete visual interface for all Rocketdoo v3 features:
538
549
 
539
550
  | Section | What you can do |
@@ -551,7 +562,10 @@ Additional interface features:
551
562
  - Real-time log streaming via WebSocket
552
563
  - Runs entirely from the installed package — no Node.js or build step required
553
564
 
554
- > The GUI server runs locally and is bound to `127.0.0.1` by default, so it is only accessible from your own machine.
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.
555
569
 
556
570
  ---
557
571
 
@@ -611,6 +625,15 @@ deploy — no manual step needed.
611
625
 
612
626
  ---
613
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
+
614
637
  ### Technical Support
615
638
 
616
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.2.1"
18
+ - "3.4.0"
19
19
 
20
20
  ----------------------------------------------------------------------------------------------------------------------------------------------------------
21
21
 
@@ -390,6 +390,10 @@ rkd mail open # Open http://localhost:8025 in your browser
390
390
 
391
391
  Mailpit is toggled by commenting/uncommenting its service block in `docker-compose.yaml` using special markers (`# rkd:mailpit`). When enabled, Odoo's SMTP configuration is automatically updated to route emails through Mailpit.
392
392
 
393
+ `rkd mail on`/`off` also write a `Mailpit (rkd)` record straight into the project's `ir.mail_server` table (Odoo's technical settings, *Outgoing Mail Servers*), so emails are captured with no extra manual setup. `on` creates or reactivates that record with `sequence = 1`; `off` archives it (never deletes it), leaving any of your own mail servers untouched. With a single database this is automatic; with more than one, pass `--db NAME` to `on`, `off`, or `status` — with several databases and no `--db`, nothing is written or read and the command tells you to re-run with it. If the `db` container isn't reachable (project stopped, Docker not running), the rest of the command still runs and it reports the reason; re-running `rkd mail on` once the project is up fixes it.
394
+
395
+ `rkd mail status` shows this record's state (active, archived, not created yet, or not checked, with the reason) plus any other active `ir.mail_server` in the database. **A `sequence = 1` on the Mailpit record does not guarantee it captures every email**: Odoo's server selection filters by `from_filter` before it sorts by sequence, so a server you (or a restored production dump) already have, with a `from_filter` matching the sender, can still win even with a higher sequence number. `mail status` warns loudly when another active server has `sequence <= 1` (it can win the sequence tie-break outright), and with a softer note whenever any other server is active at all, since `from_filter` can override sequence regardless.
396
+
393
397
  ---
394
398
 
395
399
  #### `rkd traefik` — Traefik Reverse Proxy
@@ -446,12 +450,19 @@ rkd instance status # Show configured environments
446
450
  Rocketdoo v3 includes a professional web-based management interface, accessible from your browser just like Odoo — no installation of additional tools required.
447
451
 
448
452
  ```bash
449
- rkd gui # Launch GUI on http://localhost:8070
450
- 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
451
455
  rkd gui --port 9090 # Use a custom port
452
456
  rkd gui --cwd /my/proj # Point to a specific project directory
453
457
  ```
454
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
+
455
466
  The GUI provides a complete visual interface for all Rocketdoo v3 features:
456
467
 
457
468
  | Section | What you can do |
@@ -469,7 +480,10 @@ Additional interface features:
469
480
  - Real-time log streaming via WebSocket
470
481
  - Runs entirely from the installed package — no Node.js or build step required
471
482
 
472
- > The GUI server runs locally and is bound to `127.0.0.1` by default, so it is only accessible from your own machine.
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.
473
487
 
474
488
  ---
475
489
 
@@ -529,6 +543,15 @@ deploy — no manual step needed.
529
543
 
530
544
  ---
531
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
+
532
555
  ### Technical Support
533
556
 
534
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "rocketdoo"
3
- version = "3.2.1"
3
+ version = "3.4.0"
4
4
  description = "Framework for creating Odoo development environments with Docker and custom templates."
5
5
  authors = [
6
6
  { name = "Horacio Montaño", email = "horaciomontano@hdmsoft.com.ar" }
@@ -0,0 +1,191 @@
1
+ """Access to the project's PostgreSQL container: reads and Mailpit writes.
2
+
3
+ The `pg_database` query and the container lookup used to live only in
4
+ `pack_environment`; the GUI module-update feature needs the same data, so
5
+ this consolidates them into a single place (like `core/compose.py` did for
6
+ `docker compose` in #137) instead of growing a third copy. `rkd mail on/off`
7
+ also writes the Rocketdoo `ir.mail_server` row here (#176), so this module is
8
+ no longer read-only.
9
+ """
10
+
11
+ import subprocess
12
+
13
+ from rocketdoo.project_info import read_docker_compose
14
+
15
+ PSQL_USER = "root" # POSTGRES_USER set by the docker-compose template
16
+
17
+ MAILPIT_SERVER_NAME = "Mailpit (rkd)"
18
+ MAILPIT_SMTP_HOST = "mailpit"
19
+ MAILPIT_SMTP_PORT = 1025
20
+ MAILPIT_SEQUENCE = 1 # Odoo's default is 10: Mailpit must win the tie
21
+
22
+
23
+ def db_container(compose_data: dict | None = None) -> str | None:
24
+ """Return the `db` service's container name, or None if it can't be read."""
25
+ if compose_data is None:
26
+ compose_data = read_docker_compose()
27
+ try:
28
+ return compose_data["services"]["db"]["container_name"]
29
+ except (KeyError, TypeError):
30
+ return None
31
+
32
+
33
+ def _psql(db: str, sql: str, *flags: str, compose_data: dict | None = None) -> tuple[str, str]:
34
+ """Run `sql` against `db` via `docker exec ... psql`. Returns (stdout, error).
35
+
36
+ A non-empty error means the query did not run: stdout is not meaningful.
37
+ Shared by every query/write in this module so the argv and the error
38
+ translation (missing docker, unreachable container, psql failure) live in
39
+ one place.
40
+ """
41
+ container = db_container(compose_data)
42
+ if not container:
43
+ return "", "no database container configured"
44
+
45
+ try:
46
+ result = subprocess.run(
47
+ ["docker", "exec", container, "psql", "-U", PSQL_USER, "-d", db, *flags, "-c", sql],
48
+ capture_output=True,
49
+ text=True,
50
+ timeout=15,
51
+ )
52
+ except FileNotFoundError:
53
+ return "", "docker not found"
54
+ except (OSError, subprocess.SubprocessError) as exc:
55
+ return "", str(exc)
56
+
57
+ if result.returncode != 0:
58
+ return "", result.stderr.strip()
59
+
60
+ return result.stdout, ""
61
+
62
+
63
+ def databases_result(compose_data: dict | None = None) -> tuple[list[str], str]:
64
+ """List of user databases plus the reason it came back empty, if any.
65
+
66
+ Excludes `postgres` and the templates, matching what `rkd pack` already
67
+ queried. Any failure to reach the container yields an empty list.
68
+ """
69
+ stdout, error = _psql(
70
+ "postgres",
71
+ "SELECT datname FROM pg_database WHERE datistemplate = false AND datname != 'postgres';",
72
+ "-t",
73
+ compose_data=compose_data,
74
+ )
75
+ if error:
76
+ return [], error
77
+
78
+ return [line.strip() for line in stdout.splitlines() if line.strip()], ""
79
+
80
+
81
+ def list_databases(compose_data: dict | None = None) -> list[str]:
82
+ """List of user databases. Use `databases_result` when the reason matters."""
83
+ return databases_result(compose_data)[0]
84
+
85
+
86
+ def module_states(db: str) -> tuple[dict[str, str], str]:
87
+ """Map `{module_name: state}` read from `ir_module_module` in `db`.
88
+
89
+ `db` is not validated here: that is the trust boundary's job (the GUI
90
+ layer), and keeping it in one place avoids the validation being
91
+ duplicated with different criteria. If `db` isn't an Odoo database,
92
+ psql fails and ({}, stderr) is returned.
93
+ """
94
+ stdout, error = _psql(db, "SELECT name, state FROM ir_module_module;", "-t", "-A", "-F", "\t")
95
+ if error:
96
+ return {}, error
97
+
98
+ states = {}
99
+ for line in stdout.splitlines():
100
+ name, _, state = line.strip().partition("\t")
101
+ if name and state:
102
+ states[name] = state
103
+ return states, ""
104
+
105
+
106
+ def mail_servers(db: str) -> tuple[list[dict], str]:
107
+ """Every ir.mail_server row in `db`, ordered the way Odoo orders them.
108
+
109
+ All rows are returned, active or not, so `rkd mail status` can tell
110
+ "archived" from "never created" with a single query.
111
+ """
112
+ stdout, error = _psql(
113
+ db,
114
+ "SELECT id, name, sequence, smtp_host, active FROM ir_mail_server ORDER BY sequence, id;",
115
+ "-t",
116
+ "-A",
117
+ "-F",
118
+ "\t",
119
+ )
120
+ if error:
121
+ return [], error
122
+
123
+ servers = []
124
+ for line in stdout.splitlines():
125
+ fields = line.split("\t")
126
+ if len(fields) != 5:
127
+ continue
128
+ raw_id, name, raw_sequence, smtp_host, raw_active = fields
129
+ try:
130
+ server_id = int(raw_id)
131
+ sequence = int(raw_sequence)
132
+ except ValueError:
133
+ continue
134
+ servers.append(
135
+ {
136
+ "id": server_id,
137
+ "name": name,
138
+ "sequence": sequence,
139
+ "smtp_host": smtp_host,
140
+ "active": raw_active == "t",
141
+ }
142
+ )
143
+ return servers, ""
144
+
145
+
146
+ def enable_mailpit_server(db: str) -> str:
147
+ """Create or reactivate the Rocketdoo mail server. Returns '' on success.
148
+
149
+ Matched by name + smtp_host, not by id: there is no UNIQUE constraint on
150
+ name to key an UPSERT off. Idempotent by construction, so callers can run
151
+ it unconditionally instead of tracking local state.
152
+ """
153
+ sql = (
154
+ "WITH updated AS ("
155
+ "UPDATE ir_mail_server "
156
+ f"SET active = true, smtp_port = {MAILPIT_SMTP_PORT}, sequence = {MAILPIT_SEQUENCE}, write_date = now() "
157
+ f"WHERE name = '{MAILPIT_SERVER_NAME}' AND smtp_host = '{MAILPIT_SMTP_HOST}' "
158
+ "RETURNING id"
159
+ ") "
160
+ "INSERT INTO ir_mail_server "
161
+ "(name, smtp_host, smtp_port, smtp_encryption, smtp_authentication, sequence, active, create_date, write_date) "
162
+ f"SELECT '{MAILPIT_SERVER_NAME}', '{MAILPIT_SMTP_HOST}', {MAILPIT_SMTP_PORT}, 'none', 'login', "
163
+ f"{MAILPIT_SEQUENCE}, true, now(), now() "
164
+ "WHERE NOT EXISTS (SELECT 1 FROM updated);"
165
+ )
166
+ _, error = _psql(db, sql)
167
+ return error
168
+
169
+
170
+ def disable_mailpit_server(db: str) -> tuple[int, str]:
171
+ """Archive the Rocketdoo mail server. Returns (rows archived, error).
172
+
173
+ Only rows matching both name and smtp_host are touched, and only
174
+ archived: never DELETE, so a record a user renamed or repurposed is
175
+ never at risk even if it collides on one of the two conditions.
176
+ """
177
+ sql = (
178
+ "UPDATE ir_mail_server SET active = false, write_date = now() "
179
+ f"WHERE name = '{MAILPIT_SERVER_NAME}' AND smtp_host = '{MAILPIT_SMTP_HOST}' "
180
+ "RETURNING id;"
181
+ )
182
+ # -q suppresses psql's status line: without it, `RETURNING id` with zero
183
+ # rows still prints "UPDATE 0", which counts as a row and makes the command
184
+ # report an archive that never happened.
185
+ stdout, error = _psql(db, sql, "-q", "-t", "-A")
186
+ if error:
187
+ return 0, error
188
+
189
+ # Count returned ids, not non-empty lines: psql's status line ("UPDATE 0")
190
+ # is not a row, and counting it reports an archive that never happened.
191
+ return len([line for line in stdout.splitlines() if line.strip().isdigit()]), ""
@@ -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.
@@ -176,13 +240,20 @@ def create_app(host: str = "127.0.0.1", port: int = DEFAULT_PORT) -> FastAPI:
176
240
  except Exception:
177
241
  pass
178
242
 
243
+ # The SPA is one file with a fixed name, so without this the browser keeps
244
+ # serving its stored copy across upgrades — a user on a new version saw the
245
+ # previous interface until a forced refresh. "no-cache" does not disable
246
+ # caching, it requires revalidation: with the etag already sent, an
247
+ # unchanged file still answers 304.
248
+ _SPA_HEADERS = {"Cache-Control": "no-cache"}
249
+
179
250
  @app.get("/", response_class=FileResponse)
180
251
  async def root():
181
- return FileResponse(STATIC_DIR / "index.html")
252
+ return FileResponse(STATIC_DIR / "index.html", headers=_SPA_HEADERS)
182
253
 
183
254
  @app.get("/health")
184
255
  async def health():
185
- return {"status": "ok", "version": "3.0.0"}
256
+ return {"status": "ok", "version": __version__}
186
257
 
187
258
  @app.get("/{path:path}", response_class=FileResponse)
188
259
  async def spa_fallback(path: str):
@@ -190,7 +261,7 @@ def create_app(host: str = "127.0.0.1", port: int = DEFAULT_PORT) -> FastAPI:
190
261
  return JSONResponse({"error": "Not found"}, status_code=404)
191
262
  index = STATIC_DIR / "index.html"
192
263
  if index.exists():
193
- return FileResponse(index)
264
+ return FileResponse(index, headers=_SPA_HEADERS)
194
265
  return JSONResponse({"error": "GUI not found"}, status_code=404)
195
266
 
196
267
  return app
@@ -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) { const r = await fetch('/api'+p); return r.json() },
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
- const proto = location.protocol==='https:'?'wss':'ws'
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
- const proto = location.protocol === 'https:' ? 'wss' : 'ws'
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 class="layout">
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
- url = f"http://{host}:{port}"
39
- browser_note = "opening automatically" if auto_open else "copy the URL above (--open to launch one here)"
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
- # host/port reach the app so CORS can allow exactly the origin the user
68
- # will open, and nothing else.
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")