From e1e9376792646432dcb4592e610739f077730a03 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 14:23:08 -0500 Subject: [PATCH 001/857] initial retooling --- DIY Install Sovran_SystemsOS.md | 251 ++++++++++ LICENSE | 202 ++++++++ README.md | 193 +++++++ configuration.nix | 191 +++++++ custom-add-ons.md | 124 +++++ custom.nix | 8 + ...n_SystemsOS_File_Fixes_And_New_Services.sh | 70 +++ file_fixes_and_new_services/add-custom-nix.sh | 81 +++ .../add_external_backup_app.sh | 66 +++ .../element-calling_haven.sh | 63 +++ .../nextcloud_maintenance_window_fix.sh | 62 +++ .../sovran-pro-flake-update.sh | 96 ++++ .../sovran-pro-flake-update2.sh | 98 ++++ file_fixes_and_new_services/update-agenix.sh | 83 +++ flake.lock | 408 +++++++++++++++ flake.nix | 74 +++ for_new_sovran_pros/Sovran_SystemsOS-Desktop | 472 ++++++++++++++++++ for_new_sovran_pros/flake.nix | 30 ++ for_new_sovran_pros/psp.sh | 89 ++++ for_new_sovran_pros/psp_physical_ram.sh | 85 ++++ for_new_sovran_pros/sdpsp.sh | 51 ++ for_new_sovran_pros/sp.sh | 406 +++++++++++++++ ..._SystemsOS_File_Fixes_And_New_Services.nix | 24 + modules/bip110.nix | 23 + modules/bitcoin-core.nix | 7 + modules/bitcoinecosystem.nix | 95 ++++ modules/core/caddy.nix | 108 ++++ modules/core/njalla-ddns.nix | 68 +++ modules/core/role-logic.nix | 37 ++ modules/core/roles.nix | 33 ++ modules/core/sovran-manage.nix | 13 + modules/coturn.nix | 54 ++ modules/element-calling.nix | 248 +++++++++ modules/haven.nix | 158 ++++++ modules/mempool.nix | 25 + modules/modules.nix | 25 + modules/nextcloud.nix | 224 +++++++++ modules/personalization.nix | 24 + modules/php.nix | 66 +++ modules/rdp.nix | 107 ++++ modules/synapse.nix | 136 +++++ modules/vaultwarden.nix | 47 ++ modules/wordpress.nix | 198 ++++++++ scripts/sovran-manage.sh | 46 ++ 44 files changed, 4969 insertions(+) create mode 100755 DIY Install Sovran_SystemsOS.md create mode 100755 LICENSE create mode 100755 README.md create mode 100644 configuration.nix create mode 100644 custom-add-ons.md create mode 100644 custom.nix create mode 100755 file_fixes_and_new_services/Sovran_SystemsOS_File_Fixes_And_New_Services.sh create mode 100755 file_fixes_and_new_services/add-custom-nix.sh create mode 100755 file_fixes_and_new_services/add_external_backup_app.sh create mode 100644 file_fixes_and_new_services/element-calling_haven.sh create mode 100755 file_fixes_and_new_services/nextcloud_maintenance_window_fix.sh create mode 100755 file_fixes_and_new_services/sovran-pro-flake-update.sh create mode 100755 file_fixes_and_new_services/sovran-pro-flake-update2.sh create mode 100755 file_fixes_and_new_services/update-agenix.sh create mode 100755 flake.lock create mode 100755 flake.nix create mode 100644 for_new_sovran_pros/Sovran_SystemsOS-Desktop create mode 100755 for_new_sovran_pros/flake.nix create mode 100755 for_new_sovran_pros/psp.sh create mode 100755 for_new_sovran_pros/psp_physical_ram.sh create mode 100755 for_new_sovran_pros/sdpsp.sh create mode 100755 for_new_sovran_pros/sp.sh create mode 100755 modules/Sovran_SystemsOS_File_Fixes_And_New_Services.nix create mode 100755 modules/bip110.nix create mode 100755 modules/bitcoin-core.nix create mode 100755 modules/bitcoinecosystem.nix create mode 100644 modules/core/caddy.nix create mode 100644 modules/core/njalla-ddns.nix create mode 100755 modules/core/role-logic.nix create mode 100755 modules/core/roles.nix create mode 100644 modules/core/sovran-manage.nix create mode 100755 modules/coturn.nix create mode 100755 modules/element-calling.nix create mode 100755 modules/haven.nix create mode 100755 modules/mempool.nix create mode 100644 modules/modules.nix create mode 100644 modules/nextcloud.nix create mode 100755 modules/personalization.nix create mode 100755 modules/php.nix create mode 100755 modules/rdp.nix create mode 100644 modules/synapse.nix create mode 100755 modules/vaultwarden.nix create mode 100644 modules/wordpress.nix create mode 100644 scripts/sovran-manage.sh diff --git a/DIY Install Sovran_SystemsOS.md b/DIY Install Sovran_SystemsOS.md new file mode 100755 index 0000000..958dd70 --- /dev/null +++ b/DIY Install Sovran_SystemsOS.md @@ -0,0 +1,251 @@ +# Sovran Systems offers limited support of a DIY install of Sovran_SystemsOS. You can reach out to others in the matrix room https://matrix.to/#/#DIY_Sovran_SystemsOS:anarchyislove.xyz. + +# These instructions will change over time due to new software development and Sovran Systems creator finding more efficient ways to install Sovran_SystemsOS. 9-12-2024 + +# Also, to fully complete the install, the Bitcoin blockchain will have to download. This could take up to 3 weeks. + +# Lastly, if you gift to the computer movement to receive a Sovran Pro, you do not have to do any of this. It is all done for you. On top of that, the Bitcoin blockchain is already installed. šŸ˜‰ + +### Requirements + +1. First computer with Linux OS already installed (like NixOS, Ubuntu, Arch, etc.) to download and burn the NixOS image to a USB thumb drive. +2. USB thumb drive 16GB or larger +3. Second computer that is ready to have Sovran_SystemsOS installed (Safe Boot turned off in the UEFI[BIOS] and be prepared for the entire storage drive to be ERASED!). +4. Second computer needs the following hardware specs: + +- Intel or AMD processor (NO ARM processors) +- 32GB of RAM or Larger +- First main NVME internal drive to install Sovran_SystemsOS (500GB or larger) +- Second NVME internal drive to store the Bitcoin blockchain and the automatic backups (NVME 4TB or larger) +- Also, the second NVME internal drive needs to be installed FIRST into a USB enclosure. You will need a NVME USB enclosure. The USB enclosure will be plugged into the first Linux machine. + +5. Working Internet connection for both computers +6. Personalized Domain names already purchased from Njal.la. See the explanation here: https://sovransystems.com/how-to-setup/ +7. Your Router with ports open (Port Forwarding) to your second machine's internal IP address. This will usually be `192.168.1.(some number)` You will complete this at the end. + +- Port 80 +- Port 443 +- Port 22 +- Port 5349 +- Port 8448 + +## Preparing the Second Internal Drive + +1. Install the second NVME internal drive into the USB enclosure, NOT into the Second computer yet. +2. Plug in the USB enclosure into the first computer with Linux OS already installed into one of its available USB ports. +3. **Please Make Sure You Know The Existing Storage Names On This First Linux Computer. If You Run The Script Below And You Do Not Know What You Are Doing, You Could Potentially Erase Your First Linux Computer's Data. I Am Not Responsibly For Your Errors** +4. Open a terminal in the first Linux computer and log in as root. +5. Type in or copy and paste: + +```bash +wget https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/sdpsp.sh +``` + +then press enter. + +6. Now, type `bash sdpsp.sh` then press enter. +7. Then the screen will ask for "what block..." which will be the drive in the list that is not mounted, which will be the drive you just plugged in. It might be labeled `sda`, or `sdb` etc. Type in the drive name and press `enter`. +8. Then the screen will ask for "what partition...,"which will be whatever you typed into the first prompt, but with a "1" on it. For example, `sda1` or `sdb1`. Type it into the terminal and press `enter`. +9. Since the script is made to copy the blockchain from another Sovran Pro that already has the full blockchain installed it will throw an error. However, it should complete the setup just fine. +10. Once complete, remove the second drive from the USB enclosure and install it into your second computer in which you are installing Sovran_SystemsOS. + +## Preparing the First Main Internal Drive + +### Procedure One - Installing base NixOS + + 1. Still on the first computer with Linux OS already installed, download the latest NixOS minimal (64-bit Intel/AMD) image from here: https://nixos.org/download + 2. Burn that ISO image onto the USB thumb drive. + 3. Insert the newly created USB thumb drive with the ISO image into the second computer (the one you are installing Sovran_SystemsOS). + 4. Reboot the second computer while the USB thumb drive is inserted and boot into the USB thumb drive. This may require you to press the F7 or F12 key at boot. (Also, make sure the second computer has "safe boot" turned off in the UEFI[BIOS]). + 5. Proceed with the NixOS boot menu + 6. Once at the command prompt type in `sudo su` to move to the root user + 7. Once logged into the root user type in `passwd` then set the root user password to `a` + 8. Type in `ip a` to get your internal IP address. It will usually be `192.1681.1.(somenumber)` make a note of this IP as you will need it later. + 9. Now, that you are logged in as the root user type in or copy and paste: + + ```bash + curl https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/psp_physical_ram.sh -o psp_physical_ram.sh + ``` + + the command to install the base NixOS and press enter. +10. Now, type `bash psp_physical_ram.sh` then press enter. +11. The script will ask for name of first main internal drive. It usually will be `nvme0n1`. Basically, it will be the drive without any data and it will not be mounted per the list on the screen. Type in the name and press enter on the keyboard. +12. Then the script will ask for the 'Boot' partition. It will be the SMALLER partition and usually named `nvme0n1p1`. Type in the name and press enter on the keyboard. +13. Then it will ask for the 'Primary' partition. It will be the LARGER partition usually named `nvme0n1p2`. Type in the name and press enter on the keyboard. +14. The script will finish installing the base NixOS. At the end it will ask for a root password. Type `a` and press enter and type `a` again to confirm and press enter. +15. The machine will reboot into a very basic install of NixOS command prompt. +16. Remove the USB thumb drive from the second computer. + + +### Procedure Two - Opening The Ports on Your Router - Internal IP + +1. Go to port forwarding on your router and open the above mentioned ports to the internal IP (the one you found above) of your new Sovran_SystemsOS machine + + +### Procedure Three - Installing Sovran_SystemsOS + + +1. Now at the basic install of NixOS from Procedure One, type `root` to log into root and type the password `a` when asked then press enter. +2. Now you are logged in as `root`. +3. Now type in or copy and paste: + + ```bash + wget https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/sp.sh + ``` + + then press enter. +4. Type in `bash sp.sh` then press enter. +5. Next the script will ask for your domain names from Njal.la. Type them in the corresponding prompts and then press enter for each prompt. +6. Then it will ask for an email for the SSL certificates. Type it in and press enter. +7. The script is long so it will take some time. +8. It will finish by stating `All Finished! Please Reboot then Enjoy your New Sovran Pro!` +9. Press the power button on the machine for it to turn off THEN press it again to power the machine + +## Finishing the Install + + +### Putting the External IP of your New DIY Sovran Pro into your new domain names you just bought at [njal.la](https://njal.la) + +1. On your New DIY Sovran Pro, log into your [njal.la](https://njal.la) account +2. Make a "dynamic" record for each subdomain +3. Njal.la will now display a `curl` command for each sub-domain. +4. Open the `Terminal` on your New DIY Sovran Pro and type in or copy and paste: + + ```bash + ssh root@localhost + ``` + It will as you for a password which is `gosovransystems` as this is the default temporary password from Sovran Systems. + + Now you will be logged in as root. + +5. Now type: + + `nano /var/lib/njalla/njalla.sh` + + and press enter. + + +3. Paste the `curl` commands from njal.la's website for each sub-domain. Each `curl` command gets a new line. For example: + + ```bash + ... + curl "https://njal.la/update/?h=test.testsovransystems.com&k=8n7vk3afj-jkyg37&a=${IP}" + curl "https://njal.la/update/?h=zap.testsovransystems.com&k=8no*73afj-jkygi2ea=${IP}" + ... + + ``` + ##### Make sure the default `&auto` from njal.la is replaced by `&a=${IP}` at the end of each `curl` command in the `/var/lib/njalla/njalla.sh` as in the example above. + +7. After you have added all the sub-domins into `/var/lib/njalla/njalla.sh`, press `ctrl + s` then press `ctrl + x` to save and exit `nano`. + +8. Close the `Terminal`. + +### Setting the Desktop + +1. Open the `Terminal` again and type in: `dconf load / < /home/free/Downloads/Sovran_SystemsOS-Desktop`. Do NOT log in as root. + +2. Close the `Terminal`. + +### Setting Up Nextcloud and Wordpress + +#### Nextcloud + +1. Open a web browser and navigate to your domain name you bought from [njal.la](https://njal.la) for example "cloud.myfreedomsite.com" you attributed to your Nextcloud instance. +2. Nextcloud will as you to set up a new account to be used as a log in. Do so. +3. Nextcloud will also ask you where you want the data directory. Type in `/var/lib/nextcloud/data` +4. Nextcloud will ask you to connect the database: + 1. Choose `Postgresql` from the optoins. + 2. Database username is `ncusr` + 3. Database name is `nextclouddb` + 4. Database password is found by doing this: + 1. Open the `Terminal` again, then type in or copy and paste: + + ```bash + ssh root@localhost + ``` + Now you will be logged in as root. + + 2. Now type: + + `cat /var/lib/secrets/nextclouddb` + + and press enter. + + 3. Your database password will be displayed in the `Terminal` window. + 4. Type that into the password field + +5. Now, press `Install` on the Nextcloud website and Nextcloud will be installed. It will take a few minutes. Follow the on screen prompts. + +#### Wordpress + +1. Open a web browser and navigate to your domain name you bought from [njal.la](https://njal.la) for example "myfreedomsite.com" you attributed to your Wordpress instance. +2. Wordpress will ask you to connect the database: + 1. Database username is `wpusr` + 2. Database name is `wordpressdb` + 4. Database password is found by doing this: + 1. Open the `Terminal` again, then type in or copy and paste: + + ```bash + ssh root@localhost + ``` + Now you will be logged in as root. + + 2. Now type: + + `cat /var/lib/secrets/wordpressdb` + + and press enter. + + 3. Your database password will be displayed in the `Terminal` window. + 4. Type that into the password field + +5. Now, press `Install` on the Wordpress website and Wordpress will be installed. It will take a few minutes. Follow the on screen prompts. + +### Final Install for Coturn, Flatpak, and Nextcloud + +1. Staying in the `Terminal` type in or copy and paste: + + ```bash + sed -i '$e cat /var/lib/nextcloudaddition/nextcloudaddition' /var/lib/www/nextcloud/config/config.php + + chown caddy:php /var/lib/www -R + + chmod 700 /var/lib/www R + ``` + and press enter. + +2. Now type or copy and paste: + + ```bash + set DOMAIN $(cat /var/lib/domains/matrix) && cp -n /var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/{$DOMAIN}/{$DOMAIN}.crt /var/lib/coturn/{$DOMAIN}.crt.pem && cp -n /var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/{$DOMAIN}/{$DOMAIN}.key /var/lib/coturn/{$DOMAIN}.key.pem && chown turnserver:turnserver /var/lib/coturn -R && chmod 770 /var/lib/coturn -R && systemctl restart coturn + ``` + and press enter. + +3. Now type or copy and paste: + + ```bash + sudo -u free flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + ``` + and press enter. + + It will ask for your `Administrator` password and to get the password open a new `Terminal` window and type: + + ```bash + ssh root@localhost + ``` + press enter. + + Now you will be logged in as root. + + Now type: + + ```bash + cat /var/lib/secrets/main + ``` + Then the `Administrator`'s password will be displayed. Copy and paste the password into the other `Terminal` window that is open. Then press enter. + + Now you can close the `Terminal`. + +### Everything now will be installed regarding Sovran_SystemsOS. The remaining setup will be only for the front-end user account creations for BTCpayserver, Vaultwarden, connecting the node to Sparrow wallet and Bisq. + +### Congratulations! šŸŽ‰ diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100755 index 0000000..7456bf4 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +
+
+ +

+ +

+ +
+
+
+ +# Sovran_SystemsOS + +### The Officaly Repository of Sovran_SystemsOS and the Sovran Pro + +**A declarative, self-hosted server and desktop operating system built on NixOS by [Sovran Systems](https://sovransystems.com)** + +--- + +## Overview + +Sovran_SystemsOS is a fully integrated NixOS configuration that transforms a single machine into a personal cloud, communications hub, Bitcoin node, web server, and **daily-use desktop** — all managed declaratively. + +**It comes preinstalled on The Sovran Pro** + +Every service is pre-wired: reverse proxy routing, database initialization, firewall rules, automated backups, and inter-service communication are handled out of the box. Moreover, you can activate the other custom packages; the system does the rest. + +--- + +## Architecture + +Sovran_SystemsOS is structured as a set of NixOS modules exposed via a flake. A remote machine consumes the flake and selectively enables features through a simple configuration interface. + +``` +Repository Main Flake (flake.nix) + └── Sovran_SystemsOS flake (nixosModules.Sovran_SystemsOS) + ā”œā”€ā”€ configuration.nix/ # Base system + │ ā”œā”€ā”€ gnome Desktop # Gnome Desktop Interface + │ ā”œā”€ā”€ caddy # Reverse proxy + HTTPS + │ ā”œā”€ā”€ nextcloud # Cloud storage + │ ā”œā”€ā”€ wordpress # CMS / publishing + │ ā”œā”€ā”€ element # Matrix Synapse via Element Messaging App + ā”œā”€ā”€ modules/ + │ ā”œā”€ā”€ bitcoinecosystem.nix # Bitcoin Core / Knots / BTCPay Server / Bitcoin Lightning + │ ā”œā”€ā”€ bip110.nix # Bip110 Node Consensus Policy + │ ā”œā”€ā”€ element-calling.nix # Matrix Synapse via Element + Element Voice and Video Calling + │ ā”œā”€ā”€ haven.nix # Nostr relay + │ ā”œā”€ā”€ mempool.nix # Mempool explorer + │ ā”œā”€ā”€ rdp.nix # Remote desktop (RDP) + │ ā”œā”€ā”€ vaultwarden.nix # Password management + ā”œā”€ā”€ nix-bitcoin integration + ā”œā”€ā”€ bitcoin clients integration + │ ā”œā”€ā”€ sparrow wallet # Trusted and Standard Open Source Bitcoin Wallet + │ ā”œā”€ā”€ bisq/bisq2 # Non KYC Bitcoin Buying and Selling + ā”œā”€ā”€ agenix (secrets management) + └── nixvim +``` + +## Features + +### Feature Toggles + +[Custom Add-On Guide](custom-add-ons.md) + +Every major service is gated behind a feature flag. Enable only what you need: + +```nix +# custom.nix +{ config, pkgs, lib, ... }: + +{ + + sovran_systemsOS = { + features = { + bip110 = lib.mkForce true; + element-calling = lib.mkForce true; + haven = lib.mkForce true; + mempool = lib.mkForce true; + rdp = lib.mkForce true; + }; + nostr_npub = "pasteyournpubhere"; + }; + +} +``` + +No unnecessary services run. No wasted resources. + +--- + +### Service Stack + +| Category | Service | Description | +|---|---|---| +| **Web** | Caddy | Automatic HTTPS, reverse proxy for all services | +| **Cloud** | Nextcloud | File storage, sync, and collaboration | +| **CMS** | WordPress | Self-hosted publishing and content management | +| **Passwords** | Vaultwarden | Bitwarden-compatible password vault | +| **Messaging** | Element/Matrix Synapse | Federated, decentralized messaging backend | +| **Video/Voice Calling** | Element Video and Voice Calling | Decentralized Voice Over IP for Matrix with optional TURN/STUN | +| **Bitcoin** | Bitcoin Core / Knots | **Full node with optional BIP-110 consensus policy** | +| **Bitcoin Lightning** | LND | Full LND Node Connected over Tor intergrated into BTCPay Server | +| **Payments** | BTCPay Server | Self-hosted Bitcoin payment processor | +| **Explorer** | Mempool | Bitcoin mempool visualizer and block explorer | +| **Nostr** | Haven | Nostr relay server | +| **Remote Access** | GNOME Remote Desktop | RDP access with auto-generated TLS and credentials | + +--- + +### Security + +- **SSH hardened** — password authentication disabled by default +- **Fail2ban** — active on https +- **Agenix** — encrypted secrets management integrated into the flake +- **Tor** — integration into the bitcoin ecosystem +- **Firewall** — ports managed per-module; only enabled services are exposed + +### Reliability + +- **Automated backups** via rsnapshot +- **Scheduled maintenance** via systemd timers +- **Database initialization** handled declaratively +- **Reproducible builds** — the main system is defined in code and can be rebuilt to match most systems + +--- + +### Network Configuration + +Sovran_SystemsOS hosts public-facing services (Wordpress, Element/Element Calling, Nextcloud, BTCPayserver, Haven Relay, and Vaultwarden) that require inbound connections from the internet. To make these services accessible outside your local network, you must configure **port forwarding** on your home router. + +**Before deploying, ensure you have:** + +- Access to your router's administration interface (typically at `192.168.1.1` or `192.168.0.1`) +- The ability to create port forwarding rules +- The local/private IP address of the machine running Sovran_SystemsOS +- The external public IP address of the machine running Sovran_SystemsOS + +**Required port forwards (depending on enabled features):** + +Forward each port to the **private IP address** of your Sovran_SystemsOS machine. Only forward ports for services you have enabled. + +> **Tip:** Assign a static IP or DHCP reservation to your Sovran_SystemsOS machine so the forwarding rules remain valid after reboots. + +> **Note:** If your ISP uses CGNAT (Carrier-Grade NAT), standard port forwarding will not work. Contact your ISP to request a public IP address. + +--- + +## Installation + +### Full Guide (A bit outdated as of now... will be working on a smoother DIY soon) + +šŸ‘‰ [DIY Install Sovran_SystemsOS](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/main/DIY%20Install%20Sovran_SystemsOS.md) + +--- + +## Requirements + +| Resource | Minimum | Recommended | +|---|---|---| +| CPU | 4 cores | 8+ cores | +| RAM | 16 GB | 32+ GB | +| Storage | 512 GB SSD + 4 TB SSD | 2GB SSD + 4+ TB SSD (Bitcoin node requires significant disk) | +| Network | 100 Mbs Down/20 Mbs Up + No need for DDNS if domains are brought through https://njal.la | 1 Gbs Down/1 Gbs Up + No need for DDNS if domains are brought through https://njal.la | + +--- + +## Community + +| Channel | Link | +|---|---| +| General Chat | [#sovran-systems:anarchyislove.xyz](https://matrix.to/#/#sovran-systems:anarchyislove.xyz) | +| DIY Support | [#DIY_Sovran_SystemsOS:anarchyislove.xyz](https://matrix.to/#/#DIY_Sovran_SystemsOS:anarchyislove.xyz) | + +--- + +## License + +See [LICENSE](LICENSE) for details. + +--- + +## Project Philosophy + +Sovran_SystemsOS exists to provide a complete, self-hosted infrastructure stack that eliminates dependency on third-party platforms. It is opinionated by design — services are pre-integrated so you spend time using your system, not assembling it. + +This is not a toolkit. It is a working system. + +You retain full visibility into every module, every service definition, and every configuration choice. Nothing is hidden. Everything is reproducible. + +--- + +**Be Digitally Sovereign** + diff --git a/configuration.nix b/configuration.nix new file mode 100644 index 0000000..9b5c0a6 --- /dev/null +++ b/configuration.nix @@ -0,0 +1,191 @@ +{ config, pkgs, lib, ... }: + +{ + imports = [ + ./modules/modules.nix + ]; + + # ── Boot ──────────────────────────────────────────────────── + boot.loader.systemd-boot.enable = true; + boot.loader.efi.canTouchEfiVariables = true; + boot.loader.efi.efiSysMountPoint = "/boot/efi"; + boot.kernelPackages = pkgs.linuxPackages_latest; + + # ── Filesystems ──────────────────────────────────��────────── + fileSystems."/run/media/Second_Drive" = { + device = "LABEL=BTCEcoandBackup"; + fsType = "ext4"; + options = [ "nofail" ]; + }; + + fileSystems."/boot/efi".options = [ "umask=0077" "defaults" ]; + + # ── Nix Settings ──────────────────────────────────────────── + nix.settings = { + experimental-features = [ "nix-command" "flakes" ]; + download-buffer-size = 524288000; + }; + + # ── Networking ────────────────────────────────────────────── + networking.hostName = "nixos"; + networking.networkmanager.enable = true; + networking.firewall.enable = true; + networking.firewall.allowedTCPPorts = [ 80 443 8448 3051 ]; + networking.firewall.allowedUDPPorts = [ 80 443 8448 3051 ]; + networking.firewall.allowedUDPPortRanges = [ + { from = 49152; to = 65535; } + ]; + + # ── Locale / Time ────────────────────────────────────────── + time.timeZone = "America/Los_Angeles"; + i18n.defaultLocale = "en_US.UTF-8"; + + # ── Desktop ──────────────────────────────────────────────── + services.xserver.enable = true; + services.displayManager.gdm.enable = true; + services.displayManager.gdm.autoSuspend = false; + services.desktopManager.gnome.enable = true; + services.xserver.xkb = { layout = "us"; variant = ""; }; + services.printing.enable = true; + systemd.enableEmergencyMode = false; + + # ── Audio ────────────────────────────────────────────────── + services.pulseaudio.enable = false; + security.rtkit.enable = true; + services.pipewire = { + enable = true; + alsa.enable = true; + alsa.support32Bit = true; + pulse.enable = true; + }; + + # ── Users ────────────────────────────────────────────────── + users.users.free = { + isNormalUser = true; + description = "free"; + extraGroups = [ "networkmanager" ]; + }; + + services.displayManager.autoLogin.enable = true; + services.displayManager.autoLogin.user = "free"; + + # ── Flatpak ──────────────────────────────────────────────── + services.flatpak.enable = true; + systemd.services.flatpak-repo = { + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + path = [ pkgs.flatpak ]; + script = '' + flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + ''; + }; + + # ── Packages ─────────────────────────────────────────────── + nixpkgs.config.allowUnfree = true; + nixpkgs.config.permittedInsecurePackages = [ "jitsi-meet-1.0.8043" ]; + + environment.systemPackages = with pkgs; [ + git wget fish htop btop + gnomeExtensions.transparent-top-bar-adjustable-transparency + gnomeExtensions.systemd-manager + gnomeExtensions.dash-to-dock + gnomeExtensions.vitals + gnomeExtensions.pop-shell + gnomeExtensions.just-perfection + gnomeExtensions.appindicator + gnomeExtensions.date-menu-formatter + gnome-tweaks papirus-icon-theme + ranger fastfetch gedit openssl pwgen + aspell aspellDicts.en lm_sensors + hunspell hunspellDicts.en_US + synadm brave dua bitwarden-desktop + gparted pv unzip parted screen zenity + libargon2 gnome-terminal libreoffice-fresh + dig firefox element-desktop wp-cli axel + lk-jwt-service livekit-libwebrtc livekit-cli livekit + matrix-synapse + ]; + + # ── Shell ────────────────────────────────────────────────── + programs.nixvim = { + enable = true; + colorschemes.catppuccin.enable = true; + plugins.lualine.enable = true; + }; + + programs.bash.promptInit = "fish"; + programs.fish = { enable = true; promptInit = "fastfetch"; }; + + # ── PostgreSQL base ──────────────────────────────────────── + services.postgresql = { + enable = true; + authentication = lib.mkForce '' + local all all trust + host all all 127.0.0.1/32 trust + host all all ::1/128 trust + ''; + }; + + # ── Agenix ───────────────────────────────────────────────── + age.identityPaths = [ "/root/.ssh/agenix/agenix-secret-keys" ]; + age.secrets.matrix_reg_secret = { + file = ./secrets/matrix_reg_secret.age; + mode = "0440"; + owner = "matrix-synapse"; + group = "matrix-synapse"; + }; + + # ── Backups ──────────────────────────────────────────────── + services.rsnapshot = { + enable = true; + extraConfig = '' +snapshot_root /run/media/Second_Drive/BTCEcoandBackup/NixOS_Snapshot_Backup +retain hourly 5 +retain daily 5 +backup /home/ localhost/ +backup /var/lib/ localhost/ +backup /etc/nixos/ localhost/ +backup /etc/nix-bitcoin-secrets/ localhost/ + ''; + cronIntervals = { + daily = "50 21 * * *"; + hourly = "0 * * * *"; + }; + }; + + # ── Cron (base system crons only) ───────────────────────── + services.cron = { + enable = true; + systemCronJobs = [ + "*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/njalla/njalla.sh" + "*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/external_ip/external_ip.sh" + "0 0 * * 0 docker-user yes | /run/current-system/sw/bin/docker system prune -a" + ]; + }; + + # ── Tor ──────────────────────────────────────────────────── + services.tor = { enable = true; client.enable = true; torsocks.enable = true; }; + services.privoxy.enableTor = true; + + # ── SSH ──────────────────────────────────────────────────── + services.openssh = { + enable = true; + settings = { + PasswordAuthentication = false; + KbdInteractiveAuthentication = false; + PermitRootLogin = "yes"; + }; + }; + + # ── Fail2Ban ─────────────────────────────────────────────── + services.fail2ban = { + enable = true; + ignoreIP = [ "127.0.0.0/8" "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16" "8.8.8.8" ]; + }; + + # ── Garbage Collection ───────────────────────────────────── + nix.gc = { automatic = true; dates = "weekly"; options = "--delete-older-than 7d"; }; + + system.stateVersion = "22.05"; +} diff --git a/custom-add-ons.md b/custom-add-ons.md new file mode 100644 index 0000000..6ca6e27 --- /dev/null +++ b/custom-add-ons.md @@ -0,0 +1,124 @@ +## Custom Add-ons for Sovran_SystemsOS and The Sovran Pro + +Add-ons are extra features you can have enabled before your Sovran Pro is shipped to you or you can enable them yourself. + +## The information about each Feature + +1. Since Sovran_SystemsOS runs Bitcoin Knots by default as opposed to Bitcion Core, you can customize your Sovran Pro's Bitcoin node to run Bitcoin Core. + +https://github.com/bitcoin/bitcoin + +2. BIP-110 keeps Bitcoin more efficient as Peer to Peer Cash and you can run it along side your Bitocoin node. + +https://github.com/bitcoin/bips/blob/master/bip-0110.mediawiki + +3. The Bitcoin Mempool can be added and can be accessed via Tor or on your local network. + +https://github.com/mempool/mempool + +4. The Haven Relay for NOSTR (NOTES AND OTHER STUFF TRANSMITED BY RELAYS) is a Decenterized Social Media/File Sharing. + +https://github.com/barrydeen/haven + +5. You can run the new Element Voice and Video calling backend. + +https://github.com/element-hq/element-call + +6. You can run the Gnome Remote Desktop to view your desktop from another computer in the nextwork. + +https://gitlab.gnome.org/GNOME/gnome-remote-desktop + + +--- + +## The DIY for each Feature + +All code belongs in the `custom.nix` file located at `/etc/nixos/custom.nix`. + +If you would like to enable these features yourself after you have received your Sovran Pro, then open the *terminal* app and type or paste in + +```bash +ssh root@localhost +``` +Type in the password in the diaolog box if necessary. It is the same password to run the Sovran_Systems_Updater app. + +Then press enter. + +Next, type or paste in +```bash +nano /etc/nixos/custom.nix +``` +Then press enter. + +Next type or paste the codes below *(Code for each Feature)* each on their own line into the termainl/nano window right above the last `}` + +Once done, press `ctr s` then `ctr x` to save and exit. + +Last, type or paste in +```bash +nixos-rebuild switch --impure +``` +Then press enter. + +After it is done bulding, reboot your Sovran Pro typeing or pasting in +```bash +reboot +``` + + +--- + +## The code for each Feature (All Features are disabled by default) + +1. The code to enable Bitcoin Core is as follows: + +```nix +sovran_systemsOS.features.bitcoin-core = lib.mkForce true; +``` + +2. The code to enable BIP-110 is as follows: + +```nix +sovran_systemsOS.features.bip110 = lib.mkForce true; +``` + +3. The code to enable Mempool is as follows: + +```nix +sovran_systemsOS.features.mempool = lib.mkForce true; +``` + +4. The code to enable Haven Relay is as follows (also Haven will need a new domain to work): + +```nix +sovran_systemsOS.features.haven = lib.mkForce true; +sovran_systemsOS.nostr_npub = "pasteyournpubhere"; +``` + +5. The code to enable Element Calling is as follows (also Element Calling will need a new domain to work): + +```nix +sovran_systemsOS.features.element-calling = lib.mkForce true; +``` + +6. The code to enable Gnome Remote Desktop is as follows: + +```nix +sovran_systemsOS.features.rdp = lib.mkForce true; +``` +Next, in a open the terminal app and in the new window paste this in: + +```bash +ssh root@localhost +``` +Press enter + +Type in the password if required. It will be the same password to run the Sovran_SystemsOS_Updater app. + +Last, paste in this command to see the log in information to log in from any RDP client software (i.e. Remmina) from any computer on your home network +```bash +cat /var/lib/gnome-remote-desktop/rdp-credentials +``` + + + diff --git a/custom.nix b/custom.nix new file mode 100644 index 0000000..ca605ed --- /dev/null +++ b/custom.nix @@ -0,0 +1,8 @@ +{ config, pkgs, lib, ... }: +{ + # Only enable what this machine needs + sovran_systemsOS.services.wordpress.enable = true; + sovran_systemsOS.services.nextcloud.enable = true; + sovran_systemsOS.services.synapse.enable = true; + # btcpayserver is NOT enabled — no domain file needed, no vhost created +} \ No newline at end of file diff --git a/file_fixes_and_new_services/Sovran_SystemsOS_File_Fixes_And_New_Services.sh b/file_fixes_and_new_services/Sovran_SystemsOS_File_Fixes_And_New_Services.sh new file mode 100755 index 0000000..538aa4f --- /dev/null +++ b/file_fixes_and_new_services/Sovran_SystemsOS_File_Fixes_And_New_Services.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +cd /home/free/Downloads + + +#### SCRIPT 1 #### + +/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/sovran-pro-flake-update.sh" + +/run/current-system/sw/bin/bash /home/free/Downloads/sovran-pro-flake-update.sh + +rm -rf /home/free/Downloads/sovran-pro-flake-update.sh + + +#### SCRIPT 2 #### + +/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/add-custom-nix.sh" + +/run/current-system/sw/bin/bash /home/free/Downloads/add-custom-nix.sh + +rm -rf /home/free/Downloads/add-custom-nix.sh + + +#### SCRIPT 3 #### + +/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/sovran-pro-flake-update2.sh" + +/run/current-system/sw/bin/bash /home/free/Downloads/sovran-pro-flake-update2.sh + +rm -rf /home/free/Downloads/sovran-pro-flake-update2.sh + + +#### SCRIPT 4 #### + +/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/nextcloud_maintenance_window_fix.sh" + +/run/current-system/sw/bin/bash /home/free/Downloads/nextcloud_maintenance_window_fix.sh + +rm -rf /home/free/Downloads/nextcloud_maintenance_window_fix.sh + + +#### SCRIPT 5 #### + +/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/add_external_backup_app.sh" + +/run/current-system/sw/bin/bash /home/free/Downloads/add_external_backup_app.sh + +rm -rf /home/free/Downloads/add_external_backup_app.sh + + +#### SCRIPT 6 #### + +/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/update-agenix.sh" + +/run/current-system/sw/bin/bash /home/free/Downloads/update-agenix.sh + +rm -rf /home/free/Downloads/update-agenix.sh + +#### SCRIPT 7 #### + +/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/element-calling_haven" + +/run/current-system/sw/bin/bash /home/free/Downloads/element-calling_haven.sh + +rm -rf /home/free/Downloads/element-calling_haven.sh + + +#### REMOVAL OF MAIN SCRIPT #### + +rm -rf /home/free/Downloads/Sovran_SystemsOS_File_Fixes_And_New_Services.sh diff --git a/file_fixes_and_new_services/add-custom-nix.sh b/file_fixes_and_new_services/add-custom-nix.sh new file mode 100755 index 0000000..337e659 --- /dev/null +++ b/file_fixes_and_new_services/add-custom-nix.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +function log_console () { + echo "`date` :: $1" >> /var/lib/beacons/awesome.log + echo $1 +} + + +#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE #### + +FILE=/var/lib/beacons/file_fixes_and_new_services/add-custom-nix/completed + + if [ -e $FILE ]; then + + /run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting" + + exit 1 + + fi + + +#### CREATE INITIAL TAG #### + +/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/add-custom-nix ; touch /var/lib/beacons/file_fixes_and_new_services/add-custom-nix/started + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Create Initial Tag" + + exit 1 + + fi + + +#### MAIN SCRIPT #### + +touch /etc/nixos/custom.nix + +/run/current-system/sw/bin/cat > /etc/nixos/custom.nix <<- "EOF" + +{config, pkgs, lib, ...}: + +# Add custom NixOS modules here. + +let + personalization = import ./personalization.nix; + + in +{ + + + +} + +EOF + + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Run add-custom-nix" + + exit 1 + + fi + + + +#### CREATE COMPELETE TAG #### + +/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/add-custom-nix/completed + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Create Completed Tag" + + exit 1 + + fi + + +exit 0 \ No newline at end of file diff --git a/file_fixes_and_new_services/add_external_backup_app.sh b/file_fixes_and_new_services/add_external_backup_app.sh new file mode 100755 index 0000000..877505a --- /dev/null +++ b/file_fixes_and_new_services/add_external_backup_app.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash + +function log_console () { + echo "`date` :: $1" >> /var/lib/beacons/awesome.log + echo $1 +} + + +#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE #### + +FILE=/var/lib/beacons/file_fixes_and_new_services/add_external_backup_app/completed + + if [ -e $FILE ]; then + + /run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting" + + exit 1 + + fi + + +#### CREATE INITIAL TAG #### + +/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/add_external_backup_app ; touch /var/lib/beacons/file_fixes_and_new_services/add_external_backup_app/started + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Create Initial Tag" + + exit 1 + + fi + + +#### MAIN SCRIPT #### + +cd /home/free/Downloads + +/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Software/raw/branch/main/Sovran_SystemsOS_External_Backup/sovran_systemsOS_external_backup_local_installer/sovran_systemsOS_external_backup_install.sh" + +/run/current-system/sw/bin/bash "sovran_systemsOS_external_backup_install.sh" + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Run add_external_backup_app" + + exit 1 + + fi + + + +#### CREATE COMPELETE TAG #### + +/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/add_external_backup_app/completed + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Create Completed Tag" + + exit 1 + + fi + + +exit 0 \ No newline at end of file diff --git a/file_fixes_and_new_services/element-calling_haven.sh b/file_fixes_and_new_services/element-calling_haven.sh new file mode 100644 index 0000000..331a693 --- /dev/null +++ b/file_fixes_and_new_services/element-calling_haven.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +function log_console () { + echo "`date` :: $1" >> /var/lib/beacons/awesome.log + echo $1 +} + + +#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE #### + +FILE=/var/lib/beacons/file_fixes_and_new_services/element-calling_haven/completed + + if [ -e $FILE ]; then + + /run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting" + + exit 1 + + fi + + +#### CREATE INITIAL TAG #### + +/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/element-calling_haven ; touch /var/lib/beacons/file_fixes_and_new_services/element-calling_haven/started + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Create Initial Tag" + + exit 1 + + fi + + +#### MAIN SCRIPT #### + + touch /var/lib/domains/haven + touch /var/lib/domains/element-calling + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Run element-calling_haven" + + exit 1 + + fi + + + +#### CREATE COMPELETE TAG #### + +/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/element-calling_haven/completed + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Create Completed Tag" + + exit 1 + + fi + + +exit 0 diff --git a/file_fixes_and_new_services/nextcloud_maintenance_window_fix.sh b/file_fixes_and_new_services/nextcloud_maintenance_window_fix.sh new file mode 100755 index 0000000..28be712 --- /dev/null +++ b/file_fixes_and_new_services/nextcloud_maintenance_window_fix.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +function log_console () { + echo "`date` :: $1" >> /var/lib/beacons/awesome.log + echo $1 +} + + +#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE #### + +FILE=/var/lib/beacons/file_fixes_and_new_services/nextcloud_maintenance_window_fix/completed + + if [ -e $FILE ]; then + + /run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting" + + exit 1 + + fi + + +#### CREATE INITIAL TAG #### + +/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/nextcloud_maintenance_window_fix ; touch /var/lib/beacons/file_fixes_and_new_services/nextcloud_maintenance_window_fix/started + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Create Initial Tag" + + exit 1 + + fi + + +#### MAIN SCRIPT #### + +/run/wrappers/bin/sudo -u caddy /run/current-system/sw/bin/php /var/lib/www/nextcloud/occ config:system:set maintenance_window_start --type=integer --value=1 + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Run add-custom-nix" + + exit 1 + + fi + + + +#### CREATE COMPELETE TAG #### + +/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/nextcloud_maintenance_window_fix/completed + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Create Completed Tag" + + exit 1 + + fi + + +exit 0 \ No newline at end of file diff --git a/file_fixes_and_new_services/sovran-pro-flake-update.sh b/file_fixes_and_new_services/sovran-pro-flake-update.sh new file mode 100755 index 0000000..4deaa01 --- /dev/null +++ b/file_fixes_and_new_services/sovran-pro-flake-update.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +function log_console () { + echo "`date` :: $1" >> /var/lib/beacons/awesome.log + echo $1 +} + + +#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE #### + +FILE=/var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update/completed + + if [ -e $FILE ]; then + + /run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting" + + exit 1 + + fi + + +#### CREATE INITIAL TAG #### + +/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update ; touch /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update/started + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Create Initial Tag" + + exit 1 + + fi + + +#### MAIN SCRIPT #### + +/run/current-system/sw/bin/rm /etc/nixos/flake.nix + +/run/current-system/sw/bin/cat > /etc/nixos/flake.nix <<- "EOF" + +{ + description = "Sovran_SystemsOS for the Sovran Pro from Sovran Systems"; + + inputs = { + + Sovran_Systems.url = "git+https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS"; + + }; + + outputs = { self, Sovran_Systems, ... }@inputs: { + + nixosConfigurations."nixos" = Sovran_Systems.inputs.nixpkgs.lib.nixosSystem { + + system = "x86_64-linux"; + + modules = [ + + ./hardware-configuration.nix + + Sovran_Systems.nixosModules.Sovran_SystemsOS + + ]; + + }; + + }; + +} + +EOF + + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Run sovran-pro-flake-update" + + exit 1 + + fi + + + +#### CREATE COMPELETE TAG #### + +/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update/completed + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Create Completed Tag" + + exit 1 + + fi + + +exit 0 \ No newline at end of file diff --git a/file_fixes_and_new_services/sovran-pro-flake-update2.sh b/file_fixes_and_new_services/sovran-pro-flake-update2.sh new file mode 100755 index 0000000..a594503 --- /dev/null +++ b/file_fixes_and_new_services/sovran-pro-flake-update2.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash + +function log_console () { + echo "`date` :: $1" >> /var/lib/beacons/awesome.log + echo $1 +} + + +#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE #### + +FILE=/var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update2/completed + + if [ -e $FILE ]; then + + /run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting" + + exit 1 + + fi + + +#### CREATE INITIAL TAG #### + +/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update2 ; touch /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update2/started + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Create Initial Tag" + + exit 1 + + fi + + +#### MAIN SCRIPT #### + +/run/current-system/sw/bin/rm /etc/nixos/flake.nix + +/run/current-system/sw/bin/cat > /etc/nixos/flake.nix <<- "EOF" + +{ + description = "Sovran_SystemsOS for the Sovran Pro from Sovran Systems"; + + inputs = { + + Sovran_Systems.url = "git+https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS"; + + }; + + outputs = { self, Sovran_Systems, ... }@inputs: { + + nixosConfigurations."nixos" = Sovran_Systems.inputs.nixpkgs.lib.nixosSystem { + + system = "x86_64-linux"; + + modules = [ + + ./custom.nix + + ./hardware-configuration.nix + + Sovran_Systems.nixosModules.Sovran_SystemsOS + + ]; + + }; + + }; + +} + +EOF + + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Run sovran-pro-flake-update2" + + exit 1 + + fi + + + +#### CREATE COMPELETE TAG #### + +/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update2/completed + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Create Completed Tag" + + exit 1 + + fi + + +exit 0 \ No newline at end of file diff --git a/file_fixes_and_new_services/update-agenix.sh b/file_fixes_and_new_services/update-agenix.sh new file mode 100755 index 0000000..3e73666 --- /dev/null +++ b/file_fixes_and_new_services/update-agenix.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE #### + +FILE=/var/lib/beacons/file_fixes_and_new_services/update-agenix/completed + + if [ -e $FILE ]; then + + /run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting" + + exit 1 + + fi + + +#### CREATE INITIAL TAG #### + +/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/update-agenix ; touch /var/lib/beacons/file_fixes_and_new_services/update-agenix/started + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Create Initial Tag" + + exit 1 + + fi + + +#### MAIN SCRIPT #### + +/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/nextclouddb.age + +/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/wordpressdb.age + +/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/turn.age + +/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/matrixdb.age + +/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/matrix_reg_secret.age + + +pushd /var/lib/agenix-secrets/ + + + /run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/wordpressdb) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e wordpressdb.age -i /root/.ssh/agenix/agenix-secret-keys + + /run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/nextclouddb) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e nextclouddb.age -i /root/.ssh/agenix/agenix-secret-keys + + /run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/matrixdb) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e matrixdb.age -i /root/.ssh/agenix/agenix-secret-keys + + /run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/turn) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e turn.age -i /root/.ssh/agenix/agenix-secret-keys + + /run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/matrix_reg_secret) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e matrix_reg_secret.age -i /root/.ssh/agenix/agenix-secret-keys + + +popd + + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Run update-agenix" + + exit 1 + + fi + + + +#### CREATE COMPELETE TAG #### + +/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/update-agenix/completed + + if [[ $? != 0 ]]; then + + /run/current-system/sw/bin/echo "Could Not Create Completed Tag" + + exit 1 + + fi + + +exit 0 + diff --git a/flake.lock b/flake.lock new file mode 100755 index 0000000..ec7c7aa --- /dev/null +++ b/flake.lock @@ -0,0 +1,408 @@ +{ + "nodes": { + "agenix": { + "inputs": { + "darwin": [], + "home-manager": "home-manager", + "nixpkgs": "nixpkgs", + "systems": "systems" + }, + "locked": { + "lastModified": 1770165109, + "narHash": "sha256-9VnK6Oqai65puVJ4WYtCTvlJeXxMzAp/69HhQuTdl/I=", + "owner": "ryantm", + "repo": "agenix", + "rev": "b027ee29d959fda4b60b57566d64c98a202e0feb", + "type": "github" + }, + "original": { + "owner": "ryantm", + "repo": "agenix", + "type": "github" + } + }, + "bip110": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1773169138, + "narHash": "sha256-6X41z8o2z8KjF4gMzLTPD41WjvCDGXTc0muPGmwcOMk=", + "owner": "emmanuelrosa", + "repo": "bitcoin-knots-bip-110-nix", + "rev": "b9d018b71e20ce8c1567cbc2401b6edc2c1c7793", + "type": "github" + }, + "original": { + "owner": "emmanuelrosa", + "repo": "bitcoin-knots-bip-110-nix", + "type": "github" + } + }, + "btc-clients": { + "inputs": { + "nixpkgs": "nixpkgs_3", + "oldNixpkgs": "oldNixpkgs" + }, + "locked": { + "lastModified": 1774138208, + "narHash": "sha256-a0jEd8Q9DI0uSWKQcDRRLfYvQUWojKtyY61jZ5W+6Js=", + "owner": "emmanuelrosa", + "repo": "btc-clients-nix", + "rev": "8671254e14ed042384729662c8ab8e970b4a6d87", + "type": "github" + }, + "original": { + "owner": "emmanuelrosa", + "repo": "btc-clients-nix", + "type": "github" + } + }, + "extra-container": { + "inputs": { + "flake-utils": [ + "nix-bitcoin", + "flake-utils" + ], + "nixpkgs": [ + "nix-bitcoin", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1766155727, + "narHash": "sha256-XGp4HHH6D6ZKiO5RnMzqYJYnZB538EnEflvlTsOKpvo=", + "owner": "erikarvstedt", + "repo": "extra-container", + "rev": "b450bdb24fca1076973c852d87bcb49b8eb5fd49", + "type": "github" + }, + "original": { + "owner": "erikarvstedt", + "ref": "0.14", + "repo": "extra-container", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "nixvim", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1769996383, + "narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "57928607ea566b5db3ad13af0e57e921e6b12381", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "agenix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1745494811, + "narHash": "sha256-YZCh2o9Ua1n9uCvrvi5pRxtuVNml8X2a03qIFfRKpFs=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "abfad3d2958c9e6300a883bd443512c55dfeb1be", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, + "nix-bitcoin": { + "inputs": { + "extra-container": "extra-container", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs_4", + "nixpkgs-25_05": "nixpkgs-25_05", + "nixpkgs-unstable": "nixpkgs-unstable" + }, + "locked": { + "lastModified": 1767721199, + "narHash": "sha256-UzRxDiJlopBGPTjyhCdMP+QdTwXK+l+y45urXCyH69A=", + "owner": "fort-nix", + "repo": "nix-bitcoin", + "rev": "5b532698ce9e8bd79b07d77ab4fc60e1a8408f73", + "type": "github" + }, + "original": { + "owner": "fort-nix", + "ref": "release", + "repo": "nix-bitcoin", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1754028485, + "narHash": "sha256-IiiXB3BDTi6UqzAZcf2S797hWEPCRZOwyNThJIYhUfk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "59e69648d345d6e8fef86158c555730fa12af9de", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-25_05": { + "locked": { + "lastModified": 1767051569, + "narHash": "sha256-0MnuWoN+n1UYaGBIpqpPs9I9ZHW4kynits4mrnh1Pk4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "40ee5e1944bebdd128f9fbada44faefddfde29bd", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-stable": { + "locked": { + "lastModified": 1751274312, + "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-unstable": { + "locked": { + "lastModified": 1767364772, + "narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1772380631, + "narHash": "sha256-FhW0uxeXjefINP0vUD4yRBB52Us7fXZPk9RiPAopfiY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6d3b61b190a899042ce82a5355111976ba76d698", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "master", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1772380631, + "narHash": "sha256-FhW0uxeXjefINP0vUD4yRBB52Us7fXZPk9RiPAopfiY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6d3b61b190a899042ce82a5355111976ba76d698", + "type": "github" + }, + "original": { + "owner": "nixos", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_4": { + "locked": { + "lastModified": 1767480499, + "narHash": "sha256-8IQQUorUGiSmFaPnLSo2+T+rjHtiNWc+OAzeHck7N48=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "30a3c519afcf3f99e2c6df3b359aec5692054d92", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_5": { + "locked": { + "lastModified": 1774106199, + "narHash": "sha256-US5Tda2sKmjrg2lNHQL3jRQ6p96cgfWh3J1QBliQ8Ws=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6c9a78c09ff4d6c21d0319114873508a6ec01655", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_6": { + "locked": { + "lastModified": 1770380644, + "narHash": "sha256-P7dWMHRUWG5m4G+06jDyThXO7kwSk46C1kgjEWcybkE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ae67888ff7ef9dff69b3cf0cc0fbfbcd3a722abe", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixvim": { + "inputs": { + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs_6", + "systems": "systems_3" + }, + "locked": { + "lastModified": 1774309640, + "narHash": "sha256-8oWL7YLwElBY9ebYri1LlSlhf/gd1Qoqj0nbBwG2yso=", + "owner": "nix-community", + "repo": "nixvim", + "rev": "28c58bf023bf537354f78d6e496a349d7a0ed554", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixvim", + "type": "github" + } + }, + "oldNixpkgs": { + "locked": { + "lastModified": 1727619874, + "narHash": "sha256-a4Jcd+vjQAzF675/7B1LN3U2ay22jfDAVA8pOml5J/0=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "6710d0dd013f55809648dfb1265b8f85447d30a6", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "6710d0dd013f55809648dfb1265b8f85447d30a6", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "agenix": "agenix", + "bip110": "bip110", + "btc-clients": "btc-clients", + "nix-bitcoin": "nix-bitcoin", + "nixpkgs": "nixpkgs_5", + "nixpkgs-stable": "nixpkgs-stable", + "nixvim": "nixvim" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_3": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100755 index 0000000..f686d46 --- /dev/null +++ b/flake.nix @@ -0,0 +1,74 @@ +{ + description = "The Ultimate Sovran_SystemsOS Configuration from Sovran Systems"; + + inputs = { + + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release"; + + agenix.url = "github:ryantm/agenix"; + + agenix.inputs.darwin.follows = ""; + + nixvim.url = "github:nix-community/nixvim"; + + btc-clients.url = "github:emmanuelrosa/btc-clients-nix"; + + nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-24.11"; + + bip110.url = "github:emmanuelrosa/bitcoin-knots-bip-110-nix"; + + }; + + outputs = { self, nixpkgs, nix-bitcoin, nixvim, agenix, btc-clients, nixpkgs-stable, bip110, ... }: + + let + + overlay-stable = final: prev: { + + stable = import nixpkgs-stable { + system = prev.stdenv.hostPlatform.system; + config.allowUnfree = true; + + }; + + }; + + in + + { + + nixosConfigurations.nixos = nixpkgs.lib.nixosSystem { + + modules = [ + { nixpkgs.hostPlatform = "x86_64-linux"; } + ]; + + }; + + nixosModules.Sovran_SystemsOS = { pkgs, lib, config, ... }: { + + imports = [ + ({ config, pkgs, ... }: { + nixpkgs.overlays = [ overlay-stable ]; + }) + + ./configuration.nix + nix-bitcoin.nixosModules.default + agenix.nixosModules.default + nixvim.nixosModules.nixvim + ]; + + config = { + environment.systemPackages = with pkgs; [ + btc-clients.packages.${pkgs.system}.bisq + btc-clients.packages.${pkgs.system}.bisq2 + btc-clients.packages.${pkgs.system}.sparrow + ]; + + sovran_systemsOS.packages.bip110 = bip110.packages.${pkgs.system}.bitcoind-knots-bip-110; + }; + }; + }; +} diff --git a/for_new_sovran_pros/Sovran_SystemsOS-Desktop b/for_new_sovran_pros/Sovran_SystemsOS-Desktop new file mode 100644 index 0000000..581fbf7 --- /dev/null +++ b/for_new_sovran_pros/Sovran_SystemsOS-Desktop @@ -0,0 +1,472 @@ +[com/ftpix/transparentbar] +dark-full-screen=false + +[org/gnome/Connections] +first-run=false + +[org/gnome/Console] +font-scale=1.6000000000000005 +last-window-size=(1912, 1037) + +[org/gnome/Geary] +migrated-config=true +window-height=516 +window-width=954 + +[org/gnome/TextEditor] +last-save-directory='file:///home/free/Downloads' + +[org/gnome/Totem] +active-plugins=['mpris', 'vimeo', 'screenshot', 'movie-properties', 'autoload-subtitles', 'screensaver', 'apple-trailers', 'save-file', 'rotation', 'open-directory', 'recent', 'variable-rate', 'skipto'] +subtitle-encoding='UTF-8' + +[org/gnome/baobab/ui] +is-maximized=false +window-size=(1912, 1037) + +[org/gnome/calculator] +accuracy=9 +angle-units='degrees' +base=10 +button-mode='basic' +number-format='automatic' +show-thousands=false +show-zeroes=false +source-currency='' +source-units='degree' +target-currency='' +target-units='radian' +word-size=64 + +[org/gnome/calendar] +active-view='month' +window-maximized=false +window-size=(1912, 1037) + +[org/gnome/control-center] +last-panel='background' +window-state=(1912, 1040, false) + +[org/gnome/desktop/app-folders] +folder-children=['Utilities', 'YaST', 'd737daeb-6dbb-4a5d-9ec7-e674398539ce', '7d66e46a-a135-4e42-91bb-d438e499d251', '3fea025e-f5e4-4905-9912-e70e38cd0419', '83d8148a-1f0b-4f83-814a-11c33ab8debc', '68c075b1-a254-4b7c-ba63-c45f88bc2a58', '534e2716-83c7-4a2a-9678-8144999213ed', '4acaa2d8-d284-4efd-bba3-40f150f1ace5', '1e62b69b-d9bb-4e80-be8d-5e9b4d777fc8'] + +[org/gnome/desktop/app-folders/folders/1e62b69b-d9bb-4e80-be8d-5e9b4d777fc8] +apps=['math.desktop', 'writer.desktop', 'impress.desktop', 'draw.desktop', 'calc.desktop', 'base.desktop', 'startcenter.desktop'] +name='Office' + +[org/gnome/desktop/app-folders/folders/3fea025e-f5e4-4905-9912-e70e38cd0419] +apps=['cups.desktop', 'simple-scan.desktop'] +name='Printing' +translate=false + +[org/gnome/desktop/app-folders/folders/4acaa2d8-d284-4efd-bba3-40f150f1ace5] +apps=['org.gnome.DiskUtility.desktop', 'org.gnome.baobab.desktop', 'gparted.desktop', 'gnome-system-monitor.desktop'] +name='Utilities' + +[org/gnome/desktop/app-folders/folders/534e2716-83c7-4a2a-9678-8144999213ed] +apps=['org.gnome.Epiphany.desktop', 'librewolf.desktop', 'io.lbry.lbry-app.desktop', 'bitwarden.desktop', 'com.nextcloud.desktopclient.nextcloud.desktop', 'brave-browser.desktop', 'chromium-browser.desktop'] +name='Internet' + +[org/gnome/desktop/app-folders/folders/68c075b1-a254-4b7c-ba63-c45f88bc2a58] +apps=['org.gnome.Extensions.desktop', 'org.gnome.tweaks.desktop'] +name='Customize Look' +translate=false + +[org/gnome/desktop/app-folders/folders/7d66e46a-a135-4e42-91bb-d438e499d251] +apps=['org.gnome.Photos.desktop', 'org.gnome.Music.desktop', 'org.gnome.Totem.desktop', 'org.gnome.Cheese.desktop', 'org.gnome.Loupe.desktop', 'org.gnome.Snapshot.desktop'] +name='Media' +translate=false + +[org/gnome/desktop/app-folders/folders/83d8148a-1f0b-4f83-814a-11c33ab8debc] +apps=['org.gnome.Tour.desktop', 'yelp.desktop', 'nixos-manual.desktop'] +name='Help' +translate=false + +[org/gnome/desktop/app-folders/folders/Utilities] +apps=['gnome-abrt.desktop', 'gnome-system-log.desktop', 'nm-connection-editor.desktop', 'org.gnome.Connections.desktop', 'org.gnome.DejaDup.desktop', 'org.gnome.Dictionary.desktop', 'org.gnome.eog.desktop', 'org.gnome.Evince.desktop', 'org.gnome.FileRoller.desktop', 'org.gnome.fonts.desktop', 'org.gnome.seahorse.Application.desktop', 'org.gnome.Usage.desktop', 'vinagre.desktop', 'org.gnome.TextEditor.desktop', 'org.gnome.gedit.desktop', 'org.gnome.SystemMonitor.desktop'] +categories=['X-GNOME-Utilities'] +excluded-apps=['org.gnome.Console.desktop', 'org.gnome.tweaks.desktop', 'org.gnome.DiskUtility.desktop', 'org.gnome.baobab.desktop'] +name='X-GNOME-Utilities.directory' +translate=true + +[org/gnome/desktop/app-folders/folders/YaST] +categories=['X-SuSE-YaST'] +name='suse-yast.directory' +translate=true + +[org/gnome/desktop/app-folders/folders/d737daeb-6dbb-4a5d-9ec7-e674398539ce] +apps=['fish.desktop', 'org.gnome.Console.desktop', 'htop.desktop', 'ranger.desktop', 'xterm.desktop', 'org.gnome.Terminal.desktop'] +name='Terminal Fun' +translate=false + +[org/gnome/desktop/background] +color-shading-type='solid' +picture-options='zoom' +picture-uri='file:///run/current-system/sw/share/backgrounds/gnome/amber-l.jxl' +picture-uri-dark='file:///run/current-system/sw/share/backgrounds/gnome/amber-d.jxl' +primary-color='#ff7800' +secondary-color='#000000' + +[org/gnome/desktop/calendar] +show-weekdate=false + +[org/gnome/desktop/input-sources] +sources=[('xkb', 'us')] +xkb-options=['terminate:ctrl_alt_bksp'] + +[org/gnome/desktop/interface] +clock-format='12h' +clock-show-seconds=false +clock-show-weekday=false +color-scheme='prefer-dark' +enable-animations=true +font-antialiasing='rgba' +font-hinting='full' +gtk-theme='Adwaita-dark' +icon-theme='Papirus-Dark' +text-scaling-factor=1.0 + +[org/gnome/desktop/notifications] +application-children=['gnome-power-panel', 'org-gnome-nautilus', 'org-gnome-software', 'gnome-network-panel', 'sparrow', 'org-gnome-settings', 'org-gnome-console', 'gnome-printers-panel', 'org-gnome-epiphany', 'com-obsproject-studio', 'io-github-seadve-kooha', 'xdg-desktop-portal-gnome', 'org-gnome-baobab', 'org-gnome-geary', 'sparrow-desktop', 'impress', 'brave-browser', 'org-gnome-connections'] +show-in-lock-screen=false + +[org/gnome/desktop/notifications/application/brave-browser] +application-id='brave-browser.desktop' + +[org/gnome/desktop/notifications/application/com-obsproject-studio] +application-id='com.obsproject.Studio.desktop' + +[org/gnome/desktop/notifications/application/gnome-network-panel] +application-id='gnome-network-panel.desktop' + +[org/gnome/desktop/notifications/application/gnome-power-panel] +application-id='gnome-power-panel.desktop' + +[org/gnome/desktop/notifications/application/gnome-printers-panel] +application-id='gnome-printers-panel.desktop' + +[org/gnome/desktop/notifications/application/impress] +application-id='impress.desktop' + +[org/gnome/desktop/notifications/application/io-github-seadve-kooha] +application-id='io.github.seadve.Kooha.desktop' + +[org/gnome/desktop/notifications/application/org-gnome-baobab] +application-id='org.gnome.baobab.desktop' + +[org/gnome/desktop/notifications/application/org-gnome-connections] +application-id='org.gnome.Connections.desktop' + +[org/gnome/desktop/notifications/application/org-gnome-console] +application-id='org.gnome.Console.desktop' + +[org/gnome/desktop/notifications/application/org-gnome-epiphany] +application-id='org.gnome.Epiphany.desktop' + +[org/gnome/desktop/notifications/application/org-gnome-geary] +application-id='org.gnome.Geary.desktop' + +[org/gnome/desktop/notifications/application/org-gnome-nautilus] +application-id='org.gnome.Nautilus.desktop' + +[org/gnome/desktop/notifications/application/org-gnome-settings] +application-id='org.gnome.Settings.desktop' + +[org/gnome/desktop/notifications/application/org-gnome-software] +application-id='org.gnome.Software.desktop' + +[org/gnome/desktop/notifications/application/sparrow-desktop] +application-id='sparrow-desktop.desktop' + +[org/gnome/desktop/notifications/application/sparrow] +application-id='Sparrow.desktop' + +[org/gnome/desktop/notifications/application/xdg-desktop-portal-gnome] +application-id='xdg-desktop-portal-gnome.desktop' + +[org/gnome/desktop/peripherals/keyboard] +numlock-state=false + +[org/gnome/desktop/peripherals/mouse] +natural-scroll=true +speed=-0.63779527559055116 + +[org/gnome/desktop/peripherals/touchpad] +two-finger-scrolling-enabled=true + +[org/gnome/desktop/privacy] +old-files-age=uint32 30 +recent-files-max-age=-1 + +[org/gnome/desktop/screensaver] +color-shading-type='solid' +lock-enabled=false +picture-options='zoom' +picture-uri='file:///run/current-system/sw/share/backgrounds/gnome/amber-l.jxl' +primary-color='#ff7800' +secondary-color='#000000' + +[org/gnome/desktop/session] +idle-delay=uint32 900 + +[org/gnome/desktop/sound] +event-sounds=true +theme-name='__custom' + +[org/gnome/desktop/wm/preferences] +button-layout='appmenu:minimize,maximize,close' + +[org/gnome/epiphany] +ask-for-default=false + +[org/gnome/epiphany/state] +is-maximized=false +window-size=(1912, 1037) + +[org/gnome/evolution-data-server] +migrated=true +network-monitor-gio-name='' + +[org/gnome/file-roller/dialogs/extract] +recreate-folders=true +skip-newer=false + +[org/gnome/file-roller/listing] +list-mode='as-folder' +name-column-width=250 +show-path=false +sort-method='name' +sort-type='ascending' + +[org/gnome/file-roller/ui] +sidebar-width=200 +window-height=993 +window-width=954 + +[org/gnome/gnome-system-monitor] +current-tab='processes' +maximized=false +network-total-in-bits=false +show-dependencies=false +show-whose-processes='all' +window-height=1040 +window-state=(1912, 1040, 26, 23) +window-width=1912 + +[org/gnome/gnome-system-monitor/disktreenew] +col-6-visible=true +col-6-width=0 + +[org/gnome/gnome-system-monitor/proctree] +columns-order=[0, 1, 2, 3, 4, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] +sort-col=8 +sort-order=0 + +[org/gnome/maps] +last-viewed-location=[34.015438242460405, -118.32766985901287] +map-type='MapsStreetSource' +transportation-type='pedestrian' +window-maximized=false +window-size=[1912, 1037] +zoom-level=9 + +[org/gnome/mutter] +attach-modal-dialogs=true +dynamic-workspaces=true +edge-tiling=false +focus-change-on-pointer-rest=true +workspaces-only-on-primary=true + +[org/gnome/nautilus/icon-view] +default-zoom-level='large' + +[org/gnome/nautilus/preferences] +default-folder-viewer='icon-view' +fts-enabled=false +migrated-gtk-settings=true +search-filter-time-type='last_modified' +search-view='list-view' + +[org/gnome/nautilus/window-state] +initial-size=(1912, 1040) +maximized=false + +[org/gnome/nm-applet/eap/202ce1d2-7306-40ac-b3bb-5b092c0f9734] +ignore-ca-cert=false +ignore-phase2-ca-cert=false + +[org/gnome/nm-applet/eap/2afa07ed-64ca-44a0-948e-d8f265fa52b0] +ignore-ca-cert=false +ignore-phase2-ca-cert=false + +[org/gnome/nm-applet/eap/8da70f78-fe38-3e50-a305-8fa32b2af624] +ignore-ca-cert=false +ignore-phase2-ca-cert=false + +[org/gnome/nm-applet/eap/a9f5fb1c-2546-4fb9-82d0-7792e8982565] +ignore-ca-cert=false +ignore-phase2-ca-cert=false + +[org/gnome/nm-applet/eap/e5e312d5-e2db-3928-8c98-8ec8a7cf61f2] +ignore-ca-cert=false +ignore-phase2-ca-cert=false + +[org/gnome/portal/filechooser/brave-browser] +last-folder-path='/home/free/Downloads' + +[org/gnome/portal/filechooser/chromium-browser] +last-folder-path='/home/free/Downloads' + +[org/gnome/settings-daemon/plugins/color] +night-light-enabled=true +night-light-schedule-automatic=false +night-light-schedule-from=18.0 +night-light-temperature=uint32 1744 + +[org/gnome/settings-daemon/plugins/power] +power-button-action='nothing' +sleep-inactive-ac-type='nothing' + +[org/gnome/shell] +app-picker-layout=[{'org.gnome.Weather.desktop': <{'position': <0>}>, 'org.gnome.clocks.desktop': <{'position': <1>}>, 'org.gnome.Maps.desktop': <{'position': <2>}>, 'org.gnome.Calculator.desktop': <{'position': <3>}>, '68c075b1-a254-4b7c-ba63-c45f88bc2a58': <{'position': <4>}>, '3fea025e-f5e4-4905-9912-e70e38cd0419': <{'position': <5>}>, '83d8148a-1f0b-4f83-814a-11c33ab8debc': <{'position': <6>}>, 'Utilities': <{'position': <7>}>, 'd737daeb-6dbb-4a5d-9ec7-e674398539ce': <{'position': <8>}>, '7d66e46a-a135-4e42-91bb-d438e499d251': <{'position': <9>}>, '534e2716-83c7-4a2a-9678-8144999213ed': <{'position': <10>}>, '4acaa2d8-d284-4efd-bba3-40f150f1ace5': <{'position': <11>}>, '1e62b69b-d9bb-4e80-be8d-5e9b4d777fc8': <{'position': <12>}>, 'Bisq-hidpi.desktop': <{'position': <13>}>, 'com.obsproject.Studio.desktop': <{'position': <14>}>, 'Sovran_SystemsOS_External_Backup.desktop': <{'position': <15>}>, 'firefox.desktop': <{'position': <16>}>}] +disable-user-extensions=false +disabled-extensions=['transparent-top-bar@zhanghai.me'] +enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'dash-to-dock-cosmic-@halfmexicanhalfamazing@gmail.com', 'Vitals@CoreCoding.com', 'dash-to-dock@micxgx.gmail.com', 'transparent-top-bar@ftpix.com', 'just-perfection-desktop@just-perfection', 'pop-shell@system76.com', 'date-menu-formatter@marcinjakubowski.github.com', 'systemd-manager@hardpixel.eu', 'light-style@gnome-shell-extensions.gcampax.github.com'] +favorite-apps=['firefox.desktop', 'org.gnome.Nautilus.desktop', 'Sovran_SystemsOS_Updater.desktop', 'org.gnome.Settings.desktop', 'org.gnome.Software.desktop', 'io.freetubeapp.FreeTube.desktop', 'org.onlyoffice.desktopeditors.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Calendar.desktop', 'Bisq.desktop', 'sparrow-desktop.desktop'] +last-selected-power-profile='performance' +welcome-dialog-last-shown-version='42.3.1' + +[org/gnome/shell/extensions/dash-to-dock-pop] +apply-glossy-effect=false +background-color='rgb(0,0,0)' +background-opacity=0.25 +border-radius=17 +custom-background-color=true +custom-theme-shrink=false +dash-max-icon-size=64 +dock-alignment='CENTRE' +dock-position='BOTTOM' +extend-height=false +floating-margin=0 +force-straight-corner=false +height-fraction=0.90000000000000002 +intellihide-mode='ALL_WINDOWS' +preferred-monitor=-2 +preferred-monitor-by-connector='HDMI-1' +preview-size-scale=0.059999999999999998 +running-indicator-style='DASHES' +show-apps-at-top=false +show-mounts=false +show-show-apps-button=true +show-trash=false +transparency-mode='FIXED' +unity-backlit-items=false + +[org/gnome/shell/extensions/dash-to-dock] +apply-custom-theme=false +background-color='rgb(0,0,0)' +background-opacity=0.17000000000000001 +custom-background-color=true +dash-max-icon-size=57 +dock-position='BOTTOM' +extend-height=false +height-fraction=0.89000000000000001 +icon-size-fixed=false +intellihide-mode='ALL_WINDOWS' +preferred-monitor=-2 +preferred-monitor-by-connector='HDMI-2' +preview-size-scale=0.22 +running-indicator-style='DASHES' +show-mounts=false +show-mounts-only-mounted=false +show-trash=false +transparency-mode='FIXED' + +[org/gnome/shell/extensions/date-menu-formatter] +font-size=14 +pattern='EEEE MMMM d h: mm a' +text-align='center' + +[org/gnome/shell/extensions/just-perfection] +accessibility-menu=false + +[org/gnome/shell/extensions/pop-shell] +active-hint-border-radius=uint32 3 +gap-inner=uint32 1 +gap-outer=uint32 1 +tile-by-default=true + +[org/gnome/shell/extensions/systemd-manager] +command-method='systemctl' +systemd=['{"name":"Bitcoind","service":"bitcoind.service","type":"system"}', '{"name":"Electrs","service":"electrs.service","type":"system"}', '{"name":"BTCPayserver","service":"btcpayserver.service","type":"system"}', '{"name":"Nbxplorer","service":"nbxplorer.service","type":"system"}', '{"name":"Caddy","service":"caddy.service","type":"system"}', '{"name":"Phpfpm-Mypool","service":"phpfpm-mypool.service","type":"system"}', '{"name":"Mysql","service":"mysql.service","type":"system"}', '{"name":"Postgresql","service":"postgresql.service","type":"system"}', '{"name":"Matrix-Synapse","service":"matrix-synapse.service","type":"system"}', '{"name":"Coturn","service":"coturn.service","type":"system"}', '{"name":"Tor","service":"tor.service","type":"system"}', '{"name":"VaultWarden","service":"vaultwarden.service","type":"system"}', '{"name":"LND","service":"lnd.service","type":"system"}', '{"name":"LND Loop","service":"lightning-loop.service","type":"system"}', '{"name":"Ride The Lightning","service":"rtl.service","type":"system"}'] + +[org/gnome/shell/extensions/vitals] +fixed-widths=false +hot-sensors=['_memory_usage_', '__network-tx_max__', '_processor_usage_', '_storage_free_', '_temperature_processor_0_'] +show-fan=false +show-storage=true +show-voltage=false + +[org/gnome/shell/weather] +automatic-location=true +locations=@av [] + +[org/gnome/shell/world-clocks] +locations=@av [] + +[org/gnome/software] +check-timestamp=int64 1715525466 +first-run=false +flatpak-purge-timestamp=int64 1715478601 +online-updates-timestamp=int64 1675355639 +update-notification-timestamp=int64 1666382024 + +[org/gnome/terminal/legacy/profiles:/:b1dcc9dd-5262-4d8d-a863-c897e6d979b9] +font='Monospace 14' +use-system-font=false + +[org/gnome/tweaks] +show-extensions-notice=false + +[org/gtk/gtk4/settings/color-chooser] +selected-color=(true, 0.0, 0.0, 0.0, 1.0) + +[org/gtk/gtk4/settings/file-chooser] +date-format='regular' +location-mode='path-bar' +show-hidden=false +show-size-column=true +show-type-column=true +sidebar-width=140 +sort-column='name' +sort-directories-first=false +sort-order='ascending' +type-format='category' +view-type='list' +window-size=(1912, 1040) + +[org/gtk/settings/file-chooser] +clock-format='12h' +date-format='regular' +location-mode='path-bar' +show-hidden=true +show-size-column=true +show-type-column=true +sidebar-width=165 +sort-column='modified' +sort-directories-first=false +sort-order='descending' +type-format='category' +window-position=(26, 23) +window-size=(1401, 998) + +[system/proxy] +ignore-hosts=@as [] +mode='none' + +[system/proxy/http] +port=0 + +[system/proxy/socks] +host='127.0.0.1' +port=9050 diff --git a/for_new_sovran_pros/flake.nix b/for_new_sovran_pros/flake.nix new file mode 100755 index 0000000..416e872 --- /dev/null +++ b/for_new_sovran_pros/flake.nix @@ -0,0 +1,30 @@ +{ + description = "Sovran_SystemsOS for the Sovran Pro from Sovran Systems"; + + inputs = { + + Sovran_Systems.url = "git+https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS"; + + }; + + outputs = { self, Sovran_Systems, ... }@inputs: { + + nixosConfigurations."nixos" = Sovran_Systems.inputs.nixpkgs.lib.nixosSystem { + + modules = [ + + { nixpkgs.hostPlatform = "x86_64-linux"; } + + ./hardware-configuration.nix + + ./custom.nix + + Sovran_Systems.nixosModules.Sovran_SystemsOS + + ]; + + }; + + }; + +} diff --git a/for_new_sovran_pros/psp.sh b/for_new_sovran_pros/psp.sh new file mode 100755 index 0000000..e519f70 --- /dev/null +++ b/for_new_sovran_pros/psp.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +# Begin: curl https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/psp.sh -o psp.sh + +GREEN="\e[32m" +LIGHTBLUE="\e[94m" +ENDCOLOR="\e[0m" + +lsblk + +echo -e "${GREEN}What block for file-tree-root of drive (usually nvme0n1)?${ENDCOLOR}";read commitroot + +parted /dev/"$commitroot" -- mklabel gpt +parted /dev/"$commitroot" -- mkpart primary 512MB -16GB +parted /dev/"$commitroot" -- mkpart swap linux-swap -16GB 100% +parted /dev/"$commitroot" -- mkpart ESP fat32 1MB 512MB +parted /dev/"$commitroot" -- set 3 esp on + +lsblk + +echo -e "${GREEN}What partition for Boot-Partition (usually nvme0n1p1)?${ENDCOLOR}";read commitbootpartition + +echo -e "${GREEN}What partition for Main-Partition (usually nvme0n1p2)?${ENDCOLOR}";read commitmainpartition + +echo -e "${GREEN}What partition for Swap-Partition (usually nvme0n1p3)?${ENDCOLOR}";read commitswappartition + + + +mkfs.ext4 -L nixos /dev/"$commitmainpartition" + +mkswap -L swap /dev/"$commitswappartition" + +mkfs.fat -F 32 -n boot /dev/"$commitbootpartition" + +mount /dev/disk/by-label/nixos /mnt + +mkdir -p /mnt/boot/efi + +mount /dev/disk/by-label/boot /mnt/boot/efi + + + +nixos-generate-config --root /mnt + +rm /mnt/etc/nixos/configuration.nix + +cat <> /mnt/etc/nixos/configuration.nix +{ config, pkgs, ... }: { + + imports = [ + + ./hardware-configuration.nix + + ]; + + boot.loader.systemd-boot.enable = true; + boot.loader.efi.canTouchEfiVariables = true; + boot.loader.efi.efiSysMountPoint = "/boot/efi"; + + nix.settings.experimental-features = [ "nix-command" "flakes" ]; + + users.users = { + free = { + isNormalUser = true; + description = "free"; + extraGroups = [ "networkmanager" ]; + }; + }; + + environment.systemPackages = with pkgs; [ + wget + git + ranger + fish + pwgen + openssl + ]; + + services.openssh = { + enable = true; + permitRootLogin = "yes"; + }; +} + +EOT + +nixos-install + +reboot \ No newline at end of file diff --git a/for_new_sovran_pros/psp_physical_ram.sh b/for_new_sovran_pros/psp_physical_ram.sh new file mode 100755 index 0000000..10f1300 --- /dev/null +++ b/for_new_sovran_pros/psp_physical_ram.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash + +# Begin: curl https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/psp_physical_ram.sh -o psp_physical_ram.sh + +GREEN="\e[32m" +LIGHTBLUE="\e[94m" +ENDCOLOR="\e[0m" + +lsblk + +echo -e "${GREEN}What block for file-tree-root of drive (usually nvme0n1)?${ENDCOLOR}";read commitroot + +parted /dev/"$commitroot" -- mklabel gpt +parted /dev/"$commitroot" -- mkpart ESP fat32 1MB 512MB +parted /dev/"$commitroot" -- set 1 esp on +parted /dev/"$commitroot" -- mkpart primary ext4 512MB 100% + +lsblk + +echo -e "${GREEN}What partition for Boot-Partition (usually nvme0n1p1)?${ENDCOLOR}";read commitbootpartition + +echo -e "${GREEN}What partition for Primary-Partition (usually nvme0n1p2)?${ENDCOLOR}";read commitprimarypartition + + +mkfs.ext4 -L nixos /dev/"$commitprimarypartition" + +mkfs.fat -F 32 -n boot /dev/"$commitbootpartition" + +mount /dev/disk/by-label/nixos /mnt + +mkdir -p /mnt/boot/efi + +mount /dev/disk/by-label/boot /mnt/boot/efi + +### Disk Step-up Finished + +### Adding Configuration.nix + +nixos-generate-config --root /mnt + +rm /mnt/etc/nixos/configuration.nix + +cat <> /mnt/etc/nixos/configuration.nix +{ config, pkgs, ... }: { + + imports = [ + + ./hardware-configuration.nix + + ]; + + boot.loader.systemd-boot.enable = true; + boot.loader.efi.canTouchEfiVariables = true; + boot.loader.efi.efiSysMountPoint = "/boot/efi"; + + nix.settings.experimental-features = [ "nix-command" "flakes" ]; + + users.users = { + free = { + isNormalUser = true; + description = "free"; + extraGroups = [ "networkmanager" ]; + }; + }; + + environment.systemPackages = with pkgs; [ + wget + git + ranger + fish + pwgen + openssl + ]; + + services.openssh = { + enable = true; + permitRootLogin = "yes"; + }; +} + +EOT + +nixos-install + +reboot diff --git a/for_new_sovran_pros/sdpsp.sh b/for_new_sovran_pros/sdpsp.sh new file mode 100755 index 0000000..7272d22 --- /dev/null +++ b/for_new_sovran_pros/sdpsp.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +GREEN="\e[32m" +LIGHTBLUE="\e[94m" +ENDCOLOR="\e[0m" + +lsblk + +echo -e "${GREEN}What block for New Sovran Pro Second drive?${ENDCOLOR}";read commitroot + +parted /dev/"$commitroot" -- mklabel gpt +parted /dev/"$commitroot" -- mkpart primary 0% 100% + +lsblk + +echo -e "${GREEN}What partition with New Sovran Pro Second Drive?${ENDCOLOR}";read commitsecond + +mkfs.ext4 -L "BTCEcoandBackup" /dev/"$commitsecond" + +sudo mkdir -p /mnt + +mount /dev/"$commitsecond" /mnt + +sudo mkdir -p /mnt/BTCEcoandBackup/Bitcoin_Node + +sudo mkdir -p /mnt/BTCEcoandBackup/Electrs_Data + +sudo mkdir -p /mnt/BTCEcoandBackup/NixOS_Snapshot_Backup + +sudo mkdir -p /mnt/BTCEcoandBackup/clightning_db_backup + +sudo systemctl stop bitcoind electrs nbxplorer btcpayserver lnd rtl lightning-loop clightning + +rsync -ar --info=progress2 --info=name0 /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node/ /mnt/BTCEcoandBackup/Bitcoin_Node/ + +rsync -ar --info=progress2 --info=name0 /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data/ /mnt/BTCEcoandBackup/Electrs_Data/ + +sudo systemctl start bitcoind electrs nbxplorer btcpayserver lnd rtl lightning-loop clightning + +sudo chown bitcoin:bitcoin /mnt/BTCEcoandBackup/Bitcoin_Node -R + +sudo chown electrs:electrs /mnt/BTCEcoandBackup/Electrs_Data -R + +sudo chmod 770 /mnt/BTCEcoandBackup/Bitcoin_Node -R + +sudo chmod 770 /mnt/BTCEcoandBackup/Electrs_Data -R + +sudo umount /dev/"$commitsecond" + +echo -e "All Finished!" + diff --git a/for_new_sovran_pros/sp.sh b/for_new_sovran_pros/sp.sh new file mode 100755 index 0000000..60356ce --- /dev/null +++ b/for_new_sovran_pros/sp.sh @@ -0,0 +1,406 @@ +#!/usr/bin/env bash + +# wget https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/sp.sh + + +GREEN="\e[32m" +LIGHTBLUE="\e[94m" + +# + +pushd /etc/nixos/ + + wget https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/flake.nix + + chown root:root /etc/nixos/ -R + + chmod 770 /etc/nixos/ -R + +popd + +# + +mkdir /var/lib/domains + +touch /var/lib/domains/btcpayserver +touch /var/lib/domains/matrix +touch /var/lib/domains/nextcloud +touch /var/lib/domains/sslemail +touch /var/lib/domains/vaultwarden +touch /var/lib/domains/wordpress + +# + +echo -e "${GREEN}What is your New Matrix (Element Chat) domain name?${ENDCOLOR}" +read +echo -n $REPLY > /var/lib/domains/matrix + +echo -e "${GREEN}What is your New Wordpress domain name?${ENDCOLOR}" +read +echo -n $REPLY > /var/lib/domains/wordpress + +echo -e "${GREEN}What is your New Nextcloud domain name?${ENDCOLOR}" +read +echo -n $REPLY > /var/lib/domains/nextcloud + +echo -e "${GREEN}What is your New BTCPayserver domain name?${ENDCOLOR}" +read +echo -n $REPLY > /var/lib/domains/btcpayserver + +echo -e "${GREEN}What is your New Vaultwarden domain name?${ENDCOLOR}" +read +echo -n $REPLY > /var/lib/domains/vaultwarden + +echo -e "${GREEN}What is the email you would like to use to manage the SSL certificates for your domains?${ENDCOLOR}" +read +echo -n $REPLY > /var/lib/domains/sslemail + +# + +mkdir /var/lib/nextcloudaddition + +cat > /var/lib/nextcloudaddition/nextcloudaddition <<- "EOF" + +'trusted_proxies' => + array ( + 0 => '127.0.0.1', + ), + 'default_locale' => 'en_US', + 'default_phone_region' => 'US', + 'memcache.local' =>'\OC\Memcache\APCu' , + +EOF + +# + +mkdir /var/lib/njalla/ + +cat > /var/lib/njalla/njalla.sh <<- "EOF" + +#!/usr/bin/env bash + +IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4) + +## Manually Add DDNS Script From Njalla User Account AFTER Install + +curl "https://...${IP}" + +EOF + +# + +mkdir /var/lib/external_ip + +cat > /var/lib/external_ip/external_ip.sh <<- "EOF" + +#!/usr/bin/env bash + +IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4) + +echo "${IP}" > /var/lib/secrets/external_ip + +EOF + +# + +mkdir /var/lib/internal_ip + +cat > /var/lib/internal_ip/internal_ip.sh <<- "EOF" + +#!/usr/bin/env bash + +sudo echo -n $(ip route get 1.2.3.4 | awk '{print $7}') > /var/lib/secrets/internal_ip + +exit 0 + + +EOF + +# + +touch /etc/nixos/custom.nix + +cat > /etc/nixos/custom.nix <<- "EOF" + +{config, pkgs, lib, ...}: + +let + personalization = import ./personalization.nix; + + in +{ +} + +EOF + +# + +mkdir /var/lib/agenix-secrets/ + +cat > /var/lib/agenix-secrets/secrets.nix <<- "EOF" + +let + + root = "placeholder" ; + +in + +{ + + "wordpressdb.age".publicKeys = [ root ]; + + "matrixdb.age".publicKeys = [ root ]; + + "nextclouddb.age".publicKeys = [ root ]; + + "turn.age".publicKeys = [ root ]; + + "matrix_reg_secret.age".publicKeys = [ root ]; + +} + +EOF + +# + +mkdir /var/lib/secrets +mkdir /var/lib/secrets/vaultwarden + +touch /var/lib/secrets/nextclouddb +touch /var/lib/secrets/wordpressdb +touch /var/lib/secrets/matrixdb +touch /var/lib/secrets/turn +touch /var/lib/secrets/matrix_reg_secret +touch /var/lib/secrets/main +touch /var/lib/secrets/vaultwarden/vaultwarden.env +touch /var/lib/secrets/external_ip +touch /var/lib/secrets/internal_ip + +echo -n $(pwgen -s 17 -1) > /var/lib/secrets/nextclouddb +echo -n $(pwgen -s 17 -1) > /var/lib/secrets/wordpressdb +echo -n $(pwgen -s 17 -1) > /var/lib/secrets/matrixdb +echo -n $(pwgen -s 17 -1) > /var/lib/secrets/turn +echo -n $(pwgen -s 17 -1) > /var/lib/secrets/matrix_reg_secret +echo -n $(pwgen -s 17 -1) > /var/lib/secrets/main +echo -n ADMIN_TOKEN=$(openssl rand -base64 48 +) > /var/lib/secrets/vaultwarden/vaultwarden.env + +# + +mkdir -p /root/.ssh/agenix + +ssh-keygen -q -N "" -t ed25519 -f /root/.ssh/agenix/agenix-secret-keys + +sed -i -e "0,/root.*/{s::root = $(cat /root/.ssh/agenix/agenix-secret-keys.pub):};s:root@nixos::" /var/lib/agenix-secrets/secrets.nix + +sed -i 's:\(root =[[:blank:]]*\)\(.*\):\1"\2";:' /var/lib/agenix-secrets/secrets.nix + +# + +pushd /var/lib/agenix-secrets + + echo -n $(cat /var/lib/secrets/wordpressdb) | EDITOR='cp /dev/stdin' nix run github:ryantm/agenix -- -e wordpressdb.age -i /root/.ssh/agenix/agenix-secret-keys + + echo -n $(cat /var/lib/secrets/nextclouddb) | EDITOR='cp /dev/stdin' nix run github:ryantm/agenix -- -e nextclouddb.age -i /root/.ssh/agenix/agenix-secret-keys + + echo -n $(cat /var/lib/secrets/matrixdb) | EDITOR='cp /dev/stdin' nix run github:ryantm/agenix -- -e matrixdb.age -i /root/.ssh/agenix/agenix-secret-keys + + echo -n $(cat /var/lib/secrets/turn) | EDITOR='cp /dev/stdin' nix run github:ryantm/agenix -- -e turn.age -i /root/.ssh/agenix/agenix-secret-keys + + echo -n $(cat /var/lib/secrets/matrix_reg_secret) | EDITOR='cp /dev/stdin' nix run github:ryantm/agenix -- -e matrix_reg_secret.age -i /root/.ssh/agenix/agenix-secret-keys + +popd + + +# + +pushd /etc/nixos + + nix flake update + + nixos-rebuild switch --impure + +popd + +# + +chown root:root /var/lib/secrets/main -R + +chown root:root /var/lib/secrets/external_ip -R + +chown root:root /var/lib/secrets/internal_ip -R + +chown matrix-synapse:matrix-synapse /var/lib/secrets/matrix_reg_secret -R + +chown matrix-synapse:matrix-synapse /var/lib/secrets/matrixdb -R + +chown postgres:postgres /var/lib/secrets/nextclouddb -R + +chown turnserver:turnserver /var/lib/secrets/turn -R + +chown mysql:mysql /var/lib/secrets/wordpressdb -R + +chown vaultwarden:vaultwarden /var/lib/secrets/vaultwarden -R + + +chmod 770 /var/lib/secrets/ -R + +# + +chown caddy:php /var/lib/domains -R + +chmod 770 /var/lib/domains -R + +# + +set -x + +wget -P /var/lib/www/downloadwp https://wordpress.org/latest.zip + +wget -P /var/lib/www/downloadnc https://download.nextcloud.com/server/releases/latest.zip + +unzip /var/lib/www/downloadwp/latest.zip -d /var/lib/www/ + +unzip /var/lib/www/downloadnc/latest.zip -d /var/lib/www/ + +rm -rf /var/lib/www/downloadwp + +rm -rf /var/lib/www/downloadnc + +chown caddy:php /var/lib/www -R + +chmod 770 /var/lib/www -R + +# + +mkdir /var/lib/nextcloud + +chown caddy:php /var/lib/nextcloud -R + +chmod 770 /var/lib/nextcloud -R + +# + +mkdir /var/lib/coturn + +chown turnserver:turnserver /var/lib/coturn -R + +chmod 770 /var/lib/coturn -R + +# + +rm -rf /root/sp.sh + +# + +chown bitcoin:bitcoin /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node -R + +chmod 770 /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node -R + +chown electrs:electrs /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data -R + +chmod 770 /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data -R + +# + +mkdir -p /home/free/Downloads + +pushd /home/free/Downloads + + wget https://git.sovransystems.com/Sovran_Systems/Software/raw/branch/main/Sovran_SystemsOS_Resetter/sovran_systemsOS_resetter_local_installer/sovran_systemsOS_resetter_install.sh + + bash sovran_systemsOS_resetter_install.sh + +popd + +# + +pushd /home/free/Downloads + + wget https://git.sovransystems.com/Sovran_Systems/Software/raw/branch/main/Sovran_SystemsOS_Updater/sovran_systemsOS_updater_local_installer/sovran_systemsOS_updater_install.sh + + bash sovran_systemsOS_updater_install.sh + +popd + +# + +mkdir -p /home/free/Pictures + +pushd /home/free/Pictures + + wget https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/Wallpaper_Dark_Wide.png + +popd + +chown free:users /home/free -R + +chmod 700 /home/free -R + +# + +pushd /home/free/Downloads + + sudo -u free wget https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/Sovran_SystemsOS-Desktop + +popd + +# + +wp=$(cat /var/lib/secrets/wordpressdb) + +sudo mysql -u root -e "SET PASSWORD FOR wpusr@localhost = PASSWORD('${wp}')"; + +# + +mkdir /root/.ssh + +mkdir -p /home/free/.ssh + +chown free:users /home/free/.ssh -R + +touch /root/.ssh/authorized_keys + +sudo -u free ssh-keygen -q -N "gosovransystems" -t ed25519 -f /home/free/.ssh/factory_login + +chmod 700 /home/free/.ssh -R + +echo "$(cat /home/free/.ssh/factory_login.pub)" >> /root/.ssh/authorized_keys + +# + +sudo matrix-synapse-register_new_matrix_user -u admin -p a -a + +sudo echo "no" | matrix-synapse-register_new_matrix_user -u test -p a + +# + +# This key is removed before shipping as it allows Sovran Systems to access the machine via root remotely. + +echo "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCQa3DEhx9RUtV0WopfFuL3cjQt2fBzp5wOg/hkj0FXyZXpp+F47Td1B9mKMNvucINaMQB6T0mW6c70fyT92gZO2OqCff6aeWovtTd9ynRgtJbny/qvVSShDbJcR7nSMeVPoDRaYs18fuA50guYnfoYAkaXyXPmVQ0uK84HwIB5j8gq6GMji7vv+TTNhDP8qOceUzt1DYPo9Z2JSnkFey+Z/fmxWJGsu+MSrA0/PPENEmf6L0ZSgxnu3gHEtdyX2hrFzjE16y3G0wSQzbWJb8MJO0KRSMcyvz6AzOSW4RYdXR1c+4JiciKRdnIAYYHfg7tnZT9wC9AzHjdEbmmrlF05mtjXKnxbPgGY0tlRSYo7B5E0k2zfi30MkIJ6kIE9TMM2z/+1KstrQN4OKBTGomBTYQaRQCT6dGpRTR+b8lOvUcnCSuat1sUC2M2VGFcBbDbKD0FyXy/vOk1pgA4I7GoESWQClnl+ntRg8HrW4oVTX2KpqR2CXjlF956HJGqHW6k= free@nixos" >> /root/.ssh/authorized_keys + +# + +pushd /etc/nixos + + nix flake update + + nixos-rebuild switch --impure + +popd + +# + +echo "root:$(cat /var/lib/secrets/main)" | chpasswd -c SHA512 + +echo "free:a" | chpasswd -c SHA512 + +# + +chown free:users /home/free -R + +chmod 700 /home/free -R + +# + +echo -e "${GREEN}All Finished! Please Reboot then Enjoy your New Sovran Pro!" diff --git a/modules/Sovran_SystemsOS_File_Fixes_And_New_Services.nix b/modules/Sovran_SystemsOS_File_Fixes_And_New_Services.nix new file mode 100755 index 0000000..5d766e1 --- /dev/null +++ b/modules/Sovran_SystemsOS_File_Fixes_And_New_Services.nix @@ -0,0 +1,24 @@ +{config, pkgs, lib, ...}: + +{ + + systemd.services.Sovran_SystemsOS_File_Fixes_And_New_Services = { + + unitConfig = { + After = "btcpayserver.service"; + Requires = "network-online.target"; + }; + + serviceConfig = { + ExecStartPre= "/run/current-system/sw/bin/sleep 30"; + ExecStart = "/run/current-system/sw/bin/wget https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/Sovran_SystemsOS_File_Fixes_And_New_Services.sh -O /home/free/Downloads/Sovran_SystemsOS_File_Fixes_And_New_Services.sh ; /run/current-system/sw/bin/bash /home/free/Downloads/Sovran_SystemsOS_File_Fixes_And_New_Services.sh"; + RemainAfterExit = "yes"; + User = "root"; + Type = "oneshot"; + }; + + wantedBy = [ "multi-user.target" ]; + + }; + +} diff --git a/modules/bip110.nix b/modules/bip110.nix new file mode 100755 index 0000000..e229a80 --- /dev/null +++ b/modules/bip110.nix @@ -0,0 +1,23 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.sovran_systemsOS; +in +{ + options.sovran_systemsOS.packages.bip110 = lib.mkOption { + type = lib.types.nullOr lib.types.package; + default = null; + description = "BIP110 Bitcoin package"; + }; + + config = lib.mkIf ( + cfg.features.bip110 && + cfg.packages.bip110 != null + ) { + services.bitcoind.package = lib.mkForce cfg.packages.bip110; + + environment.systemPackages = [ + cfg.packages.bip110 + ]; + }; +} diff --git a/modules/bitcoin-core.nix b/modules/bitcoin-core.nix new file mode 100755 index 0000000..609c8f3 --- /dev/null +++ b/modules/bitcoin-core.nix @@ -0,0 +1,7 @@ +{ config, pkgs, lib, ... }: + +lib.mkIf config.sovran_systemsOS.features.bitcoin-core { + + services.bitcoind.package = lib.mkForce config.nix-bitcoin.pkgs.bitcoind; + +} diff --git a/modules/bitcoinecosystem.nix b/modules/bitcoinecosystem.nix new file mode 100755 index 0000000..a468d85 --- /dev/null +++ b/modules/bitcoinecosystem.nix @@ -0,0 +1,95 @@ +{ config, pkgs, lib, ... }: + +lib.mkIf config.sovran_systemsOS.features.bitcoin { + + ## Bitcoind + + services.bitcoind = { + enable = true; + package = config.nix-bitcoin.pkgs.bitcoind-knots; + dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node"; + txindex = true; + tor.proxy = true; + tor.enforce = true; + disablewallet = true; + extraConfig = '' + peerbloomfilters=1 + server=1 + ''; + }; + + nix-bitcoin.onionServices.bitcoind.enable = true; + nix-bitcoin.onionServices.electrs.enable = true; + nix-bitcoin.onionServices.rtl.enable = true; + + + ## Electrs + + services.electrs = { + enable = true; + tor.enforce = true; + dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data"; + }; + + + ## LND + + services.lnd = { + enable = true; + tor.enforce = true; + tor.proxy = true; + extraConfig = '' + protocol.option-scid-alias=true + ''; + }; + + nix-bitcoin.onionServices.lnd.public = true; + + + ## LNDconnect + + services.lnd.lndconnect = { + enable = true; + onion = true; + }; + + + ## RTL + + services.rtl = { + enable = true; + tor.enforce = true; + port = 3050; + nightTheme = true; + nodes = { + lnd = { + enable = true; + }; + + }; + }; + + + ## BTCpayserver + + services.btcpayserver = { + enable = true; + }; + + services.btcpayserver.lightningBackend = "lnd"; + + + ## System + + nix-bitcoin.generateSecrets = true; + + nix-bitcoin.nodeinfo.enable = true; + + nix-bitcoin.operator = { + enable = true; + name = "free"; + }; + + nix-bitcoin.useVersionLockedPkgs = false; + +} diff --git a/modules/core/caddy.nix b/modules/core/caddy.nix new file mode 100644 index 0000000..2c20efc --- /dev/null +++ b/modules/core/caddy.nix @@ -0,0 +1,108 @@ +{ config, pkgs, lib, ... }: + +{ + services.caddy = { + enable = true; + user = "caddy"; + group = "root"; + configFile = "/run/caddy/Caddyfile"; + }; + + systemd.services.caddy-generate-config = { + description = "Generate Caddyfile from /var/lib/domains at runtime"; + before = [ "caddy.service" ]; + requiredBy = [ "caddy.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + RuntimeDirectory = "caddy"; + }; + path = [ pkgs.coreutils ]; + script = '' + MATRIX=$(cat /var/lib/domains/matrix) + WORDPRESS=$(cat /var/lib/domains/wordpress) + NEXTCLOUD=$(cat /var/lib/domains/nextcloud) + BTCPAY=$(cat /var/lib/domains/btcpayserver) + VAULTWARDEN=$(cat /var/lib/domains/vaultwarden) + HAVEN=$(cat /var/lib/domains/haven) + ACME_EMAIL=$(cat /var/lib/domains/sslemail) + + # Start with global config + cat > /run/caddy/Caddyfile <> /run/caddy/Caddyfile + else + # Fallback: basic Matrix vhosts without element-calling + cat >> /run/caddy/Caddyfile <> /run/caddy/Caddyfile < "$LAST_IP_FILE" + echo "IP changed to $IP, updating DNS records..." + + # Update external_ip secret + echo -n "$IP" > /var/lib/secrets/external_ip + + # Process each DDNS hook + HOOKS_DIR="/var/lib/njalla/hooks.d" + mkdir -p "$HOOKS_DIR" + + for hook in "$HOOKS_DIR"/*; do + [ -f "$hook" ] || continue + DDNS_URL=$(cat "$hook") + SERVICE=$(basename "$hook") + echo "Updating $SERVICE..." + ${pkgs.curl}/bin/curl -s "''${DDNS_URL}''${IP}" || echo "Failed: $SERVICE" + done + + echo "Done." + ''; + }; + + # Run every 15 minutes + systemd.timers.njalla-ddns = { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "*:0/15"; + Persistent = true; + }; + }; + + # Ensure directory exists + systemd.tmpfiles.rules = [ + "d /var/lib/njalla 0700 root root -" + "d /var/lib/njalla/hooks.d 0700 root root -" + ]; +} \ No newline at end of file diff --git a/modules/core/role-logic.nix b/modules/core/role-logic.nix new file mode 100755 index 0000000..e52331b --- /dev/null +++ b/modules/core/role-logic.nix @@ -0,0 +1,37 @@ +{ config, lib, ... }: + +{ + config = lib.mkMerge [ + + # Server-Desktop Role most services enabled + (lib.mkIf config.sovran_systemsOS.roles.server-desktop { + sovran_systemsOS.features = { + synapse = true; + bitcoin = true; + coturn = true; + vaultwarden = true; + haven = false; + mempool = false; + bip110 = false; + element-calling = false; + bitcoin-core = false; + rdp = false; + }; + }) + + # Desktop role + (lib.mkIf config.sovran_systemsOS.roles.desktop { + services.xserver.enable = true; + services.desktopManager.gnome.enable = true; + }) + + # Bitcoin node role + (lib.mkIf config.sovran_systemsOS.roles.node { + sovran_systemsOS.features = { + bitcoin = true; + bip110 = false; + }; + }) + + ]; +} diff --git a/modules/core/roles.nix b/modules/core/roles.nix new file mode 100755 index 0000000..01ae202 --- /dev/null +++ b/modules/core/roles.nix @@ -0,0 +1,33 @@ +{ config, lib, ... }: + +{ + options.sovran_systemsOS = { + roles = { + server-desktop = lib.mkOption { + type = lib.types.bool; + default = !config.sovran_systemsOS.roles.desktop && !config.sovran_systemsOS.roles.node; + }; + desktop = lib.mkEnableOption "Desktop Role"; + node = lib.mkEnableOption "Bitcoin Node Only Role"; + }; + + features = { + coturn = lib.mkEnableOption "TURN server"; + synapse = lib.mkEnableOption "Matrix Synapse"; + bitcoin = lib.mkEnableOption "Bitcoin Ecosystem"; + vaultwarden = lib.mkEnableOption "Vaultwarden"; + haven = lib.mkEnableOption "Haven NOSTR relay"; + bip110 = lib.mkEnableOption "BIP-110 Bitcoin Better Money"; + mempool = lib.mkEnableOption "Bitcoin Mempool Explorer"; + element-calling = lib.mkEnableOption "Element Video and Audio Calling"; + bitcoin-core = lib.mkEnableOption "Bitcoin Core"; + rdp = lib.mkEnableOption "Gnome Remote Desktop"; + }; + + nostr_npub = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Nostr public key (npub1...) for Haven relay"; + }; + }; +} diff --git a/modules/core/sovran-manage.nix b/modules/core/sovran-manage.nix new file mode 100644 index 0000000..825007e --- /dev/null +++ b/modules/core/sovran-manage.nix @@ -0,0 +1,13 @@ +{ config, pkgs, lib, ... }: + +let + sovran-manage = pkgs.writeShellScriptBin "sovran-manage" (builtins.readFile ../../scripts/sovran-manage.sh); +in +{ + environment.systemPackages = [ + sovran-manage + pkgs.pwgen + pkgs.dig + pkgs.curl + ]; +} \ No newline at end of file diff --git a/modules/coturn.nix b/modules/coturn.nix new file mode 100755 index 0000000..fac4c86 --- /dev/null +++ b/modules/coturn.nix @@ -0,0 +1,54 @@ +{config, pkgs, lib, ...}: + +let + personalization = import ./personalization.nix; + + in +lib.mkIf config.sovran_systemsOS.features.coturn { + + systemd.services.coturn-helper = { + + script = '' + + systemctl restart coturn + + ''; + + unitConfig = { + Type = "simple"; + After = "btcpayserver.service"; + Requires = "network-online.target"; + }; + + serviceConfig = { + RemainAfterExit = "yes"; + Type = "oneshot"; + }; + + wantedBy = [ "multi-user.target" ]; + + }; + + + services.coturn = { + + enable = true; + use-auth-secret = true; + static-auth-secret = "${personalization.coturn_static_auth_secret}"; + realm = personalization.matrix_url; + cert = "/var/lib/coturn/${personalization.matrix_url}.crt.pem"; + pkey = "/var/lib/coturn/${personalization.matrix_url}.key.pem"; + min-port = 49152; + max-port = 65535; + listening-port = 5349; + no-cli = true; + extraConfig = '' + verbose + external-ip=${personalization.external_ip_secret} + stale-nonce + fingerprint + ''; + + }; + +} diff --git a/modules/element-calling.nix b/modules/element-calling.nix new file mode 100755 index 0000000..df90e69 --- /dev/null +++ b/modules/element-calling.nix @@ -0,0 +1,248 @@ +{ config, pkgs, lib, ... }: + +let + livekitKeyFile = "/var/lib/livekit/livekit_keyFile"; +in + +lib.mkIf config.sovran_systemsOS.features.element-calling { + + ####### LIVEKIT KEY GENERATION ####### + systemd.tmpfiles.rules = [ + "d /var/lib/livekit 0750 root root -" + ]; + + systemd.services.livekit-key-setup = { + description = "Generate LiveKit key file if missing"; + wantedBy = [ "multi-user.target" ]; + before = [ "livekit.service" "lk-jwt-service.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ pkgs.openssl ]; + script = '' + if [ ! -f ${livekitKeyFile} ]; then + API_KEY="devkey_$(openssl rand -hex 16)" + API_SECRET="$(openssl rand -base64 36 | tr -d '\n')" + echo "$API_KEY: $API_SECRET" > ${livekitKeyFile} + chmod 600 ${livekitKeyFile} + echo "LiveKit key file generated at ${livekitKeyFile}" + else + echo "LiveKit key file already exists, skipping generation" + fi + ''; + }; + + ####### ENSURE SERVICES START AFTER KEY EXISTS ####### + systemd.services.livekit.after = [ "livekit-key-setup.service" ]; + systemd.services.livekit.wants = [ "livekit-key-setup.service" ]; + systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ]; + systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ]; + + ####### CADDY SNIPPET — written to /run/caddy for caddy.nix to pick up ####### + systemd.services.element-calling-caddy-config = { + description = "Generate Element Calling Caddy config snippet"; + before = [ "caddy-generate-config.service" ]; + requiredBy = [ "caddy-generate-config.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ pkgs.coreutils ]; + script = '' + MATRIX=$(cat /var/lib/domains/matrix) + ELEMENT_CALLING=$(cat /var/lib/domains/element-calling) + + mkdir -p /run/caddy + + cat > /run/caddy/element-calling.snippet < /run/livekit/runtime-config.yaml < /run/lk-jwt-service/env < /run/matrix-synapse/element-calling-config.yaml < /run/haven/runtime.env </dev/null; then + echo '[]' > "$FILE" + chown haven:haven "$FILE" + chmod 770 "$FILE" + echo "Wrote valid empty JSON array to $FILE" + else + echo "$FILE already contains valid JSON, skipping" + fi + ''; + }; + + systemd.services.haven.after = [ "haven-whitelist-setup.service" "haven-runtime-config.service" ]; + systemd.services.haven.wants = [ "haven-whitelist-setup.service" "haven-runtime-config.service" ]; +} diff --git a/modules/mempool.nix b/modules/mempool.nix new file mode 100755 index 0000000..5a6b1d3 --- /dev/null +++ b/modules/mempool.nix @@ -0,0 +1,25 @@ +{ config, pkgs, lib, ... }: + +lib.mkIf config.sovran_systemsOS.features.mempool { + + services.mempool = { + enable = true; + frontend.enable = true; + }; + + services.mysql.package = lib.mkForce pkgs.mariadb; + + nix-bitcoin.onionServices.mempool-frontend.enable = true; + + services.caddy = { + virtualHosts = { + ":60847" = { + extraConfig = '' + reverse_proxy :60845 + encode gzip zstd + ''; + }; + }; + }; + +} diff --git a/modules/modules.nix b/modules/modules.nix new file mode 100644 index 0000000..8531a99 --- /dev/null +++ b/modules/modules.nix @@ -0,0 +1,25 @@ +{ config, pkgs, lib, ... }: + +{ + imports = [ + ./core/roles.nix + ./core/role-logic.nix + ./core/caddy.nix + ./core/sovran-manage.nix + ./php.nix + ./Sovran_SystemsOS_File_Fixes_And_New_Services.nix + ./synapse.nix + ./coturn.nix + ./wordpress.nix + ./nextcloud.nix + ./btcpayserver.nix + ./vaultwarden.nix + ./haven.nix + ./bip110.nix + ./element-calling.nix + ./mempool.nix + ./bitcoin-core.nix + ./rdp.nix + ./bitcoinecosystem.nix + ]; +} diff --git a/modules/nextcloud.nix b/modules/nextcloud.nix new file mode 100644 index 0000000..3c7e933 --- /dev/null +++ b/modules/nextcloud.nix @@ -0,0 +1,224 @@ +{ config, pkgs, lib, ... }: + +let + cfg = config.sovran_systemsOS.services.nextcloud; +in +{ + options.sovran_systemsOS.services.nextcloud = { + enable = lib.mkEnableOption "Nextcloud (raw PHP served by Caddy)"; + }; + + config = lib.mkIf cfg.enable { + + # ── Caddy vhost is now handled centrally in caddy.nix ───── + + # ── PostgreSQL database ─────────────────────────────────── + services.postgresql = { + enable = true; + }; + + # ── Auto-generate DB password and initialize ────────────── + systemd.services.nextcloud-db-init = { + description = "Initialize Nextcloud PostgreSQL database with auto-generated password"; + after = [ "postgresql.service" ]; + requires = [ "postgresql.service" ]; + before = [ "nextcloud-init.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ config.services.postgresql.package pkgs.pwgen pkgs.coreutils ]; + script = '' + set -euo pipefail + + SECRET_FILE="/var/lib/secrets/nextclouddb" + + # Existing machines already have this file — leave it alone + if [ ! -f "$SECRET_FILE" ]; then + mkdir -p /var/lib/secrets + pwgen -s 64 1 > "$SECRET_FILE" + chmod 600 "$SECRET_FILE" + fi + + DB_PASS=$(cat "$SECRET_FILE") + + # Create role if it doesn't exist, update password either way + psql -U postgres </dev/null; then + echo "Database ready." + break + fi + sleep 2 + done + + # ── Run Nextcloud install via occ ─────────────── + echo "Running Nextcloud installation..." + su -s /bin/sh caddy -c " + php $INSTALL_DIR/occ maintenance:install \ + --database 'pgsql' \ + --database-name '$DB_NAME' \ + --database-user '$DB_USER' \ + --database-pass '$DB_PASS' \ + --database-host '$DB_HOST' \ + --admin-user '$ADMIN_USER' \ + --admin-pass '$ADMIN_PASS' \ + --data-dir '$DATA_DIR' + " + + # ── Configure trusted domains ─────────────────── + echo "Configuring trusted domains..." + su -s /bin/sh caddy -c " + php $INSTALL_DIR/occ config:system:set trusted_domains 0 --value='$DOMAIN' + php $INSTALL_DIR/occ config:system:set overwrite.cli.url --value='https://$DOMAIN' + php $INSTALL_DIR/occ config:system:set overwriteprotocol --value='https' + " + + # ── Set recommended settings ─��────────────────── + echo "Applying recommended settings..." + su -s /bin/sh caddy -c " + php $INSTALL_DIR/occ config:system:set default_phone_region --value='US' + php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu' + php $INSTALL_DIR/occ background:cron + " + + # ── Install default apps ──────────────────────── + echo "Installing default apps..." + su -s /bin/sh caddy -c " + php $INSTALL_DIR/occ app:install calendar || true + php $INSTALL_DIR/occ app:install contacts || true + php $INSTALL_DIR/occ app:install tasks || true + php $INSTALL_DIR/occ app:install notes || true + php $INSTALL_DIR/occ app:install deck || true + php $INSTALL_DIR/occ app:enable calendar || true + php $INSTALL_DIR/occ app:enable contacts || true + php $INSTALL_DIR/occ app:enable tasks || true + php $INSTALL_DIR/occ app:enable notes || true + php $INSTALL_DIR/occ app:enable deck || true + " + + # ── Save admin credentials ────────────────────── + CREDS_FILE="/var/lib/secrets/nextcloud-admin" + cat > "$CREDS_FILE" << CREDS +Nextcloud Admin Credentials +═══════════════════════════ +URL: https://$DOMAIN/ +Username: $ADMIN_USER +Password: $ADMIN_PASS +CREDS + chmod 600 "$CREDS_FILE" + + echo "" + echo "══════════════════════════════════════════════" + echo " Nextcloud installation complete!" + echo "" + echo " URL: https://$DOMAIN/" + echo " Username: $ADMIN_USER" + echo " Password: $ADMIN_PASS" + echo "" + echo " Installed apps: Calendar, Contacts, Tasks," + echo " Notes, Deck" + echo "" + echo " Credentials saved to: $CREDS_FILE" + echo "══════════════════════════════════════════════" + ''; + }; + + # ── Cron ────────────────────────────────────────────────── + services.cron.systemCronJobs = [ + "*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php" + ]; + + # ── Ensure directories ──────────────────────────────────── + systemd.tmpfiles.rules = [ + "d /var/lib/www 0755 caddy root -" + "d /var/lib/www/nextcloud 0750 caddy root -" + "d /var/lib/www/nextcloud-data 0770 caddy root -" + ]; + + environment.systemPackages = with pkgs; [ + unzip + ]; + }; +} diff --git a/modules/personalization.nix b/modules/personalization.nix new file mode 100755 index 0000000..f828a53 --- /dev/null +++ b/modules/personalization.nix @@ -0,0 +1,24 @@ +{ + +matrix_url = builtins.readFile /var/lib/domains/matrix; +wordpress_url = builtins.readFile /var/lib/domains/wordpress; +nextcloud_url = builtins.readFile /var/lib/domains/nextcloud; +btcpayserver_url = builtins.readFile /var/lib/domains/btcpayserver; +caddy_email_for_acme = builtins.readFile /var/lib/domains/sslemail; +vaultwarden_url = builtins.readFile /var/lib/domains/vaultwarden; +haven_url = builtins.readFile /var/lib/domains/haven; +element-calling_url = builtins.readFile /var/lib/domains/element-calling; + +## + +external_ip_secret = builtins.readFile /var/lib/secrets/external_ip; +coturn_static_auth_secret = builtins.readFile /var/lib/secrets/turn; + +## + +matrixdb = builtins.readFile /var/lib/secrets/matrixdb; +nextclouddb = builtins.readFile /var/lib/secrets/nextclouddb; +wordpressdb = builtins.readFile /var/lib/secrets/wordpressdb; + + +} diff --git a/modules/php.nix b/modules/php.nix new file mode 100755 index 0000000..f432c0f --- /dev/null +++ b/modules/php.nix @@ -0,0 +1,66 @@ +{ config, pkgs, lib, ... }: + + +let + + custom-php = pkgs.php83.buildEnv { + extensions = { enabled, all }: enabled ++ (with all; [ bz2 apcu redis imagick memcached ]); + extraConfig = '' + + display_errors = On + display_startup_errors = On + max_execution_time = 10000 + max_input_time = 3000 + memory_limit = 1G; + opcache.enable=1; + opcache.memory_consumption=512; + opcache_revalidate_freq = 240; + opcache.max_accelerated_files=20000; + post_max_size = 3G + upload_max_filesize = 3G + apc.enable_cli=1 + opcache.interned_strings_buffer = 192 + redis.session.locking_enabled=1 + redis.session.lock_retries=-1 + redis.session.lock_wait_time=10000 + + ''; + }; +in + +{ + users.users = { + + php = { + isSystemUser = true; + createHome = false; + uid = 7777; + }; + }; + + users.users.php.group = "php"; + + users.groups.php = {}; + + environment.systemPackages = with pkgs; [ + + custom-php + ]; + + services.phpfpm.pools = { + mypool = { + user = "caddy"; + group = "php"; + phpPackage = custom-php; + settings = { + "pm" = "dynamic"; + "pm.max_children" = 75; + "pm.start_servers" = 10; + "pm.min_spare_servers" = 5; + "pm.max_spare_servers" = 20; + "pm.max_requests" = 500; + "clear_env" = "no"; + }; + }; + }; +} diff --git a/modules/rdp.nix b/modules/rdp.nix new file mode 100755 index 0000000..67b4c34 --- /dev/null +++ b/modules/rdp.nix @@ -0,0 +1,107 @@ +{ config, pkgs, lib, ... }: + +lib.mkIf config.sovran_systemsOS.features.rdp { + + services.gnome.gnome-remote-desktop.enable = true; + + networking.firewall.allowedTCPPorts = [ 3389 ]; + + environment.systemPackages = with pkgs; [ + freerdp + ]; + + # The NixOS module installs the unit but doesn't enable it — we just need to start it and order it + systemd.services.gnome-remote-desktop = { + wantedBy = [ "graphical.target" ]; + after = [ "gnome-remote-desktop-setup.service" ]; + wants = [ "gnome-remote-desktop-setup.service" ]; + }; + + systemd.tmpfiles.rules = [ + "d /var/lib/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -" + "d /var/lib/gnome-remote-desktop/.local 0750 gnome-remote-desktop gnome-remote-desktop -" + "d /var/lib/gnome-remote-desktop/.local/share 0750 gnome-remote-desktop gnome-remote-desktop -" + "d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -" + ]; + + systemd.services.gnome-remote-desktop-setup = { + description = "Configure GNOME Remote Desktop RDP"; + wantedBy = [ "multi-user.target" ]; + before = [ "gnome-remote-desktop.service" ]; + after = [ "systemd-tmpfiles-setup.service" "network-online.target" ]; + wants = [ "network-online.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ + pkgs.gnome-remote-desktop + pkgs.polkit + pkgs.openssl + pkgs.hostname + pkgs.gawk + ]; + script = '' + # Ensure directory structure exists + mkdir -p /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop + chown -R gnome-remote-desktop:gnome-remote-desktop /var/lib/gnome-remote-desktop + + TLS_DIR="/var/lib/gnome-remote-desktop/tls" + CRED_FILE="/var/lib/gnome-remote-desktop/rdp-credentials" + + # Generate TLS certificate if it doesn't exist + if [ ! -f "$TLS_DIR/rdp-tls.crt" ]; then + mkdir -p "$TLS_DIR" + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ + -sha256 -nodes -days 3650 \ + -keyout "$TLS_DIR/rdp-tls.key" \ + -out "$TLS_DIR/rdp-tls.crt" \ + -subj "/CN=gnome-remote-desktop" + chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR" + chmod 600 "$TLS_DIR/rdp-tls.key" + chmod 644 "$TLS_DIR/rdp-tls.crt" + echo "Generated RDP TLS certificate" + fi + + # Configure TLS certificate + grdctl --system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt" + grdctl --system rdp set-tls-key "$TLS_DIR/rdp-tls.key" + + # Generate password on first boot only + PASSWORD="" + if [ ! -f /var/lib/gnome-remote-desktop/rdp-password ]; then + PASSWORD=$(openssl rand -base64 16) + echo "$PASSWORD" > /var/lib/gnome-remote-desktop/rdp-password + chmod 600 /var/lib/gnome-remote-desktop/rdp-password + else + PASSWORD=$(cat /var/lib/gnome-remote-desktop/rdp-password) + fi + + # Get current IP address + LOCAL_IP=$(hostname -I | awk '{print $1}') + + # Always rewrite the credentials file with the current IP + cat > "$CRED_FILE" < "$SECRET_FILE" + chmod 600 "$SECRET_FILE" + chown matrix-synapse:matrix-synapse "$SECRET_FILE" + fi + + DB_PASS=$(cat "$SECRET_FILE") + + psql -U postgres -c "ALTER ROLE \"matrix-synapse\" WITH LOGIN PASSWORD '$DB_PASS';" + + if ! psql -U postgres -lqt | cut -d \| -f 1 | grep -qw "matrix-synapse"; then + psql -U postgres -c "CREATE DATABASE \"matrix-synapse\" WITH OWNER \"matrix-synapse\" TEMPLATE template0 LC_COLLATE = 'C' LC_CTYPE = 'C';" + fi + ''; + }; + + # ── Generate Synapse runtime config from /var/lib/domains ─── + systemd.services.matrix-synapse-runtime-config = { + description = "Generate Matrix Synapse runtime config from domain files"; + before = [ "matrix-synapse.service" ]; + after = [ "matrix-synapse-db-init.service" ]; + requiredBy = [ "matrix-synapse.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ pkgs.coreutils ]; + script = '' + MATRIX=$(cat /var/lib/domains/matrix) + RUNTIME_DIR="/run/matrix-synapse" + mkdir -p "$RUNTIME_DIR" + + cat > "$RUNTIME_DIR/runtime-config.yaml" < /run/vaultwarden/runtime.env < "$SECRET_FILE" + chmod 600 "$SECRET_FILE" + fi + + DB_PASS=$(cat "$SECRET_FILE") + + mysql -u root </dev/null; then + break + fi + sleep 2 + done + + # ── Run WordPress install ─────────────────────── + echo "Running WordPress core install..." + su -s /bin/sh caddy -c " + wp core install \ + --url='https://$DOMAIN' \ + --title='Sovran_SystemsOS' \ + --admin_user='$ADMIN_USER' \ + --admin_password='$ADMIN_PASS' \ + --admin_email='$ADMIN_EMAIL' \ + --skip-email + " + + # ── Configure WordPress settings ──────────────── + echo "Configuring WordPress..." + su -s /bin/sh caddy -c " + wp option update blogdescription 'Powered by Sovran_SystemsOS' + wp option update permalink_structure '/%postname%/' + wp option update default_ping_status 'closed' + wp option update default_comment_status 'closed' + wp rewrite flush + " + + # ── Security hardening ────────────────────────── + echo "Applying security settings..." + su -s /bin/sh caddy -c " + wp config set DISALLOW_FILE_EDIT true --raw + wp config set WP_AUTO_UPDATE_CORE true --raw + wp config set FORCE_SSL_ADMIN true --raw + " + + # ── Save admin credentials ────────────────────── + CREDS_FILE="/var/lib/secrets/wordpress-admin" + cat > "$CREDS_FILE" << CREDS +WordPress Admin Credentials +═══════════════════════════ +URL: https://$DOMAIN/wp-admin/ +Username: $ADMIN_USER +Password: $ADMIN_PASS +Email: $ADMIN_EMAIL +CREDS + chmod 600 "$CREDS_FILE" + + echo "" + echo "══════════════════════════════════════════════" + echo " WordPress installation complete!" + echo "" + echo " URL: https://$DOMAIN/wp-admin/" + echo " Username: $ADMIN_USER" + echo " Password: $ADMIN_PASS" + echo "" + echo " Credentials saved to: $CREDS_FILE" + echo "══════════════════════════════════════════════" + ''; + }; + + # ── Ensure directories ──────────────────────────────────── + systemd.tmpfiles.rules = [ + "d /var/lib/www 0755 caddy root -" + "d /var/lib/www/wordpress 0755 caddy root -" + ]; + + environment.systemPackages = with pkgs; [ + wp-cli + unzip + ]; + }; +} diff --git a/scripts/sovran-manage.sh b/scripts/sovran-manage.sh new file mode 100644 index 0000000..9e4a1b0 --- /dev/null +++ b/scripts/sovran-manage.sh @@ -0,0 +1,46 @@ + case "$service" in + wordpress) + echo -e " ${BOLD}WordPress has been fully configured.${NC}" + echo "" + echo " View your admin credentials:" + echo -e " ${CYAN}sovran-manage show-creds wordpress${NC}" + echo "" + echo -e " Login at: ${CYAN}https://${domain}/wp-admin/${NC}" + echo "" + echo " Manage plugins:" + echo -e " ${CYAN}sovran-manage wp plugin install woocommerce --activate${NC}" + echo -e " ${CYAN}sovran-manage wp plugin list${NC}" + echo -e " ${CYAN}sovran-manage wp theme install flavor flavor --activate${NC}" + echo "" + ;; + + nextcloud) + echo -e " ${BOLD}Nextcloud has been fully configured.${NC}" + echo "" + echo " Pre-installed apps: Calendar, Contacts, Tasks, Notes, Deck" + echo "" + echo " View your admin credentials:" + echo -e " ${CYAN}sovran-manage show-creds nextcloud${NC}" + echo "" + echo -e " Login at: ${CYAN}https://${domain}/${NC}" + echo "" + echo " Manage apps:" + echo -e " ${CYAN}sovran-manage occ app:install cookbook${NC}" + echo -e " ${CYAN}sovran-manage occ app:list${NC}" + echo "" + ;; + + matrix) + echo -e " Matrix Synapse is running." + echo -e " URL: ${CYAN}https://${domain}${NC}" + echo "" + echo " Create your first user:" + echo -e " ${CYAN}sovran-manage matrix register-user${NC}" + echo "" + ;; + + *) + echo -e " URL: ${CYAN}https://${domain}${NC}" + echo "" + ;; + esac \ No newline at end of file -- 2.53.0 From bec00bd50614d8f5cc44d2b691d8df42d64e8d18 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 14:58:45 -0500 Subject: [PATCH 002/857] set services to default retooling --- custom.nix | 16 +- modules/bitcoinecosystem.nix | 135 ++++++-------- modules/core/role-logic.nix | 26 +-- modules/core/roles.nix | 39 +++- modules/modules.nix | 34 +--- modules/nextcloud.nix | 348 ++++++++++++++++------------------- modules/personalization.nix | 24 --- modules/synapse.nix | 208 ++++++++------------- modules/vaultwarden.nix | 32 +--- modules/wordpress.nix | 303 ++++++++++++++---------------- 10 files changed, 485 insertions(+), 680 deletions(-) delete mode 100755 modules/personalization.nix diff --git a/custom.nix b/custom.nix index ca605ed..ce46153 100644 --- a/custom.nix +++ b/custom.nix @@ -1,8 +1,10 @@ -{ config, pkgs, lib, ... }: { - # Only enable what this machine needs - sovran_systemsOS.services.wordpress.enable = true; - sovran_systemsOS.services.nextcloud.enable = true; - sovran_systemsOS.services.synapse.enable = true; - # btcpayserver is NOT enabled — no domain file needed, no vhost created -} \ No newline at end of file + # ── Disable services you don't want ───────────── + sovran_systemsOS.services.wordpress = false; + sovran_systemsOS.services.nextcloud = false; + + # ── Enable features you do want ───────────────── + sovran_systemsOS.features.haven = true; + sovran_systemsOS.features.element-calling = true; + sovran_systemsOS.nostr_npub = "npub1abc123..."; +} diff --git a/modules/bitcoinecosystem.nix b/modules/bitcoinecosystem.nix index a468d85..e7d1912 100755 --- a/modules/bitcoinecosystem.nix +++ b/modules/bitcoinecosystem.nix @@ -1,95 +1,72 @@ { config, pkgs, lib, ... }: -lib.mkIf config.sovran_systemsOS.features.bitcoin { - - ## Bitcoind - - services.bitcoind = { - enable = true; +lib.mkIf config.sovran_systemsOS.services.bitcoin { + + services.bitcoind = { + enable = true; package = config.nix-bitcoin.pkgs.bitcoind-knots; - dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node"; - txindex = true; - tor.proxy = true; + dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node"; + txindex = true; + tor.proxy = true; tor.enforce = true; - disablewallet = true; - extraConfig = '' - peerbloomfilters=1 - server=1 - ''; - }; + disablewallet = true; + extraConfig = '' + peerbloomfilters=1 + server=1 + ''; + }; - nix-bitcoin.onionServices.bitcoind.enable = true; - nix-bitcoin.onionServices.electrs.enable = true; - nix-bitcoin.onionServices.rtl.enable = true; + nix-bitcoin.onionServices.bitcoind.enable = true; + nix-bitcoin.onionServices.electrs.enable = true; + nix-bitcoin.onionServices.rtl.enable = true; + services.electrs = { + enable = true; + tor.enforce = true; + dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data"; + }; - ## Electrs - - services.electrs = { - enable = true; - tor.enforce = true; - dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data"; - }; + services.lnd = { + enable = true; + tor.enforce = true; + tor.proxy = true; + extraConfig = '' + protocol.option-scid-alias=true + ''; + }; + nix-bitcoin.onionServices.lnd.public = true; - ## LND - - services.lnd = { - enable = true; - tor.enforce = true; - tor.proxy = true; - extraConfig = '' - protocol.option-scid-alias=true - ''; - }; + services.lnd.lndconnect = { + enable = true; + onion = true; + }; - nix-bitcoin.onionServices.lnd.public = true; + services.rtl = { + enable = true; + tor.enforce = true; + port = 3050; + nightTheme = true; + nodes = { + lnd = { + enable = true; + }; + }; + }; + services.btcpayserver = { + enable = true; + }; - ## LNDconnect + services.btcpayserver.lightningBackend = "lnd"; - services.lnd.lndconnect = { - enable = true; - onion = true; - }; + nix-bitcoin.generateSecrets = true; + nix-bitcoin.nodeinfo.enable = true; - - ## RTL - - services.rtl = { - enable = true; - tor.enforce = true; - port = 3050; - nightTheme = true; - nodes = { - lnd = { - enable = true; - }; - - }; - }; + nix-bitcoin.operator = { + enable = true; + name = "free"; + }; - - ## BTCpayserver - - services.btcpayserver = { - enable = true; - }; - - services.btcpayserver.lightningBackend = "lnd"; - - - ## System - - nix-bitcoin.generateSecrets = true; - - nix-bitcoin.nodeinfo.enable = true; - - nix-bitcoin.operator = { - enable = true; - name = "free"; - }; - - nix-bitcoin.useVersionLockedPkgs = false; - + nix-bitcoin.useVersionLockedPkgs = false; } diff --git a/modules/core/role-logic.nix b/modules/core/role-logic.nix index e52331b..d560210 100755 --- a/modules/core/role-logic.nix +++ b/modules/core/role-logic.nix @@ -3,20 +3,11 @@ { config = lib.mkMerge [ - # Server-Desktop Role most services enabled + # Server-Desktop Role — services already default to on, + # so we only need to set features here (lib.mkIf config.sovran_systemsOS.roles.server-desktop { - sovran_systemsOS.features = { - synapse = true; - bitcoin = true; - coturn = true; - vaultwarden = true; - haven = false; - mempool = false; - bip110 = false; - element-calling = false; - bitcoin-core = false; - rdp = false; - }; + # All services are default=true, nothing to set + # All features are default=false, nothing to set }) # Desktop role @@ -25,11 +16,14 @@ services.desktopManager.gnome.enable = true; }) - # Bitcoin node role + # Bitcoin node role — only bitcoin, disable other services (lib.mkIf config.sovran_systemsOS.roles.node { - sovran_systemsOS.features = { + sovran_systemsOS.services = { bitcoin = true; - bip110 = false; + synapse = false; + vaultwarden = false; + wordpress = false; + nextcloud = false; }; }) diff --git a/modules/core/roles.nix b/modules/core/roles.nix index 01ae202..9a5ba31 100755 --- a/modules/core/roles.nix +++ b/modules/core/roles.nix @@ -11,11 +11,37 @@ node = lib.mkEnableOption "Bitcoin Node Only Role"; }; + # ── Services (default ON — user can disable in custom.nix) ── + services = { + synapse = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Matrix Synapse homeserver"; + }; + bitcoin = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Bitcoin Ecosystem (bitcoind, electrs, lnd, rtl, btcpay)"; + }; + vaultwarden = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Vaultwarden password manager"; + }; + wordpress = lib.mkOption { + type = lib.types.bool; + default = true; + description = "WordPress (raw PHP served by Caddy)"; + }; + nextcloud = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Nextcloud (raw PHP served by Caddy)"; + }; + }; + + # ── Features (default OFF — user can enable in custom.nix) ── features = { - coturn = lib.mkEnableOption "TURN server"; - synapse = lib.mkEnableOption "Matrix Synapse"; - bitcoin = lib.mkEnableOption "Bitcoin Ecosystem"; - vaultwarden = lib.mkEnableOption "Vaultwarden"; haven = lib.mkEnableOption "Haven NOSTR relay"; bip110 = lib.mkEnableOption "BIP-110 Bitcoin Better Money"; mempool = lib.mkEnableOption "Bitcoin Mempool Explorer"; @@ -29,5 +55,10 @@ default = ""; description = "Nostr public key (npub1...) for Haven relay"; }; + + packages.bip110 = lib.mkOption { + type = lib.types.package; + description = "BIP-110 bitcoind-knots package"; + }; }; } diff --git a/modules/modules.nix b/modules/modules.nix index 8450ef3..2ce083f 100644 --- a/modules/modules.nix +++ b/modules/modules.nix @@ -1,46 +1,30 @@ { config, pkgs, lib, ... }: { -<<<<<<< HEAD imports = [ + # ── Core (always loaded) ────────────────────────────────── ./core/roles.nix ./core/role-logic.nix ./core/caddy.nix ./core/sovran-manage.nix - ./php.nix - ./Sovran_SystemsOS_File_Fixes_And_New_Services.nix - ./synapse.nix - ./coturn.nix - ./wordpress.nix - ./nextcloud.nix - ./btcpayserver.nix -======= - - imports = [ - - ./core/roles.nix - ./core/role-logic.nix + + # ── Always on (no flag) ─────────────────────────────────── ./php.nix ./Sovran_SystemsOS_File_Fixes_And_New_Services.nix - # Always imported feature modules + # ── Services (default ON — disable in custom.nix) ───────── ./synapse.nix - ./coturn.nix - ./bitcoinecosystem.nix ->>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 + ./wordpress.nix + ./nextcloud.nix ./vaultwarden.nix + ./bitcoinecosystem.nix + + # ── Features (default OFF — enable in custom.nix) ───────── ./haven.nix ./bip110.nix ./element-calling.nix ./mempool.nix ./bitcoin-core.nix ./rdp.nix -<<<<<<< HEAD - ./bitcoinecosystem.nix ]; -======= - - ]; - ->>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 } diff --git a/modules/nextcloud.nix b/modules/nextcloud.nix index 3c7e933..3c24cf6 100644 --- a/modules/nextcloud.nix +++ b/modules/nextcloud.nix @@ -1,224 +1,186 @@ { config, pkgs, lib, ... }: -let - cfg = config.sovran_systemsOS.services.nextcloud; -in -{ - options.sovran_systemsOS.services.nextcloud = { - enable = lib.mkEnableOption "Nextcloud (raw PHP served by Caddy)"; +lib.mkIf config.sovran_systemsOS.services.nextcloud { + + # ── PostgreSQL database ─────────────────────────────────── + services.postgresql = { + enable = true; }; - config = lib.mkIf cfg.enable { + # ── Auto-generate DB password and initialize ────────────── + systemd.services.nextcloud-db-init = { + description = "Initialize Nextcloud PostgreSQL database with auto-generated password"; + after = [ "postgresql.service" ]; + requires = [ "postgresql.service" ]; + before = [ "nextcloud-init.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ config.services.postgresql.package pkgs.pwgen pkgs.coreutils ]; + script = '' + set -euo pipefail - # ── Caddy vhost is now handled centrally in caddy.nix ───── + SECRET_FILE="/var/lib/secrets/nextclouddb" - # ── PostgreSQL database ─────────────────────────────────── - services.postgresql = { - enable = true; + if [ ! -f "$SECRET_FILE" ]; then + mkdir -p /var/lib/secrets + pwgen -s 64 1 > "$SECRET_FILE" + chmod 600 "$SECRET_FILE" + fi + + DB_PASS=$(cat "$SECRET_FILE") + + psql -U postgres < "$SECRET_FILE" - chmod 600 "$SECRET_FILE" - fi - - DB_PASS=$(cat "$SECRET_FILE") - - # Create role if it doesn't exist, update password either way - psql -U postgres </dev/null; then + echo "Database ready." + break + fi + sleep 2 + done - # ── Create data directory ─────────────────────── - mkdir -p "$DATA_DIR" + echo "Running Nextcloud installation..." + su -s /bin/sh caddy -c " + php $INSTALL_DIR/occ maintenance:install \ + --database 'pgsql' \ + --database-name '$DB_NAME' \ + --database-user '$DB_USER' \ + --database-pass '$DB_PASS' \ + --database-host '$DB_HOST' \ + --admin-user '$ADMIN_USER' \ + --admin-pass '$ADMIN_PASS' \ + --data-dir '$DATA_DIR' + " - # ── Set permissions ───────────────────────────── - chown -R caddy:root "$INSTALL_DIR" - chown -R caddy:root "$DATA_DIR" - find "$INSTALL_DIR" -type d -exec chmod 750 {} \; - find "$INSTALL_DIR" -type f -exec chmod 640 {} \; - chmod -R 770 "$INSTALL_DIR/apps" - chmod -R 770 "$INSTALL_DIR/config" - chmod -R 770 "$DATA_DIR" + su -s /bin/sh caddy -c " + php $INSTALL_DIR/occ config:system:set trusted_domains 0 --value='$DOMAIN' + php $INSTALL_DIR/occ config:system:set overwrite.cli.url --value='https://$DOMAIN' + php $INSTALL_DIR/occ config:system:set overwriteprotocol --value='https' + " - # ── Wait for database ─────────────────────────── - echo "Waiting for PostgreSQL..." - for i in $(seq 1 30); do - if su -s /bin/sh caddy -c "php -r \"new PDO('pgsql:host=$DB_HOST;dbname=$DB_NAME', '$DB_USER', '$DB_PASS');\"" 2>/dev/null; then - echo "Database ready." - break - fi - sleep 2 - done + su -s /bin/sh caddy -c " + php $INSTALL_DIR/occ config:system:set default_phone_region --value='US' + php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu' + php $INSTALL_DIR/occ background:cron + " - # ── Run Nextcloud install via occ ─────────────── - echo "Running Nextcloud installation..." - su -s /bin/sh caddy -c " - php $INSTALL_DIR/occ maintenance:install \ - --database 'pgsql' \ - --database-name '$DB_NAME' \ - --database-user '$DB_USER' \ - --database-pass '$DB_PASS' \ - --database-host '$DB_HOST' \ - --admin-user '$ADMIN_USER' \ - --admin-pass '$ADMIN_PASS' \ - --data-dir '$DATA_DIR' - " + su -s /bin/sh caddy -c " + php $INSTALL_DIR/occ app:install calendar || true + php $INSTALL_DIR/occ app:install contacts || true + php $INSTALL_DIR/occ app:install tasks || true + php $INSTALL_DIR/occ app:install notes || true + php $INSTALL_DIR/occ app:install deck || true + php $INSTALL_DIR/occ app:enable calendar || true + php $INSTALL_DIR/occ app:enable contacts || true + php $INSTALL_DIR/occ app:enable tasks || true + php $INSTALL_DIR/occ app:enable notes || true + php $INSTALL_DIR/occ app:enable deck || true + " - # ── Configure trusted domains ─────────────────── - echo "Configuring trusted domains..." - su -s /bin/sh caddy -c " - php $INSTALL_DIR/occ config:system:set trusted_domains 0 --value='$DOMAIN' - php $INSTALL_DIR/occ config:system:set overwrite.cli.url --value='https://$DOMAIN' - php $INSTALL_DIR/occ config:system:set overwriteprotocol --value='https' - " - - # ── Set recommended settings ─��────────────────── - echo "Applying recommended settings..." - su -s /bin/sh caddy -c " - php $INSTALL_DIR/occ config:system:set default_phone_region --value='US' - php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu' - php $INSTALL_DIR/occ background:cron - " - - # ── Install default apps ──────────────────────── - echo "Installing default apps..." - su -s /bin/sh caddy -c " - php $INSTALL_DIR/occ app:install calendar || true - php $INSTALL_DIR/occ app:install contacts || true - php $INSTALL_DIR/occ app:install tasks || true - php $INSTALL_DIR/occ app:install notes || true - php $INSTALL_DIR/occ app:install deck || true - php $INSTALL_DIR/occ app:enable calendar || true - php $INSTALL_DIR/occ app:enable contacts || true - php $INSTALL_DIR/occ app:enable tasks || true - php $INSTALL_DIR/occ app:enable notes || true - php $INSTALL_DIR/occ app:enable deck || true - " - - # ── Save admin credentials ────────────────────── - CREDS_FILE="/var/lib/secrets/nextcloud-admin" - cat > "$CREDS_FILE" << CREDS + CREDS_FILE="/var/lib/secrets/nextcloud-admin" + cat > "$CREDS_FILE" << CREDS Nextcloud Admin Credentials ═══════════════════════════ URL: https://$DOMAIN/ Username: $ADMIN_USER Password: $ADMIN_PASS CREDS - chmod 600 "$CREDS_FILE" + chmod 600 "$CREDS_FILE" - echo "" - echo "══════════════════════════════════════════════" - echo " Nextcloud installation complete!" - echo "" - echo " URL: https://$DOMAIN/" - echo " Username: $ADMIN_USER" - echo " Password: $ADMIN_PASS" - echo "" - echo " Installed apps: Calendar, Contacts, Tasks," - echo " Notes, Deck" - echo "" - echo " Credentials saved to: $CREDS_FILE" - echo "══════════════════════════════════════════════" - ''; - }; - - # ── Cron ────────────────────────────────────────────────── - services.cron.systemCronJobs = [ - "*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php" - ]; - - # ── Ensure directories ──────────────────────────────────── - systemd.tmpfiles.rules = [ - "d /var/lib/www 0755 caddy root -" - "d /var/lib/www/nextcloud 0750 caddy root -" - "d /var/lib/www/nextcloud-data 0770 caddy root -" - ]; - - environment.systemPackages = with pkgs; [ - unzip - ]; + echo "" + echo "══════════════════════════════════════════════" + echo " Nextcloud installation complete!" + echo " Credentials saved to: $CREDS_FILE" + echo "══════════════════════════════════════════════" + ''; }; + + services.cron.systemCronJobs = [ + "*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php" + ]; + + systemd.tmpfiles.rules = [ + "d /var/lib/www 0755 caddy root -" + "d /var/lib/www/nextcloud 0750 caddy root -" + "d /var/lib/www/nextcloud-data 0770 caddy root -" + ]; + + environment.systemPackages = with pkgs; [ unzip ]; } diff --git a/modules/personalization.nix b/modules/personalization.nix deleted file mode 100755 index f828a53..0000000 --- a/modules/personalization.nix +++ /dev/null @@ -1,24 +0,0 @@ -{ - -matrix_url = builtins.readFile /var/lib/domains/matrix; -wordpress_url = builtins.readFile /var/lib/domains/wordpress; -nextcloud_url = builtins.readFile /var/lib/domains/nextcloud; -btcpayserver_url = builtins.readFile /var/lib/domains/btcpayserver; -caddy_email_for_acme = builtins.readFile /var/lib/domains/sslemail; -vaultwarden_url = builtins.readFile /var/lib/domains/vaultwarden; -haven_url = builtins.readFile /var/lib/domains/haven; -element-calling_url = builtins.readFile /var/lib/domains/element-calling; - -## - -external_ip_secret = builtins.readFile /var/lib/secrets/external_ip; -coturn_static_auth_secret = builtins.readFile /var/lib/secrets/turn; - -## - -matrixdb = builtins.readFile /var/lib/secrets/matrixdb; -nextclouddb = builtins.readFile /var/lib/secrets/nextclouddb; -wordpressdb = builtins.readFile /var/lib/secrets/wordpressdb; - - -} diff --git a/modules/synapse.nix b/modules/synapse.nix index d978b61..d172924 100644 --- a/modules/synapse.nix +++ b/modules/synapse.nix @@ -1,7 +1,7 @@ { config, pkgs, lib, ... }: -<<<<<<< HEAD -{ +lib.mkIf config.sovran_systemsOS.services.synapse { + # ── PostgreSQL database for Matrix ────────────────────────── services.postgresql = { enable = true; @@ -27,6 +27,8 @@ }; path = [ config.services.postgresql.package pkgs.pwgen pkgs.coreutils ]; script = '' + set -euo pipefail + SECRET_DIR="/var/lib/secrets" SECRET_FILE="$SECRET_DIR/matrix_db_secret" @@ -48,7 +50,7 @@ ''; }; - # ── Generate Synapse runtime config from /var/lib/domains ─── + # ── Generate Synapse runtime config from domain files ─────── systemd.services.matrix-synapse-runtime-config = { description = "Generate Matrix Synapse runtime config from domain files"; before = [ "matrix-synapse.service" ]; @@ -61,13 +63,27 @@ }; path = [ pkgs.coreutils ]; script = '' + set -euo pipefail + MATRIX=$(cat /var/lib/domains/matrix) RUNTIME_DIR="/run/matrix-synapse" mkdir -p "$RUNTIME_DIR" - cat > "$RUNTIME_DIR/runtime-config.yaml" < "$RUNTIME_DIR/runtime-config.yaml" < "$RUNTIME_DIR/runtime-config.yaml" <>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 + }; } diff --git a/modules/vaultwarden.nix b/modules/vaultwarden.nix index 00b0e54..8c4775e 100755 --- a/modules/vaultwarden.nix +++ b/modules/vaultwarden.nix @@ -1,11 +1,7 @@ { config, pkgs, lib, ... }: -<<<<<<< HEAD -lib.mkIf config.sovran_systemsOS.features.vaultwarden { +lib.mkIf config.sovran_systemsOS.services.vaultwarden { - # ── Caddy vhost is now handled centrally in caddy.nix ───── - - # ── Generate Vaultwarden runtime config from domain files ── systemd.services.vaultwarden-runtime-config = { description = "Generate Vaultwarden runtime config from domain files"; before = [ "vaultwarden.service" ]; @@ -22,8 +18,8 @@ lib.mkIf config.sovran_systemsOS.features.vaultwarden { mkdir -p /run/vaultwarden cat > /run/vaultwarden/runtime.env <>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 } diff --git a/modules/wordpress.nix b/modules/wordpress.nix index 5e614dc..ad454b3 100644 --- a/modules/wordpress.nix +++ b/modules/wordpress.nix @@ -1,167 +1,146 @@ { config, pkgs, lib, ... }: -let - cfg = config.sovran_systemsOS.services.wordpress; -in -{ - options.sovran_systemsOS.services.wordpress = { - enable = lib.mkEnableOption "WordPress (raw PHP served by Caddy)"; +lib.mkIf config.sovran_systemsOS.services.wordpress { + + # ── MariaDB database ────────────────────────────────────── + services.mysql = { + enable = true; + package = pkgs.mariadb; }; - config = lib.mkIf cfg.enable { + # ── Auto-generate DB password and initialize ────────���───── + systemd.services.wordpress-db-init = { + description = "Initialize WordPress MariaDB database with auto-generated password"; + after = [ "mysql.service" ]; + requires = [ "mysql.service" ]; + before = [ "wordpress-init.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ config.services.mysql.package pkgs.pwgen pkgs.coreutils ]; + script = '' + set -euo pipefail - # ── Caddy vhost is now handled centrally in caddy.nix ───── + SECRET_FILE="/var/lib/secrets/wordpressdb" - # ── MariaDB database ────────────────────────────────────── - services.mysql = { - enable = true; - package = pkgs.mariadb; + if [ ! -f "$SECRET_FILE" ]; then + mkdir -p /var/lib/secrets + pwgen -s 64 1 > "$SECRET_FILE" + chmod 600 "$SECRET_FILE" + fi + + DB_PASS=$(cat "$SECRET_FILE") + + mysql -u root < "$SECRET_FILE" - chmod 600 "$SECRET_FILE" - fi - - DB_PASS=$(cat "$SECRET_FILE") - - mysql -u root </dev/null; then + break + fi + sleep 2 + done - # ── Set permissions ───────────────────────────── - chown -R caddy:root "$INSTALL_DIR" - find "$INSTALL_DIR" -type d -exec chmod 755 {} \; - find "$INSTALL_DIR" -type f -exec chmod 644 {} \; - chmod -R 775 "$INSTALL_DIR/wp-content" + echo "Running WordPress core install..." + su -s /bin/sh caddy -c " + wp core install \ + --url='https://$DOMAIN' \ + --title='Sovran_SystemsOS' \ + --admin_user='$ADMIN_USER' \ + --admin_password='$ADMIN_PASS' \ + --admin_email='$ADMIN_EMAIL' \ + --skip-email + " - # ── Generate wp-config.php ────────────────────── - echo "Generating wp-config.php..." - cd "$INSTALL_DIR" - su -s /bin/sh caddy -c " - wp config create \ - --dbname='$DB_NAME' \ - --dbuser='$DB_USER' \ - --dbpass='$DB_PASS' \ - --dbhost='$DB_HOST' \ - --skip-check - " + su -s /bin/sh caddy -c " + wp option update blogdescription 'Powered by Sovran_SystemsOS' + wp option update permalink_structure '/%postname%/' + wp option update default_ping_status 'closed' + wp option update default_comment_status 'closed' + wp rewrite flush + " - # ── Wait for database to be ready ─────────────── - echo "Waiting for database..." - for i in $(seq 1 30); do - if su -s /bin/sh caddy -c "wp db check" 2>/dev/null; then - break - fi - sleep 2 - done + su -s /bin/sh caddy -c " + wp config set DISALLOW_FILE_EDIT true --raw + wp config set WP_AUTO_UPDATE_CORE true --raw + wp config set FORCE_SSL_ADMIN true --raw + " - # ── Run WordPress install ─────────────────────── - echo "Running WordPress core install..." - su -s /bin/sh caddy -c " - wp core install \ - --url='https://$DOMAIN' \ - --title='Sovran_SystemsOS' \ - --admin_user='$ADMIN_USER' \ - --admin_password='$ADMIN_PASS' \ - --admin_email='$ADMIN_EMAIL' \ - --skip-email - " - - # ── Configure WordPress settings ──────────────── - echo "Configuring WordPress..." - su -s /bin/sh caddy -c " - wp option update blogdescription 'Powered by Sovran_SystemsOS' - wp option update permalink_structure '/%postname%/' - wp option update default_ping_status 'closed' - wp option update default_comment_status 'closed' - wp rewrite flush - " - - # ── Security hardening ────────────────────────── - echo "Applying security settings..." - su -s /bin/sh caddy -c " - wp config set DISALLOW_FILE_EDIT true --raw - wp config set WP_AUTO_UPDATE_CORE true --raw - wp config set FORCE_SSL_ADMIN true --raw - " - - # ── Save admin credentials ────────────────────── - CREDS_FILE="/var/lib/secrets/wordpress-admin" - cat > "$CREDS_FILE" << CREDS + CREDS_FILE="/var/lib/secrets/wordpress-admin" + cat > "$CREDS_FILE" << CREDS WordPress Admin Credentials ═══════════════════════════ URL: https://$DOMAIN/wp-admin/ @@ -169,30 +148,20 @@ Username: $ADMIN_USER Password: $ADMIN_PASS Email: $ADMIN_EMAIL CREDS - chmod 600 "$CREDS_FILE" + chmod 600 "$CREDS_FILE" - echo "" - echo "══════════════════════════════════════════════" - echo " WordPress installation complete!" - echo "" - echo " URL: https://$DOMAIN/wp-admin/" - echo " Username: $ADMIN_USER" - echo " Password: $ADMIN_PASS" - echo "" - echo " Credentials saved to: $CREDS_FILE" - echo "══════════════════════════════════════════════" - ''; - }; - - # ── Ensure directories ──────────────────────────────────── - systemd.tmpfiles.rules = [ - "d /var/lib/www 0755 caddy root -" - "d /var/lib/www/wordpress 0755 caddy root -" - ]; - - environment.systemPackages = with pkgs; [ - wp-cli - unzip - ]; + echo "" + echo "══════════════════════════════════════════════" + echo " WordPress installation complete!" + echo " Credentials saved to: $CREDS_FILE" + echo "══════════════════════════════════════════════" + ''; }; + + systemd.tmpfiles.rules = [ + "d /var/lib/www 0755 caddy root -" + "d /var/lib/www/wordpress 0755 caddy root -" + ]; + + environment.systemPackages = with pkgs; [ wp-cli unzip ]; } -- 2.53.0 From 3a77231a1ed83c8809f66674bf8f2499a2548827 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 15:00:05 -0500 Subject: [PATCH 003/857] ownership --- modules/core/caddy.nix | 0 modules/core/njalla-ddns.nix | 0 modules/core/sovran-manage.nix | 0 modules/modules.nix | 0 modules/nextcloud.nix | 0 modules/synapse.nix | 0 modules/wordpress.nix | 0 7 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 modules/core/caddy.nix mode change 100644 => 100755 modules/core/njalla-ddns.nix mode change 100644 => 100755 modules/core/sovran-manage.nix mode change 100644 => 100755 modules/modules.nix mode change 100644 => 100755 modules/nextcloud.nix mode change 100644 => 100755 modules/synapse.nix mode change 100644 => 100755 modules/wordpress.nix diff --git a/modules/core/caddy.nix b/modules/core/caddy.nix old mode 100644 new mode 100755 diff --git a/modules/core/njalla-ddns.nix b/modules/core/njalla-ddns.nix old mode 100644 new mode 100755 diff --git a/modules/core/sovran-manage.nix b/modules/core/sovran-manage.nix old mode 100644 new mode 100755 diff --git a/modules/modules.nix b/modules/modules.nix old mode 100644 new mode 100755 diff --git a/modules/nextcloud.nix b/modules/nextcloud.nix old mode 100644 new mode 100755 diff --git a/modules/synapse.nix b/modules/synapse.nix old mode 100644 new mode 100755 diff --git a/modules/wordpress.nix b/modules/wordpress.nix old mode 100644 new mode 100755 -- 2.53.0 From 0899ddd55c9321d2a44b7a4e51ff1d40a2c812b2 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 15:04:54 -0500 Subject: [PATCH 004/857] updated configuration --- configuration.nix | 399 +--------------------------------------------- 1 file changed, 3 insertions(+), 396 deletions(-) diff --git a/configuration.nix b/configuration.nix index 6ee88b4..938a1d3 100644 --- a/configuration.nix +++ b/configuration.nix @@ -1,6 +1,5 @@ { config, pkgs, lib, ... }: -<<<<<<< HEAD { imports = [ ./modules/modules.nix @@ -12,7 +11,7 @@ boot.loader.efi.efiSysMountPoint = "/boot/efi"; boot.kernelPackages = pkgs.linuxPackages_latest; - # ── Filesystems ──────────────────────────────────��────────── + # ── Filesystems ───────────────────────────────────────────── fileSystems."/run/media/Second_Drive" = { device = "LABEL=BTCEcoandBackup"; fsType = "ext4"; @@ -72,107 +71,11 @@ # ── Flatpak ──────────────────────────────────────────────── services.flatpak.enable = true; -======= - -let - personalization = import ./modules/personalization.nix; -in - -{ - - imports = - - [ - - ./modules/modules.nix - - ]; - - - # Bootloader. - boot.loader.systemd-boot.enable = true; - boot.loader.efi.canTouchEfiVariables = true; - boot.loader.efi.efiSysMountPoint = "/boot/efi"; - boot.kernelPackages = pkgs.linuxPackages_latest; - - # Enable Automount without Fail for Internal Drive. - fileSystems."/run/media/Second_Drive" = { - device = "LABEL=BTCEcoandBackup"; - fsType = "ext4"; - options = [ "nofail" ]; - }; - - fileSystems."/boot/efi".options = [ "umask=0077" "defaults" ]; - - nix.settings = { - - experimental-features = [ "nix-command" "flakes" ]; - download-buffer-size = 524288000; - - }; - - networking.hostName = "nixos"; # Define your hostname. - - # Enable networking - networking.networkmanager.enable = true; - - # Set your time zone. - time.timeZone = "America/Los_Angeles"; - - # Select internationalisation properties. - i18n.defaultLocale = "en_US.UTF-8"; - - # Enable the X11 windowing system. - services.xserver.enable = true; - - # Enable the GNOME Desktop Environment. - services.displayManager.gdm.enable = true; - services.desktopManager.gnome.enable = true; - - # Configure keymap in X11 - services.xserver.xkb = { - layout = "us"; - variant = ""; - }; - - # Enable CUPS to print documents. - services.printing.enable = true; - - # Systemd Settings - systemd.enableEmergencyMode = false; - - # Enable sound with pipewire. - services.pulseaudio.enable = false; - security.rtkit.enable = true; - services.pipewire = { - enable = true; - alsa.enable = true; - alsa.support32Bit = true; - pulse.enable = true; - }; - - users.users = { - free = { - isNormalUser = true; - description = "free"; - extraGroups = [ "networkmanager" ]; - }; - }; - - # Enable automatic login for the user. - services.displayManager.autoLogin.enable = true; - services.displayManager.autoLogin.user = "free"; - - # Allow Flatpak - services.flatpak.enable = true; - ->>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 systemd.services.flatpak-repo = { wantedBy = [ "multi-user.target" ]; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; path = [ pkgs.flatpak ]; -<<<<<<< HEAD script = '' flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo ''; @@ -227,8 +130,8 @@ in # ── Agenix ───────────────────────────────────────────────── age.identityPaths = [ "/root/.ssh/agenix/agenix-secret-keys" ]; age.secrets.matrix_reg_secret = { - file = ./secrets/matrix_reg_secret.age; - mode = "0440"; + file = /var/lib/agenix-secrets/matrix_reg_secret.age; + mode = "770"; owner = "matrix-synapse"; group = "matrix-synapse"; }; @@ -237,222 +140,6 @@ in services.rsnapshot = { enable = true; extraConfig = '' -======= - script = '' - flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo - ''; - }; - - # Allow unfree packages - nixpkgs.config.allowUnfree = true; - - nixpkgs.config.permittedInsecurePackages = [ - - "jitsi-meet-1.0.8043" - ]; - - # List packages installed - environment.systemPackages = with pkgs; [ - git - wget - fish - htop - btop - gnomeExtensions.transparent-top-bar-adjustable-transparency - gnomeExtensions.systemd-manager - gnomeExtensions.dash-to-dock - gnomeExtensions.vitals - gnomeExtensions.pop-shell - gnomeExtensions.just-perfection - gnomeExtensions.appindicator - gnomeExtensions.date-menu-formatter - gnome-tweaks - papirus-icon-theme - ranger - fastfetch - gedit - matrix-synapse - openssl - pwgen - aspell - aspellDicts.en - lm_sensors - hunspell - hunspellDicts.en_US - synadm - brave - dua - bitwarden-desktop - gparted - pv - unzip - parted - screen - zenity - libargon2 - gnome-terminal - libreoffice-fresh - dig - firefox - element-desktop - wp-cli - axel - lk-jwt-service - livekit-libwebrtc - livekit-cli - livekit - ]; - - programs.nixvim = { - enable = true; - colorschemes.catppuccin.enable = true; - plugins.lualine.enable = true; - }; - - - programs.bash.promptInit = "fish"; - - programs.fish = { - enable = true; - promptInit = "fastfetch"; - }; - - ####### CADDY ####### - services.caddy = { - enable = true; - user = "caddy"; - group = "root"; - email = "${personalization.caddy_email_for_acme}"; - - virtualHosts = { - "${personalization.wordpress_url}" = { - extraConfig = '' - encode gzip zstd - root * /var/lib/www/wordpress - php_fastcgi unix//run/phpfpm/mypool.sock - file_server browse - ''; - }; - - "${personalization.nextcloud_url}" = { - extraConfig = '' - encode gzip zstd - root * /var/lib/www/nextcloud - php_fastcgi unix//run/phpfpm/mypool.sock { - trusted_proxies private_ranges - } - file_server - redir /.well-known/carddav /remote.php/dav/ 301 - redir /.well-known/caldav /remote.php/dav/ 301 - header { - Strict-Transport-Security max-age=31536000; - } - ''; - }; - - "${personalization.matrix_url}" = { - extraConfig = '' - reverse_proxy /_matrix/* http://localhost:8008 - reverse_proxy /_synapse/client/* http://localhost:8008 - ''; - }; - - "${personalization.matrix_url}:8448" = { - extraConfig = '' - reverse_proxy http://localhost:8008 - ''; - }; - - "${personalization.btcpayserver_url}" = { - extraConfig = '' - reverse_proxy http://localhost:23000 - encode gzip zstd - ''; - }; - - "https://${personalization.vaultwarden_url}" = { - extraConfig = '' - reverse_proxy http://localhost:8777 - encode gzip zstd - ''; - }; - - ":3051" = { - extraConfig = '' - reverse_proxy :3050 - encode gzip zstd - ''; - }; - }; - }; - - ###### AGENIX ###### - age.identityPaths = [ "/root/.ssh/agenix/agenix-secret-keys" ]; - - age.secrets.matrix_reg_secret = { - - file = /var/lib/agenix-secrets/matrix_reg_secret.age; - mode = "770"; - owner = "matrix-synapse"; - group = "matrix-synapse"; - - }; - - ###### CREATE DATABASE (WORDPRESS, MATRIX_SYNAPSE, AND NEXTCLOUD) ####### - services.postgresql = { - enable = true; - }; - - - services.postgresql.authentication = lib.mkForce '' - # Generated file; do not edit! - # TYPE DATABASE USER ADDRESS METHOD - local all all trust - host all all 127.0.0.1/32 trust - host all all ::1/128 trust - ''; - - - services.mysql = { - enable = true; - package = pkgs.mariadb; - }; - - - services.postgresql.initialScript = pkgs.writeText "begin-init.sql" '' - CREATE ROLE "ncusr" WITH LOGIN PASSWORD '${personalization.nextclouddb}'; - CREATE DATABASE "nextclouddb" WITH OWNER "ncusr" - TEMPLATE template0 - LC_COLLATE = "C" - LC_CTYPE = "C"; - - - CREATE ROLE "matrix-synapse" WITH LOGIN PASSWORD '${personalization.matrixdb}'; - CREATE DATABASE "matrix-synapse" WITH OWNER "matrix-synapse" - TEMPLATE template0 - LC_COLLATE = "C" - LC_CTYPE = "C"; - - '' - ; - - services.mysql.initialScript = pkgs.writeText "wordpress-init.sql" '' - CREATE DATABASE wordpressdb; - CREATE USER 'wpusr'@'localhost' IDENTIFIED BY '${personalization.wordpressdb}'; - GRANT ALL ON wordpressdb.* TO 'wpusr'@'localhost'; - FLUSH PRIVILEGES; - '' - ; - - ####### KEEP AWAKE for DISPLAY and HEADLESS ####### - services.displayManager.gdm.autoSuspend = false; - - - ####### BACKUP TO INTERNAL DRIVE ####### - services.rsnapshot = { - enable = true; - extraConfig = '' ->>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 snapshot_root /run/media/Second_Drive/BTCEcoandBackup/NixOS_Snapshot_Backup retain hourly 5 retain daily 5 @@ -460,7 +147,6 @@ backup /home/ localhost/ backup /var/lib/ localhost/ backup /etc/nixos/ localhost/ backup /etc/nix-bitcoin-secrets/ localhost/ -<<<<<<< HEAD ''; cronIntervals = { daily = "50 21 * * *"; @@ -502,83 +188,4 @@ backup /etc/nix-bitcoin-secrets/ localhost/ nix.gc = { automatic = true; dates = "weekly"; options = "--delete-older-than 7d"; }; system.stateVersion = "22.05"; -======= - ''; - cronIntervals = { - daily = "50 21 * * *"; - hourly = "0 * * * *"; - }; - }; - - - ####### CRON ####### - services.cron = { - enable = true; - systemCronJobs = [ - - "*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php" - "*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/njalla/njalla.sh" - "*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/external_ip/external_ip.sh" - "0 0 * * 0 docker-user yes | /run/current-system/sw/bin/docker system prune -a" - - ]; - }; - - - ####### TOR ####### - services.tor = { - enable = true; - client.enable = true; - torsocks.enable = true; - }; - - services.privoxy.enableTor = true; - - - ####### Enable the SSH ####### - services.openssh = { - enable = true; - settings = { - PasswordAuthentication = false; - KbdInteractiveAuthentication = false; - PermitRootLogin = "yes"; - }; - }; - - - #######FailtoBan####### - services.fail2ban = { - enable = true; - ignoreIP = [ - "127.0.0.0/8" - "10.0.0.0/8" - "172.16.0.0/12" - "192.168.0.0/16" - "8.8.8.8" - ]; - }; - - - ####### Open ports in the firewall ####### - networking.firewall.allowedTCPPorts = [ 80 443 5349 8448 3051 ]; - networking.firewall.allowedUDPPorts = [ 80 443 5349 8448 3051 ]; - - networking.firewall.allowedUDPPortRanges = [ - { from=49152; to=65535; } # TURN relay - ]; - - networking.firewall.enable = true; - - - ####### AUTO COLLECT GARABAGE ####### - nix.gc = { - automatic = true; - dates = "weekly"; - options = "--delete-older-than 7d"; - }; - - - system.stateVersion = "22.05"; - ->>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 } -- 2.53.0 From 46ab127ea0c327e71733b6f4ad7ce8467f304049 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 15:07:22 -0500 Subject: [PATCH 005/857] updated haven --- modules/haven.nix | 82 ++++++----------------------------------------- 1 file changed, 10 insertions(+), 72 deletions(-) diff --git a/modules/haven.nix b/modules/haven.nix index 25b5708..9f5cf05 100755 --- a/modules/haven.nix +++ b/modules/haven.nix @@ -1,18 +1,11 @@ { config, pkgs, lib, ... }: let -<<<<<<< HEAD -======= - personalization = import ./personalization.nix; ->>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 npub = config.sovran_systemsOS.nostr_npub; in lib.mkIf (config.sovran_systemsOS.features.haven && npub != "") { -<<<<<<< HEAD - # ── Caddy vhost is now handled centrally in caddy.nix ───── - # ── Generate Haven runtime config from domain files ─────── systemd.services.haven-runtime-config = { description = "Generate Haven runtime config from domain files"; @@ -30,33 +23,27 @@ lib.mkIf (config.sovran_systemsOS.features.haven && npub != "") { mkdir -p /run/haven cat > /run/haven/runtime.env <>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 services.haven = { enable = true; settings = { OWNER_NPUB = npub; -<<<<<<< HEAD # RELAY_URL injected at runtime via EnvironmentFile -======= - RELAY_URL = personalization.haven_url; ->>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 RELAY_PORT = 3355; RELAY_BIND_ADDRESS = "0.0.0.0"; @@ -64,7 +51,6 @@ lib.mkIf (config.sovran_systemsOS.features.haven && npub != "") { LMDB_MAPSIZE = 3000000000; BLOSSOM_PATH = "blossom/"; -<<<<<<< HEAD # Relay names/descriptions injected at runtime via EnvironmentFile PRIVATE_RELAY_NPUB = npub; CHAT_RELAY_NPUB = npub; @@ -72,27 +58,6 @@ lib.mkIf (config.sovran_systemsOS.features.haven && npub != "") { INBOX_PULL_INTERVAL_SECONDS = 600; -======= - PRIVATE_RELAY_NAME = "${personalization.haven_url} private relay"; - PRIVATE_RELAY_NPUB = npub; - PRIVATE_RELAY_DESCRIPTION = "The Relay From Sovran Systems"; - - CHAT_RELAY_NAME = "${personalization.haven_url} chat relay"; - CHAT_RELAY_NPUB = npub; - CHAT_RELAY_DESCRIPTION = "a relay for private chats"; - - OUTBOX_RELAY_NAME = "${personalization.haven_url} outbox relay"; - OUTBOX_RELAY_NPUB = npub; - OUTBOX_RELAY_DESCRIPTION = "a relay and Blossom server for public messages and media"; - - INBOX_RELAY_NAME = "${personalization.haven_url} inbox relay"; - INBOX_RELAY_NPUB = npub; - INBOX_RELAY_DESCRIPTION = "send your interactions with my notes here"; - - INBOX_PULL_INTERVAL_SECONDS = 600; - - # ... all your rate limiter and WOT settings unchanged ... ->>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 PRIVATE_RELAY_EVENT_IP_LIMITER_TOKENS_PER_INTERVAL = 50; PRIVATE_RELAY_EVENT_IP_LIMITER_INTERVAL = 1; PRIVATE_RELAY_EVENT_IP_LIMITER_MAX_TOKENS = 100; @@ -157,13 +122,10 @@ lib.mkIf (config.sovran_systemsOS.features.haven && npub != "") { ]; }; -<<<<<<< HEAD systemd.services.haven.serviceConfig.EnvironmentFile = [ "/run/haven/runtime.env" ]; -======= ->>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 systemd.tmpfiles.rules = [ "d /var/lib/haven 0750 haven haven -" ]; @@ -189,30 +151,6 @@ lib.mkIf (config.sovran_systemsOS.features.haven && npub != "") { ''; }; -<<<<<<< HEAD systemd.services.haven.after = [ "haven-whitelist-setup.service" "haven-runtime-config.service" ]; systemd.services.haven.wants = [ "haven-whitelist-setup.service" "haven-runtime-config.service" ]; -======= - systemd.services.haven.after = [ "haven-whitelist-setup.service" ]; - systemd.services.haven.wants = [ "haven-whitelist-setup.service" ]; - - services.caddy.virtualHosts = { - "${personalization.haven_url}" = { - extraConfig = '' - reverse_proxy localhost:3355 { - header_up Host {host} - header_up X-Real-IP {remote_host} - header_up X-Forwarded-For {remote_host} - header_up X-Forwarded-Proto {scheme} - transport http { - versions 1.1 - } - } - request_body { - max_size 100MB - } - ''; - }; - }; ->>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 } -- 2.53.0 From 424962412f589cc3a11b80aeabe6c30bfa411c04 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 15:13:15 -0500 Subject: [PATCH 006/857] fixed synce errors --- modules/bip110.nix | 8 -- modules/coturn.nix | 54 ------------- modules/element-calling.nix | 157 +++++++++++------------------------- 3 files changed, 48 insertions(+), 171 deletions(-) delete mode 100755 modules/coturn.nix diff --git a/modules/bip110.nix b/modules/bip110.nix index 104a797..e229a80 100755 --- a/modules/bip110.nix +++ b/modules/bip110.nix @@ -4,20 +4,12 @@ let cfg = config.sovran_systemsOS; in { -<<<<<<< HEAD -======= - # āœ… Option definition ->>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 options.sovran_systemsOS.packages.bip110 = lib.mkOption { type = lib.types.nullOr lib.types.package; default = null; description = "BIP110 Bitcoin package"; }; -<<<<<<< HEAD -======= - # āœ… Implementation ->>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 config = lib.mkIf ( cfg.features.bip110 && cfg.packages.bip110 != null diff --git a/modules/coturn.nix b/modules/coturn.nix deleted file mode 100755 index fac4c86..0000000 --- a/modules/coturn.nix +++ /dev/null @@ -1,54 +0,0 @@ -{config, pkgs, lib, ...}: - -let - personalization = import ./personalization.nix; - - in -lib.mkIf config.sovran_systemsOS.features.coturn { - - systemd.services.coturn-helper = { - - script = '' - - systemctl restart coturn - - ''; - - unitConfig = { - Type = "simple"; - After = "btcpayserver.service"; - Requires = "network-online.target"; - }; - - serviceConfig = { - RemainAfterExit = "yes"; - Type = "oneshot"; - }; - - wantedBy = [ "multi-user.target" ]; - - }; - - - services.coturn = { - - enable = true; - use-auth-secret = true; - static-auth-secret = "${personalization.coturn_static_auth_secret}"; - realm = personalization.matrix_url; - cert = "/var/lib/coturn/${personalization.matrix_url}.crt.pem"; - pkey = "/var/lib/coturn/${personalization.matrix_url}.key.pem"; - min-port = 49152; - max-port = 65535; - listening-port = 5349; - no-cli = true; - extraConfig = '' - verbose - external-ip=${personalization.external_ip_secret} - stale-nonce - fingerprint - ''; - - }; - -} diff --git a/modules/element-calling.nix b/modules/element-calling.nix index 492e9d7..67859a5 100755 --- a/modules/element-calling.nix +++ b/modules/element-calling.nix @@ -1,10 +1,6 @@ { config, pkgs, lib, ... }: let -<<<<<<< HEAD -======= - personalization = import ./personalization.nix; ->>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 livekitKeyFile = "/var/lib/livekit/livekit_keyFile"; in @@ -19,10 +15,6 @@ lib.mkIf config.sovran_systemsOS.features.element-calling { description = "Generate LiveKit key file if missing"; wantedBy = [ "multi-user.target" ]; before = [ "livekit.service" "lk-jwt-service.service" ]; -<<<<<<< HEAD -======= - requires = []; ->>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 serviceConfig = { Type = "oneshot"; RemainAfterExit = true; @@ -47,7 +39,6 @@ lib.mkIf config.sovran_systemsOS.features.element-calling { systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ]; systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ]; -<<<<<<< HEAD ####### CADDY SNIPPET — written to /run/caddy for caddy.nix to pick up ####### systemd.services.element-calling-caddy-config = { description = "Generate Element Calling Caddy config snippet"; @@ -66,51 +57,35 @@ lib.mkIf config.sovran_systemsOS.features.element-calling { mkdir -p /run/caddy cat > /run/caddy/element-calling.snippet <>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 - reverse_proxy /_matrix/* http://localhost:8008 - reverse_proxy /_synapse/client/* http://localhost:8008 - header /.well-known/matrix/* Content-Type "application/json" - header /.well-known/matrix/* Access-Control-Allow-Origin "*" - header /.well-known/matrix/* Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" - header /.well-known/matrix/* Access-Control-Allow-Headers "X-Requested-With, Content-Type, Authorization" -<<<<<<< HEAD - respond /.well-known/matrix/client \`{ "m.homeserver": {"base_url": "https://$MATRIX" }, "org.matrix.msc4143.rtc_foci": [{ "type":"livekit", "livekit_service_url":"https://$ELEMENT_CALLING/livekit/jwt" }] }\` - } +$MATRIX { + reverse_proxy /_matrix/* http://localhost:8008 + reverse_proxy /_synapse/client/* http://localhost:8008 + header /.well-known/matrix/* Content-Type "application/json" + header /.well-known/matrix/* Access-Control-Allow-Origin "*" + header /.well-known/matrix/* Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" + header /.well-known/matrix/* Access-Control-Allow-Headers "X-Requested-With, Content-Type, Authorization" + respond /.well-known/matrix/client \`{ "m.homeserver": {"base_url": "https://$MATRIX" }, "org.matrix.msc4143.rtc_foci": [{ "type":"livekit", "livekit_service_url":"https://$ELEMENT_CALLING/livekit/jwt" }] }\` +} - $MATRIX:8448 { - reverse_proxy http://localhost:8008 - } +$MATRIX:8448 { + reverse_proxy http://localhost:8008 +} - $ELEMENT_CALLING { -======= - respond /.well-known/matrix/client `{ "m.homeserver": {"base_url": "https://${personalization.matrix_url}" }, "org.matrix.msc4143.rtc_foci": [{ "type":"livekit", "livekit_service_url":"https://${personalization.element-calling_url}/livekit/jwt" }] }` - ''; - }; - - "${personalization.element-calling_url}" = { - extraConfig = '' ->>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 - handle /livekit/jwt/sfu/get { - uri strip_prefix /livekit/jwt - reverse_proxy [::1]:8073 { - header_up Host {host} - header_up X-Forwarded-Server {host} - header_up X-Real-IP {remote_host} - header_up X-Forwarded-For {remote_host} - } - } - handle { - reverse_proxy localhost:7880 - } -<<<<<<< HEAD - } - EOF +$ELEMENT_CALLING { + handle /livekit/jwt/sfu/get { + uri strip_prefix /livekit/jwt + reverse_proxy [::1]:8073 { + header_up Host {host} + header_up X-Forwarded-Server {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + } + } + handle { + reverse_proxy localhost:7880 + } +} +EOF ''; }; @@ -132,18 +107,14 @@ lib.mkIf config.sovran_systemsOS.features.element-calling { mkdir -p /run/livekit cat > /run/livekit/runtime-config.yaml <>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 }; ####### LIVEKIT SERVICE ####### @@ -157,16 +128,8 @@ lib.mkIf config.sovran_systemsOS.features.element-calling { room.auto_create = false; turn = { enabled = true; -<<<<<<< HEAD tls_port = 5349; udp_port = 3478; -======= - domain = "${personalization.matrix_url}"; - tls_port = 5349; - udp_port = 3478; - cert_file = "/var/lib/livekit/${personalization.matrix_url}.crt"; - key_file = "/var/lib/livekit/${personalization.matrix_url}.key"; ->>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 }; }; }; @@ -177,7 +140,6 @@ lib.mkIf config.sovran_systemsOS.features.element-calling { ]; ####### JWT SERVICE ####### -<<<<<<< HEAD systemd.services.lk-jwt-service-runtime-config = { description = "Generate lk-jwt-service runtime config from domain files"; before = [ "lk-jwt-service.service" ]; @@ -195,8 +157,8 @@ lib.mkIf config.sovran_systemsOS.features.element-calling { mkdir -p /run/lk-jwt-service cat > /run/lk-jwt-service/env < /run/matrix-synapse/element-calling-config.yaml <>>>>>> 5bee5ad99bb7890df011d88e9928b6944c3565f8 url_preview_enabled = true; group_unread_count_by_room = false; encryption_enabled_by_default_for_room_type = "invite"; -- 2.53.0 From 5e9a01e0616bbf3d24b2a8ef92a1370de0fb95aa Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 15:16:50 -0500 Subject: [PATCH 007/857] fixed bip110 declar --- modules/core/roles.nix | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/modules/core/roles.nix b/modules/core/roles.nix index 9a5ba31..a7551ce 100755 --- a/modules/core/roles.nix +++ b/modules/core/roles.nix @@ -55,10 +55,6 @@ default = ""; description = "Nostr public key (npub1...) for Haven relay"; }; - - packages.bip110 = lib.mkOption { - type = lib.types.package; - description = "BIP-110 bitcoind-knots package"; - }; }; + } -- 2.53.0 From aaed7170f5502d51eb0f473d0525e8d3cebf8951 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 15:21:15 -0500 Subject: [PATCH 008/857] fixed caddy --- modules/core/caddy.nix | 66 ++++++++++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/modules/core/caddy.nix b/modules/core/caddy.nix index 2c20efc..0558a0f 100755 --- a/modules/core/caddy.nix +++ b/modules/core/caddy.nix @@ -20,13 +20,21 @@ }; path = [ pkgs.coreutils ]; script = '' - MATRIX=$(cat /var/lib/domains/matrix) - WORDPRESS=$(cat /var/lib/domains/wordpress) - NEXTCLOUD=$(cat /var/lib/domains/nextcloud) - BTCPAY=$(cat /var/lib/domains/btcpayserver) - VAULTWARDEN=$(cat /var/lib/domains/vaultwarden) - HAVEN=$(cat /var/lib/domains/haven) - ACME_EMAIL=$(cat /var/lib/domains/sslemail) + read_domain() { + if [ -f "/var/lib/domains/$1" ]; then + cat "/var/lib/domains/$1" + else + echo "" + fi + } + + MATRIX=$(read_domain matrix) + WORDPRESS=$(read_domain wordpress) + NEXTCLOUD=$(read_domain nextcloud) + BTCPAY=$(read_domain btcpayserver) + VAULTWARDEN=$(read_domain vaultwarden) + HAVEN=$(read_domain haven) + ACME_EMAIL=$(read_domain sslemail) # Start with global config cat > /run/caddy/Caddyfile <> /run/caddy/Caddyfile - else - # Fallback: basic Matrix vhosts without element-calling - cat >> /run/caddy/Caddyfile <> /run/caddy/Caddyfile + else + cat >> /run/caddy/Caddyfile <> /run/caddy/Caddyfile <> /run/caddy/Caddyfile <> /run/caddy/Caddyfile <> /run/caddy/Caddyfile <> /run/caddy/Caddyfile <> /run/caddy/Caddyfile < Date: Fri, 27 Mar 2026 15:42:21 -0500 Subject: [PATCH 009/857] fixed roles --- modules/core/caddy.nix | 9 ++++++++- modules/core/role-logic.nix | 34 +++++++++++++++++++++++----------- modules/core/roles.nix | 10 +++++++++- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/modules/core/caddy.nix b/modules/core/caddy.nix index 0558a0f..b7d885c 100755 --- a/modules/core/caddy.nix +++ b/modules/core/caddy.nix @@ -1,5 +1,8 @@ { config, pkgs, lib, ... }: +let + exposeBtcpay = config.sovran_systemsOS.web.btcpayserver; +in { services.caddy = { enable = true; @@ -95,7 +98,8 @@ $NEXTCLOUD { EOF fi - # ── BTCPay ────────────────────────────────────── + # ── BTCPay (only if web exposure is enabled) ──── + ${if exposeBtcpay then '' if [ -n "$BTCPAY" ]; then cat >> /run/caddy/Caddyfile < Date: Fri, 27 Mar 2026 15:45:03 -0500 Subject: [PATCH 010/857] Update Node Role --- modules/core/role-logic.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/core/role-logic.nix b/modules/core/role-logic.nix index 115f806..25e23e7 100755 --- a/modules/core/role-logic.nix +++ b/modules/core/role-logic.nix @@ -28,6 +28,7 @@ (lib.mkIf config.sovran_systemsOS.roles.node { sovran_systemsOS.services = { bitcoin = lib.mkDefault true; + bip110 = lib.mkDefault true; synapse = lib.mkDefault false; vaultwarden = lib.mkDefault false; wordpress = lib.mkDefault false; -- 2.53.0 From 49e70d0ad8f7f6990126a7327273cc7063093935 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 15:46:19 -0500 Subject: [PATCH 011/857] Update Node Role --- modules/core/role-logic.nix | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/core/role-logic.nix b/modules/core/role-logic.nix index 25e23e7..b8562d7 100755 --- a/modules/core/role-logic.nix +++ b/modules/core/role-logic.nix @@ -24,18 +24,20 @@ }) # ── Bitcoin Node Only Role ──────────────────────────────── - # Bitcoin ecosystem + mempool, BTCPay runs but not exposed via Caddy + # Bitcoin ecosystem + mempool + bip110, BTCPay runs but not exposed via Caddy (lib.mkIf config.sovran_systemsOS.roles.node { sovran_systemsOS.services = { bitcoin = lib.mkDefault true; - bip110 = lib.mkDefault true; synapse = lib.mkDefault false; vaultwarden = lib.mkDefault false; wordpress = lib.mkDefault false; nextcloud = lib.mkDefault false; }; - sovran_systemsOS.features.mempool = lib.mkDefault true; + sovran_systemsOS.features = { + mempool = lib.mkDefault true; + bip110 = lib.mkDefault true; + }; sovran_systemsOS.web.btcpayserver = lib.mkDefault false; }) -- 2.53.0 From be984d0293fef64ae21ee37745cfdae302a721d2 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 15:53:24 -0500 Subject: [PATCH 012/857] removed x11 --- configuration.nix | 4 +--- modules/core/role-logic.nix | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/configuration.nix b/configuration.nix index 938a1d3..19bd4ac 100644 --- a/configuration.nix +++ b/configuration.nix @@ -41,11 +41,10 @@ i18n.defaultLocale = "en_US.UTF-8"; # ── Desktop ──────────────────────────────────────────────── - services.xserver.enable = true; services.displayManager.gdm.enable = true; services.displayManager.gdm.autoSuspend = false; + services.displayManager.gdm.wayland = true; services.desktopManager.gnome.enable = true; - services.xserver.xkb = { layout = "us"; variant = ""; }; services.printing.enable = true; systemd.enableEmergencyMode = false; @@ -166,7 +165,6 @@ backup /etc/nix-bitcoin-secrets/ localhost/ # ── Tor ──────────────────────────────────────────────────── services.tor = { enable = true; client.enable = true; torsocks.enable = true; }; - services.privoxy.enableTor = true; # ── SSH ──────────────────────────────────────────────────── services.openssh = { diff --git a/modules/core/role-logic.nix b/modules/core/role-logic.nix index b8562d7..ad14f81 100755 --- a/modules/core/role-logic.nix +++ b/modules/core/role-logic.nix @@ -9,7 +9,6 @@ # ── Desktop Only Role ───────────────────────────────────── (lib.mkIf config.sovran_systemsOS.roles.desktop { - services.xserver.enable = true; services.desktopManager.gnome.enable = true; sovran_systemsOS.services = { -- 2.53.0 From 143a6a07ff7c0ac0e8a06eaf194c1e052c975560 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 15:57:35 -0500 Subject: [PATCH 013/857] formated flake.nix --- flake.nix | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/flake.nix b/flake.nix index f686d46..737def0 100755 --- a/flake.nix +++ b/flake.nix @@ -2,71 +2,49 @@ description = "The Ultimate Sovran_SystemsOS Configuration from Sovran Systems"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - - nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release"; - + nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release"; agenix.url = "github:ryantm/agenix"; - - agenix.inputs.darwin.follows = ""; - + agenix.inputs.darwin.follows = ""; nixvim.url = "github:nix-community/nixvim"; - btc-clients.url = "github:emmanuelrosa/btc-clients-nix"; - nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-24.11"; - bip110.url = "github:emmanuelrosa/bitcoin-knots-bip-110-nix"; - }; outputs = { self, nixpkgs, nix-bitcoin, nixvim, agenix, btc-clients, nixpkgs-stable, bip110, ... }: let - overlay-stable = final: prev: { - stable = import nixpkgs-stable { system = prev.stdenv.hostPlatform.system; config.allowUnfree = true; - }; - }; - in - { nixosConfigurations.nixos = nixpkgs.lib.nixosSystem { - modules = [ { nixpkgs.hostPlatform = "x86_64-linux"; } ]; - }; - nixosModules.Sovran_SystemsOS = { pkgs, lib, config, ... }: { - imports = [ ({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; }) - ./configuration.nix nix-bitcoin.nixosModules.default agenix.nixosModules.default nixvim.nixosModules.nixvim ]; - config = { environment.systemPackages = with pkgs; [ btc-clients.packages.${pkgs.system}.bisq btc-clients.packages.${pkgs.system}.bisq2 btc-clients.packages.${pkgs.system}.sparrow - ]; - + ] sovran_systemsOS.packages.bip110 = bip110.packages.${pkgs.system}.bitcoind-knots-bip-110; }; }; -- 2.53.0 From 4b8a90ff3869db5c18f40f0320a6745a4d225956 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 15:59:04 -0500 Subject: [PATCH 014/857] removed unecessary files --- DIY Install Sovran_SystemsOS.md | 251 ---------- README.md | 193 -------- for_new_sovran_pros/Sovran_SystemsOS-Desktop | 472 ------------------- for_new_sovran_pros/flake.nix | 30 -- for_new_sovran_pros/psp.sh | 89 ---- for_new_sovran_pros/psp_physical_ram.sh | 85 ---- for_new_sovran_pros/sdpsp.sh | 51 -- for_new_sovran_pros/sp.sh | 406 ---------------- 8 files changed, 1577 deletions(-) delete mode 100755 DIY Install Sovran_SystemsOS.md delete mode 100755 README.md delete mode 100644 for_new_sovran_pros/Sovran_SystemsOS-Desktop delete mode 100755 for_new_sovran_pros/flake.nix delete mode 100755 for_new_sovran_pros/psp.sh delete mode 100755 for_new_sovran_pros/psp_physical_ram.sh delete mode 100755 for_new_sovran_pros/sdpsp.sh delete mode 100755 for_new_sovran_pros/sp.sh diff --git a/DIY Install Sovran_SystemsOS.md b/DIY Install Sovran_SystemsOS.md deleted file mode 100755 index 958dd70..0000000 --- a/DIY Install Sovran_SystemsOS.md +++ /dev/null @@ -1,251 +0,0 @@ -# Sovran Systems offers limited support of a DIY install of Sovran_SystemsOS. You can reach out to others in the matrix room https://matrix.to/#/#DIY_Sovran_SystemsOS:anarchyislove.xyz. - -# These instructions will change over time due to new software development and Sovran Systems creator finding more efficient ways to install Sovran_SystemsOS. 9-12-2024 - -# Also, to fully complete the install, the Bitcoin blockchain will have to download. This could take up to 3 weeks. - -# Lastly, if you gift to the computer movement to receive a Sovran Pro, you do not have to do any of this. It is all done for you. On top of that, the Bitcoin blockchain is already installed. šŸ˜‰ - -### Requirements - -1. First computer with Linux OS already installed (like NixOS, Ubuntu, Arch, etc.) to download and burn the NixOS image to a USB thumb drive. -2. USB thumb drive 16GB or larger -3. Second computer that is ready to have Sovran_SystemsOS installed (Safe Boot turned off in the UEFI[BIOS] and be prepared for the entire storage drive to be ERASED!). -4. Second computer needs the following hardware specs: - -- Intel or AMD processor (NO ARM processors) -- 32GB of RAM or Larger -- First main NVME internal drive to install Sovran_SystemsOS (500GB or larger) -- Second NVME internal drive to store the Bitcoin blockchain and the automatic backups (NVME 4TB or larger) -- Also, the second NVME internal drive needs to be installed FIRST into a USB enclosure. You will need a NVME USB enclosure. The USB enclosure will be plugged into the first Linux machine. - -5. Working Internet connection for both computers -6. Personalized Domain names already purchased from Njal.la. See the explanation here: https://sovransystems.com/how-to-setup/ -7. Your Router with ports open (Port Forwarding) to your second machine's internal IP address. This will usually be `192.168.1.(some number)` You will complete this at the end. - -- Port 80 -- Port 443 -- Port 22 -- Port 5349 -- Port 8448 - -## Preparing the Second Internal Drive - -1. Install the second NVME internal drive into the USB enclosure, NOT into the Second computer yet. -2. Plug in the USB enclosure into the first computer with Linux OS already installed into one of its available USB ports. -3. **Please Make Sure You Know The Existing Storage Names On This First Linux Computer. If You Run The Script Below And You Do Not Know What You Are Doing, You Could Potentially Erase Your First Linux Computer's Data. I Am Not Responsibly For Your Errors** -4. Open a terminal in the first Linux computer and log in as root. -5. Type in or copy and paste: - -```bash -wget https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/sdpsp.sh -``` - -then press enter. - -6. Now, type `bash sdpsp.sh` then press enter. -7. Then the screen will ask for "what block..." which will be the drive in the list that is not mounted, which will be the drive you just plugged in. It might be labeled `sda`, or `sdb` etc. Type in the drive name and press `enter`. -8. Then the screen will ask for "what partition...,"which will be whatever you typed into the first prompt, but with a "1" on it. For example, `sda1` or `sdb1`. Type it into the terminal and press `enter`. -9. Since the script is made to copy the blockchain from another Sovran Pro that already has the full blockchain installed it will throw an error. However, it should complete the setup just fine. -10. Once complete, remove the second drive from the USB enclosure and install it into your second computer in which you are installing Sovran_SystemsOS. - -## Preparing the First Main Internal Drive - -### Procedure One - Installing base NixOS - - 1. Still on the first computer with Linux OS already installed, download the latest NixOS minimal (64-bit Intel/AMD) image from here: https://nixos.org/download - 2. Burn that ISO image onto the USB thumb drive. - 3. Insert the newly created USB thumb drive with the ISO image into the second computer (the one you are installing Sovran_SystemsOS). - 4. Reboot the second computer while the USB thumb drive is inserted and boot into the USB thumb drive. This may require you to press the F7 or F12 key at boot. (Also, make sure the second computer has "safe boot" turned off in the UEFI[BIOS]). - 5. Proceed with the NixOS boot menu - 6. Once at the command prompt type in `sudo su` to move to the root user - 7. Once logged into the root user type in `passwd` then set the root user password to `a` - 8. Type in `ip a` to get your internal IP address. It will usually be `192.1681.1.(somenumber)` make a note of this IP as you will need it later. - 9. Now, that you are logged in as the root user type in or copy and paste: - - ```bash - curl https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/psp_physical_ram.sh -o psp_physical_ram.sh - ``` - - the command to install the base NixOS and press enter. -10. Now, type `bash psp_physical_ram.sh` then press enter. -11. The script will ask for name of first main internal drive. It usually will be `nvme0n1`. Basically, it will be the drive without any data and it will not be mounted per the list on the screen. Type in the name and press enter on the keyboard. -12. Then the script will ask for the 'Boot' partition. It will be the SMALLER partition and usually named `nvme0n1p1`. Type in the name and press enter on the keyboard. -13. Then it will ask for the 'Primary' partition. It will be the LARGER partition usually named `nvme0n1p2`. Type in the name and press enter on the keyboard. -14. The script will finish installing the base NixOS. At the end it will ask for a root password. Type `a` and press enter and type `a` again to confirm and press enter. -15. The machine will reboot into a very basic install of NixOS command prompt. -16. Remove the USB thumb drive from the second computer. - - -### Procedure Two - Opening The Ports on Your Router - Internal IP - -1. Go to port forwarding on your router and open the above mentioned ports to the internal IP (the one you found above) of your new Sovran_SystemsOS machine - - -### Procedure Three - Installing Sovran_SystemsOS - - -1. Now at the basic install of NixOS from Procedure One, type `root` to log into root and type the password `a` when asked then press enter. -2. Now you are logged in as `root`. -3. Now type in or copy and paste: - - ```bash - wget https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/sp.sh - ``` - - then press enter. -4. Type in `bash sp.sh` then press enter. -5. Next the script will ask for your domain names from Njal.la. Type them in the corresponding prompts and then press enter for each prompt. -6. Then it will ask for an email for the SSL certificates. Type it in and press enter. -7. The script is long so it will take some time. -8. It will finish by stating `All Finished! Please Reboot then Enjoy your New Sovran Pro!` -9. Press the power button on the machine for it to turn off THEN press it again to power the machine - -## Finishing the Install - - -### Putting the External IP of your New DIY Sovran Pro into your new domain names you just bought at [njal.la](https://njal.la) - -1. On your New DIY Sovran Pro, log into your [njal.la](https://njal.la) account -2. Make a "dynamic" record for each subdomain -3. Njal.la will now display a `curl` command for each sub-domain. -4. Open the `Terminal` on your New DIY Sovran Pro and type in or copy and paste: - - ```bash - ssh root@localhost - ``` - It will as you for a password which is `gosovransystems` as this is the default temporary password from Sovran Systems. - - Now you will be logged in as root. - -5. Now type: - - `nano /var/lib/njalla/njalla.sh` - - and press enter. - - -3. Paste the `curl` commands from njal.la's website for each sub-domain. Each `curl` command gets a new line. For example: - - ```bash - ... - curl "https://njal.la/update/?h=test.testsovransystems.com&k=8n7vk3afj-jkyg37&a=${IP}" - curl "https://njal.la/update/?h=zap.testsovransystems.com&k=8no*73afj-jkygi2ea=${IP}" - ... - - ``` - ##### Make sure the default `&auto` from njal.la is replaced by `&a=${IP}` at the end of each `curl` command in the `/var/lib/njalla/njalla.sh` as in the example above. - -7. After you have added all the sub-domins into `/var/lib/njalla/njalla.sh`, press `ctrl + s` then press `ctrl + x` to save and exit `nano`. - -8. Close the `Terminal`. - -### Setting the Desktop - -1. Open the `Terminal` again and type in: `dconf load / < /home/free/Downloads/Sovran_SystemsOS-Desktop`. Do NOT log in as root. - -2. Close the `Terminal`. - -### Setting Up Nextcloud and Wordpress - -#### Nextcloud - -1. Open a web browser and navigate to your domain name you bought from [njal.la](https://njal.la) for example "cloud.myfreedomsite.com" you attributed to your Nextcloud instance. -2. Nextcloud will as you to set up a new account to be used as a log in. Do so. -3. Nextcloud will also ask you where you want the data directory. Type in `/var/lib/nextcloud/data` -4. Nextcloud will ask you to connect the database: - 1. Choose `Postgresql` from the optoins. - 2. Database username is `ncusr` - 3. Database name is `nextclouddb` - 4. Database password is found by doing this: - 1. Open the `Terminal` again, then type in or copy and paste: - - ```bash - ssh root@localhost - ``` - Now you will be logged in as root. - - 2. Now type: - - `cat /var/lib/secrets/nextclouddb` - - and press enter. - - 3. Your database password will be displayed in the `Terminal` window. - 4. Type that into the password field - -5. Now, press `Install` on the Nextcloud website and Nextcloud will be installed. It will take a few minutes. Follow the on screen prompts. - -#### Wordpress - -1. Open a web browser and navigate to your domain name you bought from [njal.la](https://njal.la) for example "myfreedomsite.com" you attributed to your Wordpress instance. -2. Wordpress will ask you to connect the database: - 1. Database username is `wpusr` - 2. Database name is `wordpressdb` - 4. Database password is found by doing this: - 1. Open the `Terminal` again, then type in or copy and paste: - - ```bash - ssh root@localhost - ``` - Now you will be logged in as root. - - 2. Now type: - - `cat /var/lib/secrets/wordpressdb` - - and press enter. - - 3. Your database password will be displayed in the `Terminal` window. - 4. Type that into the password field - -5. Now, press `Install` on the Wordpress website and Wordpress will be installed. It will take a few minutes. Follow the on screen prompts. - -### Final Install for Coturn, Flatpak, and Nextcloud - -1. Staying in the `Terminal` type in or copy and paste: - - ```bash - sed -i '$e cat /var/lib/nextcloudaddition/nextcloudaddition' /var/lib/www/nextcloud/config/config.php - - chown caddy:php /var/lib/www -R - - chmod 700 /var/lib/www R - ``` - and press enter. - -2. Now type or copy and paste: - - ```bash - set DOMAIN $(cat /var/lib/domains/matrix) && cp -n /var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/{$DOMAIN}/{$DOMAIN}.crt /var/lib/coturn/{$DOMAIN}.crt.pem && cp -n /var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/{$DOMAIN}/{$DOMAIN}.key /var/lib/coturn/{$DOMAIN}.key.pem && chown turnserver:turnserver /var/lib/coturn -R && chmod 770 /var/lib/coturn -R && systemctl restart coturn - ``` - and press enter. - -3. Now type or copy and paste: - - ```bash - sudo -u free flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo - ``` - and press enter. - - It will ask for your `Administrator` password and to get the password open a new `Terminal` window and type: - - ```bash - ssh root@localhost - ``` - press enter. - - Now you will be logged in as root. - - Now type: - - ```bash - cat /var/lib/secrets/main - ``` - Then the `Administrator`'s password will be displayed. Copy and paste the password into the other `Terminal` window that is open. Then press enter. - - Now you can close the `Terminal`. - -### Everything now will be installed regarding Sovran_SystemsOS. The remaining setup will be only for the front-end user account creations for BTCpayserver, Vaultwarden, connecting the node to Sparrow wallet and Bisq. - -### Congratulations! šŸŽ‰ diff --git a/README.md b/README.md deleted file mode 100755 index 7456bf4..0000000 --- a/README.md +++ /dev/null @@ -1,193 +0,0 @@ -
-
- -

- -

- -
-
-
- -# Sovran_SystemsOS - -### The Officaly Repository of Sovran_SystemsOS and the Sovran Pro - -**A declarative, self-hosted server and desktop operating system built on NixOS by [Sovran Systems](https://sovransystems.com)** - ---- - -## Overview - -Sovran_SystemsOS is a fully integrated NixOS configuration that transforms a single machine into a personal cloud, communications hub, Bitcoin node, web server, and **daily-use desktop** — all managed declaratively. - -**It comes preinstalled on The Sovran Pro** - -Every service is pre-wired: reverse proxy routing, database initialization, firewall rules, automated backups, and inter-service communication are handled out of the box. Moreover, you can activate the other custom packages; the system does the rest. - ---- - -## Architecture - -Sovran_SystemsOS is structured as a set of NixOS modules exposed via a flake. A remote machine consumes the flake and selectively enables features through a simple configuration interface. - -``` -Repository Main Flake (flake.nix) - └── Sovran_SystemsOS flake (nixosModules.Sovran_SystemsOS) - ā”œā”€ā”€ configuration.nix/ # Base system - │ ā”œā”€ā”€ gnome Desktop # Gnome Desktop Interface - │ ā”œā”€ā”€ caddy # Reverse proxy + HTTPS - │ ā”œā”€ā”€ nextcloud # Cloud storage - │ ā”œā”€ā”€ wordpress # CMS / publishing - │ ā”œā”€ā”€ element # Matrix Synapse via Element Messaging App - ā”œā”€ā”€ modules/ - │ ā”œā”€ā”€ bitcoinecosystem.nix # Bitcoin Core / Knots / BTCPay Server / Bitcoin Lightning - │ ā”œā”€ā”€ bip110.nix # Bip110 Node Consensus Policy - │ ā”œā”€ā”€ element-calling.nix # Matrix Synapse via Element + Element Voice and Video Calling - │ ā”œā”€ā”€ haven.nix # Nostr relay - │ ā”œā”€ā”€ mempool.nix # Mempool explorer - │ ā”œā”€ā”€ rdp.nix # Remote desktop (RDP) - │ ā”œā”€ā”€ vaultwarden.nix # Password management - ā”œā”€ā”€ nix-bitcoin integration - ā”œā”€ā”€ bitcoin clients integration - │ ā”œā”€ā”€ sparrow wallet # Trusted and Standard Open Source Bitcoin Wallet - │ ā”œā”€ā”€ bisq/bisq2 # Non KYC Bitcoin Buying and Selling - ā”œā”€ā”€ agenix (secrets management) - └── nixvim -``` - -## Features - -### Feature Toggles - -[Custom Add-On Guide](custom-add-ons.md) - -Every major service is gated behind a feature flag. Enable only what you need: - -```nix -# custom.nix -{ config, pkgs, lib, ... }: - -{ - - sovran_systemsOS = { - features = { - bip110 = lib.mkForce true; - element-calling = lib.mkForce true; - haven = lib.mkForce true; - mempool = lib.mkForce true; - rdp = lib.mkForce true; - }; - nostr_npub = "pasteyournpubhere"; - }; - -} -``` - -No unnecessary services run. No wasted resources. - ---- - -### Service Stack - -| Category | Service | Description | -|---|---|---| -| **Web** | Caddy | Automatic HTTPS, reverse proxy for all services | -| **Cloud** | Nextcloud | File storage, sync, and collaboration | -| **CMS** | WordPress | Self-hosted publishing and content management | -| **Passwords** | Vaultwarden | Bitwarden-compatible password vault | -| **Messaging** | Element/Matrix Synapse | Federated, decentralized messaging backend | -| **Video/Voice Calling** | Element Video and Voice Calling | Decentralized Voice Over IP for Matrix with optional TURN/STUN | -| **Bitcoin** | Bitcoin Core / Knots | **Full node with optional BIP-110 consensus policy** | -| **Bitcoin Lightning** | LND | Full LND Node Connected over Tor intergrated into BTCPay Server | -| **Payments** | BTCPay Server | Self-hosted Bitcoin payment processor | -| **Explorer** | Mempool | Bitcoin mempool visualizer and block explorer | -| **Nostr** | Haven | Nostr relay server | -| **Remote Access** | GNOME Remote Desktop | RDP access with auto-generated TLS and credentials | - ---- - -### Security - -- **SSH hardened** — password authentication disabled by default -- **Fail2ban** — active on https -- **Agenix** — encrypted secrets management integrated into the flake -- **Tor** — integration into the bitcoin ecosystem -- **Firewall** — ports managed per-module; only enabled services are exposed - -### Reliability - -- **Automated backups** via rsnapshot -- **Scheduled maintenance** via systemd timers -- **Database initialization** handled declaratively -- **Reproducible builds** — the main system is defined in code and can be rebuilt to match most systems - ---- - -### Network Configuration - -Sovran_SystemsOS hosts public-facing services (Wordpress, Element/Element Calling, Nextcloud, BTCPayserver, Haven Relay, and Vaultwarden) that require inbound connections from the internet. To make these services accessible outside your local network, you must configure **port forwarding** on your home router. - -**Before deploying, ensure you have:** - -- Access to your router's administration interface (typically at `192.168.1.1` or `192.168.0.1`) -- The ability to create port forwarding rules -- The local/private IP address of the machine running Sovran_SystemsOS -- The external public IP address of the machine running Sovran_SystemsOS - -**Required port forwards (depending on enabled features):** - -Forward each port to the **private IP address** of your Sovran_SystemsOS machine. Only forward ports for services you have enabled. - -> **Tip:** Assign a static IP or DHCP reservation to your Sovran_SystemsOS machine so the forwarding rules remain valid after reboots. - -> **Note:** If your ISP uses CGNAT (Carrier-Grade NAT), standard port forwarding will not work. Contact your ISP to request a public IP address. - ---- - -## Installation - -### Full Guide (A bit outdated as of now... will be working on a smoother DIY soon) - -šŸ‘‰ [DIY Install Sovran_SystemsOS](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/main/DIY%20Install%20Sovran_SystemsOS.md) - ---- - -## Requirements - -| Resource | Minimum | Recommended | -|---|---|---| -| CPU | 4 cores | 8+ cores | -| RAM | 16 GB | 32+ GB | -| Storage | 512 GB SSD + 4 TB SSD | 2GB SSD + 4+ TB SSD (Bitcoin node requires significant disk) | -| Network | 100 Mbs Down/20 Mbs Up + No need for DDNS if domains are brought through https://njal.la | 1 Gbs Down/1 Gbs Up + No need for DDNS if domains are brought through https://njal.la | - ---- - -## Community - -| Channel | Link | -|---|---| -| General Chat | [#sovran-systems:anarchyislove.xyz](https://matrix.to/#/#sovran-systems:anarchyislove.xyz) | -| DIY Support | [#DIY_Sovran_SystemsOS:anarchyislove.xyz](https://matrix.to/#/#DIY_Sovran_SystemsOS:anarchyislove.xyz) | - ---- - -## License - -See [LICENSE](LICENSE) for details. - ---- - -## Project Philosophy - -Sovran_SystemsOS exists to provide a complete, self-hosted infrastructure stack that eliminates dependency on third-party platforms. It is opinionated by design — services are pre-integrated so you spend time using your system, not assembling it. - -This is not a toolkit. It is a working system. - -You retain full visibility into every module, every service definition, and every configuration choice. Nothing is hidden. Everything is reproducible. - ---- - -**Be Digitally Sovereign** - diff --git a/for_new_sovran_pros/Sovran_SystemsOS-Desktop b/for_new_sovran_pros/Sovran_SystemsOS-Desktop deleted file mode 100644 index 581fbf7..0000000 --- a/for_new_sovran_pros/Sovran_SystemsOS-Desktop +++ /dev/null @@ -1,472 +0,0 @@ -[com/ftpix/transparentbar] -dark-full-screen=false - -[org/gnome/Connections] -first-run=false - -[org/gnome/Console] -font-scale=1.6000000000000005 -last-window-size=(1912, 1037) - -[org/gnome/Geary] -migrated-config=true -window-height=516 -window-width=954 - -[org/gnome/TextEditor] -last-save-directory='file:///home/free/Downloads' - -[org/gnome/Totem] -active-plugins=['mpris', 'vimeo', 'screenshot', 'movie-properties', 'autoload-subtitles', 'screensaver', 'apple-trailers', 'save-file', 'rotation', 'open-directory', 'recent', 'variable-rate', 'skipto'] -subtitle-encoding='UTF-8' - -[org/gnome/baobab/ui] -is-maximized=false -window-size=(1912, 1037) - -[org/gnome/calculator] -accuracy=9 -angle-units='degrees' -base=10 -button-mode='basic' -number-format='automatic' -show-thousands=false -show-zeroes=false -source-currency='' -source-units='degree' -target-currency='' -target-units='radian' -word-size=64 - -[org/gnome/calendar] -active-view='month' -window-maximized=false -window-size=(1912, 1037) - -[org/gnome/control-center] -last-panel='background' -window-state=(1912, 1040, false) - -[org/gnome/desktop/app-folders] -folder-children=['Utilities', 'YaST', 'd737daeb-6dbb-4a5d-9ec7-e674398539ce', '7d66e46a-a135-4e42-91bb-d438e499d251', '3fea025e-f5e4-4905-9912-e70e38cd0419', '83d8148a-1f0b-4f83-814a-11c33ab8debc', '68c075b1-a254-4b7c-ba63-c45f88bc2a58', '534e2716-83c7-4a2a-9678-8144999213ed', '4acaa2d8-d284-4efd-bba3-40f150f1ace5', '1e62b69b-d9bb-4e80-be8d-5e9b4d777fc8'] - -[org/gnome/desktop/app-folders/folders/1e62b69b-d9bb-4e80-be8d-5e9b4d777fc8] -apps=['math.desktop', 'writer.desktop', 'impress.desktop', 'draw.desktop', 'calc.desktop', 'base.desktop', 'startcenter.desktop'] -name='Office' - -[org/gnome/desktop/app-folders/folders/3fea025e-f5e4-4905-9912-e70e38cd0419] -apps=['cups.desktop', 'simple-scan.desktop'] -name='Printing' -translate=false - -[org/gnome/desktop/app-folders/folders/4acaa2d8-d284-4efd-bba3-40f150f1ace5] -apps=['org.gnome.DiskUtility.desktop', 'org.gnome.baobab.desktop', 'gparted.desktop', 'gnome-system-monitor.desktop'] -name='Utilities' - -[org/gnome/desktop/app-folders/folders/534e2716-83c7-4a2a-9678-8144999213ed] -apps=['org.gnome.Epiphany.desktop', 'librewolf.desktop', 'io.lbry.lbry-app.desktop', 'bitwarden.desktop', 'com.nextcloud.desktopclient.nextcloud.desktop', 'brave-browser.desktop', 'chromium-browser.desktop'] -name='Internet' - -[org/gnome/desktop/app-folders/folders/68c075b1-a254-4b7c-ba63-c45f88bc2a58] -apps=['org.gnome.Extensions.desktop', 'org.gnome.tweaks.desktop'] -name='Customize Look' -translate=false - -[org/gnome/desktop/app-folders/folders/7d66e46a-a135-4e42-91bb-d438e499d251] -apps=['org.gnome.Photos.desktop', 'org.gnome.Music.desktop', 'org.gnome.Totem.desktop', 'org.gnome.Cheese.desktop', 'org.gnome.Loupe.desktop', 'org.gnome.Snapshot.desktop'] -name='Media' -translate=false - -[org/gnome/desktop/app-folders/folders/83d8148a-1f0b-4f83-814a-11c33ab8debc] -apps=['org.gnome.Tour.desktop', 'yelp.desktop', 'nixos-manual.desktop'] -name='Help' -translate=false - -[org/gnome/desktop/app-folders/folders/Utilities] -apps=['gnome-abrt.desktop', 'gnome-system-log.desktop', 'nm-connection-editor.desktop', 'org.gnome.Connections.desktop', 'org.gnome.DejaDup.desktop', 'org.gnome.Dictionary.desktop', 'org.gnome.eog.desktop', 'org.gnome.Evince.desktop', 'org.gnome.FileRoller.desktop', 'org.gnome.fonts.desktop', 'org.gnome.seahorse.Application.desktop', 'org.gnome.Usage.desktop', 'vinagre.desktop', 'org.gnome.TextEditor.desktop', 'org.gnome.gedit.desktop', 'org.gnome.SystemMonitor.desktop'] -categories=['X-GNOME-Utilities'] -excluded-apps=['org.gnome.Console.desktop', 'org.gnome.tweaks.desktop', 'org.gnome.DiskUtility.desktop', 'org.gnome.baobab.desktop'] -name='X-GNOME-Utilities.directory' -translate=true - -[org/gnome/desktop/app-folders/folders/YaST] -categories=['X-SuSE-YaST'] -name='suse-yast.directory' -translate=true - -[org/gnome/desktop/app-folders/folders/d737daeb-6dbb-4a5d-9ec7-e674398539ce] -apps=['fish.desktop', 'org.gnome.Console.desktop', 'htop.desktop', 'ranger.desktop', 'xterm.desktop', 'org.gnome.Terminal.desktop'] -name='Terminal Fun' -translate=false - -[org/gnome/desktop/background] -color-shading-type='solid' -picture-options='zoom' -picture-uri='file:///run/current-system/sw/share/backgrounds/gnome/amber-l.jxl' -picture-uri-dark='file:///run/current-system/sw/share/backgrounds/gnome/amber-d.jxl' -primary-color='#ff7800' -secondary-color='#000000' - -[org/gnome/desktop/calendar] -show-weekdate=false - -[org/gnome/desktop/input-sources] -sources=[('xkb', 'us')] -xkb-options=['terminate:ctrl_alt_bksp'] - -[org/gnome/desktop/interface] -clock-format='12h' -clock-show-seconds=false -clock-show-weekday=false -color-scheme='prefer-dark' -enable-animations=true -font-antialiasing='rgba' -font-hinting='full' -gtk-theme='Adwaita-dark' -icon-theme='Papirus-Dark' -text-scaling-factor=1.0 - -[org/gnome/desktop/notifications] -application-children=['gnome-power-panel', 'org-gnome-nautilus', 'org-gnome-software', 'gnome-network-panel', 'sparrow', 'org-gnome-settings', 'org-gnome-console', 'gnome-printers-panel', 'org-gnome-epiphany', 'com-obsproject-studio', 'io-github-seadve-kooha', 'xdg-desktop-portal-gnome', 'org-gnome-baobab', 'org-gnome-geary', 'sparrow-desktop', 'impress', 'brave-browser', 'org-gnome-connections'] -show-in-lock-screen=false - -[org/gnome/desktop/notifications/application/brave-browser] -application-id='brave-browser.desktop' - -[org/gnome/desktop/notifications/application/com-obsproject-studio] -application-id='com.obsproject.Studio.desktop' - -[org/gnome/desktop/notifications/application/gnome-network-panel] -application-id='gnome-network-panel.desktop' - -[org/gnome/desktop/notifications/application/gnome-power-panel] -application-id='gnome-power-panel.desktop' - -[org/gnome/desktop/notifications/application/gnome-printers-panel] -application-id='gnome-printers-panel.desktop' - -[org/gnome/desktop/notifications/application/impress] -application-id='impress.desktop' - -[org/gnome/desktop/notifications/application/io-github-seadve-kooha] -application-id='io.github.seadve.Kooha.desktop' - -[org/gnome/desktop/notifications/application/org-gnome-baobab] -application-id='org.gnome.baobab.desktop' - -[org/gnome/desktop/notifications/application/org-gnome-connections] -application-id='org.gnome.Connections.desktop' - -[org/gnome/desktop/notifications/application/org-gnome-console] -application-id='org.gnome.Console.desktop' - -[org/gnome/desktop/notifications/application/org-gnome-epiphany] -application-id='org.gnome.Epiphany.desktop' - -[org/gnome/desktop/notifications/application/org-gnome-geary] -application-id='org.gnome.Geary.desktop' - -[org/gnome/desktop/notifications/application/org-gnome-nautilus] -application-id='org.gnome.Nautilus.desktop' - -[org/gnome/desktop/notifications/application/org-gnome-settings] -application-id='org.gnome.Settings.desktop' - -[org/gnome/desktop/notifications/application/org-gnome-software] -application-id='org.gnome.Software.desktop' - -[org/gnome/desktop/notifications/application/sparrow-desktop] -application-id='sparrow-desktop.desktop' - -[org/gnome/desktop/notifications/application/sparrow] -application-id='Sparrow.desktop' - -[org/gnome/desktop/notifications/application/xdg-desktop-portal-gnome] -application-id='xdg-desktop-portal-gnome.desktop' - -[org/gnome/desktop/peripherals/keyboard] -numlock-state=false - -[org/gnome/desktop/peripherals/mouse] -natural-scroll=true -speed=-0.63779527559055116 - -[org/gnome/desktop/peripherals/touchpad] -two-finger-scrolling-enabled=true - -[org/gnome/desktop/privacy] -old-files-age=uint32 30 -recent-files-max-age=-1 - -[org/gnome/desktop/screensaver] -color-shading-type='solid' -lock-enabled=false -picture-options='zoom' -picture-uri='file:///run/current-system/sw/share/backgrounds/gnome/amber-l.jxl' -primary-color='#ff7800' -secondary-color='#000000' - -[org/gnome/desktop/session] -idle-delay=uint32 900 - -[org/gnome/desktop/sound] -event-sounds=true -theme-name='__custom' - -[org/gnome/desktop/wm/preferences] -button-layout='appmenu:minimize,maximize,close' - -[org/gnome/epiphany] -ask-for-default=false - -[org/gnome/epiphany/state] -is-maximized=false -window-size=(1912, 1037) - -[org/gnome/evolution-data-server] -migrated=true -network-monitor-gio-name='' - -[org/gnome/file-roller/dialogs/extract] -recreate-folders=true -skip-newer=false - -[org/gnome/file-roller/listing] -list-mode='as-folder' -name-column-width=250 -show-path=false -sort-method='name' -sort-type='ascending' - -[org/gnome/file-roller/ui] -sidebar-width=200 -window-height=993 -window-width=954 - -[org/gnome/gnome-system-monitor] -current-tab='processes' -maximized=false -network-total-in-bits=false -show-dependencies=false -show-whose-processes='all' -window-height=1040 -window-state=(1912, 1040, 26, 23) -window-width=1912 - -[org/gnome/gnome-system-monitor/disktreenew] -col-6-visible=true -col-6-width=0 - -[org/gnome/gnome-system-monitor/proctree] -columns-order=[0, 1, 2, 3, 4, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26] -sort-col=8 -sort-order=0 - -[org/gnome/maps] -last-viewed-location=[34.015438242460405, -118.32766985901287] -map-type='MapsStreetSource' -transportation-type='pedestrian' -window-maximized=false -window-size=[1912, 1037] -zoom-level=9 - -[org/gnome/mutter] -attach-modal-dialogs=true -dynamic-workspaces=true -edge-tiling=false -focus-change-on-pointer-rest=true -workspaces-only-on-primary=true - -[org/gnome/nautilus/icon-view] -default-zoom-level='large' - -[org/gnome/nautilus/preferences] -default-folder-viewer='icon-view' -fts-enabled=false -migrated-gtk-settings=true -search-filter-time-type='last_modified' -search-view='list-view' - -[org/gnome/nautilus/window-state] -initial-size=(1912, 1040) -maximized=false - -[org/gnome/nm-applet/eap/202ce1d2-7306-40ac-b3bb-5b092c0f9734] -ignore-ca-cert=false -ignore-phase2-ca-cert=false - -[org/gnome/nm-applet/eap/2afa07ed-64ca-44a0-948e-d8f265fa52b0] -ignore-ca-cert=false -ignore-phase2-ca-cert=false - -[org/gnome/nm-applet/eap/8da70f78-fe38-3e50-a305-8fa32b2af624] -ignore-ca-cert=false -ignore-phase2-ca-cert=false - -[org/gnome/nm-applet/eap/a9f5fb1c-2546-4fb9-82d0-7792e8982565] -ignore-ca-cert=false -ignore-phase2-ca-cert=false - -[org/gnome/nm-applet/eap/e5e312d5-e2db-3928-8c98-8ec8a7cf61f2] -ignore-ca-cert=false -ignore-phase2-ca-cert=false - -[org/gnome/portal/filechooser/brave-browser] -last-folder-path='/home/free/Downloads' - -[org/gnome/portal/filechooser/chromium-browser] -last-folder-path='/home/free/Downloads' - -[org/gnome/settings-daemon/plugins/color] -night-light-enabled=true -night-light-schedule-automatic=false -night-light-schedule-from=18.0 -night-light-temperature=uint32 1744 - -[org/gnome/settings-daemon/plugins/power] -power-button-action='nothing' -sleep-inactive-ac-type='nothing' - -[org/gnome/shell] -app-picker-layout=[{'org.gnome.Weather.desktop': <{'position': <0>}>, 'org.gnome.clocks.desktop': <{'position': <1>}>, 'org.gnome.Maps.desktop': <{'position': <2>}>, 'org.gnome.Calculator.desktop': <{'position': <3>}>, '68c075b1-a254-4b7c-ba63-c45f88bc2a58': <{'position': <4>}>, '3fea025e-f5e4-4905-9912-e70e38cd0419': <{'position': <5>}>, '83d8148a-1f0b-4f83-814a-11c33ab8debc': <{'position': <6>}>, 'Utilities': <{'position': <7>}>, 'd737daeb-6dbb-4a5d-9ec7-e674398539ce': <{'position': <8>}>, '7d66e46a-a135-4e42-91bb-d438e499d251': <{'position': <9>}>, '534e2716-83c7-4a2a-9678-8144999213ed': <{'position': <10>}>, '4acaa2d8-d284-4efd-bba3-40f150f1ace5': <{'position': <11>}>, '1e62b69b-d9bb-4e80-be8d-5e9b4d777fc8': <{'position': <12>}>, 'Bisq-hidpi.desktop': <{'position': <13>}>, 'com.obsproject.Studio.desktop': <{'position': <14>}>, 'Sovran_SystemsOS_External_Backup.desktop': <{'position': <15>}>, 'firefox.desktop': <{'position': <16>}>}] -disable-user-extensions=false -disabled-extensions=['transparent-top-bar@zhanghai.me'] -enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'dash-to-dock-cosmic-@halfmexicanhalfamazing@gmail.com', 'Vitals@CoreCoding.com', 'dash-to-dock@micxgx.gmail.com', 'transparent-top-bar@ftpix.com', 'just-perfection-desktop@just-perfection', 'pop-shell@system76.com', 'date-menu-formatter@marcinjakubowski.github.com', 'systemd-manager@hardpixel.eu', 'light-style@gnome-shell-extensions.gcampax.github.com'] -favorite-apps=['firefox.desktop', 'org.gnome.Nautilus.desktop', 'Sovran_SystemsOS_Updater.desktop', 'org.gnome.Settings.desktop', 'org.gnome.Software.desktop', 'io.freetubeapp.FreeTube.desktop', 'org.onlyoffice.desktopeditors.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Calendar.desktop', 'Bisq.desktop', 'sparrow-desktop.desktop'] -last-selected-power-profile='performance' -welcome-dialog-last-shown-version='42.3.1' - -[org/gnome/shell/extensions/dash-to-dock-pop] -apply-glossy-effect=false -background-color='rgb(0,0,0)' -background-opacity=0.25 -border-radius=17 -custom-background-color=true -custom-theme-shrink=false -dash-max-icon-size=64 -dock-alignment='CENTRE' -dock-position='BOTTOM' -extend-height=false -floating-margin=0 -force-straight-corner=false -height-fraction=0.90000000000000002 -intellihide-mode='ALL_WINDOWS' -preferred-monitor=-2 -preferred-monitor-by-connector='HDMI-1' -preview-size-scale=0.059999999999999998 -running-indicator-style='DASHES' -show-apps-at-top=false -show-mounts=false -show-show-apps-button=true -show-trash=false -transparency-mode='FIXED' -unity-backlit-items=false - -[org/gnome/shell/extensions/dash-to-dock] -apply-custom-theme=false -background-color='rgb(0,0,0)' -background-opacity=0.17000000000000001 -custom-background-color=true -dash-max-icon-size=57 -dock-position='BOTTOM' -extend-height=false -height-fraction=0.89000000000000001 -icon-size-fixed=false -intellihide-mode='ALL_WINDOWS' -preferred-monitor=-2 -preferred-monitor-by-connector='HDMI-2' -preview-size-scale=0.22 -running-indicator-style='DASHES' -show-mounts=false -show-mounts-only-mounted=false -show-trash=false -transparency-mode='FIXED' - -[org/gnome/shell/extensions/date-menu-formatter] -font-size=14 -pattern='EEEE MMMM d h: mm a' -text-align='center' - -[org/gnome/shell/extensions/just-perfection] -accessibility-menu=false - -[org/gnome/shell/extensions/pop-shell] -active-hint-border-radius=uint32 3 -gap-inner=uint32 1 -gap-outer=uint32 1 -tile-by-default=true - -[org/gnome/shell/extensions/systemd-manager] -command-method='systemctl' -systemd=['{"name":"Bitcoind","service":"bitcoind.service","type":"system"}', '{"name":"Electrs","service":"electrs.service","type":"system"}', '{"name":"BTCPayserver","service":"btcpayserver.service","type":"system"}', '{"name":"Nbxplorer","service":"nbxplorer.service","type":"system"}', '{"name":"Caddy","service":"caddy.service","type":"system"}', '{"name":"Phpfpm-Mypool","service":"phpfpm-mypool.service","type":"system"}', '{"name":"Mysql","service":"mysql.service","type":"system"}', '{"name":"Postgresql","service":"postgresql.service","type":"system"}', '{"name":"Matrix-Synapse","service":"matrix-synapse.service","type":"system"}', '{"name":"Coturn","service":"coturn.service","type":"system"}', '{"name":"Tor","service":"tor.service","type":"system"}', '{"name":"VaultWarden","service":"vaultwarden.service","type":"system"}', '{"name":"LND","service":"lnd.service","type":"system"}', '{"name":"LND Loop","service":"lightning-loop.service","type":"system"}', '{"name":"Ride The Lightning","service":"rtl.service","type":"system"}'] - -[org/gnome/shell/extensions/vitals] -fixed-widths=false -hot-sensors=['_memory_usage_', '__network-tx_max__', '_processor_usage_', '_storage_free_', '_temperature_processor_0_'] -show-fan=false -show-storage=true -show-voltage=false - -[org/gnome/shell/weather] -automatic-location=true -locations=@av [] - -[org/gnome/shell/world-clocks] -locations=@av [] - -[org/gnome/software] -check-timestamp=int64 1715525466 -first-run=false -flatpak-purge-timestamp=int64 1715478601 -online-updates-timestamp=int64 1675355639 -update-notification-timestamp=int64 1666382024 - -[org/gnome/terminal/legacy/profiles:/:b1dcc9dd-5262-4d8d-a863-c897e6d979b9] -font='Monospace 14' -use-system-font=false - -[org/gnome/tweaks] -show-extensions-notice=false - -[org/gtk/gtk4/settings/color-chooser] -selected-color=(true, 0.0, 0.0, 0.0, 1.0) - -[org/gtk/gtk4/settings/file-chooser] -date-format='regular' -location-mode='path-bar' -show-hidden=false -show-size-column=true -show-type-column=true -sidebar-width=140 -sort-column='name' -sort-directories-first=false -sort-order='ascending' -type-format='category' -view-type='list' -window-size=(1912, 1040) - -[org/gtk/settings/file-chooser] -clock-format='12h' -date-format='regular' -location-mode='path-bar' -show-hidden=true -show-size-column=true -show-type-column=true -sidebar-width=165 -sort-column='modified' -sort-directories-first=false -sort-order='descending' -type-format='category' -window-position=(26, 23) -window-size=(1401, 998) - -[system/proxy] -ignore-hosts=@as [] -mode='none' - -[system/proxy/http] -port=0 - -[system/proxy/socks] -host='127.0.0.1' -port=9050 diff --git a/for_new_sovran_pros/flake.nix b/for_new_sovran_pros/flake.nix deleted file mode 100755 index 416e872..0000000 --- a/for_new_sovran_pros/flake.nix +++ /dev/null @@ -1,30 +0,0 @@ -{ - description = "Sovran_SystemsOS for the Sovran Pro from Sovran Systems"; - - inputs = { - - Sovran_Systems.url = "git+https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS"; - - }; - - outputs = { self, Sovran_Systems, ... }@inputs: { - - nixosConfigurations."nixos" = Sovran_Systems.inputs.nixpkgs.lib.nixosSystem { - - modules = [ - - { nixpkgs.hostPlatform = "x86_64-linux"; } - - ./hardware-configuration.nix - - ./custom.nix - - Sovran_Systems.nixosModules.Sovran_SystemsOS - - ]; - - }; - - }; - -} diff --git a/for_new_sovran_pros/psp.sh b/for_new_sovran_pros/psp.sh deleted file mode 100755 index e519f70..0000000 --- a/for_new_sovran_pros/psp.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env bash - -# Begin: curl https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/psp.sh -o psp.sh - -GREEN="\e[32m" -LIGHTBLUE="\e[94m" -ENDCOLOR="\e[0m" - -lsblk - -echo -e "${GREEN}What block for file-tree-root of drive (usually nvme0n1)?${ENDCOLOR}";read commitroot - -parted /dev/"$commitroot" -- mklabel gpt -parted /dev/"$commitroot" -- mkpart primary 512MB -16GB -parted /dev/"$commitroot" -- mkpart swap linux-swap -16GB 100% -parted /dev/"$commitroot" -- mkpart ESP fat32 1MB 512MB -parted /dev/"$commitroot" -- set 3 esp on - -lsblk - -echo -e "${GREEN}What partition for Boot-Partition (usually nvme0n1p1)?${ENDCOLOR}";read commitbootpartition - -echo -e "${GREEN}What partition for Main-Partition (usually nvme0n1p2)?${ENDCOLOR}";read commitmainpartition - -echo -e "${GREEN}What partition for Swap-Partition (usually nvme0n1p3)?${ENDCOLOR}";read commitswappartition - - - -mkfs.ext4 -L nixos /dev/"$commitmainpartition" - -mkswap -L swap /dev/"$commitswappartition" - -mkfs.fat -F 32 -n boot /dev/"$commitbootpartition" - -mount /dev/disk/by-label/nixos /mnt - -mkdir -p /mnt/boot/efi - -mount /dev/disk/by-label/boot /mnt/boot/efi - - - -nixos-generate-config --root /mnt - -rm /mnt/etc/nixos/configuration.nix - -cat <> /mnt/etc/nixos/configuration.nix -{ config, pkgs, ... }: { - - imports = [ - - ./hardware-configuration.nix - - ]; - - boot.loader.systemd-boot.enable = true; - boot.loader.efi.canTouchEfiVariables = true; - boot.loader.efi.efiSysMountPoint = "/boot/efi"; - - nix.settings.experimental-features = [ "nix-command" "flakes" ]; - - users.users = { - free = { - isNormalUser = true; - description = "free"; - extraGroups = [ "networkmanager" ]; - }; - }; - - environment.systemPackages = with pkgs; [ - wget - git - ranger - fish - pwgen - openssl - ]; - - services.openssh = { - enable = true; - permitRootLogin = "yes"; - }; -} - -EOT - -nixos-install - -reboot \ No newline at end of file diff --git a/for_new_sovran_pros/psp_physical_ram.sh b/for_new_sovran_pros/psp_physical_ram.sh deleted file mode 100755 index 10f1300..0000000 --- a/for_new_sovran_pros/psp_physical_ram.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bash - -# Begin: curl https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/psp_physical_ram.sh -o psp_physical_ram.sh - -GREEN="\e[32m" -LIGHTBLUE="\e[94m" -ENDCOLOR="\e[0m" - -lsblk - -echo -e "${GREEN}What block for file-tree-root of drive (usually nvme0n1)?${ENDCOLOR}";read commitroot - -parted /dev/"$commitroot" -- mklabel gpt -parted /dev/"$commitroot" -- mkpart ESP fat32 1MB 512MB -parted /dev/"$commitroot" -- set 1 esp on -parted /dev/"$commitroot" -- mkpart primary ext4 512MB 100% - -lsblk - -echo -e "${GREEN}What partition for Boot-Partition (usually nvme0n1p1)?${ENDCOLOR}";read commitbootpartition - -echo -e "${GREEN}What partition for Primary-Partition (usually nvme0n1p2)?${ENDCOLOR}";read commitprimarypartition - - -mkfs.ext4 -L nixos /dev/"$commitprimarypartition" - -mkfs.fat -F 32 -n boot /dev/"$commitbootpartition" - -mount /dev/disk/by-label/nixos /mnt - -mkdir -p /mnt/boot/efi - -mount /dev/disk/by-label/boot /mnt/boot/efi - -### Disk Step-up Finished - -### Adding Configuration.nix - -nixos-generate-config --root /mnt - -rm /mnt/etc/nixos/configuration.nix - -cat <> /mnt/etc/nixos/configuration.nix -{ config, pkgs, ... }: { - - imports = [ - - ./hardware-configuration.nix - - ]; - - boot.loader.systemd-boot.enable = true; - boot.loader.efi.canTouchEfiVariables = true; - boot.loader.efi.efiSysMountPoint = "/boot/efi"; - - nix.settings.experimental-features = [ "nix-command" "flakes" ]; - - users.users = { - free = { - isNormalUser = true; - description = "free"; - extraGroups = [ "networkmanager" ]; - }; - }; - - environment.systemPackages = with pkgs; [ - wget - git - ranger - fish - pwgen - openssl - ]; - - services.openssh = { - enable = true; - permitRootLogin = "yes"; - }; -} - -EOT - -nixos-install - -reboot diff --git a/for_new_sovran_pros/sdpsp.sh b/for_new_sovran_pros/sdpsp.sh deleted file mode 100755 index 7272d22..0000000 --- a/for_new_sovran_pros/sdpsp.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash - -GREEN="\e[32m" -LIGHTBLUE="\e[94m" -ENDCOLOR="\e[0m" - -lsblk - -echo -e "${GREEN}What block for New Sovran Pro Second drive?${ENDCOLOR}";read commitroot - -parted /dev/"$commitroot" -- mklabel gpt -parted /dev/"$commitroot" -- mkpart primary 0% 100% - -lsblk - -echo -e "${GREEN}What partition with New Sovran Pro Second Drive?${ENDCOLOR}";read commitsecond - -mkfs.ext4 -L "BTCEcoandBackup" /dev/"$commitsecond" - -sudo mkdir -p /mnt - -mount /dev/"$commitsecond" /mnt - -sudo mkdir -p /mnt/BTCEcoandBackup/Bitcoin_Node - -sudo mkdir -p /mnt/BTCEcoandBackup/Electrs_Data - -sudo mkdir -p /mnt/BTCEcoandBackup/NixOS_Snapshot_Backup - -sudo mkdir -p /mnt/BTCEcoandBackup/clightning_db_backup - -sudo systemctl stop bitcoind electrs nbxplorer btcpayserver lnd rtl lightning-loop clightning - -rsync -ar --info=progress2 --info=name0 /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node/ /mnt/BTCEcoandBackup/Bitcoin_Node/ - -rsync -ar --info=progress2 --info=name0 /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data/ /mnt/BTCEcoandBackup/Electrs_Data/ - -sudo systemctl start bitcoind electrs nbxplorer btcpayserver lnd rtl lightning-loop clightning - -sudo chown bitcoin:bitcoin /mnt/BTCEcoandBackup/Bitcoin_Node -R - -sudo chown electrs:electrs /mnt/BTCEcoandBackup/Electrs_Data -R - -sudo chmod 770 /mnt/BTCEcoandBackup/Bitcoin_Node -R - -sudo chmod 770 /mnt/BTCEcoandBackup/Electrs_Data -R - -sudo umount /dev/"$commitsecond" - -echo -e "All Finished!" - diff --git a/for_new_sovran_pros/sp.sh b/for_new_sovran_pros/sp.sh deleted file mode 100755 index 60356ce..0000000 --- a/for_new_sovran_pros/sp.sh +++ /dev/null @@ -1,406 +0,0 @@ -#!/usr/bin/env bash - -# wget https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/sp.sh - - -GREEN="\e[32m" -LIGHTBLUE="\e[94m" - -# - -pushd /etc/nixos/ - - wget https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/flake.nix - - chown root:root /etc/nixos/ -R - - chmod 770 /etc/nixos/ -R - -popd - -# - -mkdir /var/lib/domains - -touch /var/lib/domains/btcpayserver -touch /var/lib/domains/matrix -touch /var/lib/domains/nextcloud -touch /var/lib/domains/sslemail -touch /var/lib/domains/vaultwarden -touch /var/lib/domains/wordpress - -# - -echo -e "${GREEN}What is your New Matrix (Element Chat) domain name?${ENDCOLOR}" -read -echo -n $REPLY > /var/lib/domains/matrix - -echo -e "${GREEN}What is your New Wordpress domain name?${ENDCOLOR}" -read -echo -n $REPLY > /var/lib/domains/wordpress - -echo -e "${GREEN}What is your New Nextcloud domain name?${ENDCOLOR}" -read -echo -n $REPLY > /var/lib/domains/nextcloud - -echo -e "${GREEN}What is your New BTCPayserver domain name?${ENDCOLOR}" -read -echo -n $REPLY > /var/lib/domains/btcpayserver - -echo -e "${GREEN}What is your New Vaultwarden domain name?${ENDCOLOR}" -read -echo -n $REPLY > /var/lib/domains/vaultwarden - -echo -e "${GREEN}What is the email you would like to use to manage the SSL certificates for your domains?${ENDCOLOR}" -read -echo -n $REPLY > /var/lib/domains/sslemail - -# - -mkdir /var/lib/nextcloudaddition - -cat > /var/lib/nextcloudaddition/nextcloudaddition <<- "EOF" - -'trusted_proxies' => - array ( - 0 => '127.0.0.1', - ), - 'default_locale' => 'en_US', - 'default_phone_region' => 'US', - 'memcache.local' =>'\OC\Memcache\APCu' , - -EOF - -# - -mkdir /var/lib/njalla/ - -cat > /var/lib/njalla/njalla.sh <<- "EOF" - -#!/usr/bin/env bash - -IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4) - -## Manually Add DDNS Script From Njalla User Account AFTER Install - -curl "https://...${IP}" - -EOF - -# - -mkdir /var/lib/external_ip - -cat > /var/lib/external_ip/external_ip.sh <<- "EOF" - -#!/usr/bin/env bash - -IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4) - -echo "${IP}" > /var/lib/secrets/external_ip - -EOF - -# - -mkdir /var/lib/internal_ip - -cat > /var/lib/internal_ip/internal_ip.sh <<- "EOF" - -#!/usr/bin/env bash - -sudo echo -n $(ip route get 1.2.3.4 | awk '{print $7}') > /var/lib/secrets/internal_ip - -exit 0 - - -EOF - -# - -touch /etc/nixos/custom.nix - -cat > /etc/nixos/custom.nix <<- "EOF" - -{config, pkgs, lib, ...}: - -let - personalization = import ./personalization.nix; - - in -{ -} - -EOF - -# - -mkdir /var/lib/agenix-secrets/ - -cat > /var/lib/agenix-secrets/secrets.nix <<- "EOF" - -let - - root = "placeholder" ; - -in - -{ - - "wordpressdb.age".publicKeys = [ root ]; - - "matrixdb.age".publicKeys = [ root ]; - - "nextclouddb.age".publicKeys = [ root ]; - - "turn.age".publicKeys = [ root ]; - - "matrix_reg_secret.age".publicKeys = [ root ]; - -} - -EOF - -# - -mkdir /var/lib/secrets -mkdir /var/lib/secrets/vaultwarden - -touch /var/lib/secrets/nextclouddb -touch /var/lib/secrets/wordpressdb -touch /var/lib/secrets/matrixdb -touch /var/lib/secrets/turn -touch /var/lib/secrets/matrix_reg_secret -touch /var/lib/secrets/main -touch /var/lib/secrets/vaultwarden/vaultwarden.env -touch /var/lib/secrets/external_ip -touch /var/lib/secrets/internal_ip - -echo -n $(pwgen -s 17 -1) > /var/lib/secrets/nextclouddb -echo -n $(pwgen -s 17 -1) > /var/lib/secrets/wordpressdb -echo -n $(pwgen -s 17 -1) > /var/lib/secrets/matrixdb -echo -n $(pwgen -s 17 -1) > /var/lib/secrets/turn -echo -n $(pwgen -s 17 -1) > /var/lib/secrets/matrix_reg_secret -echo -n $(pwgen -s 17 -1) > /var/lib/secrets/main -echo -n ADMIN_TOKEN=$(openssl rand -base64 48 -) > /var/lib/secrets/vaultwarden/vaultwarden.env - -# - -mkdir -p /root/.ssh/agenix - -ssh-keygen -q -N "" -t ed25519 -f /root/.ssh/agenix/agenix-secret-keys - -sed -i -e "0,/root.*/{s::root = $(cat /root/.ssh/agenix/agenix-secret-keys.pub):};s:root@nixos::" /var/lib/agenix-secrets/secrets.nix - -sed -i 's:\(root =[[:blank:]]*\)\(.*\):\1"\2";:' /var/lib/agenix-secrets/secrets.nix - -# - -pushd /var/lib/agenix-secrets - - echo -n $(cat /var/lib/secrets/wordpressdb) | EDITOR='cp /dev/stdin' nix run github:ryantm/agenix -- -e wordpressdb.age -i /root/.ssh/agenix/agenix-secret-keys - - echo -n $(cat /var/lib/secrets/nextclouddb) | EDITOR='cp /dev/stdin' nix run github:ryantm/agenix -- -e nextclouddb.age -i /root/.ssh/agenix/agenix-secret-keys - - echo -n $(cat /var/lib/secrets/matrixdb) | EDITOR='cp /dev/stdin' nix run github:ryantm/agenix -- -e matrixdb.age -i /root/.ssh/agenix/agenix-secret-keys - - echo -n $(cat /var/lib/secrets/turn) | EDITOR='cp /dev/stdin' nix run github:ryantm/agenix -- -e turn.age -i /root/.ssh/agenix/agenix-secret-keys - - echo -n $(cat /var/lib/secrets/matrix_reg_secret) | EDITOR='cp /dev/stdin' nix run github:ryantm/agenix -- -e matrix_reg_secret.age -i /root/.ssh/agenix/agenix-secret-keys - -popd - - -# - -pushd /etc/nixos - - nix flake update - - nixos-rebuild switch --impure - -popd - -# - -chown root:root /var/lib/secrets/main -R - -chown root:root /var/lib/secrets/external_ip -R - -chown root:root /var/lib/secrets/internal_ip -R - -chown matrix-synapse:matrix-synapse /var/lib/secrets/matrix_reg_secret -R - -chown matrix-synapse:matrix-synapse /var/lib/secrets/matrixdb -R - -chown postgres:postgres /var/lib/secrets/nextclouddb -R - -chown turnserver:turnserver /var/lib/secrets/turn -R - -chown mysql:mysql /var/lib/secrets/wordpressdb -R - -chown vaultwarden:vaultwarden /var/lib/secrets/vaultwarden -R - - -chmod 770 /var/lib/secrets/ -R - -# - -chown caddy:php /var/lib/domains -R - -chmod 770 /var/lib/domains -R - -# - -set -x - -wget -P /var/lib/www/downloadwp https://wordpress.org/latest.zip - -wget -P /var/lib/www/downloadnc https://download.nextcloud.com/server/releases/latest.zip - -unzip /var/lib/www/downloadwp/latest.zip -d /var/lib/www/ - -unzip /var/lib/www/downloadnc/latest.zip -d /var/lib/www/ - -rm -rf /var/lib/www/downloadwp - -rm -rf /var/lib/www/downloadnc - -chown caddy:php /var/lib/www -R - -chmod 770 /var/lib/www -R - -# - -mkdir /var/lib/nextcloud - -chown caddy:php /var/lib/nextcloud -R - -chmod 770 /var/lib/nextcloud -R - -# - -mkdir /var/lib/coturn - -chown turnserver:turnserver /var/lib/coturn -R - -chmod 770 /var/lib/coturn -R - -# - -rm -rf /root/sp.sh - -# - -chown bitcoin:bitcoin /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node -R - -chmod 770 /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node -R - -chown electrs:electrs /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data -R - -chmod 770 /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data -R - -# - -mkdir -p /home/free/Downloads - -pushd /home/free/Downloads - - wget https://git.sovransystems.com/Sovran_Systems/Software/raw/branch/main/Sovran_SystemsOS_Resetter/sovran_systemsOS_resetter_local_installer/sovran_systemsOS_resetter_install.sh - - bash sovran_systemsOS_resetter_install.sh - -popd - -# - -pushd /home/free/Downloads - - wget https://git.sovransystems.com/Sovran_Systems/Software/raw/branch/main/Sovran_SystemsOS_Updater/sovran_systemsOS_updater_local_installer/sovran_systemsOS_updater_install.sh - - bash sovran_systemsOS_updater_install.sh - -popd - -# - -mkdir -p /home/free/Pictures - -pushd /home/free/Pictures - - wget https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/Wallpaper_Dark_Wide.png - -popd - -chown free:users /home/free -R - -chmod 700 /home/free -R - -# - -pushd /home/free/Downloads - - sudo -u free wget https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/for_new_sovran_pros/Sovran_SystemsOS-Desktop - -popd - -# - -wp=$(cat /var/lib/secrets/wordpressdb) - -sudo mysql -u root -e "SET PASSWORD FOR wpusr@localhost = PASSWORD('${wp}')"; - -# - -mkdir /root/.ssh - -mkdir -p /home/free/.ssh - -chown free:users /home/free/.ssh -R - -touch /root/.ssh/authorized_keys - -sudo -u free ssh-keygen -q -N "gosovransystems" -t ed25519 -f /home/free/.ssh/factory_login - -chmod 700 /home/free/.ssh -R - -echo "$(cat /home/free/.ssh/factory_login.pub)" >> /root/.ssh/authorized_keys - -# - -sudo matrix-synapse-register_new_matrix_user -u admin -p a -a - -sudo echo "no" | matrix-synapse-register_new_matrix_user -u test -p a - -# - -# This key is removed before shipping as it allows Sovran Systems to access the machine via root remotely. - -echo "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCQa3DEhx9RUtV0WopfFuL3cjQt2fBzp5wOg/hkj0FXyZXpp+F47Td1B9mKMNvucINaMQB6T0mW6c70fyT92gZO2OqCff6aeWovtTd9ynRgtJbny/qvVSShDbJcR7nSMeVPoDRaYs18fuA50guYnfoYAkaXyXPmVQ0uK84HwIB5j8gq6GMji7vv+TTNhDP8qOceUzt1DYPo9Z2JSnkFey+Z/fmxWJGsu+MSrA0/PPENEmf6L0ZSgxnu3gHEtdyX2hrFzjE16y3G0wSQzbWJb8MJO0KRSMcyvz6AzOSW4RYdXR1c+4JiciKRdnIAYYHfg7tnZT9wC9AzHjdEbmmrlF05mtjXKnxbPgGY0tlRSYo7B5E0k2zfi30MkIJ6kIE9TMM2z/+1KstrQN4OKBTGomBTYQaRQCT6dGpRTR+b8lOvUcnCSuat1sUC2M2VGFcBbDbKD0FyXy/vOk1pgA4I7GoESWQClnl+ntRg8HrW4oVTX2KpqR2CXjlF956HJGqHW6k= free@nixos" >> /root/.ssh/authorized_keys - -# - -pushd /etc/nixos - - nix flake update - - nixos-rebuild switch --impure - -popd - -# - -echo "root:$(cat /var/lib/secrets/main)" | chpasswd -c SHA512 - -echo "free:a" | chpasswd -c SHA512 - -# - -chown free:users /home/free -R - -chmod 700 /home/free -R - -# - -echo -e "${GREEN}All Finished! Please Reboot then Enjoy your New Sovran Pro!" -- 2.53.0 From aa8d6066e6a6a8aba5c405aa2d1638322f9f300f Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 16:05:10 -0500 Subject: [PATCH 015/857] updated custom.nix --- custom.nix | 196 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 188 insertions(+), 8 deletions(-) diff --git a/custom.nix b/custom.nix index ce46153..6dfdd3a 100644 --- a/custom.nix +++ b/custom.nix @@ -1,10 +1,190 @@ -{ - # ── Disable services you don't want ───────────── - sovran_systemsOS.services.wordpress = false; - sovran_systemsOS.services.nextcloud = false; +{ config, lib, ... }: - # ── Enable features you do want ───────────────── - sovran_systemsOS.features.haven = true; - sovran_systemsOS.features.element-calling = true; - sovran_systemsOS.nostr_npub = "npub1abc123..."; +{ + ########################################################### + # # + # Sovran_SystemsOS — custom.nix # + # # + # This is YOUR configuration file. Edit it to customize # + # which services and features run on your machine. # + # # + # After making changes, rebuild with: # + # # + # sudo nixos-rebuild switch --flake /etc/nixos#nixos # + # # + ########################################################### + + + # ═══════════════════════════════════════════════════════════ + # STEP 1: CHOOSE YOUR ROLE + # ═══════════════════════════════════════════════════════════ + # + # Pick ONE role by uncommenting it. If none is chosen, + # you get the Server-Desktop role by default. + # + # Server-Desktop (default): + # - Full server + desktop environment + # - All services ON by default + # - All features OFF by default + # + # Desktop Only: + # - Desktop environment, no server services + # - All services OFF by default + # + # Bitcoin Node Only: + # - Bitcoin ecosystem, mempool, bip110 + # - BTCPay runs but is NOT exposed to the web + # - All other services OFF by default + # + # ─────────────────────────────────────────────────────────── + + # sovran_systemsOS.roles.desktop = true; + # sovran_systemsOS.roles.node = true; + + + # ═══════════════════════════════════════════════════════════ + # STEP 2: SERVICES (default: ON) + # ═══════════════════════════════════════════════════════════ + # + # These are all ON by default in the Server-Desktop role. + # Set any to "false" to disable it. + # + # ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + # │ Service │ What it does │ + # ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ + # │ synapse │ Matrix Synapse homeserver │ + # │ bitcoin │ Bitcoin ecosystem (bitcoind, │ + # │ │ electrs, lnd, rtl, btcpay) │ + # │ vaultwarden │ Vaultwarden password manager │ + # │ wordpress │ WordPress website │ + # │ nextcloud │ Nextcloud file hosting │ + # ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + # + # Example — disable WordPress and Nextcloud: + # + # sovran_systemsOS.services.wordpress = false; + # sovran_systemsOS.services.nextcloud = false; + # + # ─────────────────────────────────────────────────────────── + + # sovran_systemsOS.services.synapse = false; + # sovran_systemsOS.services.bitcoin = false; + # sovran_systemsOS.services.vaultwarden = false; + # sovran_systemsOS.services.wordpress = false; + # sovran_systemsOS.services.nextcloud = false; + + + # ═══════════════════════════════════════════════════════════ + # STEP 3: FEATURES (default: OFF) + # ═══════════════════════════════════════════════════════════ + # + # These are all OFF by default. Set to "true" to enable. + # + # ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + # │ Feature │ What it does │ + # ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ + # │ haven │ Haven NOSTR relay │ + # │ │ (requires nostr_npub below) │ + # │ element-calling │ Element video/audio calls │ + # │ │ (LiveKit + lk-jwt-service) │ + # │ mempool │ Bitcoin Mempool Explorer │ + # │ bip110 │ BIP-110 Bitcoin Better Money │ + # │ bitcoin-core │ Bitcoin Core (standalone) │ + # │ rdp │ GNOME Remote Desktop (RDP) │ + # ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + # + # Example — enable Haven and Element Calling: + # + # sovran_systemsOS.features.haven = true; + # sovran_systemsOS.features.element-calling = true; + # + # ─────────────────────────────────────────────────────────── + + # sovran_systemsOS.features.haven = true; + # sovran_systemsOS.features.element-calling = true; + # sovran_systemsOS.features.mempool = true; + # sovran_systemsOS.features.bip110 = true; + # sovran_systemsOS.features.bitcoin-core = true; + # sovran_systemsOS.features.rdp = true; + + + # ═══════════════════════════════════════════════════════════ + # STEP 4: WEB EXPOSURE (controls Caddy reverse proxy) + # ═══════════════════════════════════════════════════════════ + # + # These control whether a service gets a public Caddy + # vhost. The service itself still runs regardless. + # + # ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + # │ Option │ Default │ + # ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ + # │ btcpayserver │ true (false in Node role) │ + # ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + # + # Example — hide BTCPay from the web: + # + # sovran_systemsOS.web.btcpayserver = false; + # + # ─────────────────────────────────────────────────────────── + + # sovran_systemsOS.web.btcpayserver = false; + + + # ═══════════════════════════════════════════════════════════ + # STEP 5: NOSTR PUBLIC KEY (required for Haven) + # ═══════════════════════════════════════════════════════════ + # + # If you enabled Haven above, paste your npub here. + # Haven will NOT start without a valid npub. + # + # Example: + # + # sovran_systemsOS.nostr_npub = "npub1abc123..."; + # + # ─────────────────────────────────────────────────────────── + + # sovran_systemsOS.nostr_npub = ""; + + + # ═══════════════════════════════════════════════════════════ + # QUICK REFERENCE — COMMON SETUPS + # ═══════════════════════════════════════════════════════════ + # + # ── Full Server (default, change nothing) ────────────── + # + # All services ON, all features OFF. + # Just leave this file as-is. + # + # + # ── Server without WordPress ─────────────────────────── + # + # sovran_systemsOS.services.wordpress = false; + # + # + # ── Server with Haven + Element Calling ──────────────── + # + # sovran_systemsOS.features.haven = true; + # sovran_systemsOS.features.element-calling = true; + # sovran_systemsOS.nostr_npub = "npub1your_key_here"; + # + # + # ── Bitcoin Node Only ────────────────────────────────── + # + # sovran_systemsOS.roles.node = true; + # + # (Gives you: bitcoind, electrs, lnd, rtl, btcpay, + # mempool, bip110 — no web services) + # + # + # ── Desktop Only (no server) ─────────────────────────── + # + # sovran_systemsOS.roles.desktop = true; + # + # + # ── Node with BTCPay web access ──────────────────────── + # + # sovran_systemsOS.roles.node = true; + # sovran_systemsOS.web.btcpayserver = true; + # + # ═══════════════════════════════════════════════════════════ } -- 2.53.0 From 02f6bd3ad3fa288c1ba7a2cb1eb5d34ef3b41685 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 16:07:34 -0500 Subject: [PATCH 016/857] updated custom.nix --- custom.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom.nix b/custom.nix index 6dfdd3a..98052ca 100644 --- a/custom.nix +++ b/custom.nix @@ -10,7 +10,7 @@ # # # After making changes, rebuild with: # # # - # sudo nixos-rebuild switch --flake /etc/nixos#nixos # + # nixos-rebuild switch --impure # # # ########################################################### -- 2.53.0 From d7af813c71b7d625f294635376a5e0ff16833546 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 16:10:04 -0500 Subject: [PATCH 017/857] typo flake.nix --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 737def0..748b9a5 100755 --- a/flake.nix +++ b/flake.nix @@ -44,7 +44,7 @@ btc-clients.packages.${pkgs.system}.bisq btc-clients.packages.${pkgs.system}.bisq2 btc-clients.packages.${pkgs.system}.sparrow - ] + ]; sovran_systemsOS.packages.bip110 = bip110.packages.${pkgs.system}.bitcoind-knots-bip-110; }; }; -- 2.53.0 From 4630ff0e1b4fa198e9b9e580266faeb75a4cc50b Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 16:27:10 -0500 Subject: [PATCH 018/857] updated element calling --- modules/element-calling.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/element-calling.nix b/modules/element-calling.nix index 67859a5..80e2176 100755 --- a/modules/element-calling.nix +++ b/modules/element-calling.nix @@ -168,6 +168,7 @@ EOF enable = true; port = 8073; keyFile = livekitKeyFile; + livekitUrl = "wss://placeholder.local"; # overridden at runtime by EnvironmentFile }; systemd.services.lk-jwt-service.serviceConfig.EnvironmentFile = [ -- 2.53.0 From 0175c497a5014cc2c619b5a74674a35eebce24ef Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 16:49:30 -0500 Subject: [PATCH 019/857] added manual config for domains --- initial_setup/manual_domain_setup.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 initial_setup/manual_domain_setup.sh diff --git a/initial_setup/manual_domain_setup.sh b/initial_setup/manual_domain_setup.sh new file mode 100644 index 0000000..a4ef473 --- /dev/null +++ b/initial_setup/manual_domain_setup.sh @@ -0,0 +1,13 @@ +sudo mkdir -p /var/lib/domains + +# One domain per file — just the bare domain, no https:// +echo "matrix.yourdomain.com" | sudo tee /var/lib/domains/matrix +echo "cloud.yourdomain.com" | sudo tee /var/lib/domains/nextcloud +echo "blog.yourdomain.com" | sudo tee /var/lib/domains/wordpress +echo "pay.yourdomain.com" | sudo tee /var/lib/domains/btcpayserver +echo "vault.yourdomain.com" | sudo tee /var/lib/domains/vaultwarden +echo "you@yourdomain.com" | sudo tee /var/lib/domains/sslemail + +# Only if you enable these features: +echo "relay.yourdomain.com" | sudo tee /var/lib/domains/haven +echo "call.yourdomain.com" | sudo tee /var/lib/domains/element-calling -- 2.53.0 From 75098079381d648c558c574a68df7b2ea3ca5343 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 27 Mar 2026 17:12:11 -0500 Subject: [PATCH 020/857] added tooling for domains --- modules/bitcoinecosystem.nix | 4 + modules/core/roles.nix | 16 ++- modules/core/sovran-manage-domains.nix | 133 +++++++++++++++++++++++++ modules/element-calling.nix | 3 + modules/haven.nix | 4 + modules/nextcloud.nix | 4 + modules/synapse.nix | 4 + modules/vaultwarden.nix | 3 + modules/wordpress.nix | 4 + 9 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 modules/core/sovran-manage-domains.nix diff --git a/modules/bitcoinecosystem.nix b/modules/bitcoinecosystem.nix index e7d1912..d356263 100755 --- a/modules/bitcoinecosystem.nix +++ b/modules/bitcoinecosystem.nix @@ -69,4 +69,8 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin { }; nix-bitcoin.useVersionLockedPkgs = false; + + sovran_systemsOS.domainRequirements = [ + { name = "btcpayserver"; label = "BTCPay Server"; example = "pay.yourdomain.com"; } + ]; } diff --git a/modules/core/roles.nix b/modules/core/roles.nix index 28b230e..3f4dc86 100755 --- a/modules/core/roles.nix +++ b/modules/core/roles.nix @@ -55,10 +55,24 @@ btcpayserver = lib.mkOption { type = lib.types.bool; default = true; - description = "Expose BTCPay Server via Caddy (service still runs via nix-bitcoin regardless)"; + description = "Expose BTCPay Server via Caddy"; }; }; + # ── Domain setup registry ───────────────────────────────── + domainRequirements = lib.mkOption { + type = lib.types.listOf (lib.types.submodule { + options = { + name = lib.mkOption { type = lib.types.str; }; + label = lib.mkOption { type = lib.types.str; }; + example = lib.mkOption { type = lib.types.str; }; + needsDDNS = lib.mkOption { type = lib.types.bool; default = true; }; + }; + }); + default = []; + description = "Domain requirements registered by each module"; + }; + nostr_npub = lib.mkOption { type = lib.types.str; default = ""; diff --git a/modules/core/sovran-manage-domains.nix b/modules/core/sovran-manage-domains.nix new file mode 100644 index 0000000..ab44e26 --- /dev/null +++ b/modules/core/sovran-manage-domains.nix @@ -0,0 +1,133 @@ +{ config, pkgs, lib, ... }: + +let + domains = config.sovran_systemsOS.domainRequirements; + + # Build the domain prompts dynamically from registered modules + domainPrompts = lib.concatMapStringsSep "\n" (d: '' + echo "" + echo -e "''${GREEN}── ${d.label} ──''${NC}" + EXISTING="" + if [ -f "/var/lib/domains/${d.name}" ]; then + EXISTING=$(cat "/var/lib/domains/${d.name}") + echo -e " Current: ''${CYAN}$EXISTING''${NC}" + fi + read -p " Subdomain (e.g. ${d.example}) or Enter to keep current: " DOMAIN_INPUT + DOMAIN="''${DOMAIN_INPUT:-$EXISTING}" + + if [ -n "$DOMAIN" ]; then + echo "$DOMAIN" > "/var/lib/domains/${d.name}" + echo " Saved: $DOMAIN" + ${lib.optionalString d.needsDDNS '' + read -p " Njal.la DDNS URL for $DOMAIN (paste full URL, or Enter to skip): " DDNS_URL + if [ -n "$DDNS_URL" ]; then + NJALLA_ENTRIES="$NJALLA_ENTRIES +curl \"''${DDNS_URL%auto}''${DOLLAR}{IP}\"" + fi + ''} + else + echo " Skipped." + fi + '') domains; + + # Build the summary list + domainSummary = lib.concatMapStringsSep "\n" (d: '' + if [ -f "/var/lib/domains/${d.name}" ]; then + echo " ${d.label}: $(cat /var/lib/domains/${d.name})" + fi + '') domains; +in +{ + environment.systemPackages = [ + (pkgs.writeShellScriptBin "sovran-setup-domains" '' + set -euo pipefail + + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + CYAN='\033[0;36m' + NC='\033[0m' + DOLLAR='$' + + echo "" + echo -e "''${CYAN}══════════════════════════════════════════════''${NC}" + echo -e "''${CYAN} Sovran_SystemsOS — Domain & DDNS Setup''${NC}" + echo -e "''${CYAN}══════════════════════════════════════════════''${NC}" + echo "" + echo -e "''${YELLOW}Before running this, you need:''${NC}" + echo "" + echo " 1. Domains/subdomains purchased on https://njal.la" + echo " 2. For each subdomain, add a Dynamic record in" + echo " your Njal.la dashboard." + echo " 3. Njal.la will give you a DDNS URL like:" + echo "" + echo -e " ''${CYAN}https://njal.la/update/?h=sub.domain.com&k=abc123&auto''${NC}" + echo "" + echo " Have those URLs ready." + echo "" + read -p "Press Enter to continue..." + + # ── Create directories ──────────────────────────── + mkdir -p /var/lib/domains + mkdir -p /var/lib/njalla + + NJALLA_ENTRIES="" + + # ── SSL Email ───────────────────────────────────── + echo "" + echo -e "''${GREEN}── SSL Certificate Email ──''${NC}" + echo "Let's Encrypt needs an email for certificate notifications." + EXISTING_EMAIL="" + if [ -f "/var/lib/domains/sslemail" ]; then + EXISTING_EMAIL=$(cat /var/lib/domains/sslemail) + echo -e " Current: ''${CYAN}$EXISTING_EMAIL''${NC}" + fi + read -p " Email address (or Enter to keep current): " EMAIL_INPUT + SSL_EMAIL="''${EMAIL_INPUT:-$EXISTING_EMAIL}" + if [ -n "$SSL_EMAIL" ]; then + echo "$SSL_EMAIL" > /var/lib/domains/sslemail + echo " Saved." + fi + + # ── Module domains (auto-generated from enabled modules) ── + ${domainPrompts} + + # ── Write njalla.sh ─────────────────────────────── + echo "" + echo -e "''${GREEN}── Generating DDNS script ──''${NC}" + + cat > /var/lib/njalla/njalla.sh < + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GitHub Ā· Change is constant. GitHub keeps you ahead. Ā· GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + + + + + + + + + + +

The future of building happens together

Tools and trends evolve, but collaboration endures. With GitHub, developers, agents, and code come together on one platform.

Try GitHub Copilot free

GitHub features

A demonstration animation of a code editor using GitHub Copilot Chat, where the user requests GitHub Copilot to refactor duplicated logic and extract it into a reusable function for a given code snippet.

Write, test, and fix code quickly with GitHub Copilot, from simple boilerplate to complex features.

GitHub customers

American AirlinesDuolingoErnst and YoungFordInfoSysMercado LibreMercedes-BenzShopifyPhilipsSociƩtƩ GƩnƩraleSpotifyVodafone

Accelerate your entire workflow

From your first line of code to final deployment, GitHub provides AI and automation tools to help you build and ship better software faster.

A Copilot chat window with the 'Ask' mode enabled. The user switches from 'Ask' mode to 'Agent' mode from a dropdown menu, then sends the prompt 'Update the website to allow searching for running races by name.' Copilot analyzes the codebase, then explains the required edits for three files before generating them. Copilot then confirms completion and summarizes the implemented changes for the new functionality allowing users to search races by name and view paginated, filtered results.

Your AI partner everywhere. Copilot is ready to work with you at each step of the software development lifecycle.

Duolingo boosts developer speed by 25% with GitHub Copilot

Read customer story

2025 GartnerĀ® Magic Quadrantā„¢ for AI Code Assistants

Read industry report

Ship faster with secure, reliable CI/CD.

Explore GitHub Actions

Built-in application security where found means fixed

Use AI to find and fix vulnerabilities so your team can ship more secure software faster.

Apply fixes in seconds. Spend less time debugging and more time building features with Copilot Autofix.

Copilot Autofix identifies vulnerable code and provides an explanation, together with a secure code suggestion to remediate the vulnerability.

Security debt, solved. Leverage security campaigns and Copilot Autofix to reduce application vulnerabilities.

Learn about GitHub Code Security
A security campaign screen displays the campaign’s progress bar with 97% completed of 701 alerts. A total of 23 alerts are left with 13 in progress, and the campaign started 20 days ago. The status below shows that there are 7 days left in the campaign with a due date of November 15, 2024.

Dependencies you can depend on. Update vulnerable dependencies with supported fixes for breaking changes.

Learn about Dependabot
List of dependencies defined in a requirements .txt file.

Your secrets, your business. Detect, prevent, and remediate leaked secrets across your organization.

Learn about GitHub Secret Protection
GitHub push protection confirms and displays an active secret, and blocks the push.

70% MTTR reduction with Copilot Autofix

8.3M secret leaks stopped in the past 12 months with push protection

Work together, achieve more

From planning and discussion to code review, GitHub keeps your team’s conversation and context next to your code.

A project management dashboard showing tasks for the ā€˜OctoArcade Invaders’ project, with tasks grouped under project phase categories like ā€˜Prototype,’ ā€˜Beta,’ and ā€˜Launch’ in a table layout. One of the columns displays sub-issue progress bars with percentages for each issue.

Plan with clarity. Organize everything from high-level roadmaps to everyday tasks.

It helps us onboard new software engineers and get them productive right away. We have all our source code, issues, and pull requests in one place... GitHub is a complete platform that frees us from menial tasks and enables us to do our best work.
Fabian FaulhaberApplication manager at Mercedes-Benz

Create issues and manage projects with tools that adapt to your code.

Explore GitHub Issues

Millions of developers and businesses call GitHub home

Whether you’re scaling your development process or just learning how to code, GitHub is where you belong. Join the world’s most widely adopted developer platform to build the technologies that shape what’s next.

+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/app/icons/app/icons/lnd.png b/app/icons/app/icons/lnd.png new file mode 100644 index 0000000000000000000000000000000000000000..9c60603b257e0a7b7b626b809368d4b111938399 GIT binary patch literal 1592 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 literal 0 HcmV?d00001 diff --git a/app/icons/app/icons/rtl.png b/app/icons/app/icons/rtl.png new file mode 100644 index 0000000000000000000000000000000000000000..773a47e3f45dd170982e6c7a45bc6c59dd12cf4f GIT binary patch literal 1564 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i983%h3?KE5B{49tKK68R45^s&=K98oa~uR5 z0@rqlbOL4?_}ALGd$V=;*ClKX-~ab@h0V5qOoe}J!&XlT(lT>%H>5;^V%T|6pvtmE*Y*A1^oIUk*W$}k~b$N1yG562Tdwo8Q^^*Hq z%dU(2o>w{w$n2?~wXAa9?EmsCjn6B^E6Wbww|>s3zoKI4^ZjS~%VJ~-Wzr+Vc5d7g z*7LZ+Z1z=k&R>7))?ep7yrHOiTI%DE7M_z9@f`lVr*8lMFJG<{)Xq+goTRe9?yr`6 znBbeQReS69$Nvm`x@z99Y!j{EX-B!wZ#pyQbi80&qTti4tyPtkn)@&LEl*bSyJG74 z)V$1U?yBpi5rTOmgMK>-^~kO`$g-LiERJz^XJcdPBN+aW8y0> zFaLV`@j@BbixatjuaE!Vq8#1d-+yi94`CfK?ytN(i&lL4x4K-7gXy@}ibqU^SKN8> zF2B68tF>%J{?yARw##{vZRA!zJnDKTZ8Nvu;p%zIuT4pN5%#CpL@Kho#HJ}UyDT+* zbL7wY=g*(Nw!3m(x><1=@6Nk<;@z#utvO){``(v(T@l~%IiLM|!>skP(y!h})|<@r z^X*8xo3r7-?rzq#gaGQT(Mh} z`$EAHYWF`_C+UzbsEp_I)yvQS`Z4|Hni4D7 z*Hwo$e&T3y_}dk=TKqAy>5#Hevi!E8 zv+?}%{xb@8pW%6Ve!l(M?X$NZ_!VhqYrE2&yXk;!ANS!4zn+%uxjSE=aZ`+*|2v17 zK5DD4u6kG(AklW*k5zJd=i!fcv?7lGE;Z;&UViwoMHusqezCTPM^76(`~36o-@gZU z|J--K=Kj`4S?;N_^WSaHzkjYW#khIi+r4}Dt_@qQz0S;Uc3id+N7KWC6=gnLeK#jI z=Na}T&)obgETJ1|L#AVJ!kPF zgJ@yTNj}Rj3(s3xTd2{e8Gd|Ek#s2Eh3PBbAItn*Qtu~|r?0R7-2Q~9`sGts*p7a$ zE%l0<_xzghvOE9gSlqjR|7!mvm7OOVzC0*-qAtEMb4&e29*jDb^rg(RXLgJE?qefetH z(m7V>2ka_;_V|0%Ug6SM&G()n4cy{AcO1Fg}mdHs`G$ z&3Z8T&K1{R^H;8&En35brv|`Y!(Fmhm)O5&&lLTVoj2`0+xr84E*3ee`ttvg!2K*U zE&j=^zIv+4@^1L+uR&8jh6l=jpME#*`%CWTvIGaNfv72A_4I#E28RFt=dc*%N;SN3 RXJBAp@O1TaS?83{1OVUe8F2sr literal 0 HcmV?d00001 diff --git a/app/icons/app/icons/tor.png b/app/icons/app/icons/tor.png new file mode 100644 index 0000000000000000000000000000000000000000..43ce3d5d5e1f93dcf459b6a9a59d44daf99e8b7c GIT binary patch literal 1505 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i983%h3?KE5B{49tuJCkm45^s&=GsNhLk35?rbbjVInD067KG0GX5VQ|PO_tLg^ zm(vc0{&a8ONxwuzuf#iQ&+v=WmfsSO-^eo5olB)(`BTHh!0`XSv8ph`zopr0Nf1UbN~PV literal 0 HcmV?d00001 diff --git a/app/icons/app/icons/vaultwarden.png b/app/icons/app/icons/vaultwarden.png new file mode 100644 index 0000000000000000000000000000000000000000..209754aaa3c360f60df3c4cd8d55191fbd82aa61 GIT binary patch literal 1597 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 AqyPW_ literal 0 HcmV?d00001 -- 2.53.0 From fb4b750a299a301722e613f922cae25b55f0e5b7 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 10:55:32 -0500 Subject: [PATCH 111/857] updated hub --- modules/core/sovran-hub.nix | 104 +++--------------------------------- 1 file changed, 7 insertions(+), 97 deletions(-) diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 0ee8bdf..cfb19e5 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -1,9 +1,9 @@ # modules/core/sovran-hub.nix # # Declarative NixOS module that: -# 1. Fetches high-quality PNG logos for each service -# 2. Builds the Sovran_SystemsOS_Hub GTK4 app as a Nix derivation -# 3. Generates its config.json from existing sovran_systemsOS options +# 1. Builds the Sovran_SystemsOS_Hub GTK4 app as a Nix derivation +# 2. Generates its config.json from existing sovran_systemsOS options +# 3. Uses logos committed directly in the repo (no fetchurl hashes) # 4. Installs a .desktop file so it appears in GNOME Activities { config, pkgs, lib, ... }: @@ -11,97 +11,7 @@ let cfg = config.sovran_systemsOS; - # ── Fetch service logos ────────────────────────────────────── - # - # Each logo is fetched once at build time and placed in a - # single directory as .png so the Python app can - # load them by name. - - logos = { - bitcoind = pkgs.fetchurl { - url = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/46/Bitcoin.svg/240px-Bitcoin.svg.png"; - sha256 = "0000000000000000000000000000000000000000000000000000"; # replace after first build - name = "bitcoind.png"; - }; - electrs = pkgs.fetchurl { - url = "https://raw.githubusercontent.com/nicehash/electrumx-client/master/electrum-logo.png"; - sha256 = "0000000000000000000000000000000000000000000000000000"; - name = "electrs.png"; - }; - lnd = pkgs.fetchurl { - url = "https://raw.githubusercontent.com/lightningnetwork/lnd/master/logo.png"; - sha256 = "0000000000000000000000000000000000000000000000000000"; - name = "lnd.png"; - }; - rtl = pkgs.fetchurl { - url = "https://raw.githubusercontent.com/Ride-The-Lightning/RTL/master/src/assets/images/rtl-logo.png"; - sha256 = "0000000000000000000000000000000000000000000000000000"; - name = "rtl.png"; - }; - btcpayserver = pkgs.fetchurl { - url = "https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/img/logo.png"; - sha256 = "sha256-5yKCvEZ7df61fSQWYx2WqVx4F3v+VxqAsMSUZ2sheeI="; - name = "btcpayserver.png"; - }; - synapse = pkgs.fetchurl { - url = "https://raw.githubusercontent.com/nicehash/element-web/develop/res/themes/element/img/logos/element-logo.png"; - sha256 = "0000000000000000000000000000000000000000000000000000"; - name = "synapse.png"; - }; - vaultwarden = pkgs.fetchurl { - url = "https://raw.githubusercontent.com/dani-garcia/vaultwarden/main/resources/vaultwarden-icon.png"; - sha256 = "0000000000000000000000000000000000000000000000000000"; - name = "vaultwarden.png"; - }; - nextcloud = pkgs.fetchurl { - url = "https://upload.wikimedia.org/wikipedia/commons/thumb/6/60/Nextcloud_Logo.svg/240px-Nextcloud_Logo.svg.png"; - sha256 = "0000000000000000000000000000000000000000000000000000"; - name = "nextcloud.png"; - }; - wordpress = pkgs.fetchurl { - url = "https://s.w.org/style/images/about/WordPress-logotype-wmark.png"; - sha256 = "0000000000000000000000000000000000000000000000000000"; - name = "wordpress.png"; - }; - haven = pkgs.fetchurl { - url = "https://raw.githubusercontent.com/nicehash/nostr-rs-relay/master/logo.png"; - sha256 = "0000000000000000000000000000000000000000000000000000"; - name = "haven.png"; - }; - mempool = pkgs.fetchurl { - url = "https://raw.githubusercontent.com/nicehash/mempool/master/frontend/src/resources/mempool-space-logo.png"; - sha256 = "0000000000000000000000000000000000000000000000000000"; - name = "mempool.png"; - }; - livekit = pkgs.fetchurl { - url = "https://avatars.githubusercontent.com/u/70location?s=200"; - sha256 = "0000000000000000000000000000000000000000000000000000"; - name = "livekit.png"; - }; - caddy = pkgs.fetchurl { - url = "https://caddyserver.com/resources/images/caddy-logo.png"; - sha256 = "0000000000000000000000000000000000000000000000000000"; - name = "caddy.png"; - }; - tor = pkgs.fetchurl { - url = "https://upload.wikimedia.org/wikipedia/commons/thumb/1/15/Tor-logo-2011-flat.svg/240px-Tor-logo-2011-flat.svg.png"; - sha256 = "0000000000000000000000000000000000000000000000000000"; - name = "tor.png"; - }; - }; - - # Bundle all logos into a single derivation directory - logoDir = pkgs.runCommand "sovran-hub-icons" {} '' - mkdir -p $out - ${lib.concatStringsSep "\n" (lib.mapAttrsToList (key: src: - "cp ${src} $out/${key}.png" - ) logos)} - ''; - # ── Build the list of monitored units from NixOS option state ── - # - # Each entry now includes an "icon" key that matches a filename - # in the logoDir (without extension). monitoredServices = (lib.optional cfg.services.bitcoin @@ -174,12 +84,12 @@ let # Copy CSS cp style.css $out/lib/sovran-hub/style.css + # Copy logos from the repo (no fetchurl needed) + cp icons/*.png $out/share/sovran-hub/icons/ + # Install the generated config cp ${generatedConfig} $out/lib/sovran-hub/config.json - # Install logos - cp -r ${logoDir}/* $out/share/sovran-hub/icons/ - # Create the launcher script cat > $out/bin/sovran-hub <<'LAUNCHER' #!/usr/bin/env python3 @@ -220,4 +130,4 @@ in config = { environment.systemPackages = [ sovran-hub ]; }; -} +} \ No newline at end of file -- 2.53.0 From c6024ca9686283b75acdd2ac5e7027d157b2eb1a Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 10:58:11 -0500 Subject: [PATCH 112/857] updated name --- app/{app_style.css => style.css} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/{app_style.css => style.css} (100%) diff --git a/app/app_style.css b/app/style.css similarity index 100% rename from app/app_style.css rename to app/style.css -- 2.53.0 From de93dc89baf27e2d4b68339f40a9b1c8e3542126 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 11:01:12 -0500 Subject: [PATCH 113/857] updated icons --- app/{ => app}/icons/app/icons/bitcoind.png | Bin .../icons/app/icons/btcpayserver.png | Bin app/{ => app}/icons/app/icons/caddy.png | Bin app/{ => app}/icons/app/icons/electrs.png | Bin app/{ => app}/icons/app/icons/haven.png | Bin app/{ => app}/icons/app/icons/livekit.png | 0 app/{ => app}/icons/app/icons/lnd.png | Bin app/{ => app}/icons/app/icons/mempool.png | Bin app/{ => app}/icons/app/icons/nextcloud.png | Bin app/{ => app}/icons/app/icons/rtl.png | Bin app/{ => app}/icons/app/icons/synapse.png | Bin app/{ => app}/icons/app/icons/tor.png | Bin app/{ => app}/icons/app/icons/vaultwarden.png | Bin app/{ => app}/icons/app/icons/wordpress.png | Bin app/app/icons/bitcoind.png | Bin 0 -> 4806 bytes app/app/icons/btcpayserver.png | Bin 0 -> 5065 bytes app/app/icons/caddy.png | Bin 0 -> 1584 bytes app/app/icons/electrs.png | Bin 0 -> 1541 bytes app/app/icons/haven.png | Bin 0 -> 1520 bytes app/app/icons/livekit.png | 1402 +++++++++++++++++ app/app/icons/lnd.png | Bin 0 -> 1592 bytes app/app/icons/mempool.png | Bin 0 -> 1535 bytes app/app/icons/nextcloud.png | Bin 0 -> 12075 bytes app/app/icons/rtl.png | Bin 0 -> 1564 bytes app/app/icons/synapse.png | Bin 0 -> 2129 bytes app/app/icons/tor.png | Bin 0 -> 1505 bytes app/app/icons/vaultwarden.png | Bin 0 -> 1597 bytes app/app/icons/wordpress.png | Bin 0 -> 18579 bytes 28 files changed, 1402 insertions(+) rename app/{ => app}/icons/app/icons/bitcoind.png (100%) rename app/{ => app}/icons/app/icons/btcpayserver.png (100%) rename app/{ => app}/icons/app/icons/caddy.png (100%) rename app/{ => app}/icons/app/icons/electrs.png (100%) rename app/{ => app}/icons/app/icons/haven.png (100%) rename app/{ => app}/icons/app/icons/livekit.png (100%) rename app/{ => app}/icons/app/icons/lnd.png (100%) rename app/{ => app}/icons/app/icons/mempool.png (100%) rename app/{ => app}/icons/app/icons/nextcloud.png (100%) rename app/{ => app}/icons/app/icons/rtl.png (100%) rename app/{ => app}/icons/app/icons/synapse.png (100%) rename app/{ => app}/icons/app/icons/tor.png (100%) rename app/{ => app}/icons/app/icons/vaultwarden.png (100%) rename app/{ => app}/icons/app/icons/wordpress.png (100%) create mode 100644 app/app/icons/bitcoind.png create mode 100644 app/app/icons/btcpayserver.png create mode 100644 app/app/icons/caddy.png create mode 100644 app/app/icons/electrs.png create mode 100644 app/app/icons/haven.png create mode 100644 app/app/icons/livekit.png create mode 100644 app/app/icons/lnd.png create mode 100644 app/app/icons/mempool.png create mode 100644 app/app/icons/nextcloud.png create mode 100644 app/app/icons/rtl.png create mode 100644 app/app/icons/synapse.png create mode 100644 app/app/icons/tor.png create mode 100644 app/app/icons/vaultwarden.png create mode 100644 app/app/icons/wordpress.png diff --git a/app/icons/app/icons/bitcoind.png b/app/app/icons/app/icons/bitcoind.png similarity index 100% rename from app/icons/app/icons/bitcoind.png rename to app/app/icons/app/icons/bitcoind.png diff --git a/app/icons/app/icons/btcpayserver.png b/app/app/icons/app/icons/btcpayserver.png similarity index 100% rename from app/icons/app/icons/btcpayserver.png rename to app/app/icons/app/icons/btcpayserver.png diff --git a/app/icons/app/icons/caddy.png b/app/app/icons/app/icons/caddy.png similarity index 100% rename from app/icons/app/icons/caddy.png rename to app/app/icons/app/icons/caddy.png diff --git a/app/icons/app/icons/electrs.png b/app/app/icons/app/icons/electrs.png similarity index 100% rename from app/icons/app/icons/electrs.png rename to app/app/icons/app/icons/electrs.png diff --git a/app/icons/app/icons/haven.png b/app/app/icons/app/icons/haven.png similarity index 100% rename from app/icons/app/icons/haven.png rename to app/app/icons/app/icons/haven.png diff --git a/app/icons/app/icons/livekit.png b/app/app/icons/app/icons/livekit.png similarity index 100% rename from app/icons/app/icons/livekit.png rename to app/app/icons/app/icons/livekit.png diff --git a/app/icons/app/icons/lnd.png b/app/app/icons/app/icons/lnd.png similarity index 100% rename from app/icons/app/icons/lnd.png rename to app/app/icons/app/icons/lnd.png diff --git a/app/icons/app/icons/mempool.png b/app/app/icons/app/icons/mempool.png similarity index 100% rename from app/icons/app/icons/mempool.png rename to app/app/icons/app/icons/mempool.png diff --git a/app/icons/app/icons/nextcloud.png b/app/app/icons/app/icons/nextcloud.png similarity index 100% rename from app/icons/app/icons/nextcloud.png rename to app/app/icons/app/icons/nextcloud.png diff --git a/app/icons/app/icons/rtl.png b/app/app/icons/app/icons/rtl.png similarity index 100% rename from app/icons/app/icons/rtl.png rename to app/app/icons/app/icons/rtl.png diff --git a/app/icons/app/icons/synapse.png b/app/app/icons/app/icons/synapse.png similarity index 100% rename from app/icons/app/icons/synapse.png rename to app/app/icons/app/icons/synapse.png diff --git a/app/icons/app/icons/tor.png b/app/app/icons/app/icons/tor.png similarity index 100% rename from app/icons/app/icons/tor.png rename to app/app/icons/app/icons/tor.png diff --git a/app/icons/app/icons/vaultwarden.png b/app/app/icons/app/icons/vaultwarden.png similarity index 100% rename from app/icons/app/icons/vaultwarden.png rename to app/app/icons/app/icons/vaultwarden.png diff --git a/app/icons/app/icons/wordpress.png b/app/app/icons/app/icons/wordpress.png similarity index 100% rename from app/icons/app/icons/wordpress.png rename to app/app/icons/app/icons/wordpress.png diff --git a/app/app/icons/bitcoind.png b/app/app/icons/bitcoind.png new file mode 100644 index 0000000000000000000000000000000000000000..e1ba21e8609b2d446a064b098a12afc1e9c57e56 GIT binary patch literal 4806 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 Oz~JfX=d#Wzp$Py<82(iN literal 0 HcmV?d00001 diff --git a/app/app/icons/btcpayserver.png b/app/app/icons/btcpayserver.png new file mode 100644 index 0000000000000000000000000000000000000000..0b976420b016ccccd7ecb157c07a68fdeecec9c1 GIT binary patch literal 5065 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i9Lx+144#t~vNJF+EDG=maXoeF)W3iK&YnH{ z?c29==gvKQ_Uz4@H;*4bK7aoFg9i^@y?XWG!-tC(FW$Iu(8G*pFe-TcJ127 zj~`#Ze*Ngtqwn9p-@JLV_qO#R1_q%=o-U3d6>)D4wDUF_2rxLD|7f;%>GwDLrINT* zN~ZU*F<^iftDf1PoBr_6x&1;PEpmT#Y-)(uidbzJUur{D_Wvqzf;JZ z=aCnjE|t(&!0H{X@GFBRCZ>d9WOyh(MRShHfdKW~U!|1H>!@yE=kIU8RW zsi;1c;oAId!jX+W#dkH8<236c*4VwUtgF7gQJ{Ou#@p{CU7tJ-`7W?hL_XzrkCIu8 z*r^V;Q>)BtyH2{lee}R{<*{AkBCpbDNiRO@JRS~D$*;Dm zy=%2NbV^U`{33LDYcE5Po1*jj!_%y7CTz;K+k2xT@xit$DMvk+>elw`_kP=_!aXhf z!SWD&=O9hr56iEt^S`7iTQGH3kB8S%wtY*qswZ7r)h+U1sz+K*+M~NOjol`FKF@bg zGXBSshL3#jvnMFJPT5uA|E%-M!)+G#E~I&Qo%H#Tcgj7;@9e>7%^gb?d}MupIB)VI zKel^Ej;W{mvRAODO`fvt>4kfVUR~0q3wMesKiw();*8Eju{4=mSv{V@%F?rD?OxKG z$ZGY-^b)7Fv;DPsCYMVVNap+8l8n4?D#1f)R@H?Qb0$vv!?oq2QtU?FF9-aWY&0!- zpyQeC%J)<<_fpJc=fbr4Tgpoom}ooaFIZdRb&Y**>)wfJD*GpH(wr$-74%DIspQhf zlMEMGuloHotMU!MXWh(4mDe}ONL*8K-!5x8<+04mKAk#4wa>RYCN7EC9cow*dM$hJ zk?AG=-_JYfZ#DEho_o%>X7h4uzW|@Ucajg;^XDqglzGlqx9YNOq=u?@x#$P;$u}oF z3D|oz`9wmAKO>jsh1fHP?tlC_FK+$)$2*p~eBZz|B`~ohTzb1{QBT~e+JmyCyH0;! zz;S;0?{hnjuiN>_?8XL(yQW(gvK0I9mlV#QSsUncmQC*FuuNcH^JA^!4Wh zS!_*NmzC?nzj`tUiZ*^cG=b@pz1#8Bbu-Sc`@BD8`_VhEETs5fPON1MABTkW>;m;YB;cND!eyxzQ|$B^}* z~`?&Jq?wp)Asj0os;BJ zHmWnuE$8a&3(}kTMSe1m(8b`4YXzb0k933#OBI@RIW4m{%?Qo<7+2QgoNM9Vw!L2LS#Q?F?Ud274&rd%bEd&b(jk#= zv4L8akK6i90THcEnU7qhMEuye9(!{w?Y?di=#i??GhL%cDeh2`ND#MHv71O!_9M}2 zBG=@+jE|rCbN-1l*U9e-wtbp#B-C<}`SJ-j_*PjP2wk68uUNgc;TKnyPKGVlOBUHV zQ)aZD*O(*N&Nqi&Xj^FNtq0Q=wzp~jzp~K%;hBZq#+WcFj8%KkSAff}L0 zzg`##9nn$AbX-`nV6thamPdV#->^P{v=HCx7p=4(3Sa`z!P0ZtT6~{Fm~pBex&&b$hf-#_HBYiL?_n zcgox59h-Eo<$UYz_Jzy$ENSg?w#+wKS(v^`E0*!uRhC^@lV-G~@c#&9yY*_qj6GXY zSt~P67kYYdaz4=O+`Mr8oR+Ef>_X;E$DRpIvwVK}@B8Q1zEyg5Pm;QD(_^3GN%h3g zj9g!*wxWA6v5wi|FJGN}rLvB1t)xm?^A6F=iZ5?Azna+Qn5YeO#Yj*a>tbo8UI`>sbDg|~!He+jsP==t()iJ2EoYP?QQ@wvIn?&;+Tbsr|xuem%S<(Hn5 z{;Ua?n4ZUd%D!2`rFYq4Zm-%BlOcnLo9~Y-ZJu=4W5(2+?H9LH|90HQ9b(s-;uu|dGwgdU z*Gj(JsU8v5H?s|2zSQ~tGX7mj$f>i}y3gia(1=#6EA+WNJEW@h>zbYOH{Gyz4_lmn zqu|c{>0!&0Hb$KOw>a$2@l};M{YH;pP55`zCu7x~GiNtFuUda;gKW*Cuf zv-U^*?Kf8O^WXEu3NutxZdj?ZF>iI>tgV0keEOOkJ5%lW{2iI)>djvHar(~5eRm~F zxYkCkyE1=QiS+EgSzBF~KUDCW8kAf3(uyI$RPXcAT{EKP+~uz>^bbFrSUN{iwtsbu z;qG(dW_wR3T-v&D<<%XhJOWq^H*0?_&9hHcIpJL%F8s>tv~iEqtflVH_wVeuKIKr6 z?28u~iII0FIYlqY-`!%zv1i$ZV=})^p02Rm=l<~Q!udTt>{sPlO=SG0U+7P)+jKhh z_MgA2i^`2wmON_>dmADZ8ozPk$-C}}DQ?F@3vXLpH{Tok)F5-p>18h+f?IRUcAxtn z|LyYi_piR*%e$~)?tN*Vo1yoYuFiV&HR!r-$I(5HX8uU3$qNg$+FNBiKRmWfYJGY!_T6)~!#l4VFif$2{mf!z?HS#_i97W5%H}a2cv<(Z=SNN99)) z#N~>MiwYZP{Y;&@$sx#?{r_kEZ46WNTXQ$ZFJ*|4w$d~fGSp4AHcPnO5k0fx&bMvX zSp)7pEmrC1dUu%_)m) zth;`NTYlG=(`&M|{a5%D$zZPKb1H22SnOYOd5z<>^Yha4uKI4p2G9zU2qQ4>2m3HwzZxFP>lMB=3fU-qY4Wrpot;O2 zmTDYadGYcyk4X=MN^bFtb@zev+b6*pkwi zs>$bhNh@$5jJ8g~6a?rT@h zO8wLN^f^pLWAj}}kG}8>K25dk7*7-K^`HKqGx)|(@M7|Ye>v|I%bqRWx+bV*Myq%x zTht!Q`6oB<*?kmSEb!<2C8xfM8`Eowy$rPD#$mck{LLUGtdp zFI~Q6Ht*uw;7w9N(|f0_d4E2Z{f~?PtsPE%LCzGc)|{R zJ~w4S+ZWrPeP(spA!`(#+A=A}R+M_35n6A$@OYbOO?pp!{p`gKo*zFX`BwFuEi{ez zI^EXf&h__=shX1NduAQiXgIn(a7|w?%j!cyst31*YEISvbCdtG$hMBX=LKbpy*O_? z`kE7-v1PkmN5(Byo%u%$`2%+a{HYNZ$_Nx#)1?0==!Rt?`@673>lu4bZJ49UYN+$r zzs6`UN<|pe!p76kE->_>uuD2 zY@B>{{rC8nUsWR_yCz4snPfFNp9;5NosleL@TBO<;m-J|(9CxZku@(3lx9UWJl|h) zFmTb2FFTuhk4A{c?^+vqm!YBg_p7~6ERQ~CSh?m<-(vCaM_oDD7VkKtAii1Z**abE zOoL5RA8~t}T>om)|FXYw;jR2qd9oGq>;DSeo0$~USTbvo{m*ybV-95mF`RLB{=(KA7Tikk}OQc!s$&(ZfhU-Vn@A@5OOwjxGCD`=3v4Tgze0~3m zw~M##-%$APG_!@wgUMG{%dWm&b8D-w`1hN|jT3AdJ6G*K%$E9@Pfso= znlVMTrYY32;7*eNW+p{lZcpF$RugV`)%*=8dw*|5rs1NOjP=LeRYZhhCuuHO@0fPX z{X*oAs}C$>*2^+H*qd|uu=;Yl(^c2~KK{LZ_TT0o2N%@){Fe&4dc?o)7V{*F^Strx zZ+l+If34qqk72`IYgf*50R~H?{~y&F~CCQ z`VoIU*UnIdy}GvBb`D7Ijp{Vt5xiW z23JKVsfWw6bf$Mroo~})G4J|r@k<}vJW?LEulzKbtuWSprf1gMIFEm8S)KH+O*pp9 zPyUX^gG~2}6Nu|T-@kE zyvOq?8ph8#4(^kmRIII{+&00BZ$YJ3$lA(@?I|h}mo74X@$bwi**9G{|BVXY$|nw) z&*m}BUMD6heE-9{cQG>EhTMgQMcNTR9!gM_EK_Ee50FI-4{hk zeg4cltzmmM)+uiF%{XSGCKfc^uClPb4vvTk3PH*v9noeF2?R*QQF z@2D3BTRNTc`JndZlD6}w4|nG^t+K3;Fa0m7cy%66X3W9pjoyXrB?tL$E_X_bW`Ca+ z*K58c_K4zF^$O>-z=ad;7$2{;N=f2;DNyU7&7I#Z-lo+uMe@N~Nn^2KkGU(HR*6~M zo4dV6Yss^<6P{gcd9qG+NyEW^ofD>QY&+s-wr@$9q@{CIY2nmYsZY39{6sHcMtYW34YK1dQw6QPiDh=pZRsktGSvyeEsqo*2Qfr@K_riEy()oQ{gs&kW)9d zy!8KeUL(JC%kHZ$)ay6@O`F{D=kkO4w|8%Q9d;04OYk-k)V;zSGJbFT=!Ad;Aw|F8>=_P@DdF)#Pn27fo2VYFM9)>uu}` zuF4&Ay%TrtQQ*tZj}EKfYxws3@}m`NtIgc3?K4kli7nowlfUU^mUw<4#%NW;SI!Gc V)1wW#Y5BU*`+KErS-1Y}zS?gs@1Mkf=Q{uA^&a}UfL=aDv8Sc<8-v~H+PKZ? z2l^w9No+RXGgZ&vXxW?1>U$=gkDzgwqqv05oQPu*=VNxAe?8Or!0w#m{|-$3+9~($lDnEN|JwyH7|1TLC!Ue1q9N#psY-AQX z!skI-PU3924@bW}o-@zk;^)Ho^FB(^h^*xi~zl*N>S*l0Bq(!eJjABpXY`FvJH~WjLnD(@urgi2) zv3Wo^54T;Lb??CP8~w#qhhE#te}BnY!!mu?=37XiR1lr*o6k^TO-Fxz5N2Tb|G#Cg XO!3Y&=L#4Y7#KWV{an^LB{Ts5x3>6{ literal 0 HcmV?d00001 diff --git a/app/app/icons/haven.png b/app/app/icons/haven.png new file mode 100644 index 0000000000000000000000000000000000000000..acd73c93a3edb807fad6e4acb585f716c7ffdb22 GIT binary patch literal 1520 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i983%h3?KE5B{49tZufL?45^s&=Gw;6W(NV+ zz|-wLRx3KCR(7hHDV7V1X@7Z_n^5=VoKA9&d_(?s>uQDrvPbwl1gsSt-!!mnWEP?= zCn9fm$=u_)+Z)&;xBqR^wxg4^^zt4SdsdnweIr_+lMP_${d<*Fmfib zt8~atP#AukL)krs)yxNO|D;EH!V=)LPsAg{(rGuDe<)n9JTvdZ|7*GPZ5#U^Y^HVb vFz_K;(QBBN&ro4KpMH7sgE#}j|NrMgMfSfv9L~nTz`)??>gTe~DWM4f2Gi&b literal 0 HcmV?d00001 diff --git a/app/app/icons/livekit.png b/app/app/icons/livekit.png new file mode 100644 index 0000000..1bb245e --- /dev/null +++ b/app/app/icons/livekit.png @@ -0,0 +1,1402 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GitHub Ā· Change is constant. GitHub keeps you ahead. Ā· GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + + + + + + + + + + +

The future of building happens together

Tools and trends evolve, but collaboration endures. With GitHub, developers, agents, and code come together on one platform.

Try GitHub Copilot free

GitHub features

A demonstration animation of a code editor using GitHub Copilot Chat, where the user requests GitHub Copilot to refactor duplicated logic and extract it into a reusable function for a given code snippet.

Write, test, and fix code quickly with GitHub Copilot, from simple boilerplate to complex features.

GitHub customers

American AirlinesDuolingoErnst and YoungFordInfoSysMercado LibreMercedes-BenzShopifyPhilipsSociƩtƩ GƩnƩraleSpotifyVodafone

Accelerate your entire workflow

From your first line of code to final deployment, GitHub provides AI and automation tools to help you build and ship better software faster.

A Copilot chat window with the 'Ask' mode enabled. The user switches from 'Ask' mode to 'Agent' mode from a dropdown menu, then sends the prompt 'Update the website to allow searching for running races by name.' Copilot analyzes the codebase, then explains the required edits for three files before generating them. Copilot then confirms completion and summarizes the implemented changes for the new functionality allowing users to search races by name and view paginated, filtered results.

Your AI partner everywhere. Copilot is ready to work with you at each step of the software development lifecycle.

Duolingo boosts developer speed by 25% with GitHub Copilot

Read customer story

2025 GartnerĀ® Magic Quadrantā„¢ for AI Code Assistants

Read industry report

Ship faster with secure, reliable CI/CD.

Explore GitHub Actions

Built-in application security where found means fixed

Use AI to find and fix vulnerabilities so your team can ship more secure software faster.

Apply fixes in seconds. Spend less time debugging and more time building features with Copilot Autofix.

Copilot Autofix identifies vulnerable code and provides an explanation, together with a secure code suggestion to remediate the vulnerability.

Security debt, solved. Leverage security campaigns and Copilot Autofix to reduce application vulnerabilities.

Learn about GitHub Code Security
A security campaign screen displays the campaign’s progress bar with 97% completed of 701 alerts. A total of 23 alerts are left with 13 in progress, and the campaign started 20 days ago. The status below shows that there are 7 days left in the campaign with a due date of November 15, 2024.

Dependencies you can depend on. Update vulnerable dependencies with supported fixes for breaking changes.

Learn about Dependabot
List of dependencies defined in a requirements .txt file.

Your secrets, your business. Detect, prevent, and remediate leaked secrets across your organization.

Learn about GitHub Secret Protection
GitHub push protection confirms and displays an active secret, and blocks the push.

70% MTTR reduction with Copilot Autofix

8.3M secret leaks stopped in the past 12 months with push protection

Work together, achieve more

From planning and discussion to code review, GitHub keeps your team’s conversation and context next to your code.

A project management dashboard showing tasks for the ā€˜OctoArcade Invaders’ project, with tasks grouped under project phase categories like ā€˜Prototype,’ ā€˜Beta,’ and ā€˜Launch’ in a table layout. One of the columns displays sub-issue progress bars with percentages for each issue.

Plan with clarity. Organize everything from high-level roadmaps to everyday tasks.

It helps us onboard new software engineers and get them productive right away. We have all our source code, issues, and pull requests in one place... GitHub is a complete platform that frees us from menial tasks and enables us to do our best work.
Fabian FaulhaberApplication manager at Mercedes-Benz

Create issues and manage projects with tools that adapt to your code.

Explore GitHub Issues

Millions of developers and businesses call GitHub home

Whether you’re scaling your development process or just learning how to code, GitHub is where you belong. Join the world’s most widely adopted developer platform to build the technologies that shape what’s next.

+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/app/app/icons/lnd.png b/app/app/icons/lnd.png new file mode 100644 index 0000000000000000000000000000000000000000..9c60603b257e0a7b7b626b809368d4b111938399 GIT binary patch literal 1592 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 literal 0 HcmV?d00001 diff --git a/app/app/icons/rtl.png b/app/app/icons/rtl.png new file mode 100644 index 0000000000000000000000000000000000000000..773a47e3f45dd170982e6c7a45bc6c59dd12cf4f GIT binary patch literal 1564 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i983%h3?KE5B{49tKK68R45^s&=K98oa~uR5 z0@rqlbOL4?_}ALGd$V=;*ClKX-~ab@h0V5qOoe}J!&XlT(lT>%H>5;^V%T|6pvtmE*Y*A1^oIUk*W$}k~b$N1yG562Tdwo8Q^^*Hq z%dU(2o>w{w$n2?~wXAa9?EmsCjn6B^E6Wbww|>s3zoKI4^ZjS~%VJ~-Wzr+Vc5d7g z*7LZ+Z1z=k&R>7))?ep7yrHOiTI%DE7M_z9@f`lVr*8lMFJG<{)Xq+goTRe9?yr`6 znBbeQReS69$Nvm`x@z99Y!j{EX-B!wZ#pyQbi80&qTti4tyPtkn)@&LEl*bSyJG74 z)V$1U?yBpi5rTOmgMK>-^~kO`$g-LiERJz^XJcdPBN+aW8y0> zFaLV`@j@BbixatjuaE!Vq8#1d-+yi94`CfK?ytN(i&lL4x4K-7gXy@}ibqU^SKN8> zF2B68tF>%J{?yARw##{vZRA!zJnDKTZ8Nvu;p%zIuT4pN5%#CpL@Kho#HJ}UyDT+* zbL7wY=g*(Nw!3m(x><1=@6Nk<;@z#utvO){``(v(T@l~%IiLM|!>skP(y!h})|<@r z^X*8xo3r7-?rzq#gaGQT(Mh} z`$EAHYWF`_C+UzbsEp_I)yvQS`Z4|Hni4D7 z*Hwo$e&T3y_}dk=TKqAy>5#Hevi!E8 zv+?}%{xb@8pW%6Ve!l(M?X$NZ_!VhqYrE2&yXk;!ANS!4zn+%uxjSE=aZ`+*|2v17 zK5DD4u6kG(AklW*k5zJd=i!fcv?7lGE;Z;&UViwoMHusqezCTPM^76(`~36o-@gZU z|J--K=Kj`4S?;N_^WSaHzkjYW#khIi+r4}Dt_@qQz0S;Uc3id+N7KWC6=gnLeK#jI z=Na}T&)obgETJ1|L#AVJ!kPF zgJ@yTNj}Rj3(s3xTd2{e8Gd|Ek#s2Eh3PBbAItn*Qtu~|r?0R7-2Q~9`sGts*p7a$ zE%l0<_xzghvOE9gSlqjR|7!mvm7OOVzC0*-qAtEMb4&e29*jDb^rg(RXLgJE?qefetH z(m7V>2ka_;_V|0%Ug6SM&G()n4cy{AcO1Fg}mdHs`G$ z&3Z8T&K1{R^H;8&En35brv|`Y!(Fmhm)O5&&lLTVoj2`0+xr84E*3ee`ttvg!2K*U zE&j=^zIv+4@^1L+uR&8jh6l=jpME#*`%CWTvIGaNfv72A_4I#E28RFt=dc*%N;SN3 RXJBAp@O1TaS?83{1OVUe8F2sr literal 0 HcmV?d00001 diff --git a/app/app/icons/tor.png b/app/app/icons/tor.png new file mode 100644 index 0000000000000000000000000000000000000000..43ce3d5d5e1f93dcf459b6a9a59d44daf99e8b7c GIT binary patch literal 1505 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i983%h3?KE5B{49tuJCkm45^s&=GsNhLk35?rbbjVInD067KG0GX5VQ|PO_tLg^ zm(vc0{&a8ONxwuzuf#iQ&+v=WmfsSO-^eo5olB)(`BTHh!0`XSv8ph`zopr0Nf1UbN~PV literal 0 HcmV?d00001 diff --git a/app/app/icons/vaultwarden.png b/app/app/icons/vaultwarden.png new file mode 100644 index 0000000000000000000000000000000000000000..209754aaa3c360f60df3c4cd8d55191fbd82aa61 GIT binary patch literal 1597 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 AqyPW_ literal 0 HcmV?d00001 -- 2.53.0 From e686cb60aac6a405493a3b6afc01246c7c24ce63 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 11:04:48 -0500 Subject: [PATCH 114/857] updated icons --- app/app/icons/app/icons/livekit.png | 1402 ----------------- app/app/icons/bitcoind.png | Bin 4806 -> 0 bytes app/app/icons/btcpayserver.png | Bin 5065 -> 0 bytes app/app/icons/caddy.png | Bin 1584 -> 0 bytes app/app/icons/electrs.png | Bin 1541 -> 0 bytes app/app/icons/haven.png | Bin 1520 -> 0 bytes app/app/icons/lnd.png | Bin 1592 -> 0 bytes app/app/icons/mempool.png | Bin 1535 -> 0 bytes app/app/icons/nextcloud.png | Bin 12075 -> 0 bytes app/app/icons/rtl.png | Bin 1564 -> 0 bytes app/app/icons/synapse.png | Bin 2129 -> 0 bytes app/app/icons/tor.png | Bin 1505 -> 0 bytes app/app/icons/vaultwarden.png | Bin 1597 -> 0 bytes app/app/icons/wordpress.png | Bin 18579 -> 0 bytes app/{app/icons/app => }/icons/bitcoind.png | Bin .../icons/app => }/icons/btcpayserver.png | Bin app/{app/icons/app => }/icons/caddy.png | Bin app/{app/icons/app => }/icons/electrs.png | Bin app/{app/icons/app => }/icons/haven.png | Bin app/{app => }/icons/livekit.png | 0 app/{app/icons/app => }/icons/lnd.png | Bin app/{app/icons/app => }/icons/mempool.png | Bin app/{app/icons/app => }/icons/nextcloud.png | Bin app/{app/icons/app => }/icons/rtl.png | Bin app/{app/icons/app => }/icons/synapse.png | Bin app/{app/icons/app => }/icons/tor.png | Bin app/{app/icons/app => }/icons/vaultwarden.png | Bin app/{app/icons/app => }/icons/wordpress.png | Bin 28 files changed, 1402 deletions(-) delete mode 100644 app/app/icons/app/icons/livekit.png delete mode 100644 app/app/icons/bitcoind.png delete mode 100644 app/app/icons/btcpayserver.png delete mode 100644 app/app/icons/caddy.png delete mode 100644 app/app/icons/electrs.png delete mode 100644 app/app/icons/haven.png delete mode 100644 app/app/icons/lnd.png delete mode 100644 app/app/icons/mempool.png delete mode 100644 app/app/icons/nextcloud.png delete mode 100644 app/app/icons/rtl.png delete mode 100644 app/app/icons/synapse.png delete mode 100644 app/app/icons/tor.png delete mode 100644 app/app/icons/vaultwarden.png delete mode 100644 app/app/icons/wordpress.png rename app/{app/icons/app => }/icons/bitcoind.png (100%) rename app/{app/icons/app => }/icons/btcpayserver.png (100%) rename app/{app/icons/app => }/icons/caddy.png (100%) rename app/{app/icons/app => }/icons/electrs.png (100%) rename app/{app/icons/app => }/icons/haven.png (100%) rename app/{app => }/icons/livekit.png (100%) rename app/{app/icons/app => }/icons/lnd.png (100%) rename app/{app/icons/app => }/icons/mempool.png (100%) rename app/{app/icons/app => }/icons/nextcloud.png (100%) rename app/{app/icons/app => }/icons/rtl.png (100%) rename app/{app/icons/app => }/icons/synapse.png (100%) rename app/{app/icons/app => }/icons/tor.png (100%) rename app/{app/icons/app => }/icons/vaultwarden.png (100%) rename app/{app/icons/app => }/icons/wordpress.png (100%) diff --git a/app/app/icons/app/icons/livekit.png b/app/app/icons/app/icons/livekit.png deleted file mode 100644 index a84940b..0000000 --- a/app/app/icons/app/icons/livekit.png +++ /dev/null @@ -1,1402 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - GitHub Ā· Change is constant. GitHub keeps you ahead. Ā· GitHub - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- Skip to content - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - - -
- - - - - -
- - - - - - - - - -
-
- - - - - - - - - - - - -

The future of building happens together

Tools and trends evolve, but collaboration endures. With GitHub, developers, agents, and code come together on one platform.

Try GitHub Copilot free

GitHub features

A demonstration animation of a code editor using GitHub Copilot Chat, where the user requests GitHub Copilot to refactor duplicated logic and extract it into a reusable function for a given code snippet.

Write, test, and fix code quickly with GitHub Copilot, from simple boilerplate to complex features.

GitHub customers

American AirlinesDuolingoErnst and YoungFordInfoSysMercado LibreMercedes-BenzShopifyPhilipsSociƩtƩ GƩnƩraleSpotifyVodafone

Accelerate your entire workflow

From your first line of code to final deployment, GitHub provides AI and automation tools to help you build and ship better software faster.

A Copilot chat window with the 'Ask' mode enabled. The user switches from 'Ask' mode to 'Agent' mode from a dropdown menu, then sends the prompt 'Update the website to allow searching for running races by name.' Copilot analyzes the codebase, then explains the required edits for three files before generating them. Copilot then confirms completion and summarizes the implemented changes for the new functionality allowing users to search races by name and view paginated, filtered results.

Your AI partner everywhere. Copilot is ready to work with you at each step of the software development lifecycle.

Duolingo boosts developer speed by 25% with GitHub Copilot

Read customer story

2025 GartnerĀ® Magic Quadrantā„¢ for AI Code Assistants

Read industry report

Ship faster with secure, reliable CI/CD.

Explore GitHub Actions

Built-in application security where found means fixed

Use AI to find and fix vulnerabilities so your team can ship more secure software faster.

Apply fixes in seconds. Spend less time debugging and more time building features with Copilot Autofix.

Copilot Autofix identifies vulnerable code and provides an explanation, together with a secure code suggestion to remediate the vulnerability.

Security debt, solved. Leverage security campaigns and Copilot Autofix to reduce application vulnerabilities.

Learn about GitHub Code Security
A security campaign screen displays the campaign’s progress bar with 97% completed of 701 alerts. A total of 23 alerts are left with 13 in progress, and the campaign started 20 days ago. The status below shows that there are 7 days left in the campaign with a due date of November 15, 2024.

Dependencies you can depend on. Update vulnerable dependencies with supported fixes for breaking changes.

Learn about Dependabot
List of dependencies defined in a requirements .txt file.

Your secrets, your business. Detect, prevent, and remediate leaked secrets across your organization.

Learn about GitHub Secret Protection
GitHub push protection confirms and displays an active secret, and blocks the push.

70% MTTR reduction with Copilot Autofix

8.3M secret leaks stopped in the past 12 months with push protection

Work together, achieve more

From planning and discussion to code review, GitHub keeps your team’s conversation and context next to your code.

A project management dashboard showing tasks for the ā€˜OctoArcade Invaders’ project, with tasks grouped under project phase categories like ā€˜Prototype,’ ā€˜Beta,’ and ā€˜Launch’ in a table layout. One of the columns displays sub-issue progress bars with percentages for each issue.

Plan with clarity. Organize everything from high-level roadmaps to everyday tasks.

It helps us onboard new software engineers and get them productive right away. We have all our source code, issues, and pull requests in one place... GitHub is a complete platform that frees us from menial tasks and enables us to do our best work.
Fabian FaulhaberApplication manager at Mercedes-Benz

Create issues and manage projects with tools that adapt to your code.

Explore GitHub Issues

Millions of developers and businesses call GitHub home

Whether you’re scaling your development process or just learning how to code, GitHub is where you belong. Join the world’s most widely adopted developer platform to build the technologies that shape what’s next.

-
- - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - diff --git a/app/app/icons/bitcoind.png b/app/app/icons/bitcoind.png deleted file mode 100644 index e1ba21e8609b2d446a064b098a12afc1e9c57e56..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4806 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 Oz~JfX=d#Wzp$Py<82(iN diff --git a/app/app/icons/btcpayserver.png b/app/app/icons/btcpayserver.png deleted file mode 100644 index 0b976420b016ccccd7ecb157c07a68fdeecec9c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5065 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 XO!3Y&=L#4Y7#KWV{an^LB{Ts5x3>6{ diff --git a/app/app/icons/haven.png b/app/app/icons/haven.png deleted file mode 100644 index acd73c93a3edb807fad6e4acb585f716c7ffdb22..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1520 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i983%h3?KE5B{49tZufL?45^s&=Gw;6W(NV+ zz|-wLRx3KCR(7hHDV7V1X@7Z_n^5=VoKA9&d_(?s>uQDrvPbwl1gsSt-!!mnWEP?= zCn9fm$=u_)+Z)&;xBqR^wxg4^^zt4SdsdnweIr_+lMP_${d<*Fmfib zt8~atP#AukL)krs)yxNO|D;EH!V=)LPsAg{(rGuDe<)n9JTvdZ|7*GPZ5#U^Y^HVb vFz_K;(QBBN&ro4KpMH7sgE#}j|NrMgMfSfv9L~nTz`)??>gTe~DWM4f2Gi&b diff --git a/app/app/icons/lnd.png b/app/app/icons/lnd.png deleted file mode 100644 index 9c60603b257e0a7b7b626b809368d4b111938399..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1592 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 diff --git a/app/app/icons/rtl.png b/app/app/icons/rtl.png deleted file mode 100644 index 773a47e3f45dd170982e6c7a45bc6c59dd12cf4f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1564 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i983%h3?KE5B{49tKK68R45^s&=K98oa~uR5 z0@rqlbOL4?_}ALGd$V=;*ClKX-~ab@h0V5qOoe}J!&XlT(lT>%H>5;^V%T|6pvtmE*Y*A1^oIUk*W$}k~b$N1yG562Tdwo8Q^^*Hq z%dU(2o>w{w$n2?~wXAa9?EmsCjn6B^E6Wbww|>s3zoKI4^ZjS~%VJ~-Wzr+Vc5d7g z*7LZ+Z1z=k&R>7))?ep7yrHOiTI%DE7M_z9@f`lVr*8lMFJG<{)Xq+goTRe9?yr`6 znBbeQReS69$Nvm`x@z99Y!j{EX-B!wZ#pyQbi80&qTti4tyPtkn)@&LEl*bSyJG74 z)V$1U?yBpi5rTOmgMK>-^~kO`$g-LiERJz^XJcdPBN+aW8y0> zFaLV`@j@BbixatjuaE!Vq8#1d-+yi94`CfK?ytN(i&lL4x4K-7gXy@}ibqU^SKN8> zF2B68tF>%J{?yARw##{vZRA!zJnDKTZ8Nvu;p%zIuT4pN5%#CpL@Kho#HJ}UyDT+* zbL7wY=g*(Nw!3m(x><1=@6Nk<;@z#utvO){``(v(T@l~%IiLM|!>skP(y!h})|<@r z^X*8xo3r7-?rzq#gaGQT(Mh} z`$EAHYWF`_C+UzbsEp_I)yvQS`Z4|Hni4D7 z*Hwo$e&T3y_}dk=TKqAy>5#Hevi!E8 zv+?}%{xb@8pW%6Ve!l(M?X$NZ_!VhqYrE2&yXk;!ANS!4zn+%uxjSE=aZ`+*|2v17 zK5DD4u6kG(AklW*k5zJd=i!fcv?7lGE;Z;&UViwoMHusqezCTPM^76(`~36o-@gZU z|J--K=Kj`4S?;N_^WSaHzkjYW#khIi+r4}Dt_@qQz0S;Uc3id+N7KWC6=gnLeK#jI z=Na}T&)obgETJ1|L#AVJ!kPF zgJ@yTNj}Rj3(s3xTd2{e8Gd|Ek#s2Eh3PBbAItn*Qtu~|r?0R7-2Q~9`sGts*p7a$ zE%l0<_xzghvOE9gSlqjR|7!mvm7OOVzC0*-qAtEMb4&e29*jDb^rg(RXLgJE?qefetH z(m7V>2ka_;_V|0%Ug6SM&G()n4cy{AcO1Fg}mdHs`G$ z&3Z8T&K1{R^H;8&En35brv|`Y!(Fmhm)O5&&lLTVoj2`0+xr84E*3ee`ttvg!2K*U zE&j=^zIv+4@^1L+uR&8jh6l=jpME#*`%CWTvIGaNfv72A_4I#E28RFt=dc*%N;SN3 RXJBAp@O1TaS?83{1OVUe8F2sr diff --git a/app/app/icons/tor.png b/app/app/icons/tor.png deleted file mode 100644 index 43ce3d5d5e1f93dcf459b6a9a59d44daf99e8b7c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1505 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i983%h3?KE5B{49tuJCkm45^s&=GsNhLk35?rbbjVInD067KG0GX5VQ|PO_tLg^ zm(vc0{&a8ONxwuzuf#iQ&+v=WmfsSO-^eo5olB)(`BTHh!0`XSv8ph`zopr0Nf1UbN~PV diff --git a/app/app/icons/vaultwarden.png b/app/app/icons/vaultwarden.png deleted file mode 100644 index 209754aaa3c360f60df3c4cd8d55191fbd82aa61..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1597 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 AqyPW_ diff --git a/app/app/icons/app/icons/bitcoind.png b/app/icons/bitcoind.png similarity index 100% rename from app/app/icons/app/icons/bitcoind.png rename to app/icons/bitcoind.png diff --git a/app/app/icons/app/icons/btcpayserver.png b/app/icons/btcpayserver.png similarity index 100% rename from app/app/icons/app/icons/btcpayserver.png rename to app/icons/btcpayserver.png diff --git a/app/app/icons/app/icons/caddy.png b/app/icons/caddy.png similarity index 100% rename from app/app/icons/app/icons/caddy.png rename to app/icons/caddy.png diff --git a/app/app/icons/app/icons/electrs.png b/app/icons/electrs.png similarity index 100% rename from app/app/icons/app/icons/electrs.png rename to app/icons/electrs.png diff --git a/app/app/icons/app/icons/haven.png b/app/icons/haven.png similarity index 100% rename from app/app/icons/app/icons/haven.png rename to app/icons/haven.png diff --git a/app/app/icons/livekit.png b/app/icons/livekit.png similarity index 100% rename from app/app/icons/livekit.png rename to app/icons/livekit.png diff --git a/app/app/icons/app/icons/lnd.png b/app/icons/lnd.png similarity index 100% rename from app/app/icons/app/icons/lnd.png rename to app/icons/lnd.png diff --git a/app/app/icons/app/icons/mempool.png b/app/icons/mempool.png similarity index 100% rename from app/app/icons/app/icons/mempool.png rename to app/icons/mempool.png diff --git a/app/app/icons/app/icons/nextcloud.png b/app/icons/nextcloud.png similarity index 100% rename from app/app/icons/app/icons/nextcloud.png rename to app/icons/nextcloud.png diff --git a/app/app/icons/app/icons/rtl.png b/app/icons/rtl.png similarity index 100% rename from app/app/icons/app/icons/rtl.png rename to app/icons/rtl.png diff --git a/app/app/icons/app/icons/synapse.png b/app/icons/synapse.png similarity index 100% rename from app/app/icons/app/icons/synapse.png rename to app/icons/synapse.png diff --git a/app/app/icons/app/icons/tor.png b/app/icons/tor.png similarity index 100% rename from app/app/icons/app/icons/tor.png rename to app/icons/tor.png diff --git a/app/app/icons/app/icons/vaultwarden.png b/app/icons/vaultwarden.png similarity index 100% rename from app/app/icons/app/icons/vaultwarden.png rename to app/icons/vaultwarden.png diff --git a/app/app/icons/app/icons/wordpress.png b/app/icons/wordpress.png similarity index 100% rename from app/app/icons/app/icons/wordpress.png rename to app/icons/wordpress.png -- 2.53.0 From b669e6349d64d5bfdd3276fd9687f598652ff20e Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 13:35:22 -0500 Subject: [PATCH 115/857] updated icons --- app/icons/bitcoind.png | Bin 4806 -> 0 bytes app/icons/bitcoind.svg | 1 + app/icons/btcpayserver.png | Bin 5065 -> 0 bytes app/icons/btcpayserver.svg | 1 + app/icons/caddy.png | Bin 1584 -> 0 bytes app/icons/electrs.png | Bin 1541 -> 0 bytes app/icons/electrs.svg | 1 + app/icons/haven.png | Bin 1520 -> 0 bytes app/icons/livekit.png | 1402 ---------------------- app/icons/lnd.png | Bin 1592 -> 0 bytes app/icons/lnd.svg | 1 + app/icons/mempool.png | Bin 1535 -> 0 bytes app/icons/mempool.svg | 1 + app/icons/nextcloud.png | Bin 12075 -> 0 bytes app/icons/nextcloud.svg | 1 + app/icons/rtl.png | Bin 1564 -> 0 bytes app/icons/rtl.svg | 1 + app/icons/synapse.png | Bin 2129 -> 0 bytes app/icons/synapse.svg | 1 + app/icons/tor.png | Bin 1505 -> 0 bytes app/icons/vaultwarden.png | Bin 1597 -> 0 bytes app/icons/vaultwarden.svg | 1 + app/icons/wordpress.png | Bin 18579 -> 0 bytes app/icons/wordpress.svg | 1 + app/sovran_systemsos_hub/service_tile.py | 31 +- modules/core/sovran-hub.nix | 5 +- 26 files changed, 35 insertions(+), 1413 deletions(-) delete mode 100644 app/icons/bitcoind.png create mode 100644 app/icons/bitcoind.svg delete mode 100644 app/icons/btcpayserver.png create mode 100644 app/icons/btcpayserver.svg delete mode 100644 app/icons/caddy.png delete mode 100644 app/icons/electrs.png create mode 100644 app/icons/electrs.svg delete mode 100644 app/icons/haven.png delete mode 100644 app/icons/livekit.png delete mode 100644 app/icons/lnd.png create mode 100644 app/icons/lnd.svg delete mode 100644 app/icons/mempool.png create mode 100644 app/icons/mempool.svg delete mode 100644 app/icons/nextcloud.png create mode 100644 app/icons/nextcloud.svg delete mode 100644 app/icons/rtl.png create mode 100644 app/icons/rtl.svg delete mode 100644 app/icons/synapse.png create mode 100644 app/icons/synapse.svg delete mode 100644 app/icons/tor.png delete mode 100644 app/icons/vaultwarden.png create mode 100644 app/icons/vaultwarden.svg delete mode 100644 app/icons/wordpress.png create mode 100644 app/icons/wordpress.svg diff --git a/app/icons/bitcoind.png b/app/icons/bitcoind.png deleted file mode 100644 index e1ba21e8609b2d446a064b098a12afc1e9c57e56..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4806 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 Oz~JfX=d#Wzp$Py<82(iN diff --git a/app/icons/bitcoind.svg b/app/icons/bitcoind.svg new file mode 100644 index 0000000..874a747 --- /dev/null +++ b/app/icons/bitcoind.svg @@ -0,0 +1 @@ +404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file diff --git a/app/icons/btcpayserver.png b/app/icons/btcpayserver.png deleted file mode 100644 index 0b976420b016ccccd7ecb157c07a68fdeecec9c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5065 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 V)1wW#404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file diff --git a/app/icons/caddy.png b/app/icons/caddy.png deleted file mode 100644 index 302e984907b9335e99545eb6ffebcb28a52dd54d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1584 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i983%h3?KE5B{49te)n{745^s&=K8_hC0-J4 z7mo{DPT=?{ApLU!$8N5~vdwalW>Y5BU*`+KErS-1Y}zS?gs@1Mkf=Q{uA^&a}UfL=aDv8Sc<8-v~H+PKZ? z2l^w9No+RXGgZ&vXxW?1>U$=gkDzgwqqv05oQPu*=VNxAe?8Or!0w#m{|-$3+9~($lDnEN|JwyH7|1TLC!Ue1q9N#psY-AQX z!skI-PU3924@bW}o-@zk;^)Ho^FB(^h^*xi~zl*N>S*l0Bq(!eJjABpXY`FvJH~WjLnD(@urgi2) zv3Wo^54T;Lb??CP8~w#qhhE#te}BnY!!mu?=37XiR1lr*o6k^TO-Fxz5N2Tb|G#Cg XO!3Y&=L#4Y7#KWV{an^LB{Ts5x3>6{ diff --git a/app/icons/electrs.svg b/app/icons/electrs.svg new file mode 100644 index 0000000..874a747 --- /dev/null +++ b/app/icons/electrs.svg @@ -0,0 +1 @@ +404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file diff --git a/app/icons/haven.png b/app/icons/haven.png deleted file mode 100644 index acd73c93a3edb807fad6e4acb585f716c7ffdb22..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1520 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i983%h3?KE5B{49tZufL?45^s&=Gw;6W(NV+ zz|-wLRx3KCR(7hHDV7V1X@7Z_n^5=VoKA9&d_(?s>uQDrvPbwl1gsSt-!!mnWEP?= zCn9fm$=u_)+Z)&;xBqR^wxg4^^zt4SdsdnweIr_+lMP_${d<*Fmfib zt8~atP#AukL)krs)yxNO|D;EH!V=)LPsAg{(rGuDe<)n9JTvdZ|7*GPZ5#U^Y^HVb vFz_K;(QBBN&ro4KpMH7sgE#}j|NrMgMfSfv9L~nTz`)??>gTe~DWM4f2Gi&b diff --git a/app/icons/livekit.png b/app/icons/livekit.png deleted file mode 100644 index 1bb245e..0000000 --- a/app/icons/livekit.png +++ /dev/null @@ -1,1402 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - GitHub Ā· Change is constant. GitHub keeps you ahead. Ā· GitHub - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- Skip to content - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - - -
- - - - - -
- - - - - - - - - -
-
- - - - - - - - - - - - -

The future of building happens together

Tools and trends evolve, but collaboration endures. With GitHub, developers, agents, and code come together on one platform.

Try GitHub Copilot free

GitHub features

A demonstration animation of a code editor using GitHub Copilot Chat, where the user requests GitHub Copilot to refactor duplicated logic and extract it into a reusable function for a given code snippet.

Write, test, and fix code quickly with GitHub Copilot, from simple boilerplate to complex features.

GitHub customers

American AirlinesDuolingoErnst and YoungFordInfoSysMercado LibreMercedes-BenzShopifyPhilipsSociƩtƩ GƩnƩraleSpotifyVodafone

Accelerate your entire workflow

From your first line of code to final deployment, GitHub provides AI and automation tools to help you build and ship better software faster.

A Copilot chat window with the 'Ask' mode enabled. The user switches from 'Ask' mode to 'Agent' mode from a dropdown menu, then sends the prompt 'Update the website to allow searching for running races by name.' Copilot analyzes the codebase, then explains the required edits for three files before generating them. Copilot then confirms completion and summarizes the implemented changes for the new functionality allowing users to search races by name and view paginated, filtered results.

Your AI partner everywhere. Copilot is ready to work with you at each step of the software development lifecycle.

Duolingo boosts developer speed by 25% with GitHub Copilot

Read customer story

2025 GartnerĀ® Magic Quadrantā„¢ for AI Code Assistants

Read industry report

Ship faster with secure, reliable CI/CD.

Explore GitHub Actions

Built-in application security where found means fixed

Use AI to find and fix vulnerabilities so your team can ship more secure software faster.

Apply fixes in seconds. Spend less time debugging and more time building features with Copilot Autofix.

Copilot Autofix identifies vulnerable code and provides an explanation, together with a secure code suggestion to remediate the vulnerability.

Security debt, solved. Leverage security campaigns and Copilot Autofix to reduce application vulnerabilities.

Learn about GitHub Code Security
A security campaign screen displays the campaign’s progress bar with 97% completed of 701 alerts. A total of 23 alerts are left with 13 in progress, and the campaign started 20 days ago. The status below shows that there are 7 days left in the campaign with a due date of November 15, 2024.

Dependencies you can depend on. Update vulnerable dependencies with supported fixes for breaking changes.

Learn about Dependabot
List of dependencies defined in a requirements .txt file.

Your secrets, your business. Detect, prevent, and remediate leaked secrets across your organization.

Learn about GitHub Secret Protection
GitHub push protection confirms and displays an active secret, and blocks the push.

70% MTTR reduction with Copilot Autofix

8.3M secret leaks stopped in the past 12 months with push protection

Work together, achieve more

From planning and discussion to code review, GitHub keeps your team’s conversation and context next to your code.

A project management dashboard showing tasks for the ā€˜OctoArcade Invaders’ project, with tasks grouped under project phase categories like ā€˜Prototype,’ ā€˜Beta,’ and ā€˜Launch’ in a table layout. One of the columns displays sub-issue progress bars with percentages for each issue.

Plan with clarity. Organize everything from high-level roadmaps to everyday tasks.

It helps us onboard new software engineers and get them productive right away. We have all our source code, issues, and pull requests in one place... GitHub is a complete platform that frees us from menial tasks and enables us to do our best work.
Fabian FaulhaberApplication manager at Mercedes-Benz

Create issues and manage projects with tools that adapt to your code.

Explore GitHub Issues

Millions of developers and businesses call GitHub home

Whether you’re scaling your development process or just learning how to code, GitHub is where you belong. Join the world’s most widely adopted developer platform to build the technologies that shape what’s next.

-
- - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - diff --git a/app/icons/lnd.png b/app/icons/lnd.png deleted file mode 100644 index 9c60603b257e0a7b7b626b809368d4b111938399..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1592 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i983%h3?KE5B{49t{`Yio45^s&=K8_4a|}cr zF8=BGTH?VN;=*W>5$Gs3anl*4UmID!&tbba+2+p1?S{wr8@B)6S;g2Od4$hHz*@oa zO#{nDW+B>gZiMN7Ncdj5F0OIW`MNc64=3KChpqJT9Tt09)*Bw12!NF zpPOoD9ymVx^Jdvc@ze72HaaxjVB}0V|?!T;MKVm;yeE0NPS38xk%KOVr+S7H0V_ZWM za!s${GoM}Si_a-Mh_w4{n0U%8@mTj+%O#m{Kii-5{5WeYMz_LdKoY404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file diff --git a/app/icons/mempool.png b/app/icons/mempool.png deleted file mode 100644 index 05fde6afb0a3f21f4d73f6775c1b9af02d76bbdc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1535 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i983%h3?KE5B{49t9`kf@45^s&=2~EGR-k~x z#pB#Y9~#VC9`N#qAMkh)&uTKg*z0=~TiPVgvS(Jt{~7-6`oCM8A;R%Z1ItEcp(A`A z0@ezn97@Arhu-WGoA}ddjPg&u?a-g`X!Gz7+8t-kypy%N`myG1+{2pf$7{cHr57ph z`#H?hRm404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file diff --git a/app/icons/nextcloud.png b/app/icons/nextcloud.png deleted file mode 100644 index 06713faa7d5657d0c0cdcc6b2404782328c49682..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12075 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 diff --git a/app/icons/nextcloud.svg b/app/icons/nextcloud.svg new file mode 100644 index 0000000..874a747 --- /dev/null +++ b/app/icons/nextcloud.svg @@ -0,0 +1 @@ +404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file diff --git a/app/icons/rtl.png b/app/icons/rtl.png deleted file mode 100644 index 773a47e3f45dd170982e6c7a45bc6c59dd12cf4f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1564 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i983%h3?KE5B{49tKK68R45^s&=K98oa~uR5 z0@rqlbOL4404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file diff --git a/app/icons/synapse.png b/app/icons/synapse.png deleted file mode 100644 index 728be42c92ce4adfde27d8c46664268d7a4ef3f7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2129 zcmeAS@N?(olHy`uVBq!ia0y~yVAuk}983%h44c+ZOl4r;knnVI45^s&=I;B_MYj$e z|Cm0{^nLqE1t0b$O&P4SxR^wHRywS8SnCkf=)*dTOGVT}Yr@I{yVw1W5o7?_}ALGd$V=;*ClKX-~ab@h0V5qOoe}J!&XlT(lT>%H>5;^V%T|6pvtmE*Y*A1^oIUk*W$}k~b$N1yG562Tdwo8Q^^*Hq z%dU(2o>w{w$n2?~wXAa9?EmsCjn6B^E6Wbww|>s3zoKI4^ZjS~%VJ~-Wzr+Vc5d7g z*7LZ+Z1z=k&R>7))?ep7yrHOiTI%DE7M_z9@f`lVr*8lMFJG<{)Xq+goTRe9?yr`6 znBbeQReS69$Nvm`x@z99Y!j{EX-B!wZ#pyQbi80&qTti4tyPtkn)@&LEl*bSyJG74 z)V$1U?yBpi5rTOmgMK>-^~kO`$g-LiERJz^XJcdPBN+aW8y0> zFaLV`@j@BbixatjuaE!Vq8#1d-+yi94`CfK?ytN(i&lL4x4K-7gXy@}ibqU^SKN8> zF2B68tF>%J{?yARw##{vZRA!zJnDKTZ8Nvu;p%zIuT4pN5%#CpL@Kho#HJ}UyDT+* zbL7wY=g*(Nw!3m(x><1=@6Nk<;@z#utvO){``(v(T@l~%IiLM|!>skP(y!h})|<@r z^X*8xo3r7-?rzq#gaGQT(Mh} z`$EAHYWF`_C+UzbsEp_I)yvQS`Z4|Hni4D7 z*Hwo$e&T3y_}dk=TKqAy>5#Hevi!E8 zv+?}%{xb@8pW%6Ve!l(M?X$NZ_!VhqYrE2&yXk;!ANS!4zn+%uxjSE=aZ`+*|2v17 zK5DD4u6kG(AklW*k5zJd=i!fcv?7lGE;Z;&UViwoMHusqezCTPM^76(`~36o-@gZU z|J--K=Kj`4S?;N_^WSaHzkjYW#khIi+r4}Dt_@qQz0S;Uc3id+N7KWC6=gnLeK#jI z=Na}T&)obgETJ1|L#AVJ!kPF zgJ@yTNj}Rj3(s3xTd2{e8Gd|Ek#s2Eh3PBbAItn*Qtu~|r?0R7-2Q~9`sGts*p7a$ zE%l0<_xzghvOE9gSlqjR|7!mvm7OOVzC0*-qAtEMb4&e29*jDb^rg(RXLgJE?qefetH z(m7V>2ka_;_V|0%Ug6SM&G()n4cy{AcO1Fg}mdHs`G$ z&3Z8T&K1{R^H;8&En35brv|`Y!(Fmhm)O5&&lLTVoj2`0+xr84E*3ee`ttvg!2K*U zE&j=^zIv+4@^1L+uR&8jh6l=jpME#*`%CWTvIGaNfv72A_4I#E28RFt=dc*%N;SN3 RXJBAp@O1TaS?83{1OVUe8F2sr diff --git a/app/icons/synapse.svg b/app/icons/synapse.svg new file mode 100644 index 0000000..874a747 --- /dev/null +++ b/app/icons/synapse.svg @@ -0,0 +1 @@ +404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file diff --git a/app/icons/tor.png b/app/icons/tor.png deleted file mode 100644 index 43ce3d5d5e1f93dcf459b6a9a59d44daf99e8b7c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1505 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i983%h3?KE5B{49tuJCkm45^s&=GsNhLk35?rbbjVInD067KG0GX5VQ|PO_tLg^ zm(vc0{&a8ONxwuzuf#iQ&+v=WmfsSO-^eo5olB)(`BTHh!0`XSv8ph`zopr0Nf1UbN~PV diff --git a/app/icons/vaultwarden.png b/app/icons/vaultwarden.png deleted file mode 100644 index 209754aaa3c360f60df3c4cd8d55191fbd82aa61..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1597 zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i983%h3?KE5B{49tv3R;ThE&XXbNyoO90v)v zix!I}>$YrK(YQ&YMNUJJf7&OhlLw9C-dXaVQU7~ub?VM_uf-2s{yWc>A%SHhv(OPf z4*_ch$2Sc$=1fb}fBW3<&Dz9m*R84#&As#DdGTWX&*$l6JH32~ZcjuSe?|W9$}{s0 z*zXXWZ@2FCti2CoZ9j!?*?aA#@Vh5&f1VnP(I@=TT}IDH*R9y_{O^=)*R38*E!3QV~o=?q_n7`4X=>{Wb61z%=>;#3f;l404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file diff --git a/app/icons/wordpress.png b/app/icons/wordpress.png deleted file mode 100644 index 02e35aea3f1f972e928738a027434a671db3396a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18579 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 AqyPW_ diff --git a/app/icons/wordpress.svg b/app/icons/wordpress.svg new file mode 100644 index 0000000..874a747 --- /dev/null +++ b/app/icons/wordpress.svg @@ -0,0 +1 @@ +404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file diff --git a/app/sovran_systemsos_hub/service_tile.py b/app/sovran_systemsos_hub/service_tile.py index faa7786..e59d841 100644 --- a/app/sovran_systemsos_hub/service_tile.py +++ b/app/sovran_systemsos_hub/service_tile.py @@ -19,6 +19,9 @@ LOADING_STATES = {"reloading", "activating", "deactivating", "maintenance"} # Icon directory injected by the Nix derivation via environment variable ICON_DIR = os.environ.get("SOVRAN_HUB_ICONS", "") +# Supported icon extensions in priority order +ICON_EXTENSIONS = [".svg", ".png"] + class ServiceTile(Gtk.Box): """A square tile showing a service logo, name, status, toggle, and restart.""" @@ -106,16 +109,26 @@ class ServiceTile(Gtk.Box): self.refresh() def _set_logo(self, icon_name: str): - """Set the tile logo from a PNG in the icons dir, or fall back to a symbolic icon.""" + """Set the tile logo from an SVG or PNG in the icons dir.""" if icon_name and ICON_DIR: - png_path = os.path.join(ICON_DIR, f"{icon_name}.png") - if os.path.isfile(png_path): - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - png_path, 48, 48, True - ) - texture = Gdk.Texture.new_for_pixbuf(pixbuf) - self._logo.set_from_paintable(texture) - return + for ext in ICON_EXTENSIONS: + icon_path = os.path.join(ICON_DIR, f"{icon_name}{ext}") + if os.path.isfile(icon_path): + if ext == ".svg": + # GTK4 handles SVGs natively via GdkPixbuf + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + icon_path, 48, 48, True + ) + texture = Gdk.Texture.new_for_pixbuf(pixbuf) + self._logo.set_from_paintable(texture) + else: + # PNG path + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + icon_path, 48, 48, True + ) + texture = Gdk.Texture.new_for_pixbuf(pixbuf) + self._logo.set_from_paintable(texture) + return # Fallback: themed symbolic icon self._logo.set_from_icon_name("system-run-symbolic") diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index cfb19e5..449c663 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -67,6 +67,7 @@ let pkgs.libadwaita pkgs.gobject-introspection pkgs.gdk-pixbuf + pkgs.librsvg ]; propagatedBuildInputs = [ @@ -85,7 +86,7 @@ let cp style.css $out/lib/sovran-hub/style.css # Copy logos from the repo (no fetchurl needed) - cp icons/*.png $out/share/sovran-hub/icons/ + cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true # Install the generated config cp ${generatedConfig} $out/lib/sovran-hub/config.json @@ -130,4 +131,4 @@ in config = { environment.systemPackages = [ sovran-hub ]; }; -} \ No newline at end of file +} -- 2.53.0 From e145ba949b621460593e92e5dc20210ccca817cd Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 13:58:19 -0500 Subject: [PATCH 116/857] refiled directories --- app/sovran_systemsos_hub/__init__.py | 10 -- app/sovran_systemsos_hub/application.py | 74 ++++-------- app/sovran_systemsos_hub/service_tile.py | 128 ++++++--------------- app/sovran_systemsos_hub/systemctl.py | 10 -- app/style.css | 19 +--- modules/core/sovran-hub.nix | 136 +++++++++++------------ 6 files changed, 119 insertions(+), 258 deletions(-) diff --git a/app/sovran_systemsos_hub/__init__.py b/app/sovran_systemsos_hub/__init__.py index 88c4dee..651da84 100644 --- a/app/sovran_systemsos_hub/__init__.py +++ b/app/sovran_systemsos_hub/__init__.py @@ -7,7 +7,6 @@ from typing import Literal def _run(cmd: list[str]) -> str: - """Run a command and return stripped stdout (empty string on failure).""" try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) return result.stdout.strip() @@ -16,12 +15,10 @@ def _run(cmd: list[str]) -> str: def is_active(unit: str, scope: Literal["system", "user"] = "system") -> str: - """Return the ActiveState string (active / inactive / failed / …).""" return _run(["systemctl", f"--{scope}", "is-active", unit]) or "unknown" def is_enabled(unit: str, scope: Literal["system", "user"] = "system") -> str: - """Return the UnitFileState string (enabled / disabled / masked / …).""" return _run(["systemctl", f"--{scope}", "is-enabled", unit]) or "unknown" @@ -31,18 +28,11 @@ def run_action( scope: Literal["system", "user"] = "system", method: str = "systemctl", ) -> bool: - """Start / stop / restart a unit. - - For *system* units the command is elevated via *method* (pkexec or sudo). - Returns True on apparent success. - """ base_cmd = ["systemctl", f"--{scope}", action, unit] - if scope == "system" and method == "pkexec": cmd = ["pkexec", "--user", "root"] + base_cmd else: cmd = base_cmd - try: subprocess.Popen(cmd) return True diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index 9241dff..8d43dd1 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -9,84 +9,63 @@ import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") -from gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402 +from gi.repository import Adw, Gdk, Gio, GLib, Gtk -from .config import load_config # noqa: E402 -from .service_tile import ServiceTile # noqa: E402 +from .config import load_config +from .service_tile import ServiceTile APP_ID = "com.sovransystems.hub" class SovranHubWindow(Adw.ApplicationWindow): - """Primary window: a 4-across grid of service tiles with auto-refresh.""" - def __init__(self, app: Adw.Application, config: dict): + def __init__(self, app, config): super().__init__( - application=app, - title="Sovran_SystemsOS Hub", - default_width=680, - default_height=700, + application=app, title="Sovran_SystemsOS Hub", + default_width=680, default_height=700, ) self._config = config - self._tiles: list[ServiceTile] = [] + self._tiles = [] - # ── Load custom CSS ── css_path = os.environ.get("SOVRAN_HUB_CSS", "") if css_path and os.path.isfile(css_path): provider = Gtk.CssProvider() provider.load_from_path(css_path) Gtk.StyleContext.add_provider_for_display( - Gdk.Display.get_default(), - provider, + Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) - # ── Header bar ── header = Adw.HeaderBar() - - refresh_btn = Gtk.Button( - icon_name="view-refresh-symbolic", - tooltip_text="Refresh now", - ) + refresh_btn = Gtk.Button(icon_name="view-refresh-symbolic", tooltip_text="Refresh now") refresh_btn.connect("clicked", lambda _b: self._refresh_all()) header.pack_end(refresh_btn) - # ── FlowBox: 4 tiles across ── self._flowbox = Gtk.FlowBox( - max_children_per_line=4, - min_children_per_line=2, - selection_mode=Gtk.SelectionMode.NONE, - homogeneous=True, - row_spacing=12, - column_spacing=12, - margin_top=16, - margin_bottom=16, - margin_start=16, - margin_end=16, - halign=Gtk.Align.CENTER, - valign=Gtk.Align.START, + max_children_per_line=4, min_children_per_line=2, + selection_mode=Gtk.SelectionMode.NONE, homogeneous=True, + row_spacing=12, column_spacing=12, + margin_top=16, margin_bottom=16, margin_start=16, margin_end=16, + halign=Gtk.Align.CENTER, valign=Gtk.Align.START, ) - # ── Scrollable content ── scrolled = Gtk.ScrolledWindow( hscrollbar_policy=Gtk.PolicyType.NEVER, vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, - vexpand=True, - child=self._flowbox, + vexpand=True, child=self._flowbox, ) toolbar_view = Adw.ToolbarView() toolbar_view.add_top_bar(header) toolbar_view.set_content(scrolled) - self.set_content(toolbar_view) - # ── Populate ── self._build_tiles() - self._start_auto_refresh() + interval = config.get("refresh_interval", 5) + if interval and interval > 0: + GLib.timeout_add_seconds(interval, self._auto_refresh) def _build_tiles(self): - """Create ServiceTile widgets from config.""" for entry in self._config.get("services", []): tile = ServiceTile( name=entry.get("name", entry["unit"]), @@ -99,27 +78,18 @@ class SovranHubWindow(Adw.ApplicationWindow): self._tiles.append(tile) def _refresh_all(self): - for tile in self._tiles: - tile.refresh() + for t in self._tiles: + t.refresh() - def _start_auto_refresh(self): - interval = self._config.get("refresh_interval", 5) - if interval and interval > 0: - GLib.timeout_add_seconds(interval, self._auto_refresh_cb) - - def _auto_refresh_cb(self) -> bool: + def _auto_refresh(self): self._refresh_all() return True class SovranHubApp(Adw.Application): - """Sovran_SystemsOS_Hub Adw.Application.""" def __init__(self): - super().__init__( - application_id=APP_ID, - flags=Gio.ApplicationFlags.DEFAULT_FLAGS, - ) + super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.DEFAULT_FLAGS) self._config = load_config() def do_activate(self): diff --git a/app/sovran_systemsos_hub/service_tile.py b/app/sovran_systemsos_hub/service_tile.py index e59d841..33376f7 100644 --- a/app/sovran_systemsos_hub/service_tile.py +++ b/app/sovran_systemsos_hub/service_tile.py @@ -10,31 +10,19 @@ gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") gi.require_version("Gdk", "4.0") -from gi.repository import Adw, Gdk, GdkPixbuf, GLib, Gtk # noqa: E402 +from gi.repository import Adw, Gdk, GdkPixbuf, GLib, Gtk -from . import systemctl # noqa: E402 +from . import systemctl LOADING_STATES = {"reloading", "activating", "deactivating", "maintenance"} -# Icon directory injected by the Nix derivation via environment variable ICON_DIR = os.environ.get("SOVRAN_HUB_ICONS", "") - -# Supported icon extensions in priority order ICON_EXTENSIONS = [".svg", ".png"] class ServiceTile(Gtk.Box): - """A square tile showing a service logo, name, status, toggle, and restart.""" - def __init__( - self, - name: str, - unit: str, - scope: str = "system", - method: str = "systemctl", - icon_name: str = "", - **kwargs, - ): + def __init__(self, name, unit, scope="system", method="systemctl", icon_name="", **kw): super().__init__( orientation=Gtk.Orientation.VERTICAL, spacing=6, @@ -43,136 +31,84 @@ class ServiceTile(Gtk.Box): width_request=140, height_request=160, css_classes=["card", "sovran-tile"], - margin_top=6, - margin_bottom=6, - margin_start=6, - margin_end=6, - **kwargs, + margin_top=6, margin_bottom=6, margin_start=6, margin_end=6, + **kw, ) - self._unit = unit self._scope = scope self._method = method - self._name = name - # ── Logo ── - self._logo = Gtk.Image( - pixel_size=48, - margin_top=12, - halign=Gtk.Align.CENTER, - css_classes=["sovran-tile-icon"], - ) + self._logo = Gtk.Image(pixel_size=48, margin_top=12, halign=Gtk.Align.CENTER) self._set_logo(icon_name) self.append(self._logo) - # ── Service name ── - label = Gtk.Label( - label=name, - css_classes=["heading"], - halign=Gtk.Align.CENTER, - ellipsize=3, # PANGO_ELLIPSIZE_END - max_width_chars=14, - ) - self.append(label) + self.append(Gtk.Label( + label=name, css_classes=["heading"], + halign=Gtk.Align.CENTER, ellipsize=3, max_width_chars=14, + )) - # ── Status label ── - self._status_label = Gtk.Label( - css_classes=["caption"], - halign=Gtk.Align.CENTER, - ) + self._status_label = Gtk.Label(css_classes=["caption"], halign=Gtk.Align.CENTER) self.append(self._status_label) - # ── Controls row (switch + restart) ── controls = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, - spacing=8, - halign=Gtk.Align.CENTER, - margin_bottom=8, + spacing=8, halign=Gtk.Align.CENTER, margin_bottom=8, ) - self._switch = Gtk.Switch(valign=Gtk.Align.CENTER) self._switch.connect("state-set", self._on_toggled) controls.append(self._switch) restart_btn = Gtk.Button( - icon_name="view-refresh-symbolic", - valign=Gtk.Align.CENTER, - tooltip_text="Restart", - css_classes=["flat", "circular"], + icon_name="view-refresh-symbolic", valign=Gtk.Align.CENTER, + tooltip_text="Restart", css_classes=["flat", "circular"], ) restart_btn.connect("clicked", self._on_restart) controls.append(restart_btn) - self.append(controls) - # Initial state self.refresh() - def _set_logo(self, icon_name: str): - """Set the tile logo from an SVG or PNG in the icons dir.""" + def _set_logo(self, icon_name): if icon_name and ICON_DIR: for ext in ICON_EXTENSIONS: - icon_path = os.path.join(ICON_DIR, f"{icon_name}{ext}") - if os.path.isfile(icon_path): - if ext == ".svg": - # GTK4 handles SVGs natively via GdkPixbuf - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - icon_path, 48, 48, True - ) - texture = Gdk.Texture.new_for_pixbuf(pixbuf) - self._logo.set_from_paintable(texture) - else: - # PNG path - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - icon_path, 48, 48, True - ) - texture = Gdk.Texture.new_for_pixbuf(pixbuf) - self._logo.set_from_paintable(texture) + path = os.path.join(ICON_DIR, f"{icon_name}{ext}") + if os.path.isfile(path): + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, 48, 48, True) + texture = Gdk.Texture.new_for_pixbuf(pixbuf) + self._logo.set_from_paintable(texture) return - - # Fallback: themed symbolic icon self._logo.set_from_icon_name("system-run-symbolic") - # ── public API ── - def refresh(self): - """Poll systemctl and update the tile.""" - active_state = systemctl.is_active(self._unit, self._scope) - enabled_state = systemctl.is_enabled(self._unit, self._scope) + active = systemctl.is_active(self._unit, self._scope) + enabled = systemctl.is_enabled(self._unit, self._scope) + is_on = active == "active" + is_loading = active in LOADING_STATES + is_failed = active == "failed" - is_active = active_state == "active" - is_loading = active_state in LOADING_STATES - is_failed = active_state == "failed" - - # Block the handler so we don't trigger a start/stop self._switch.handler_block_by_func(self._on_toggled) - self._switch.set_active(is_active) + self._switch.set_active(is_on) self._switch.handler_unblock_by_func(self._on_toggled) - self._switch.set_sensitive(not is_loading) - # Status text + color if is_failed: self._status_label.set_label("ā— failed") self._status_label.set_css_classes(["caption", "error"]) - elif is_active: + elif is_on: self._status_label.set_label("ā— running") self._status_label.set_css_classes(["caption", "success"]) elif is_loading: - self._status_label.set_label(f"ā— {active_state}") + self._status_label.set_label(f"ā— {active}") self._status_label.set_css_classes(["caption", "warning"]) else: - self._status_label.set_label(f"ā— {active_state}") + self._status_label.set_label(f"ā— {active}") self._status_label.set_css_classes(["caption", "dim-label"]) - # ── signal handlers ── - - def _on_toggled(self, switch: Gtk.Switch, state: bool) -> bool: - action = "start" if state else "stop" - systemctl.run_action(action, self._unit, self._scope, self._method) + def _on_toggled(self, switch, state): + systemctl.run_action("start" if state else "stop", self._unit, self._scope, self._method) GLib.timeout_add(1500, self.refresh) return False - def _on_restart(self, _btn: Gtk.Button): + def _on_restart(self, _btn): systemctl.run_action("restart", self._unit, self._scope, self._method) GLib.timeout_add(1500, self.refresh) \ No newline at end of file diff --git a/app/sovran_systemsos_hub/systemctl.py b/app/sovran_systemsos_hub/systemctl.py index 88c4dee..651da84 100644 --- a/app/sovran_systemsos_hub/systemctl.py +++ b/app/sovran_systemsos_hub/systemctl.py @@ -7,7 +7,6 @@ from typing import Literal def _run(cmd: list[str]) -> str: - """Run a command and return stripped stdout (empty string on failure).""" try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) return result.stdout.strip() @@ -16,12 +15,10 @@ def _run(cmd: list[str]) -> str: def is_active(unit: str, scope: Literal["system", "user"] = "system") -> str: - """Return the ActiveState string (active / inactive / failed / …).""" return _run(["systemctl", f"--{scope}", "is-active", unit]) or "unknown" def is_enabled(unit: str, scope: Literal["system", "user"] = "system") -> str: - """Return the UnitFileState string (enabled / disabled / masked / …).""" return _run(["systemctl", f"--{scope}", "is-enabled", unit]) or "unknown" @@ -31,18 +28,11 @@ def run_action( scope: Literal["system", "user"] = "system", method: str = "systemctl", ) -> bool: - """Start / stop / restart a unit. - - For *system* units the command is elevated via *method* (pkexec or sudo). - Returns True on apparent success. - """ base_cmd = ["systemctl", f"--{scope}", action, unit] - if scope == "system" and method == "pkexec": cmd = ["pkexec", "--user", "root"] + base_cmd else: cmd = base_cmd - try: subprocess.Popen(cmd) return True diff --git a/app/style.css b/app/style.css index 0934be3..1daa90c 100644 --- a/app/style.css +++ b/app/style.css @@ -1,5 +1,3 @@ -/* Sovran_SystemsOS Hub — tile styling */ - .sovran-tile { border-radius: 16px; padding: 8px; @@ -7,21 +5,8 @@ min-height: 160px; transition: box-shadow 200ms ease-in-out; } - .sovran-tile:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); } - -.sovran-tile-icon { - margin-top: 8px; - margin-bottom: 2px; -} - -/* Status dot colors */ -.success { - color: #2ec27e; -} - -.warning { - color: #e5a50a; -} \ No newline at end of file +.success { color: #2ec27e; } +.warning { color: #e5a50a; } \ No newline at end of file diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 449c663..0292cc9 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -1,50 +1,42 @@ -# modules/core/sovran-hub.nix -# -# Declarative NixOS module that: -# 1. Builds the Sovran_SystemsOS_Hub GTK4 app as a Nix derivation -# 2. Generates its config.json from existing sovran_systemsOS options -# 3. Uses logos committed directly in the repo (no fetchurl hashes) -# 4. Installs a .desktop file so it appears in GNOME Activities - -{ config, pkgs, lib, ... }: +{ config, lib, pkgs, ... }: let cfg = config.sovran_systemsOS; - # ── Build the list of monitored units from NixOS option state ── - monitoredServices = - (lib.optional cfg.services.bitcoin - { name = "Bitcoind"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; }) - ++ (lib.optional cfg.services.bitcoin - { name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; }) - ++ (lib.optional cfg.services.bitcoin - { name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; }) - ++ (lib.optional cfg.services.bitcoin - { name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; }) - ++ (lib.optional cfg.services.bitcoin - { name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; }) - ++ (lib.optional cfg.services.synapse - { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; }) - ++ (lib.optional cfg.services.vaultwarden - { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; }) - ++ (lib.optional cfg.services.nextcloud - { name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; icon = "nextcloud"; }) - ++ (lib.optional cfg.services.wordpress - { name = "WordPress"; unit = "phpfpm-wordpress.service"; type = "system"; icon = "wordpress"; }) - ++ (lib.optional cfg.features.haven - { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; }) - ++ (lib.optional cfg.features.mempool - { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; }) - ++ (lib.optional cfg.features.element-calling - { name = "LiveKit"; unit = "livekit.service"; type = "system"; icon = "livekit"; }) - # Always-on infrastructure - ++ [ + [ { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; } { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; } + ] + ++ lib.optionals cfg.services.bitcoin [ + { name = "Bitcoind"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; } + { name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; } + { name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; } + { name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; } + { name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; } + ] + ++ lib.optionals cfg.services.synapse [ + { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; } + ] + ++ lib.optionals cfg.services.vaultwarden [ + { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; } + ] + ++ lib.optionals cfg.services.nextcloud [ + { name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; icon = "nextcloud"; } + ] + ++ lib.optionals cfg.services.wordpress [ + { name = "WordPress"; unit = "phpfpm-wordpress.service"; type = "system"; icon = "wordpress"; } + ] + ++ lib.optionals cfg.features.haven [ + { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; } + ] + ++ lib.optionals cfg.features.mempool [ + { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; } + ] + ++ lib.optionals cfg.features.element-calling [ + { name = "LiveKit"; unit = "livekit.service"; type = "system"; icon = "livekit"; } ]; - # ── Generate the config.json at build time ── generatedConfig = pkgs.writeText "sovran-hub-config.json" (builtins.toJSON { refresh_interval = 5; @@ -52,7 +44,6 @@ let services = monitoredServices; }); - # ── Package the Python GTK4 app ── sovran-hub = pkgs.python3Packages.buildPythonApplication { pname = "sovran-systemsos-hub"; version = "1.0.0"; @@ -60,54 +51,51 @@ let src = ../../app; - nativeBuildInputs = [ pkgs.wrapGAppsHook4 ]; - - buildInputs = [ - pkgs.gtk4 - pkgs.libadwaita - pkgs.gobject-introspection - pkgs.gdk-pixbuf - pkgs.librsvg + nativeBuildInputs = with pkgs; [ + wrapGAppsHook4 + gobject-introspection ]; - propagatedBuildInputs = [ - pkgs.python3Packages.pygobject3 + buildInputs = with pkgs; [ + gtk4 + libadwaita + gdk-pixbuf + librsvg + ]; + + propagatedBuildInputs = with pkgs.python3Packages; [ + pygobject3 ]; dontBuild = true; installPhase = '' - mkdir -p $out/bin $out/lib/sovran-hub $out/share/applications $out/share/sovran-hub/icons + runHook preInstall - # Copy Python source + install -d $out/lib/sovran-hub cp -r sovran_systemsos_hub $out/lib/sovran-hub/ - - # Copy CSS cp style.css $out/lib/sovran-hub/style.css - - # Copy logos from the repo (no fetchurl needed) - cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true - - # Install the generated config cp ${generatedConfig} $out/lib/sovran-hub/config.json - # Create the launcher script - cat > $out/bin/sovran-hub <<'LAUNCHER' -#!/usr/bin/env python3 -import sys, os -sys.path.insert(0, os.path.join("@out@", "lib", "sovran-hub")) -os.environ["SOVRAN_HUB_CONFIG"] = os.path.join("@out@", "lib", "sovran-hub", "config.json") -os.environ["SOVRAN_HUB_ICONS"] = os.path.join("@out@", "share", "sovran-hub", "icons") -os.environ["SOVRAN_HUB_CSS"] = os.path.join("@out@", "lib", "sovran-hub", "style.css") + install -d $out/share/sovran-hub/icons + cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true + + install -d $out/bin + cat > $out/bin/sovran-hub < $out/share/applications/Sovran_SystemsOS_Hub.desktop < $out/share/applications/Sovran_SystemsOS_Hub.desktop < Date: Tue, 31 Mar 2026 14:23:52 -0500 Subject: [PATCH 117/857] update py script --- app/sovran_systemsos_hub/application.py | 47 ++++++++++++++++++------ app/sovran_systemsos_hub/service_tile.py | 22 +++++++---- 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index 8d43dd1..42e04a2 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -16,13 +16,18 @@ from .service_tile import ServiceTile APP_ID = "com.sovransystems.hub" +# Initialize libadwaita BEFORE any widget creation +Adw.init() + class SovranHubWindow(Adw.ApplicationWindow): def __init__(self, app, config): super().__init__( - application=app, title="Sovran_SystemsOS Hub", - default_width=680, default_height=700, + application=app, + title="Sovran_SystemsOS Hub", + default_width=680, + default_height=700, ) self._config = config self._tiles = [] @@ -37,22 +42,33 @@ class SovranHubWindow(Adw.ApplicationWindow): ) header = Adw.HeaderBar() - refresh_btn = Gtk.Button(icon_name="view-refresh-symbolic", tooltip_text="Refresh now") + refresh_btn = Gtk.Button( + icon_name="view-refresh-symbolic", + tooltip_text="Refresh now", + ) refresh_btn.connect("clicked", lambda _b: self._refresh_all()) header.pack_end(refresh_btn) self._flowbox = Gtk.FlowBox( - max_children_per_line=4, min_children_per_line=2, - selection_mode=Gtk.SelectionMode.NONE, homogeneous=True, - row_spacing=12, column_spacing=12, - margin_top=16, margin_bottom=16, margin_start=16, margin_end=16, - halign=Gtk.Align.CENTER, valign=Gtk.Align.START, + max_children_per_line=4, + min_children_per_line=2, + selection_mode=Gtk.SelectionMode.NONE, + homogeneous=True, + row_spacing=12, + column_spacing=12, + margin_top=16, + margin_bottom=16, + margin_start=16, + margin_end=16, + halign=Gtk.Align.CENTER, + valign=Gtk.Align.START, ) scrolled = Gtk.ScrolledWindow( hscrollbar_policy=Gtk.PolicyType.NEVER, vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, - vexpand=True, child=self._flowbox, + vexpand=True, + child=self._flowbox, ) toolbar_view = Adw.ToolbarView() @@ -61,25 +77,31 @@ class SovranHubWindow(Adw.ApplicationWindow): self.set_content(toolbar_view) self._build_tiles() + interval = config.get("refresh_interval", 5) if interval and interval > 0: GLib.timeout_add_seconds(interval, self._auto_refresh) def _build_tiles(self): + method = self._config.get("command_method", "systemctl") for entry in self._config.get("services", []): tile = ServiceTile( name=entry.get("name", entry["unit"]), unit=entry["unit"], scope=entry.get("type", "system"), - method=self._config.get("command_method", "systemctl"), + method=method, icon_name=entry.get("icon", ""), ) self._flowbox.append(tile) self._tiles.append(tile) + # Defer first status poll so the window renders immediately + GLib.idle_add(self._refresh_all) + def _refresh_all(self): for t in self._tiles: t.refresh() + return False def _auto_refresh(self): self._refresh_all() @@ -89,7 +111,10 @@ class SovranHubWindow(Adw.ApplicationWindow): class SovranHubApp(Adw.Application): def __init__(self): - super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.DEFAULT_FLAGS) + super().__init__( + application_id=APP_ID, + flags=Gio.ApplicationFlags.DEFAULT_FLAGS, + ) self._config = load_config() def do_activate(self): diff --git a/app/sovran_systemsos_hub/service_tile.py b/app/sovran_systemsos_hub/service_tile.py index 33376f7..5966f03 100644 --- a/app/sovran_systemsos_hub/service_tile.py +++ b/app/sovran_systemsos_hub/service_tile.py @@ -10,7 +10,7 @@ gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") gi.require_version("Gdk", "4.0") -from gi.repository import Adw, Gdk, GdkPixbuf, GLib, Gtk +from gi.repository import Gdk, GdkPixbuf, GLib, Gtk from . import systemctl @@ -47,7 +47,11 @@ class ServiceTile(Gtk.Box): halign=Gtk.Align.CENTER, ellipsize=3, max_width_chars=14, )) - self._status_label = Gtk.Label(css_classes=["caption"], halign=Gtk.Align.CENTER) + self._status_label = Gtk.Label( + label="ā— …", + css_classes=["caption", "dim-label"], + halign=Gtk.Align.CENTER, + ) self.append(self._status_label) controls = Gtk.Box( @@ -66,22 +70,24 @@ class ServiceTile(Gtk.Box): controls.append(restart_btn) self.append(controls) - self.refresh() + # No self.refresh() here — the application calls it via GLib.idle_add def _set_logo(self, icon_name): if icon_name and ICON_DIR: for ext in ICON_EXTENSIONS: path = os.path.join(ICON_DIR, f"{icon_name}{ext}") if os.path.isfile(path): - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, 48, 48, True) - texture = Gdk.Texture.new_for_pixbuf(pixbuf) - self._logo.set_from_paintable(texture) - return + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, 48, 48, True) + texture = Gdk.Texture.new_for_pixbuf(pixbuf) + self._logo.set_from_paintable(texture) + return + except Exception: + break self._logo.set_from_icon_name("system-run-symbolic") def refresh(self): active = systemctl.is_active(self._unit, self._scope) - enabled = systemctl.is_enabled(self._unit, self._scope) is_on = active == "active" is_loading = active in LOADING_STATES is_failed = active == "failed" -- 2.53.0 From 8f3b9d41560355809ff055b761a20341b9454135 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 14:31:57 -0500 Subject: [PATCH 118/857] renamed livekit to element-call --- modules/core/sovran-hub.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 0292cc9..57d2bb6 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -34,7 +34,7 @@ let { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; } ] ++ lib.optionals cfg.features.element-calling [ - { name = "LiveKit"; unit = "livekit.service"; type = "system"; icon = "livekit"; } + { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; } ]; generatedConfig = pkgs.writeText "sovran-hub-config.json" @@ -121,4 +121,4 @@ in config = { environment.systemPackages = [ sovran-hub ]; }; -} \ No newline at end of file +} -- 2.53.0 From 435a2ed5b2b65b6fe5703ee067a081468cad0867 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 14:45:05 -0500 Subject: [PATCH 119/857] new visulation in Hub --- app/sovran_systemsos_hub/application.py | 113 +----------------- app/sovran_systemsos_hub/service_tile.py | 21 +++- app/style.css | 3 +- modules/core/sovran-hub.nix | 141 ++++------------------- 4 files changed, 45 insertions(+), 233 deletions(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index 42e04a2..c140335 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -1,87 +1,3 @@ -"""Sovran_SystemsOS_Hub — Main GTK4 Application.""" - -from __future__ import annotations - -import os - -import gi - -gi.require_version("Gtk", "4.0") -gi.require_version("Adw", "1") - -from gi.repository import Adw, Gdk, Gio, GLib, Gtk - -from .config import load_config -from .service_tile import ServiceTile - -APP_ID = "com.sovransystems.hub" - -# Initialize libadwaita BEFORE any widget creation -Adw.init() - - -class SovranHubWindow(Adw.ApplicationWindow): - - def __init__(self, app, config): - super().__init__( - application=app, - title="Sovran_SystemsOS Hub", - default_width=680, - default_height=700, - ) - self._config = config - self._tiles = [] - - css_path = os.environ.get("SOVRAN_HUB_CSS", "") - if css_path and os.path.isfile(css_path): - provider = Gtk.CssProvider() - provider.load_from_path(css_path) - Gtk.StyleContext.add_provider_for_display( - Gdk.Display.get_default(), provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, - ) - - header = Adw.HeaderBar() - refresh_btn = Gtk.Button( - icon_name="view-refresh-symbolic", - tooltip_text="Refresh now", - ) - refresh_btn.connect("clicked", lambda _b: self._refresh_all()) - header.pack_end(refresh_btn) - - self._flowbox = Gtk.FlowBox( - max_children_per_line=4, - min_children_per_line=2, - selection_mode=Gtk.SelectionMode.NONE, - homogeneous=True, - row_spacing=12, - column_spacing=12, - margin_top=16, - margin_bottom=16, - margin_start=16, - margin_end=16, - halign=Gtk.Align.CENTER, - valign=Gtk.Align.START, - ) - - scrolled = Gtk.ScrolledWindow( - hscrollbar_policy=Gtk.PolicyType.NEVER, - vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, - vexpand=True, - child=self._flowbox, - ) - - toolbar_view = Adw.ToolbarView() - toolbar_view.add_top_bar(header) - toolbar_view.set_content(scrolled) - self.set_content(toolbar_view) - - self._build_tiles() - - interval = config.get("refresh_interval", 5) - if interval and interval > 0: - GLib.timeout_add_seconds(interval, self._auto_refresh) - def _build_tiles(self): method = self._config.get("command_method", "systemctl") for entry in self._config.get("services", []): @@ -91,34 +7,9 @@ class SovranHubWindow(Adw.ApplicationWindow): scope=entry.get("type", "system"), method=method, icon_name=entry.get("icon", ""), + enabled=entry.get("enabled", True), ) self._flowbox.append(tile) self._tiles.append(tile) - # Defer first status poll so the window renders immediately - GLib.idle_add(self._refresh_all) - - def _refresh_all(self): - for t in self._tiles: - t.refresh() - return False - - def _auto_refresh(self): - self._refresh_all() - return True - - -class SovranHubApp(Adw.Application): - - def __init__(self): - super().__init__( - application_id=APP_ID, - flags=Gio.ApplicationFlags.DEFAULT_FLAGS, - ) - self._config = load_config() - - def do_activate(self): - win = self.get_active_window() - if not win: - win = SovranHubWindow(self, self._config) - win.present() \ No newline at end of file + GLib.idle_add(self._refresh_all) \ No newline at end of file diff --git a/app/sovran_systemsos_hub/service_tile.py b/app/sovran_systemsos_hub/service_tile.py index 5966f03..e32eca8 100644 --- a/app/sovran_systemsos_hub/service_tile.py +++ b/app/sovran_systemsos_hub/service_tile.py @@ -22,7 +22,8 @@ ICON_EXTENSIONS = [".svg", ".png"] class ServiceTile(Gtk.Box): - def __init__(self, name, unit, scope="system", method="systemctl", icon_name="", **kw): + def __init__(self, name, unit, scope="system", method="systemctl", + icon_name="", enabled=True, **kw): super().__init__( orientation=Gtk.Orientation.VERTICAL, spacing=6, @@ -37,6 +38,7 @@ class ServiceTile(Gtk.Box): self._unit = unit self._scope = scope self._method = method + self._enabled = enabled self._logo = Gtk.Image(pixel_size=48, margin_top=12, halign=Gtk.Align.CENTER) self._set_logo(icon_name) @@ -70,7 +72,14 @@ class ServiceTile(Gtk.Box): controls.append(restart_btn) self.append(controls) - # No self.refresh() here — the application calls it via GLib.idle_add + # If the feature is disabled in custom.nix, lock the tile immediately + if not self._enabled: + self._switch.set_active(False) + self._switch.set_sensitive(False) + self._status_label.set_label("ā—‹ disabled") + self._status_label.set_css_classes(["caption", "disabled-label"]) + self._logo.set_opacity(0.35) + self.set_tooltip_text(f"{name} is not enabled in custom.nix") def _set_logo(self, icon_name): if icon_name and ICON_DIR: @@ -87,6 +96,10 @@ class ServiceTile(Gtk.Box): self._logo.set_from_icon_name("system-run-symbolic") def refresh(self): + # Don't poll systemctl for disabled features + if not self._enabled: + return + active = systemctl.is_active(self._unit, self._scope) is_on = active == "active" is_loading = active in LOADING_STATES @@ -111,10 +124,14 @@ class ServiceTile(Gtk.Box): self._status_label.set_css_classes(["caption", "dim-label"]) def _on_toggled(self, switch, state): + if not self._enabled: + return True # block the toggle systemctl.run_action("start" if state else "stop", self._unit, self._scope, self._method) GLib.timeout_add(1500, self.refresh) return False def _on_restart(self, _btn): + if not self._enabled: + return systemctl.run_action("restart", self._unit, self._scope, self._method) GLib.timeout_add(1500, self.refresh) \ No newline at end of file diff --git a/app/style.css b/app/style.css index 1daa90c..d958fd9 100644 --- a/app/style.css +++ b/app/style.css @@ -9,4 +9,5 @@ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); } .success { color: #2ec27e; } -.warning { color: #e5a50a; } \ No newline at end of file +.warning { color: #e5a50a; } +.disabled-label { color: #888888; font-style: italic; } \ No newline at end of file diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 57d2bb6..fc222f7 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -1,124 +1,27 @@ -{ config, lib, pkgs, ... }: - -let - cfg = config.sovran_systemsOS; - monitoredServices = + # ── Always-on infrastructure ─────────────────────────────── [ - { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; } - { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; } + { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; } + { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; } ] - ++ lib.optionals cfg.services.bitcoin [ - { name = "Bitcoind"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; } - { name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; } - { name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; } - { name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; } - { name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; } + # ── Bitcoin ecosystem ────────────────────────────────────── + ++ [ + { name = "Bitcoind"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin; } + { name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; enabled = cfg.services.bitcoin; } + { name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; } + { name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; } + { name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.services.bitcoin; } ] - ++ lib.optionals cfg.services.synapse [ - { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; } + # ── Other services ───────────────────────────────────────── + ++ [ + { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; } + { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; enabled = cfg.services.vaultwarden; } + { name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; icon = "nextcloud"; enabled = cfg.services.nextcloud; } + { name = "WordPress"; unit = "phpfpm-wordpress.service"; type = "system"; icon = "wordpress"; enabled = cfg.services.wordpress; } ] - ++ lib.optionals cfg.services.vaultwarden [ - { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; } - ] - ++ lib.optionals cfg.services.nextcloud [ - { name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; icon = "nextcloud"; } - ] - ++ lib.optionals cfg.services.wordpress [ - { name = "WordPress"; unit = "phpfpm-wordpress.service"; type = "system"; icon = "wordpress"; } - ] - ++ lib.optionals cfg.features.haven [ - { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; } - ] - ++ lib.optionals cfg.features.mempool [ - { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; } - ] - ++ lib.optionals cfg.features.element-calling [ - { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; } - ]; - - generatedConfig = pkgs.writeText "sovran-hub-config.json" - (builtins.toJSON { - refresh_interval = 5; - command_method = "systemctl"; - services = monitoredServices; - }); - - sovran-hub = pkgs.python3Packages.buildPythonApplication { - pname = "sovran-systemsos-hub"; - version = "1.0.0"; - format = "other"; - - src = ../../app; - - nativeBuildInputs = with pkgs; [ - wrapGAppsHook4 - gobject-introspection - ]; - - buildInputs = with pkgs; [ - gtk4 - libadwaita - gdk-pixbuf - librsvg - ]; - - propagatedBuildInputs = with pkgs.python3Packages; [ - pygobject3 - ]; - - dontBuild = true; - - installPhase = '' - runHook preInstall - - install -d $out/lib/sovran-hub - cp -r sovran_systemsos_hub $out/lib/sovran-hub/ - cp style.css $out/lib/sovran-hub/style.css - cp ${generatedConfig} $out/lib/sovran-hub/config.json - - install -d $out/share/sovran-hub/icons - cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true - - install -d $out/bin - cat > $out/bin/sovran-hub < $out/share/applications/Sovran_SystemsOS_Hub.desktop < Date: Tue, 31 Mar 2026 15:46:21 -0500 Subject: [PATCH 120/857] hub update --- app/sovran_systemsos_hub/application.py | 112 ++++++++++++++++++++++- app/sovran_systemsos_hub/service_tile.py | 2 +- modules/core/sovran-hub.nix | 107 +++++++++++++++++++++- 3 files changed, 215 insertions(+), 6 deletions(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index c140335..7e2f4d5 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -1,3 +1,87 @@ +"""Sovran_SystemsOS_Hub — Main GTK4 Application.""" + +from __future__ import annotations + +import os + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from gi.repository import Adw, Gdk, Gio, GLib, Gtk + +from .config import load_config +from .service_tile import ServiceTile + +APP_ID = "com.sovransystems.hub" + +# Initialize libadwaita BEFORE any widget creation +Adw.init() + + +class SovranHubWindow(Adw.ApplicationWindow): + + def __init__(self, app, config): + super().__init__( + application=app, + title="Sovran_SystemsOS Hub", + default_width=680, + default_height=700, + ) + self._config = config + self._tiles = [] + + css_path = os.environ.get("SOVRAN_HUB_CSS", "") + if css_path and os.path.isfile(css_path): + provider = Gtk.CssProvider() + provider.load_from_path(css_path) + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + header = Adw.HeaderBar() + refresh_btn = Gtk.Button( + icon_name="view-refresh-symbolic", + tooltip_text="Refresh now", + ) + refresh_btn.connect("clicked", lambda _b: self._refresh_all()) + header.pack_end(refresh_btn) + + self._flowbox = Gtk.FlowBox( + max_children_per_line=4, + min_children_per_line=2, + selection_mode=Gtk.SelectionMode.NONE, + homogeneous=True, + row_spacing=12, + column_spacing=12, + margin_top=16, + margin_bottom=16, + margin_start=16, + margin_end=16, + halign=Gtk.Align.CENTER, + valign=Gtk.Align.START, + ) + + scrolled = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER, + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + vexpand=True, + child=self._flowbox, + ) + + toolbar_view = Adw.ToolbarView() + toolbar_view.add_top_bar(header) + toolbar_view.set_content(scrolled) + self.set_content(toolbar_view) + + self._build_tiles() + + interval = config.get("refresh_interval", 5) + if interval and interval > 0: + GLib.timeout_add_seconds(interval, self._auto_refresh) + def _build_tiles(self): method = self._config.get("command_method", "systemctl") for entry in self._config.get("services", []): @@ -12,4 +96,30 @@ self._flowbox.append(tile) self._tiles.append(tile) - GLib.idle_add(self._refresh_all) \ No newline at end of file + # Defer first status poll so the window renders immediately + GLib.idle_add(self._refresh_all) + + def _refresh_all(self): + for t in self._tiles: + t.refresh() + return False + + def _auto_refresh(self): + self._refresh_all() + return True + + +class SovranHubApp(Adw.Application): + + def __init__(self): + super().__init__( + application_id=APP_ID, + flags=Gio.ApplicationFlags.DEFAULT_FLAGS, + ) + self._config = load_config() + + def do_activate(self): + win = self.get_active_window() + if not win: + win = SovranHubWindow(self, self._config) + win.present() \ No newline at end of file diff --git a/app/sovran_systemsos_hub/service_tile.py b/app/sovran_systemsos_hub/service_tile.py index e32eca8..51ce4c0 100644 --- a/app/sovran_systemsos_hub/service_tile.py +++ b/app/sovran_systemsos_hub/service_tile.py @@ -125,7 +125,7 @@ class ServiceTile(Gtk.Box): def _on_toggled(self, switch, state): if not self._enabled: - return True # block the toggle + return True systemctl.run_action("start" if state else "stop", self._unit, self._scope, self._method) GLib.timeout_add(1500, self.refresh) return False diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index fc222f7..4fc24cc 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -1,3 +1,8 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.sovran_systemsOS; + monitoredServices = # ── Always-on infrastructure ─────────────────────────────── [ @@ -21,7 +26,101 @@ ] # ── Optional features ────────────────────────────────────── ++ [ - { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; enabled = cfg.features.haven; } - { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; } - { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; enabled = cfg.features.element-calling; } - ]; \ No newline at end of file + { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; enabled = cfg.features.haven; } + { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; } + { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; enabled = cfg.features.element-calling; } + ]; + + generatedConfig = pkgs.writeText "sovran-hub-config.json" + (builtins.toJSON { + refresh_interval = 5; + command_method = "systemctl"; + services = monitoredServices; + }); + + sovran-hub = pkgs.python3Packages.buildPythonApplication { + pname = "sovran-systemsos-hub"; + version = "1.0.0"; + format = "other"; + + src = ../../app; + + nativeBuildInputs = with pkgs; [ + wrapGAppsHook4 + gobject-introspection + ]; + + buildInputs = with pkgs; [ + gtk4 + libadwaita + gdk-pixbuf + librsvg + ]; + + propagatedBuildInputs = with pkgs.python3Packages; [ + pygobject3 + ]; + + dontBuild = true; + + installPhase = '' + runHook preInstall + + # ── Python source ───────────────────────────────���───────── + install -d $out/lib/sovran-hub + cp -r sovran_systemsos_hub $out/lib/sovran-hub/ + + # ── CSS ──────────────────────────────────────────────────── + cp style.css $out/lib/sovran-hub/style.css + + # ── Generated config ─────────────────────────────────────── + cp ${generatedConfig} $out/lib/sovran-hub/config.json + + # ── Icons (SVG + PNG) ────────────────────────────────────── + install -d $out/share/sovran-hub/icons + cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true + + # ── Launcher script ──────────────────────────────────────── + install -d $out/bin + cat > $out/bin/sovran-hub < $out/share/applications/Sovran_SystemsOS_Hub.desktop < Date: Tue, 31 Mar 2026 16:08:55 -0500 Subject: [PATCH 121/857] icon update --- app/icons/bitcoind.svg | 2 +- app/icons/btcpayserver.svg | 2 +- app/icons/caddy.svg | 1 + app/icons/electrs.svg | 2 +- app/icons/haven.svg | 1 + app/icons/livekit.svg | 1 + app/icons/lnd.svg | 2 +- app/icons/mempool.svg | 2 +- app/icons/nextcloud.svg | 2 +- app/icons/rtl.svg | 2 +- app/icons/synapse.svg | 2 +- app/icons/tor.svg | 1 + app/icons/vaultwarden.svg | 2 +- app/icons/wordpress.svg | 2 +- 14 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 app/icons/caddy.svg create mode 100644 app/icons/haven.svg create mode 100644 app/icons/livekit.svg create mode 100644 app/icons/tor.svg diff --git a/app/icons/bitcoind.svg b/app/icons/bitcoind.svg index 874a747..4b2e103 100644 --- a/app/icons/bitcoind.svg +++ b/app/icons/bitcoind.svg @@ -1 +1 @@ -404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file +₿ diff --git a/app/icons/btcpayserver.svg b/app/icons/btcpayserver.svg index 874a747..988b708 100644 --- a/app/icons/btcpayserver.svg +++ b/app/icons/btcpayserver.svg @@ -1 +1 @@ -404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file +PAY diff --git a/app/icons/caddy.svg b/app/icons/caddy.svg new file mode 100644 index 0000000..fb03a3b --- /dev/null +++ b/app/icons/caddy.svg @@ -0,0 +1 @@ + diff --git a/app/icons/electrs.svg b/app/icons/electrs.svg index 874a747..a9e2108 100644 --- a/app/icons/electrs.svg +++ b/app/icons/electrs.svg @@ -1 +1 @@ -404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file +⚔ diff --git a/app/icons/haven.svg b/app/icons/haven.svg new file mode 100644 index 0000000..0db3eff --- /dev/null +++ b/app/icons/haven.svg @@ -0,0 +1 @@ +N diff --git a/app/icons/livekit.svg b/app/icons/livekit.svg new file mode 100644 index 0000000..6c3159e --- /dev/null +++ b/app/icons/livekit.svg @@ -0,0 +1 @@ +EC diff --git a/app/icons/lnd.svg b/app/icons/lnd.svg index 874a747..0808efc 100644 --- a/app/icons/lnd.svg +++ b/app/icons/lnd.svg @@ -1 +1 @@ -404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file + diff --git a/app/icons/mempool.svg b/app/icons/mempool.svg index 874a747..efe40e6 100644 --- a/app/icons/mempool.svg +++ b/app/icons/mempool.svg @@ -1 +1 @@ -404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file + diff --git a/app/icons/nextcloud.svg b/app/icons/nextcloud.svg index 874a747..d76f4c4 100644 --- a/app/icons/nextcloud.svg +++ b/app/icons/nextcloud.svg @@ -1 +1 @@ -404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file + diff --git a/app/icons/rtl.svg b/app/icons/rtl.svg index 874a747..6f84041 100644 --- a/app/icons/rtl.svg +++ b/app/icons/rtl.svg @@ -1 +1 @@ -404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file +RTL diff --git a/app/icons/synapse.svg b/app/icons/synapse.svg index 874a747..16b65c7 100644 --- a/app/icons/synapse.svg +++ b/app/icons/synapse.svg @@ -1 +1 @@ -404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file +[M] diff --git a/app/icons/tor.svg b/app/icons/tor.svg new file mode 100644 index 0000000..10d7371 --- /dev/null +++ b/app/icons/tor.svg @@ -0,0 +1 @@ +TOR diff --git a/app/icons/vaultwarden.svg b/app/icons/vaultwarden.svg index 874a747..10c519f 100644 --- a/app/icons/vaultwarden.svg +++ b/app/icons/vaultwarden.svg @@ -1 +1 @@ -404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file +VW diff --git a/app/icons/wordpress.svg b/app/icons/wordpress.svg index 874a747..92c9828 100644 --- a/app/icons/wordpress.svg +++ b/app/icons/wordpress.svg @@ -1 +1 @@ -404: This page could not be found.Umbrel App Store

404

This page could not be found.

Ā© Trademarks and copyright of all apps belong to their developers.
\ No newline at end of file +W -- 2.53.0 From 4178ed07fb398dd08b8678293b7944bdb697b318 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 16:20:13 -0500 Subject: [PATCH 122/857] updated layout in hub --- app/sovran_systemsos_hub/application.py | 134 +++++++++++++++++++----- app/style.css | 12 ++- modules/core/sovran-hub.nix | 54 ++++++---- 3 files changed, 153 insertions(+), 47 deletions(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index 7e2f4d5..4f52e87 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -19,6 +19,21 @@ APP_ID = "com.sovransystems.hub" # Initialize libadwaita BEFORE any widget creation Adw.init() +# Category display order and labels +CATEGORY_ORDER = [ + ("infrastructure", "Infrastructure"), + ("bitcoin", "Bitcoin"), + ("communication", "Communication"), + ("apps", "Self-Hosted Apps"), + ("nostr", "Nostr"), +] + +ROLE_LABELS = { + "server_plus_desktop": "Server + Desktop", + "desktop": "Desktop Only", + "node": "Bitcoin Node", +} + class SovranHubWindow(Adw.ApplicationWindow): @@ -27,7 +42,7 @@ class SovranHubWindow(Adw.ApplicationWindow): application=app, title="Sovran_SystemsOS Hub", default_width=680, - default_height=700, + default_height=780, ) self._config = config self._tiles = [] @@ -42,6 +57,18 @@ class SovranHubWindow(Adw.ApplicationWindow): ) header = Adw.HeaderBar() + + # Show active role in header + role = config.get("role", "server_plus_desktop") + role_label = ROLE_LABELS.get(role, role) + role_tag = Gtk.Label( + label=role_label, + css_classes=["caption", "role-badge"], + ) + header.set_title_widget( + self._build_title_box(role_label) + ) + refresh_btn = Gtk.Button( icon_name="view-refresh-symbolic", tooltip_text="Refresh now", @@ -49,26 +76,17 @@ class SovranHubWindow(Adw.ApplicationWindow): refresh_btn.connect("clicked", lambda _b: self._refresh_all()) header.pack_end(refresh_btn) - self._flowbox = Gtk.FlowBox( - max_children_per_line=4, - min_children_per_line=2, - selection_mode=Gtk.SelectionMode.NONE, - homogeneous=True, - row_spacing=12, - column_spacing=12, - margin_top=16, - margin_bottom=16, - margin_start=16, - margin_end=16, - halign=Gtk.Align.CENTER, - valign=Gtk.Align.START, + # Main vertical layout + self._main_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=0, ) scrolled = Gtk.ScrolledWindow( hscrollbar_policy=Gtk.PolicyType.NEVER, vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, vexpand=True, - child=self._flowbox, + child=self._main_box, ) toolbar_view = Adw.ToolbarView() @@ -82,19 +100,85 @@ class SovranHubWindow(Adw.ApplicationWindow): if interval and interval > 0: GLib.timeout_add_seconds(interval, self._auto_refresh) + def _build_title_box(self, role_label): + box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + halign=Gtk.Align.CENTER, + ) + box.append(Gtk.Label( + label="Sovran_SystemsOS Hub", + css_classes=["title"], + )) + box.append(Gtk.Label( + label=role_label, + css_classes=["caption", "dim-label"], + )) + return box + def _build_tiles(self): method = self._config.get("command_method", "systemctl") - for entry in self._config.get("services", []): - tile = ServiceTile( - name=entry.get("name", entry["unit"]), - unit=entry["unit"], - scope=entry.get("type", "system"), - method=method, - icon_name=entry.get("icon", ""), - enabled=entry.get("enabled", True), + services = self._config.get("services", []) + + # Group services by category + grouped = {} + for entry in services: + cat = entry.get("category", "other") + grouped.setdefault(cat, []).append(entry) + + for cat_key, cat_label in CATEGORY_ORDER: + entries = grouped.get(cat_key, []) + if not entries: + continue + + # Section header + section_label = Gtk.Label( + label=cat_label, + css_classes=["title-4"], + halign=Gtk.Align.START, + margin_top=20, + margin_bottom=4, + margin_start=24, ) - self._flowbox.append(tile) - self._tiles.append(tile) + self._main_box.append(section_label) + + # Separator + sep = Gtk.Separator( + orientation=Gtk.Orientation.HORIZONTAL, + margin_start=24, + margin_end=24, + margin_bottom=8, + ) + self._main_box.append(sep) + + # FlowBox for this category + flowbox = Gtk.FlowBox( + max_children_per_line=4, + min_children_per_line=2, + selection_mode=Gtk.SelectionMode.NONE, + homogeneous=True, + row_spacing=12, + column_spacing=12, + margin_top=4, + margin_bottom=8, + margin_start=16, + margin_end=16, + halign=Gtk.Align.CENTER, + valign=Gtk.Align.START, + ) + + for entry in entries: + tile = ServiceTile( + name=entry.get("name", entry["unit"]), + unit=entry["unit"], + scope=entry.get("type", "system"), + method=method, + icon_name=entry.get("icon", ""), + enabled=entry.get("enabled", True), + ) + flowbox.append(tile) + self._tiles.append(tile) + + self._main_box.append(flowbox) # Defer first status poll so the window renders immediately GLib.idle_add(self._refresh_all) diff --git a/app/style.css b/app/style.css index d958fd9..b0214da 100644 --- a/app/style.css +++ b/app/style.css @@ -8,6 +8,12 @@ .sovran-tile:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); } -.success { color: #2ec27e; } -.warning { color: #e5a50a; } -.disabled-label { color: #888888; font-style: italic; } \ No newline at end of file +.success { color: #2ec27e; } +.warning { color: #e5a50a; } +.error { color: #e01b24; } +.disabled-label { color: #888888; font-style: italic; } +.role-badge { + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75em; +} \ No newline at end of file diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 4fc24cc..2980a2e 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -3,38 +3,54 @@ let cfg = config.sovran_systemsOS; + # ── Determine Bitcoin implementation label ─────────────────── + bitcoinImplName = + if cfg.features.bitcoin-core then "Bitcoin Core" + else if cfg.features.bip110 then "Bitcoin Knots + BIP110" + else "Bitcoin Knots"; + monitoredServices = - # ── Always-on infrastructure ─────────────────────────────── + # ── Infrastructure (always present) ──────────────────────── [ - { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; } - { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; } + { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; category = "infrastructure"; } + { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; category = "infrastructure"; } ] - # ── Bitcoin ecosystem ────────────────────────────────────── + # ── Bitcoin Ecosystem ────────────────────────────────────── ++ [ - { name = "Bitcoind"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin; } - { name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; enabled = cfg.services.bitcoin; } - { name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; } - { name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; } - { name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.services.bitcoin; } + { name = bitcoinImplName; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin; category = "bitcoin"; } + { name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; enabled = cfg.services.bitcoin; category = "bitcoin"; } + { name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin"; } + { name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin"; } + { name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.services.bitcoin; category = "bitcoin"; } + { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin"; } ] - # ── Other services ───────────────────────────────────────── + # ── Communication ────────────────────────────────────────── ++ [ - { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; } - { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; enabled = cfg.services.vaultwarden; } - { name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; icon = "nextcloud"; enabled = cfg.services.nextcloud; } - { name = "WordPress"; unit = "phpfpm-wordpress.service"; type = "system"; icon = "wordpress"; enabled = cfg.services.wordpress; } + { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; category = "communication"; } + { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; enabled = cfg.features.element-calling; category = "communication"; } ] - # ── Optional features ────────────────────────────────────── + # ── Self-Hosted Apps ─────────────────────────────────────── ++ [ - { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; enabled = cfg.features.haven; } - { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; } - { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; enabled = cfg.features.element-calling; } + { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; enabled = cfg.services.vaultwarden; category = "apps"; } + { name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; icon = "nextcloud"; enabled = cfg.services.nextcloud; category = "apps"; } + { name = "WordPress"; unit = "phpfpm-wordpress.service"; type = "system"; icon = "wordpress"; enabled = cfg.services.wordpress; category = "apps"; } + ] + # ── Nostr / Relay ────────────────────────────────────────── + ++ [ + { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; enabled = cfg.features.haven; category = "nostr"; } ]; + # ── Determine active role name ─────────────────────────────── + activeRole = + if cfg.roles.desktop then "desktop" + else if cfg.roles.node then "node" + else "server_plus_desktop"; + generatedConfig = pkgs.writeText "sovran-hub-config.json" (builtins.toJSON { refresh_interval = 5; command_method = "systemctl"; + role = activeRole; services = monitoredServices; }); @@ -66,7 +82,7 @@ let installPhase = '' runHook preInstall - # ── Python source ───────────────────────────────���───────── + # ── Python source ───────────────────────────────────────── install -d $out/lib/sovran-hub cp -r sovran_systemsos_hub $out/lib/sovran-hub/ -- 2.53.0 From dfa0249ec4ba6f7e50796e1839b3a51c8581ae46 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 16:31:15 -0500 Subject: [PATCH 123/857] updated layout in hub --- app/icons/bip110.svg | 1 + app/icons/bitcoin-core.svg | 1 + app/sovran_systemsos_hub/application.py | 23 +++++------------ modules/core/sovran-hub.nix | 34 ++++++++++++------------- 4 files changed, 24 insertions(+), 35 deletions(-) create mode 100644 app/icons/bip110.svg create mode 100644 app/icons/bitcoin-core.svg diff --git a/app/icons/bip110.svg b/app/icons/bip110.svg new file mode 100644 index 0000000..4534ad7 --- /dev/null +++ b/app/icons/bip110.svg @@ -0,0 +1 @@ +BIP110 diff --git a/app/icons/bitcoin-core.svg b/app/icons/bitcoin-core.svg new file mode 100644 index 0000000..4296a25 --- /dev/null +++ b/app/icons/bitcoin-core.svg @@ -0,0 +1 @@ +₿ diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index 4f52e87..e72a840 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -22,7 +22,8 @@ Adw.init() # Category display order and labels CATEGORY_ORDER = [ ("infrastructure", "Infrastructure"), - ("bitcoin", "Bitcoin"), + ("bitcoin-base", "Bitcoin Base"), + ("bitcoin-apps", "Bitcoin Apps"), ("communication", "Communication"), ("apps", "Self-Hosted Apps"), ("nostr", "Nostr"), @@ -57,17 +58,7 @@ class SovranHubWindow(Adw.ApplicationWindow): ) header = Adw.HeaderBar() - - # Show active role in header - role = config.get("role", "server_plus_desktop") - role_label = ROLE_LABELS.get(role, role) - role_tag = Gtk.Label( - label=role_label, - css_classes=["caption", "role-badge"], - ) - header.set_title_widget( - self._build_title_box(role_label) - ) + header.set_title_widget(self._build_title_box()) refresh_btn = Gtk.Button( icon_name="view-refresh-symbolic", @@ -76,7 +67,6 @@ class SovranHubWindow(Adw.ApplicationWindow): refresh_btn.connect("clicked", lambda _b: self._refresh_all()) header.pack_end(refresh_btn) - # Main vertical layout self._main_box = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, spacing=0, @@ -100,7 +90,9 @@ class SovranHubWindow(Adw.ApplicationWindow): if interval and interval > 0: GLib.timeout_add_seconds(interval, self._auto_refresh) - def _build_title_box(self, role_label): + def _build_title_box(self): + role = self._config.get("role", "server_plus_desktop") + role_label = ROLE_LABELS.get(role, role) box = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER, @@ -141,7 +133,6 @@ class SovranHubWindow(Adw.ApplicationWindow): ) self._main_box.append(section_label) - # Separator sep = Gtk.Separator( orientation=Gtk.Orientation.HORIZONTAL, margin_start=24, @@ -150,7 +141,6 @@ class SovranHubWindow(Adw.ApplicationWindow): ) self._main_box.append(sep) - # FlowBox for this category flowbox = Gtk.FlowBox( max_children_per_line=4, min_children_per_line=2, @@ -180,7 +170,6 @@ class SovranHubWindow(Adw.ApplicationWindow): self._main_box.append(flowbox) - # Defer first status poll so the window renders immediately GLib.idle_add(self._refresh_all) def _refresh_all(self): diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 2980a2e..8902e30 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -3,31 +3,30 @@ let cfg = config.sovran_systemsOS; - # ── Determine Bitcoin implementation label ─────────────────── - bitcoinImplName = - if cfg.features.bitcoin-core then "Bitcoin Core" - else if cfg.features.bip110 then "Bitcoin Knots + BIP110" - else "Bitcoin Knots"; - monitoredServices = # ── Infrastructure (always present) ──────────────────────── [ - { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; category = "infrastructure"; } - { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; category = "infrastructure"; } + { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; category = "infrastructure"; } + { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; category = "infrastructure"; } ] - # ── Bitcoin Ecosystem ────────────────────────────────────── + # ── Bitcoin Base (node implementations) ──────────────────── ++ [ - { name = bitcoinImplName; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin; category = "bitcoin"; } - { name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; enabled = cfg.services.bitcoin; category = "bitcoin"; } - { name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin"; } - { name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin"; } - { name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.services.bitcoin; category = "bitcoin"; } - { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin"; } + { name = "Bitcoin Knots"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core && !cfg.features.bip110; category = "bitcoin-base"; } + { name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; } + { name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; } + ] + # ── Bitcoin Apps (services on top of the node) ───────────── + ++ [ + { name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; } + { name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; } + { name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; } + { name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; } + { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; } ] # ── Communication ────────────────────────────────────────── ++ [ - { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; category = "communication"; } - { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; enabled = cfg.features.element-calling; category = "communication"; } + { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; category = "communication"; } + { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; enabled = cfg.features.element-calling; category = "communication"; } ] # ── Self-Hosted Apps ─────────────────────────────────────── ++ [ @@ -40,7 +39,6 @@ let { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; enabled = cfg.features.haven; category = "nostr"; } ]; - # ── Determine active role name ─────────────────────────────── activeRole = if cfg.roles.desktop then "desktop" else if cfg.roles.node then "node" -- 2.53.0 From 6b6a90da2a55b12cf149729881eafdc3df7ccc0a Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 16:40:04 -0500 Subject: [PATCH 124/857] Add autostart toggle, dock pinning, Bitcoin Base/Apps split --- app/sovran_systemsos_hub/application.py | 90 +++++++++++++++++++++++-- modules/core/sovran-hub.nix | 32 +++++++-- 2 files changed, 114 insertions(+), 8 deletions(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index e72a840..ad9cc6f 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -16,10 +16,8 @@ from .service_tile import ServiceTile APP_ID = "com.sovransystems.hub" -# Initialize libadwaita BEFORE any widget creation Adw.init() -# Category display order and labels CATEGORY_ORDER = [ ("infrastructure", "Infrastructure"), ("bitcoin-base", "Bitcoin Base"), @@ -35,6 +33,51 @@ ROLE_LABELS = { "node": "Bitcoin Node", } +# XDG paths for autostart control +AUTOSTART_DIR = os.path.join( + os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), + "autostart", +) +USER_AUTOSTART_FILE = os.path.join(AUTOSTART_DIR, "sovran-hub.desktop") +SYSTEM_AUTOSTART_FILE = "/etc/xdg/autostart/sovran-hub.desktop" + + +def get_autostart_enabled() -> bool: + """Check if autostart is enabled for the current user.""" + # If user has their own copy, check its X-GNOME-Autostart-enabled + if os.path.isfile(USER_AUTOSTART_FILE): + try: + with open(USER_AUTOSTART_FILE, "r") as f: + for line in f: + if line.strip().lower() == "x-gnome-autostart-enabled=false": + return False + if line.strip().lower() == "hidden=true": + return False + return True + except Exception: + return True + # No user override — system file controls it + return os.path.isfile(SYSTEM_AUTOSTART_FILE) + + +def set_autostart_enabled(enabled: bool): + """Enable or disable autostart by writing a user-level desktop file.""" + os.makedirs(AUTOSTART_DIR, exist_ok=True) + + if enabled: + # Remove the user override so the system file takes effect + if os.path.isfile(USER_AUTOSTART_FILE): + os.remove(USER_AUTOSTART_FILE) + else: + # Write a user override that disables autostart + with open(USER_AUTOSTART_FILE, "w") as f: + f.write("[Desktop Entry]\n") + f.write("Type=Application\n") + f.write("Name=Sovran_SystemsOS Hub\n") + f.write("Exec=sovran-hub\n") + f.write("X-GNOME-Autostart-enabled=false\n") + f.write("Hidden=true\n") + class SovranHubWindow(Adw.ApplicationWindow): @@ -67,6 +110,43 @@ class SovranHubWindow(Adw.ApplicationWindow): refresh_btn.connect("clicked", lambda _b: self._refresh_all()) header.pack_end(refresh_btn) + # ── Settings menu ──────────────────────────────────────── + menu_btn = Gtk.MenuButton( + icon_name="open-menu-symbolic", + tooltip_text="Settings", + ) + popover = Gtk.Popover() + menu_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=8, + margin_top=12, + margin_bottom=12, + margin_start=12, + margin_end=12, + ) + + autostart_row = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=12, + ) + autostart_label = Gtk.Label( + label="Start at login", + hexpand=True, + halign=Gtk.Align.START, + ) + self._autostart_switch = Gtk.Switch( + valign=Gtk.Align.CENTER, + active=get_autostart_enabled(), + ) + self._autostart_switch.connect("state-set", self._on_autostart_toggled) + autostart_row.append(autostart_label) + autostart_row.append(self._autostart_switch) + menu_box.append(autostart_row) + + popover.set_child(menu_box) + menu_btn.set_popover(popover) + header.pack_end(menu_btn) + self._main_box = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, spacing=0, @@ -111,7 +191,6 @@ class SovranHubWindow(Adw.ApplicationWindow): method = self._config.get("command_method", "systemctl") services = self._config.get("services", []) - # Group services by category grouped = {} for entry in services: cat = entry.get("category", "other") @@ -122,7 +201,6 @@ class SovranHubWindow(Adw.ApplicationWindow): if not entries: continue - # Section header section_label = Gtk.Label( label=cat_label, css_classes=["title-4"], @@ -172,6 +250,10 @@ class SovranHubWindow(Adw.ApplicationWindow): GLib.idle_add(self._refresh_all) + def _on_autostart_toggled(self, switch, state): + set_autostart_enabled(state) + return False + def _refresh_all(self): for t in self._tiles: t.refresh() diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 8902e30..6999a6b 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -90,7 +90,7 @@ let # ── Generated config ─────────────────────────────────────── cp ${generatedConfig} $out/lib/sovran-hub/config.json - # ── Icons (SVG + PNG) ────────────────────────────────────── + # ── Icons (SVG + PNG) ───────────────���────────────────────── install -d $out/share/sovran-hub/icons cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true @@ -109,20 +109,34 @@ sys.exit(SovranHubApp().run(sys.argv)) LAUNCHER chmod +x $out/bin/sovran-hub - # ── Desktop file ─────────────────────────────────────────── + # ── Desktop file (for app launcher + dock) ───────────────── install -d $out/share/applications - cat > $out/share/applications/Sovran_SystemsOS_Hub.desktop < $out/share/applications/sovran-hub.desktop < $out/etc/xdg/autostart/sovran-hub.desktop < Date: Tue, 31 Mar 2026 16:46:02 -0500 Subject: [PATCH 125/857] Remove systemd-manager GNOME extension, replaced by Sovran Hub --- configuration.nix | 3 +-- modules/core/sovran_systemsos-desktop.nix | 20 +------------------- 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/configuration.nix b/configuration.nix index 5b1c20d..d738c70 100644 --- a/configuration.nix +++ b/configuration.nix @@ -89,7 +89,6 @@ environment.systemPackages = with pkgs; [ git wget fish htop btop gnomeExtensions.transparent-top-bar-adjustable-transparency - gnomeExtensions.systemd-manager gnomeExtensions.dash-to-dock gnomeExtensions.vitals gnomeExtensions.pop-shell @@ -178,4 +177,4 @@ backup /etc/nix-bitcoin-secrets/ localhost/ nix.gc = { automatic = true; dates = "weekly"; options = "--delete-older-than 7d"; }; system.stateVersion = "22.05"; -} +} \ No newline at end of file diff --git a/modules/core/sovran_systemsos-desktop.nix b/modules/core/sovran_systemsos-desktop.nix index a192a58..4746d2e 100644 --- a/modules/core/sovran_systemsos-desktop.nix +++ b/modules/core/sovran_systemsos-desktop.nix @@ -75,7 +75,6 @@ in "dash-to-dock@micxgx.gmail.com" "pop-shell@system76.com" "date-menu-formatter@marcinjakubowski.github.com" - "systemd-manager@hardpixel.eu" "light-style@gnome-shell-extensions.gcampax.github.com" ]; @@ -84,7 +83,7 @@ in "org.gnome.Settings.desktop" "org.gnome.Nautilus.desktop" "Sovran_SystemsOS_Updater.desktop" - "Sovran_SystemsOS_Hub.desktop" + "sovran-hub.desktop" "org.gnome.Software.desktop" "org.gnome.Geary.desktop" "org.gnome.Contacts.desktop" @@ -126,23 +125,6 @@ in tile-by-default = true; }; - "org/gnome/shell/extensions/systemd-manager" = { - command-method = "systemctl"; - systemd = [ - "{\"name\":\"Bitcoind\",\"service\":\"bitcoind.service\",\"type\":\"system\"}" - "{\"name\":\"Electrs\",\"service\":\"electrs.service\",\"type\":\"system\"}" - "{\"name\":\"CLN\",\"service\":\"clightning.service\",\"type\":\"system\"}" - "{\"name\":\"LND\",\"service\":\"lnd.service\",\"type\":\"system\"}" - "{\"name\":\"Ride The Lightning\",\"service\":\"rtl.service\",\"type\":\"system\"}" - "{\"name\":\"BTCPayserver\",\"service\":\"btcpayserver.service\",\"type\":\"system\"}" - "{\"name\":\"Matrix-Synapse\",\"service\":\"matrix-synapse.service\",\"type\":\"system\"}" - "{\"name\":\"Coturn\",\"service\":\"coturn.service\",\"type\":\"system\"}" - "{\"name\":\"VaultWarden\",\"service\":\"vaultwarden.service\",\"type\":\"system\"}" - "{\"name\":\"Caddy\",\"service\":\"caddy.service\",\"type\":\"system\"}" - "{\"name\":\"Tor\",\"service\":\"tor.service\",\"type\":\"system\"}" - ]; - }; - "org/gnome/shell/extensions/vitals" = { hot-sensors = [ "_storage_free_" -- 2.53.0 From 68c24f6ec15d97bc2d49e9ef73281067ec3c28ca Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 16:58:10 -0500 Subject: [PATCH 126/857] added updater to hub --- app/sovran_systemsos_hub/application.py | 145 ++++++++++++++++++++++-- 1 file changed, 137 insertions(+), 8 deletions(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index ad9cc6f..5037b8b 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -3,6 +3,8 @@ from __future__ import annotations import os +import subprocess +import threading import gi @@ -33,7 +35,6 @@ ROLE_LABELS = { "node": "Bitcoin Node", } -# XDG paths for autostart control AUTOSTART_DIR = os.path.join( os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autostart", @@ -41,10 +42,14 @@ AUTOSTART_DIR = os.path.join( USER_AUTOSTART_FILE = os.path.join(AUTOSTART_DIR, "sovran-hub.desktop") SYSTEM_AUTOSTART_FILE = "/etc/xdg/autostart/sovran-hub.desktop" +UPDATE_COMMAND = [ + "ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes", + "root@localhost", + "cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y", +] + def get_autostart_enabled() -> bool: - """Check if autostart is enabled for the current user.""" - # If user has their own copy, check its X-GNOME-Autostart-enabled if os.path.isfile(USER_AUTOSTART_FILE): try: with open(USER_AUTOSTART_FILE, "r") as f: @@ -56,20 +61,15 @@ def get_autostart_enabled() -> bool: return True except Exception: return True - # No user override — system file controls it return os.path.isfile(SYSTEM_AUTOSTART_FILE) def set_autostart_enabled(enabled: bool): - """Enable or disable autostart by writing a user-level desktop file.""" os.makedirs(AUTOSTART_DIR, exist_ok=True) - if enabled: - # Remove the user override so the system file takes effect if os.path.isfile(USER_AUTOSTART_FILE): os.remove(USER_AUTOSTART_FILE) else: - # Write a user override that disables autostart with open(USER_AUTOSTART_FILE, "w") as f: f.write("[Desktop Entry]\n") f.write("Type=Application\n") @@ -79,6 +79,121 @@ def set_autostart_enabled(enabled: bool): f.write("Hidden=true\n") +class UpdateDialog(Adw.Window): + """Modal window that streams the system update output.""" + + def __init__(self, parent): + super().__init__( + title="Sovran_SystemsOS Update", + default_width=700, + default_height=500, + modal=True, + transient_for=parent, + ) + + self._process = None + + header = Adw.HeaderBar() + + self._close_btn = Gtk.Button(label="Close", sensitive=False) + self._close_btn.connect("clicked", lambda _b: self.close()) + header.pack_end(self._close_btn) + + self._spinner = Gtk.Spinner(spinning=True) + header.pack_start(self._spinner) + + self._status_label = Gtk.Label( + label="Updating…", + css_classes=["title-4"], + halign=Gtk.Align.CENTER, + margin_top=8, + margin_bottom=4, + ) + + self._textview = Gtk.TextView( + editable=False, + cursor_visible=False, + monospace=True, + wrap_mode=Gtk.WrapMode.WORD_CHAR, + top_margin=8, + bottom_margin=8, + left_margin=12, + right_margin=12, + ) + self._buffer = self._textview.get_buffer() + + scrolled = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + vexpand=True, + child=self._textview, + ) + + content = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=0, + ) + content.append(self._status_label) + content.append(scrolled) + + toolbar_view = Adw.ToolbarView() + toolbar_view.add_top_bar(header) + toolbar_view.set_content(content) + self.set_content(toolbar_view) + + self._start_update() + + def _append_text(self, text): + end_iter = self._buffer.get_end_iter() + self._buffer.insert(end_iter, text) + # Auto-scroll to bottom + mark = self._buffer.create_mark(None, self._buffer.get_end_iter(), False) + self._textview.scroll_mark_onscreen(mark) + self._buffer.delete_mark(mark) + + def _start_update(self): + self._append_text("$ ssh root@localhost 'cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y'\n\n") + thread = threading.Thread(target=self._run_update, daemon=True) + thread.start() + + def _run_update(self): + try: + self._process = subprocess.Popen( + UPDATE_COMMAND, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + for line in self._process.stdout: + GLib.idle_add(self._append_text, line) + + self._process.wait() + rc = self._process.returncode + + if rc == 0: + GLib.idle_add(self._on_finished, True, "Update complete!") + else: + GLib.idle_add(self._on_finished, False, f"Update failed (exit code {rc})") + + except Exception as e: + GLib.idle_add(self._on_finished, False, f"Error: {e}") + + def _on_finished(self, success, message): + self._spinner.set_spinning(False) + self._close_btn.set_sensitive(True) + + if success: + self._status_label.set_label("āœ“ " + message) + self._status_label.set_css_classes(["title-4", "success"]) + else: + self._status_label.set_label("āœ— " + message) + self._status_label.set_css_classes(["title-4", "error"]) + + self._append_text(f"\n{'─' * 50}\n{message}\n") + + class SovranHubWindow(Adw.ApplicationWindow): def __init__(self, app, config): @@ -103,6 +218,16 @@ class SovranHubWindow(Adw.ApplicationWindow): header = Adw.HeaderBar() header.set_title_widget(self._build_title_box()) + # ── Update button (left side, prominent) ───────────────── + update_btn = Gtk.Button( + label="Update System", + css_classes=["suggested-action"], + tooltip_text="Update Sovran_SystemsOS (flake update + rebuild + flatpak)", + ) + update_btn.connect("clicked", self._on_update_clicked) + header.pack_start(update_btn) + + # ── Refresh button ─────────────────────────────────────── refresh_btn = Gtk.Button( icon_name="view-refresh-symbolic", tooltip_text="Refresh now", @@ -250,6 +375,10 @@ class SovranHubWindow(Adw.ApplicationWindow): GLib.idle_add(self._refresh_all) + def _on_update_clicked(self, _btn): + dialog = UpdateDialog(self) + dialog.present() + def _on_autostart_toggled(self, switch, state): set_autostart_enabled(state) return False -- 2.53.0 From d93f5b9edab41d9b8b8ed2ef6a58189d5addb8ca Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 17:03:52 -0500 Subject: [PATCH 127/857] Bigger update modal, error report to Downloads, reboot button --- app/sovran_systemsos_hub/application.py | 88 +++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index 5037b8b..87d0e70 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -5,6 +5,7 @@ from __future__ import annotations import os import subprocess import threading +from datetime import datetime import gi @@ -42,12 +43,20 @@ AUTOSTART_DIR = os.path.join( USER_AUTOSTART_FILE = os.path.join(AUTOSTART_DIR, "sovran-hub.desktop") SYSTEM_AUTOSTART_FILE = "/etc/xdg/autostart/sovran-hub.desktop" +DOWNLOADS_DIR = os.path.join(os.path.expanduser("~"), "Downloads") + UPDATE_COMMAND = [ "ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes", "root@localhost", "cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y", ] +REBOOT_COMMAND = [ + "ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes", + "root@localhost", + "reboot", +] + def get_autostart_enabled() -> bool: if os.path.isfile(USER_AUTOSTART_FILE): @@ -85,20 +94,42 @@ class UpdateDialog(Adw.Window): def __init__(self, parent): super().__init__( title="Sovran_SystemsOS Update", - default_width=700, - default_height=500, + default_width=900, + default_height=700, modal=True, transient_for=parent, ) self._process = None + self._full_log = "" header = Adw.HeaderBar() + # ── Close button (disabled during update) ──────────────── self._close_btn = Gtk.Button(label="Close", sensitive=False) self._close_btn.connect("clicked", lambda _b: self.close()) header.pack_end(self._close_btn) + # ── Reboot button (hidden until update succeeds) ───────── + self._reboot_btn = Gtk.Button( + label="Reboot", + css_classes=["destructive-action"], + tooltip_text="Reboot the system now", + visible=False, + ) + self._reboot_btn.connect("clicked", self._on_reboot_clicked) + header.pack_end(self._reboot_btn) + + # ── Save error report button (hidden until failure) ────── + self._save_btn = Gtk.Button( + label="Save Error Report", + css_classes=["warning"], + tooltip_text="Save full log to ~/Downloads", + visible=False, + ) + self._save_btn.connect("clicked", self._on_save_report) + header.pack_start(self._save_btn) + self._spinner = Gtk.Spinner(spinning=True) header.pack_start(self._spinner) @@ -106,8 +137,8 @@ class UpdateDialog(Adw.Window): label="Updating…", css_classes=["title-4"], halign=Gtk.Align.CENTER, - margin_top=8, - margin_bottom=4, + margin_top=12, + margin_bottom=8, ) self._textview = Gtk.TextView( @@ -144,15 +175,18 @@ class UpdateDialog(Adw.Window): self._start_update() def _append_text(self, text): + self._full_log += text end_iter = self._buffer.get_end_iter() self._buffer.insert(end_iter, text) - # Auto-scroll to bottom mark = self._buffer.create_mark(None, self._buffer.get_end_iter(), False) self._textview.scroll_mark_onscreen(mark) self._buffer.delete_mark(mark) def _start_update(self): - self._append_text("$ ssh root@localhost 'cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y'\n\n") + self._append_text( + "$ ssh root@localhost 'cd /etc/nixos && nix flake update " + "&& nixos-rebuild switch && flatpak update -y'\n\n" + ) thread = threading.Thread(target=self._run_update, daemon=True) thread.start() @@ -187,11 +221,51 @@ class UpdateDialog(Adw.Window): if success: self._status_label.set_label("āœ“ " + message) self._status_label.set_css_classes(["title-4", "success"]) + self._reboot_btn.set_visible(True) else: self._status_label.set_label("āœ— " + message) self._status_label.set_css_classes(["title-4", "error"]) + self._save_btn.set_visible(True) - self._append_text(f"\n{'─' * 50}\n{message}\n") + self._append_text(f"\n{'─' * 60}\n{message}\n") + + def _on_save_report(self, _btn): + os.makedirs(DOWNLOADS_DIR, exist_ok=True) + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"sovran-update-error-{timestamp}.log" + filepath = os.path.join(DOWNLOADS_DIR, filename) + try: + with open(filepath, "w") as f: + f.write(f"Sovran_SystemsOS Update Error Report\n") + f.write(f"Date: {datetime.now().isoformat()}\n") + f.write(f"{'═' * 60}\n\n") + f.write(self._full_log) + self._save_btn.set_label(f"Saved: {filename}") + self._save_btn.set_sensitive(False) + self._append_text(f"\nāœ“ Error report saved to ~/Downloads/{filename}\n") + except Exception as e: + self._append_text(f"\nāœ— Failed to save report: {e}\n") + + def _on_reboot_clicked(self, _btn): + dialog = Adw.MessageDialog( + transient_for=self, + heading="Reboot Now?", + body="The system will restart immediately. Save any open work first.", + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("reboot", "Reboot") + dialog.set_response_appearance("reboot", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.set_default_response("cancel") + dialog.set_close_response("cancel") + dialog.connect("response", self._on_reboot_confirmed) + dialog.present() + + def _on_reboot_confirmed(self, dialog, response): + if response == "reboot": + try: + subprocess.Popen(REBOOT_COMMAND) + except Exception as e: + self._append_text(f"\nāœ— Reboot failed: {e}\n") class SovranHubWindow(Adw.ApplicationWindow): -- 2.53.0 From 2b01fefb240a3f6525d1d29002886657726dbcbf Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 17:08:36 -0500 Subject: [PATCH 128/857] Show update indicator when repo has new commits --- app/sovran_systemsos_hub/application.py | 120 ++++++++++++++++++++++-- app/style.css | 11 ++- 2 files changed, 120 insertions(+), 11 deletions(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index 87d0e70..880368f 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -45,6 +45,8 @@ SYSTEM_AUTOSTART_FILE = "/etc/xdg/autostart/sovran-hub.desktop" DOWNLOADS_DIR = os.path.join(os.path.expanduser("~"), "Downloads") +FLAKE_DIR = "/etc/nixos" + UPDATE_COMMAND = [ "ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes", "root@localhost", @@ -57,6 +59,9 @@ REBOOT_COMMAND = [ "reboot", ] +# How often to check for updates (seconds) — every 30 minutes +UPDATE_CHECK_INTERVAL = 1800 + def get_autostart_enabled() -> bool: if os.path.isfile(USER_AUTOSTART_FILE): @@ -88,6 +93,58 @@ def set_autostart_enabled(enabled: bool): f.write("Hidden=true\n") +def _get_local_rev(): + """Get the local HEAD commit of the flake repo.""" + try: + result = subprocess.run( + ["git", "-C", FLAKE_DIR, "rev-parse", "HEAD"], + capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception: + pass + return None + + +def _get_remote_rev(): + """Fetch and get the remote HEAD commit without pulling.""" + try: + # Fetch latest refs from origin + subprocess.run( + ["git", "-C", FLAKE_DIR, "fetch", "--quiet", "origin"], + capture_output=True, text=True, timeout=30, + ) + # Get the remote branch HEAD + result = subprocess.run( + ["git", "-C", FLAKE_DIR, "rev-parse", "origin/HEAD"], + capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0: + return result.stdout.strip() + + # Fallback: try origin/main or origin/master + for branch in ["origin/main", "origin/master"]: + result = subprocess.run( + ["git", "-C", FLAKE_DIR, "rev-parse", branch], + capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception: + pass + return None + + +def check_for_updates() -> bool: + """Return True if remote has new commits ahead of local.""" + local = _get_local_rev() + remote = _get_remote_rev() + if local and remote and local != remote: + return True + return False + + class UpdateDialog(Adw.Window): """Modal window that streams the system update output.""" @@ -105,12 +162,10 @@ class UpdateDialog(Adw.Window): header = Adw.HeaderBar() - # ── Close button (disabled during update) ──────────────── self._close_btn = Gtk.Button(label="Close", sensitive=False) self._close_btn.connect("clicked", lambda _b: self.close()) header.pack_end(self._close_btn) - # ── Reboot button (hidden until update succeeds) ───────── self._reboot_btn = Gtk.Button( label="Reboot", css_classes=["destructive-action"], @@ -120,7 +175,6 @@ class UpdateDialog(Adw.Window): self._reboot_btn.connect("clicked", self._on_reboot_clicked) header.pack_end(self._reboot_btn) - # ── Save error report button (hidden until failure) ────── self._save_btn = Gtk.Button( label="Save Error Report", css_classes=["warning"], @@ -279,6 +333,7 @@ class SovranHubWindow(Adw.ApplicationWindow): ) self._config = config self._tiles = [] + self._update_available = False css_path = os.environ.get("SOVRAN_HUB_CSS", "") if css_path and os.path.isfile(css_path): @@ -292,14 +347,22 @@ class SovranHubWindow(Adw.ApplicationWindow): header = Adw.HeaderBar() header.set_title_widget(self._build_title_box()) - # ── Update button (left side, prominent) ───────────────── - update_btn = Gtk.Button( + # ── Update button (left side) ──────────────────────────── + self._update_btn = Gtk.Button( label="Update System", css_classes=["suggested-action"], - tooltip_text="Update Sovran_SystemsOS (flake update + rebuild + flatpak)", + tooltip_text="System is up to date", ) - update_btn.connect("clicked", self._on_update_clicked) - header.pack_start(update_btn) + self._update_btn.connect("clicked", self._on_update_clicked) + header.pack_start(self._update_btn) + + # ── Update badge (dot indicator, hidden by default) ────── + self._badge = Gtk.Label( + label=" ā—", + css_classes=["update-badge"], + visible=False, + ) + header.pack_start(self._badge) # ── Refresh button ─────────────────────────────────────── refresh_btn = Gtk.Button( @@ -369,6 +432,10 @@ class SovranHubWindow(Adw.ApplicationWindow): if interval and interval > 0: GLib.timeout_add_seconds(interval, self._auto_refresh) + # ── Kick off first update check, then repeat ───────────── + GLib.timeout_add_seconds(5, self._check_for_updates_once) + GLib.timeout_add_seconds(UPDATE_CHECK_INTERVAL, self._periodic_update_check) + def _build_title_box(self): role = self._config.get("role", "server_plus_desktop") role_label = ROLE_LABELS.get(role, role) @@ -449,10 +516,47 @@ class SovranHubWindow(Adw.ApplicationWindow): GLib.idle_add(self._refresh_all) + # ── Update availability check ──────────────────────────────── + + def _check_for_updates_once(self): + thread = threading.Thread(target=self._do_update_check, daemon=True) + thread.start() + return False # run once + + def _periodic_update_check(self): + thread = threading.Thread(target=self._do_update_check, daemon=True) + thread.start() + return True # keep repeating + + def _do_update_check(self): + available = check_for_updates() + GLib.idle_add(self._set_update_indicator, available) + + def _set_update_indicator(self, available): + self._update_available = available + if available: + self._update_btn.set_label("Update Available") + self._update_btn.set_css_classes(["destructive-action"]) + self._update_btn.set_tooltip_text("A new version of Sovran_SystemsOS is available!") + self._badge.set_visible(True) + else: + self._update_btn.set_label("Update System") + self._update_btn.set_css_classes(["suggested-action"]) + self._update_btn.set_tooltip_text("System is up to date") + self._badge.set_visible(False) + + # ── Callbacks ──────────────────────────────────────────────── + def _on_update_clicked(self, _btn): dialog = UpdateDialog(self) + dialog.connect("close-request", lambda _w: self._after_update()) dialog.present() + def _after_update(self): + # Re-check after dialog closes + GLib.timeout_add_seconds(3, self._check_for_updates_once) + return False + def _on_autostart_toggled(self, switch, state): set_autostart_enabled(state) return False diff --git a/app/style.css b/app/style.css index b0214da..be2360f 100644 --- a/app/style.css +++ b/app/style.css @@ -8,12 +8,17 @@ .sovran-tile:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); } -.success { color: #2ec27e; } -.warning { color: #e5a50a; } -.error { color: #e01b24; } +.success { color: #2ec27e; } +.warning { color: #e5a50a; } +.error { color: #e01b24; } .disabled-label { color: #888888; font-style: italic; } .role-badge { padding: 2px 8px; border-radius: 4px; font-size: 0.75em; +} +.update-badge { + color: #e01b24; + font-size: 1.2em; + font-weight: bold; } \ No newline at end of file -- 2.53.0 From 0590c706e5342504e18b9660d3d588d61305b925 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 17:23:24 -0500 Subject: [PATCH 129/857] Fix update check: read branch from flake.lock, query Gitea API --- app/sovran_systemsos_hub/application.py | 88 +++++++++++-------------- 1 file changed, 37 insertions(+), 51 deletions(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index 880368f..768b0b6 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -2,9 +2,11 @@ from __future__ import annotations +import json import os import subprocess import threading +import urllib.request from datetime import datetime import gi @@ -45,7 +47,10 @@ SYSTEM_AUTOSTART_FILE = "/etc/xdg/autostart/sovran-hub.desktop" DOWNLOADS_DIR = os.path.join(os.path.expanduser("~"), "Downloads") -FLAKE_DIR = "/etc/nixos" +FLAKE_LOCK_PATH = "/etc/nixos/flake.lock" +FLAKE_INPUT_NAME = "Sovran_Systems" + +GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovran_SystemsOS/commits" UPDATE_COMMAND = [ "ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes", @@ -59,7 +64,6 @@ REBOOT_COMMAND = [ "reboot", ] -# How often to check for updates (seconds) — every 30 minutes UPDATE_CHECK_INTERVAL = 1800 @@ -93,55 +97,47 @@ def set_autostart_enabled(enabled: bool): f.write("Hidden=true\n") -def _get_local_rev(): - """Get the local HEAD commit of the flake repo.""" +def _get_locked_info(): + """Read the locked revision and branch of Sovran_Systems from flake.lock.""" try: - result = subprocess.run( - ["git", "-C", FLAKE_DIR, "rev-parse", "HEAD"], - capture_output=True, text=True, timeout=10, - ) - if result.returncode == 0: - return result.stdout.strip() + with open(FLAKE_LOCK_PATH, "r") as f: + lock = json.load(f) + nodes = lock.get("nodes", {}) + node = nodes.get(FLAKE_INPUT_NAME, {}) + locked = node.get("locked", {}) + rev = locked.get("rev") + branch = locked.get("ref") + if not branch: + branch = node.get("original", {}).get("ref") + return rev, branch except Exception: pass - return None + return None, None -def _get_remote_rev(): - """Fetch and get the remote HEAD commit without pulling.""" +def _get_remote_rev(branch=None): + """Query Gitea API for the latest commit SHA on the given branch.""" try: - # Fetch latest refs from origin - subprocess.run( - ["git", "-C", FLAKE_DIR, "fetch", "--quiet", "origin"], - capture_output=True, text=True, timeout=30, - ) - # Get the remote branch HEAD - result = subprocess.run( - ["git", "-C", FLAKE_DIR, "rev-parse", "origin/HEAD"], - capture_output=True, text=True, timeout=10, - ) - if result.returncode == 0: - return result.stdout.strip() - - # Fallback: try origin/main or origin/master - for branch in ["origin/main", "origin/master"]: - result = subprocess.run( - ["git", "-C", FLAKE_DIR, "rev-parse", branch], - capture_output=True, text=True, timeout=10, - ) - if result.returncode == 0: - return result.stdout.strip() + url = GITEA_API_BASE + "?limit=1" + if branch: + url += f"&sha={branch}" + req = urllib.request.Request(url, method="GET") + req.add_header("Accept", "application/json") + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode()) + if isinstance(data, list) and len(data) > 0: + return data[0].get("sha") except Exception: pass return None def check_for_updates() -> bool: - """Return True if remote has new commits ahead of local.""" - local = _get_local_rev() - remote = _get_remote_rev() - if local and remote and local != remote: - return True + """Return True if remote has new commits ahead of locked flake.""" + locked_rev, branch = _get_locked_info() + remote_rev = _get_remote_rev(branch) + if locked_rev and remote_rev: + return locked_rev != remote_rev return False @@ -347,7 +343,6 @@ class SovranHubWindow(Adw.ApplicationWindow): header = Adw.HeaderBar() header.set_title_widget(self._build_title_box()) - # ── Update button (left side) ──────────────────────────── self._update_btn = Gtk.Button( label="Update System", css_classes=["suggested-action"], @@ -356,7 +351,6 @@ class SovranHubWindow(Adw.ApplicationWindow): self._update_btn.connect("clicked", self._on_update_clicked) header.pack_start(self._update_btn) - # ── Update badge (dot indicator, hidden by default) ────── self._badge = Gtk.Label( label=" ā—", css_classes=["update-badge"], @@ -364,7 +358,6 @@ class SovranHubWindow(Adw.ApplicationWindow): ) header.pack_start(self._badge) - # ── Refresh button ─────────────────────────────────────── refresh_btn = Gtk.Button( icon_name="view-refresh-symbolic", tooltip_text="Refresh now", @@ -372,7 +365,6 @@ class SovranHubWindow(Adw.ApplicationWindow): refresh_btn.connect("clicked", lambda _b: self._refresh_all()) header.pack_end(refresh_btn) - # ── Settings menu ──────────────────────────────────────── menu_btn = Gtk.MenuButton( icon_name="open-menu-symbolic", tooltip_text="Settings", @@ -432,7 +424,6 @@ class SovranHubWindow(Adw.ApplicationWindow): if interval and interval > 0: GLib.timeout_add_seconds(interval, self._auto_refresh) - # ── Kick off first update check, then repeat ───────────── GLib.timeout_add_seconds(5, self._check_for_updates_once) GLib.timeout_add_seconds(UPDATE_CHECK_INTERVAL, self._periodic_update_check) @@ -516,17 +507,15 @@ class SovranHubWindow(Adw.ApplicationWindow): GLib.idle_add(self._refresh_all) - # ── Update availability check ──────────────────────────────── - def _check_for_updates_once(self): thread = threading.Thread(target=self._do_update_check, daemon=True) thread.start() - return False # run once + return False def _periodic_update_check(self): thread = threading.Thread(target=self._do_update_check, daemon=True) thread.start() - return True # keep repeating + return True def _do_update_check(self): available = check_for_updates() @@ -545,15 +534,12 @@ class SovranHubWindow(Adw.ApplicationWindow): self._update_btn.set_tooltip_text("System is up to date") self._badge.set_visible(False) - # ── Callbacks ──────────────────────────────────────────────── - def _on_update_clicked(self, _btn): dialog = UpdateDialog(self) dialog.connect("close-request", lambda _w: self._after_update()) dialog.present() def _after_update(self): - # Re-check after dialog closes GLib.timeout_add_seconds(3, self._check_for_updates_once) return False -- 2.53.0 From 209ad0010ebe5599db7903ee1b77ca10dbc884a9 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 17:28:56 -0500 Subject: [PATCH 130/857] Add internal/external IP display bar to Hub --- app/sovran_systemsos_hub/application.py | 175 ++++++++++++++++++++++-- app/style.css | 10 ++ 2 files changed, 176 insertions(+), 9 deletions(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index 768b0b6..e647fc5 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import os +import socket import subprocess import threading import urllib.request @@ -67,6 +68,8 @@ REBOOT_COMMAND = [ UPDATE_CHECK_INTERVAL = 1800 +# ── Autostart helpers ──────────────────────────────────────────── + def get_autostart_enabled() -> bool: if os.path.isfile(USER_AUTOSTART_FILE): try: @@ -97,8 +100,9 @@ def set_autostart_enabled(enabled: bool): f.write("Hidden=true\n") +# ── Update check helpers ──────────────────────────────────────── + def _get_locked_info(): - """Read the locked revision and branch of Sovran_Systems from flake.lock.""" try: with open(FLAKE_LOCK_PATH, "r") as f: lock = json.load(f) @@ -116,7 +120,6 @@ def _get_locked_info(): def _get_remote_rev(branch=None): - """Query Gitea API for the latest commit SHA on the given branch.""" try: url = GITEA_API_BASE + "?limit=1" if branch: @@ -133,7 +136,6 @@ def _get_remote_rev(branch=None): def check_for_updates() -> bool: - """Return True if remote has new commits ahead of locked flake.""" locked_rev, branch = _get_locked_info() remote_rev = _get_remote_rev(branch) if locked_rev and remote_rev: @@ -141,8 +143,57 @@ def check_for_updates() -> bool: return False +# ── IP address helpers ─────────────────────────────────────────── + +def _get_internal_ip(): + """Get the primary LAN IP address.""" + try: + # Connect to a public IP (doesn't actually send data) + # to determine which interface would be used + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(2) + s.connect(("1.1.1.1", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + pass + # Fallback: hostname -I + try: + result = subprocess.run( + ["hostname", "-I"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + parts = result.stdout.strip().split() + if parts: + return parts[0] + except Exception: + pass + return "unavailable" + + +def _get_external_ip(): + """Get the public IP via a lightweight HTTP service.""" + for url in [ + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://icanhazip.com", + ]: + try: + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req, timeout=8) as resp: + ip = resp.read().decode().strip() + if ip and len(ip) < 46: + return ip + except Exception: + continue + return "unavailable" + + +# ── UpdateDialog ───────────────────────────────────────────────── + class UpdateDialog(Adw.Window): - """Modal window that streams the system update output.""" def __init__(self, parent): super().__init__( @@ -318,6 +369,8 @@ class UpdateDialog(Adw.Window): self._append_text(f"\nāœ— Reboot failed: {e}\n") +# ── Main Window ────────────────────────────────────────────────── + class SovranHubWindow(Adw.ApplicationWindow): def __init__(self, app, config): @@ -401,21 +454,32 @@ class SovranHubWindow(Adw.ApplicationWindow): menu_btn.set_popover(popover) header.pack_end(menu_btn) + # ── Main content area ──────────────────────────────────── self._main_box = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, spacing=0, ) + # ── IP Address Banner ──────────────────────────────────── + self._ip_bar = self._build_ip_bar() + self._main_box.append(self._ip_bar) + scrolled = Gtk.ScrolledWindow( hscrollbar_policy=Gtk.PolicyType.NEVER, vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, vexpand=True, - child=self._main_box, ) + self._tiles_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=0, + ) + scrolled.set_child(self._tiles_box) + self._main_box.append(scrolled) + toolbar_view = Adw.ToolbarView() toolbar_view.add_top_bar(header) - toolbar_view.set_content(scrolled) + toolbar_view.set_content(self._main_box) self.set_content(toolbar_view) self._build_tiles() @@ -427,6 +491,93 @@ class SovranHubWindow(Adw.ApplicationWindow): GLib.timeout_add_seconds(5, self._check_for_updates_once) GLib.timeout_add_seconds(UPDATE_CHECK_INTERVAL, self._periodic_update_check) + # Fetch IPs in background + GLib.timeout_add_seconds(1, self._fetch_ips_once) + + # ── IP Address Bar ─────────────────────────────────────────── + + def _build_ip_bar(self): + bar = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=24, + halign=Gtk.Align.CENTER, + margin_top=12, + margin_bottom=4, + margin_start=24, + margin_end=24, + css_classes=["ip-bar"], + ) + + # Internal IP + internal_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=6, + ) + internal_icon = Gtk.Image( + icon_name="network-wired-symbolic", + pixel_size=16, + css_classes=["dim-label"], + ) + internal_label = Gtk.Label( + label="Internal:", + css_classes=["caption", "dim-label"], + ) + self._internal_ip_label = Gtk.Label( + label="…", + css_classes=["caption", "ip-value"], + selectable=True, + ) + internal_box.append(internal_icon) + internal_box.append(internal_label) + internal_box.append(self._internal_ip_label) + + # Separator + sep = Gtk.Separator( + orientation=Gtk.Orientation.VERTICAL, + ) + + # External IP + external_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=6, + ) + external_icon = Gtk.Image( + icon_name="network-server-symbolic", + pixel_size=16, + css_classes=["dim-label"], + ) + external_label = Gtk.Label( + label="External:", + css_classes=["caption", "dim-label"], + ) + self._external_ip_label = Gtk.Label( + label="…", + css_classes=["caption", "ip-value"], + selectable=True, + ) + external_box.append(external_icon) + external_box.append(external_label) + external_box.append(self._external_ip_label) + + bar.append(internal_box) + bar.append(sep) + bar.append(external_box) + + return bar + + def _fetch_ips_once(self): + thread = threading.Thread(target=self._do_fetch_ips, daemon=True) + thread.start() + return False + + def _do_fetch_ips(self): + internal = _get_internal_ip() + GLib.idle_add(self._internal_ip_label.set_label, internal) + external = _get_external_ip() + GLib.idle_add(self._external_ip_label.set_label, external) + + # ── Title box ──────────────────────────────────────────────── + def _build_title_box(self): role = self._config.get("role", "server_plus_desktop") role_label = ROLE_LABELS.get(role, role) @@ -444,6 +595,8 @@ class SovranHubWindow(Adw.ApplicationWindow): )) return box + # ── Service tiles ──────────────────────────────────────────── + def _build_tiles(self): method = self._config.get("command_method", "systemctl") services = self._config.get("services", []) @@ -466,7 +619,7 @@ class SovranHubWindow(Adw.ApplicationWindow): margin_bottom=4, margin_start=24, ) - self._main_box.append(section_label) + self._tiles_box.append(section_label) sep = Gtk.Separator( orientation=Gtk.Orientation.HORIZONTAL, @@ -474,7 +627,7 @@ class SovranHubWindow(Adw.ApplicationWindow): margin_end=24, margin_bottom=8, ) - self._main_box.append(sep) + self._tiles_box.append(sep) flowbox = Gtk.FlowBox( max_children_per_line=4, @@ -503,10 +656,12 @@ class SovranHubWindow(Adw.ApplicationWindow): flowbox.append(tile) self._tiles.append(tile) - self._main_box.append(flowbox) + self._tiles_box.append(flowbox) GLib.idle_add(self._refresh_all) + # ── Update check ───────────────────────────────────────────── + def _check_for_updates_once(self): thread = threading.Thread(target=self._do_update_check, daemon=True) thread.start() @@ -534,6 +689,8 @@ class SovranHubWindow(Adw.ApplicationWindow): self._update_btn.set_tooltip_text("System is up to date") self._badge.set_visible(False) + # ── Callbacks ──────────────────────────────────────────────── + def _on_update_clicked(self, _btn): dialog = UpdateDialog(self) dialog.connect("close-request", lambda _w: self._after_update()) diff --git a/app/style.css b/app/style.css index be2360f..9331a36 100644 --- a/app/style.css +++ b/app/style.css @@ -21,4 +21,14 @@ color: #e01b24; font-size: 1.2em; font-weight: bold; +} +.ip-bar { + padding: 8px 16px; + border-radius: 8px; + background: alpha(@card_bg_color, 0.5); +} +.ip-value { + font-family: monospace; + font-weight: bold; + color: @accent_color; } \ No newline at end of file -- 2.53.0 From 95ce30a209c8380d37ded77672890a9e059e21dc Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 20:00:46 -0500 Subject: [PATCH 131/857] Green update indicator, fixed-width tile grid for fullscreen --- app/sovran_systemsos_hub/application.py | 28 ++++++++++++------------- app/style.css | 13 +++++++++++- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index e647fc5..ea5d049 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -67,6 +67,8 @@ REBOOT_COMMAND = [ UPDATE_CHECK_INTERVAL = 1800 +TILE_GRID_WIDTH = 640 + # ── Autostart helpers ──────────────────────────────────────────── @@ -146,10 +148,7 @@ def check_for_updates() -> bool: # ── IP address helpers ─────────────────────────────────────────── def _get_internal_ip(): - """Get the primary LAN IP address.""" try: - # Connect to a public IP (doesn't actually send data) - # to determine which interface would be used s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(2) s.connect(("1.1.1.1", 80)) @@ -158,7 +157,6 @@ def _get_internal_ip(): return ip except Exception: pass - # Fallback: hostname -I try: result = subprocess.run( ["hostname", "-I"], @@ -174,7 +172,6 @@ def _get_internal_ip(): def _get_external_ip(): - """Get the public IP via a lightweight HTTP service.""" for url in [ "https://api.ipify.org", "https://ifconfig.me/ip", @@ -491,7 +488,6 @@ class SovranHubWindow(Adw.ApplicationWindow): GLib.timeout_add_seconds(5, self._check_for_updates_once) GLib.timeout_add_seconds(UPDATE_CHECK_INTERVAL, self._periodic_update_check) - # Fetch IPs in background GLib.timeout_add_seconds(1, self._fetch_ips_once) # ── IP Address Bar ─────────────────────────────────────────── @@ -508,7 +504,6 @@ class SovranHubWindow(Adw.ApplicationWindow): css_classes=["ip-bar"], ) - # Internal IP internal_box = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, spacing=6, @@ -531,12 +526,8 @@ class SovranHubWindow(Adw.ApplicationWindow): internal_box.append(internal_label) internal_box.append(self._internal_ip_label) - # Separator - sep = Gtk.Separator( - orientation=Gtk.Orientation.VERTICAL, - ) + sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) - # External IP external_box = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, spacing=6, @@ -629,6 +620,14 @@ class SovranHubWindow(Adw.ApplicationWindow): ) self._tiles_box.append(sep) + # Fixed-width container — tiles stay centered and compact + container = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + halign=Gtk.Align.CENTER, + css_classes=["tiles-container"], + ) + container.set_size_request(TILE_GRID_WIDTH, -1) + flowbox = Gtk.FlowBox( max_children_per_line=4, min_children_per_line=2, @@ -656,7 +655,8 @@ class SovranHubWindow(Adw.ApplicationWindow): flowbox.append(tile) self._tiles.append(tile) - self._tiles_box.append(flowbox) + container.append(flowbox) + self._tiles_box.append(container) GLib.idle_add(self._refresh_all) @@ -680,7 +680,7 @@ class SovranHubWindow(Adw.ApplicationWindow): self._update_available = available if available: self._update_btn.set_label("Update Available") - self._update_btn.set_css_classes(["destructive-action"]) + self._update_btn.set_css_classes(["update-available"]) self._update_btn.set_tooltip_text("A new version of Sovran_SystemsOS is available!") self._badge.set_visible(True) else: diff --git a/app/style.css b/app/style.css index 9331a36..7d31f99 100644 --- a/app/style.css +++ b/app/style.css @@ -18,10 +18,17 @@ font-size: 0.75em; } .update-badge { - color: #e01b24; + color: #2ec27e; font-size: 1.2em; font-weight: bold; } +.update-available { + background: #2ec27e; + color: white; +} +.update-available:hover { + background: #26a269; +} .ip-bar { padding: 8px 16px; border-radius: 8px; @@ -31,4 +38,8 @@ font-family: monospace; font-weight: bold; color: @accent_color; +} +.tiles-container { + margin-left: auto; + margin-right: auto; } \ No newline at end of file -- 2.53.0 From 28618346471738a934f14579c9c1fa2a1b3cabe1 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 20:10:30 -0500 Subject: [PATCH 132/857] Keep section labels inside fixed-width grid container --- app/sovran_systemsos_hub/application.py | 40 ++++++++++++------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index ea5d049..4c01a37 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -602,25 +602,7 @@ class SovranHubWindow(Adw.ApplicationWindow): if not entries: continue - section_label = Gtk.Label( - label=cat_label, - css_classes=["title-4"], - halign=Gtk.Align.START, - margin_top=20, - margin_bottom=4, - margin_start=24, - ) - self._tiles_box.append(section_label) - - sep = Gtk.Separator( - orientation=Gtk.Orientation.HORIZONTAL, - margin_start=24, - margin_end=24, - margin_bottom=8, - ) - self._tiles_box.append(sep) - - # Fixed-width container — tiles stay centered and compact + # Fixed-width container for label + separator + tiles container = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER, @@ -628,6 +610,24 @@ class SovranHubWindow(Adw.ApplicationWindow): ) container.set_size_request(TILE_GRID_WIDTH, -1) + section_label = Gtk.Label( + label=cat_label, + css_classes=["title-4"], + halign=Gtk.Align.START, + margin_top=20, + margin_bottom=4, + margin_start=8, + ) + container.append(section_label) + + sep = Gtk.Separator( + orientation=Gtk.Orientation.HORIZONTAL, + margin_start=8, + margin_end=8, + margin_bottom=8, + ) + container.append(sep) + flowbox = Gtk.FlowBox( max_children_per_line=4, min_children_per_line=2, @@ -727,4 +727,4 @@ class SovranHubApp(Adw.Application): win = self.get_active_window() if not win: win = SovranHubWindow(self, self._config) - win.present() \ No newline at end of file + win.present() -- 2.53.0 From 65d0364cc61f6fa425a40131e1084f52d714a73c Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 20:22:05 -0500 Subject: [PATCH 133/857] Align section labels and tile grid to same left edge --- app/sovran_systemsos_hub/application.py | 10 +++++----- modules/core/sovran-hub.nix | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index 4c01a37..6696bed 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -588,7 +588,7 @@ class SovranHubWindow(Adw.ApplicationWindow): # ── Service tiles ──────────────────────────────────────────── - def _build_tiles(self): + def _build_tiles(self): method = self._config.get("command_method", "systemctl") services = self._config.get("services", []) @@ -616,14 +616,14 @@ class SovranHubWindow(Adw.ApplicationWindow): halign=Gtk.Align.START, margin_top=20, margin_bottom=4, - margin_start=8, + margin_start=16, ) container.append(section_label) sep = Gtk.Separator( orientation=Gtk.Orientation.HORIZONTAL, - margin_start=8, - margin_end=8, + margin_start=16, + margin_end=16, margin_bottom=8, ) container.append(sep) @@ -639,7 +639,7 @@ class SovranHubWindow(Adw.ApplicationWindow): margin_bottom=8, margin_start=16, margin_end=16, - halign=Gtk.Align.CENTER, + halign=Gtk.Align.START, valign=Gtk.Align.START, ) diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 6999a6b..f6aeed3 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -11,9 +11,9 @@ let ] # ── Bitcoin Base (node implementations) ──────────────────── ++ [ + { name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; } { name = "Bitcoin Knots"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core && !cfg.features.bip110; category = "bitcoin-base"; } { name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; } - { name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; } ] # ── Bitcoin Apps (services on top of the node) ───────────── ++ [ @@ -161,4 +161,4 @@ in favorite-apps=['org.gnome.Nautilus.desktop', 'sovran-hub.desktop', 'org.gnome.Console.desktop', 'firefox.desktop'] ''; }; -} \ No newline at end of file +} -- 2.53.0 From e0c292eb0634e49cc9348cec86d989e61970480f Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 20:27:02 -0500 Subject: [PATCH 134/857] Align section labels and tile grid to same left edge --- app/sovran_systemsos_hub/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index 6696bed..6cee113 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -588,7 +588,7 @@ class SovranHubWindow(Adw.ApplicationWindow): # ── Service tiles ──────────────────────────────────────────── - def _build_tiles(self): + def _build_tiles(self): method = self._config.get("command_method", "systemctl") services = self._config.get("services", []) -- 2.53.0 From f217b6af0dc651593ac3ab34187a6b408ce1d639 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 20:36:24 -0500 Subject: [PATCH 135/857] format tile size --- app/sovran_systemsos_hub/service_tile.py | 63 +++++++++++++++++------- app/style.css | 8 +-- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/app/sovran_systemsos_hub/service_tile.py b/app/sovran_systemsos_hub/service_tile.py index 51ce4c0..261cc19 100644 --- a/app/sovran_systemsos_hub/service_tile.py +++ b/app/sovran_systemsos_hub/service_tile.py @@ -10,7 +10,7 @@ gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") gi.require_version("Gdk", "4.0") -from gi.repository import Gdk, GdkPixbuf, GLib, Gtk +from gi.repository import Gdk, GdkPixbuf, GLib, Gtk, Pango from . import systemctl @@ -19,6 +19,9 @@ LOADING_STATES = {"reloading", "activating", "deactivating", "maintenance"} ICON_DIR = os.environ.get("SOVRAN_HUB_ICONS", "") ICON_EXTENSIONS = [".svg", ".png"] +TILE_WIDTH = 148 +TILE_HEIGHT = 170 + class ServiceTile(Gtk.Box): @@ -26,13 +29,12 @@ class ServiceTile(Gtk.Box): icon_name="", enabled=True, **kw): super().__init__( orientation=Gtk.Orientation.VERTICAL, - spacing=6, + spacing=4, halign=Gtk.Align.CENTER, - valign=Gtk.Align.CENTER, - width_request=140, - height_request=160, + valign=Gtk.Align.START, + width_request=TILE_WIDTH, + height_request=TILE_HEIGHT, css_classes=["card", "sovran-tile"], - margin_top=6, margin_bottom=6, margin_start=6, margin_end=6, **kw, ) self._unit = unit @@ -40,39 +42,67 @@ class ServiceTile(Gtk.Box): self._method = method self._enabled = enabled - self._logo = Gtk.Image(pixel_size=48, margin_top=12, halign=Gtk.Align.CENTER) + # ── Icon ───────────────────────────────────────────────── + self._logo = Gtk.Image( + pixel_size=40, + margin_top=16, + halign=Gtk.Align.CENTER, + ) self._set_logo(icon_name) self.append(self._logo) - self.append(Gtk.Label( - label=name, css_classes=["heading"], - halign=Gtk.Align.CENTER, ellipsize=3, max_width_chars=14, - )) + # ── Name label (wraps, max 2 lines) ────────────────────── + self._name_label = Gtk.Label( + label=name, + css_classes=["heading"], + halign=Gtk.Align.CENTER, + justify=Gtk.Justification.CENTER, + wrap=True, + wrap_mode=Pango.WrapMode.WORD_CHAR, + max_width_chars=16, + lines=2, + ellipsize=Pango.EllipsizeMode.END, + margin_start=8, + margin_end=8, + margin_top=4, + ) + self.append(self._name_label) + # ── Status label ───────────────────────────────────────── self._status_label = Gtk.Label( label="ā— …", css_classes=["caption", "dim-label"], halign=Gtk.Align.CENTER, + margin_top=2, ) self.append(self._status_label) + # ── Spacer to push controls to bottom ──────────────────── + spacer = Gtk.Box(vexpand=True) + self.append(spacer) + + # ── Controls ───────────────────────────────────────────── controls = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, - spacing=8, halign=Gtk.Align.CENTER, margin_bottom=8, + spacing=8, + halign=Gtk.Align.CENTER, + margin_bottom=12, ) self._switch = Gtk.Switch(valign=Gtk.Align.CENTER) self._switch.connect("state-set", self._on_toggled) controls.append(self._switch) restart_btn = Gtk.Button( - icon_name="view-refresh-symbolic", valign=Gtk.Align.CENTER, - tooltip_text="Restart", css_classes=["flat", "circular"], + icon_name="view-refresh-symbolic", + valign=Gtk.Align.CENTER, + tooltip_text="Restart", + css_classes=["flat", "circular"], ) restart_btn.connect("clicked", self._on_restart) controls.append(restart_btn) self.append(controls) - # If the feature is disabled in custom.nix, lock the tile immediately + # If the feature is disabled in custom.nix, lock the tile if not self._enabled: self._switch.set_active(False) self._switch.set_sensitive(False) @@ -87,7 +117,7 @@ class ServiceTile(Gtk.Box): path = os.path.join(ICON_DIR, f"{icon_name}{ext}") if os.path.isfile(path): try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, 48, 48, True) + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, 40, 40, True) texture = Gdk.Texture.new_for_pixbuf(pixbuf) self._logo.set_from_paintable(texture) return @@ -96,7 +126,6 @@ class ServiceTile(Gtk.Box): self._logo.set_from_icon_name("system-run-symbolic") def refresh(self): - # Don't poll systemctl for disabled features if not self._enabled: return diff --git a/app/style.css b/app/style.css index 7d31f99..600f990 100644 --- a/app/style.css +++ b/app/style.css @@ -1,8 +1,10 @@ .sovran-tile { border-radius: 16px; - padding: 8px; - min-width: 140px; - min-height: 160px; + padding: 0px; + min-width: 148px; + max-width: 148px; + min-height: 170px; + max-height: 170px; transition: box-shadow 200ms ease-in-out; } .sovran-tile:hover { -- 2.53.0 From ca20cf6e90056069c9ea1e1e63a7b13b7e45e0be Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 20:41:30 -0500 Subject: [PATCH 136/857] format tile size --- app/sovran_systemsos_hub/application.py | 2 +- app/sovran_systemsos_hub/service_tile.py | 36 +++++++++++++----------- app/style.css | 8 ++++-- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index 6cee113..b313ccf 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -632,7 +632,7 @@ class SovranHubWindow(Adw.ApplicationWindow): max_children_per_line=4, min_children_per_line=2, selection_mode=Gtk.SelectionMode.NONE, - homogeneous=True, + homogeneous=False, row_spacing=12, column_spacing=12, margin_top=4, diff --git a/app/sovran_systemsos_hub/service_tile.py b/app/sovran_systemsos_hub/service_tile.py index 261cc19..158b92e 100644 --- a/app/sovran_systemsos_hub/service_tile.py +++ b/app/sovran_systemsos_hub/service_tile.py @@ -19,8 +19,7 @@ LOADING_STATES = {"reloading", "activating", "deactivating", "maintenance"} ICON_DIR = os.environ.get("SOVRAN_HUB_ICONS", "") ICON_EXTENSIONS = [".svg", ".png"] -TILE_WIDTH = 148 -TILE_HEIGHT = 170 +TILE_SIZE = 140 class ServiceTile(Gtk.Box): @@ -29,14 +28,17 @@ class ServiceTile(Gtk.Box): icon_name="", enabled=True, **kw): super().__init__( orientation=Gtk.Orientation.VERTICAL, - spacing=4, + spacing=2, halign=Gtk.Align.CENTER, valign=Gtk.Align.START, - width_request=TILE_WIDTH, - height_request=TILE_HEIGHT, css_classes=["card", "sovran-tile"], **kw, ) + # Force exact tile dimensions + self.set_size_request(TILE_SIZE, TILE_SIZE + 30) + self.set_hexpand(False) + self.set_vexpand(False) + self._unit = unit self._scope = scope self._method = method @@ -44,28 +46,30 @@ class ServiceTile(Gtk.Box): # ── Icon ───────────────────────────────────────────────── self._logo = Gtk.Image( - pixel_size=40, - margin_top=16, + pixel_size=36, + margin_top=14, halign=Gtk.Align.CENTER, ) self._set_logo(icon_name) self.append(self._logo) - # ── Name label (wraps, max 2 lines) ────────────────────── + # ── Name label (wraps within tile width) ───────────────── self._name_label = Gtk.Label( label=name, - css_classes=["heading"], + css_classes=["heading", "tile-name"], halign=Gtk.Align.CENTER, justify=Gtk.Justification.CENTER, wrap=True, wrap_mode=Pango.WrapMode.WORD_CHAR, - max_width_chars=16, lines=2, ellipsize=Pango.EllipsizeMode.END, - margin_start=8, - margin_end=8, + margin_start=6, + margin_end=6, margin_top=4, ) + # Clamp the label width so it wraps inside the tile + self._name_label.set_size_request(TILE_SIZE - 16, -1) + self._name_label.set_max_width_chars(1) # forces natural width to be small self.append(self._name_label) # ── Status label ───────────────────────────────────────── @@ -73,11 +77,11 @@ class ServiceTile(Gtk.Box): label="ā— …", css_classes=["caption", "dim-label"], halign=Gtk.Align.CENTER, - margin_top=2, + margin_top=1, ) self.append(self._status_label) - # ── Spacer to push controls to bottom ──────────────────── + # ── Spacer ─────────────────────────────────────────────── spacer = Gtk.Box(vexpand=True) self.append(spacer) @@ -86,7 +90,7 @@ class ServiceTile(Gtk.Box): orientation=Gtk.Orientation.HORIZONTAL, spacing=8, halign=Gtk.Align.CENTER, - margin_bottom=12, + margin_bottom=10, ) self._switch = Gtk.Switch(valign=Gtk.Align.CENTER) self._switch.connect("state-set", self._on_toggled) @@ -117,7 +121,7 @@ class ServiceTile(Gtk.Box): path = os.path.join(ICON_DIR, f"{icon_name}{ext}") if os.path.isfile(path): try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, 40, 40, True) + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, 36, 36, True) texture = Gdk.Texture.new_for_pixbuf(pixbuf) self._logo.set_from_paintable(texture) return diff --git a/app/style.css b/app/style.css index 600f990..6fe1f51 100644 --- a/app/style.css +++ b/app/style.css @@ -1,15 +1,19 @@ .sovran-tile { border-radius: 16px; padding: 0px; - min-width: 148px; - max-width: 148px; + min-width: 140px; + max-width: 140px; min-height: 170px; max-height: 170px; + overflow: hidden; transition: box-shadow 200ms ease-in-out; } .sovran-tile:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); } +.tile-name { + font-size: 0.8em; +} .success { color: #2ec27e; } .warning { color: #e5a50a; } .error { color: #e01b24; } -- 2.53.0 From 9ab24557df25b570ebeadced6fe016d7cd63ec34 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 20:55:39 -0500 Subject: [PATCH 137/857] Scale up all tiles and fonts, lock all dimensions proportionally --- app/sovran_systemsos_hub/application.py | 56 ++++++++++-------------- app/sovran_systemsos_hub/service_tile.py | 52 +++++++++++----------- app/style.css | 54 ++++++++++++++++++----- 3 files changed, 92 insertions(+), 70 deletions(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index b313ccf..8851bc3 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -67,7 +67,7 @@ REBOOT_COMMAND = [ UPDATE_CHECK_INTERVAL = 1800 -TILE_GRID_WIDTH = 640 +TILE_GRID_WIDTH = 820 # ── Autostart helpers ──────────────────────────────────────────── @@ -495,10 +495,10 @@ class SovranHubWindow(Adw.ApplicationWindow): def _build_ip_bar(self): bar = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, - spacing=24, + spacing=28, halign=Gtk.Align.CENTER, - margin_top=12, - margin_bottom=4, + margin_top=14, + margin_bottom=6, margin_start=24, margin_end=24, css_classes=["ip-bar"], @@ -506,20 +506,20 @@ class SovranHubWindow(Adw.ApplicationWindow): internal_box = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, - spacing=6, + spacing=8, ) internal_icon = Gtk.Image( icon_name="network-wired-symbolic", - pixel_size=16, + pixel_size=18, css_classes=["dim-label"], ) internal_label = Gtk.Label( label="Internal:", - css_classes=["caption", "dim-label"], + css_classes=["ip-label", "dim-label"], ) self._internal_ip_label = Gtk.Label( label="…", - css_classes=["caption", "ip-value"], + css_classes=["ip-value"], selectable=True, ) internal_box.append(internal_icon) @@ -530,20 +530,20 @@ class SovranHubWindow(Adw.ApplicationWindow): external_box = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, - spacing=6, + spacing=8, ) external_icon = Gtk.Image( icon_name="network-server-symbolic", - pixel_size=16, + pixel_size=18, css_classes=["dim-label"], ) external_label = Gtk.Label( label="External:", - css_classes=["caption", "dim-label"], + css_classes=["ip-label", "dim-label"], ) self._external_ip_label = Gtk.Label( label="…", - css_classes=["caption", "ip-value"], + css_classes=["ip-value"], selectable=True, ) external_box.append(external_icon) @@ -556,17 +556,6 @@ class SovranHubWindow(Adw.ApplicationWindow): return bar - def _fetch_ips_once(self): - thread = threading.Thread(target=self._do_fetch_ips, daemon=True) - thread.start() - return False - - def _do_fetch_ips(self): - internal = _get_internal_ip() - GLib.idle_add(self._internal_ip_label.set_label, internal) - external = _get_external_ip() - GLib.idle_add(self._external_ip_label.set_label, external) - # ── Title box ──────────────────────────────────────────────── def _build_title_box(self): @@ -578,17 +567,17 @@ class SovranHubWindow(Adw.ApplicationWindow): ) box.append(Gtk.Label( label="Sovran_SystemsOS Hub", - css_classes=["title"], + css_classes=["hub-title"], )) box.append(Gtk.Label( label=role_label, - css_classes=["caption", "dim-label"], + css_classes=["role-badge", "dim-label"], )) return box # ── Service tiles ──────────────────────────────────────────── - def _build_tiles(self): + def _build_tiles(self): method = self._config.get("command_method", "systemctl") services = self._config.get("services", []) @@ -602,7 +591,6 @@ class SovranHubWindow(Adw.ApplicationWindow): if not entries: continue - # Fixed-width container for label + separator + tiles container = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER, @@ -612,10 +600,10 @@ class SovranHubWindow(Adw.ApplicationWindow): section_label = Gtk.Label( label=cat_label, - css_classes=["title-4"], + css_classes=["section-header"], halign=Gtk.Align.START, - margin_top=20, - margin_bottom=4, + margin_top=24, + margin_bottom=6, margin_start=16, ) container.append(section_label) @@ -624,7 +612,7 @@ class SovranHubWindow(Adw.ApplicationWindow): orientation=Gtk.Orientation.HORIZONTAL, margin_start=16, margin_end=16, - margin_bottom=8, + margin_bottom=10, ) container.append(sep) @@ -633,10 +621,10 @@ class SovranHubWindow(Adw.ApplicationWindow): min_children_per_line=2, selection_mode=Gtk.SelectionMode.NONE, homogeneous=False, - row_spacing=12, - column_spacing=12, + row_spacing=14, + column_spacing=14, margin_top=4, - margin_bottom=8, + margin_bottom=10, margin_start=16, margin_end=16, halign=Gtk.Align.START, diff --git a/app/sovran_systemsos_hub/service_tile.py b/app/sovran_systemsos_hub/service_tile.py index 158b92e..6692970 100644 --- a/app/sovran_systemsos_hub/service_tile.py +++ b/app/sovran_systemsos_hub/service_tile.py @@ -19,7 +19,11 @@ LOADING_STATES = {"reloading", "activating", "deactivating", "maintenance"} ICON_DIR = os.environ.get("SOVRAN_HUB_ICONS", "") ICON_EXTENSIONS = [".svg", ".png"] -TILE_SIZE = 140 +# ── Locked tile dimensions ─────────────────────────────────────── +TILE_W = 180 +TILE_H = 210 +ICON_PX = 48 +LABEL_W = TILE_W - 24 # 12px padding each side class ServiceTile(Gtk.Box): @@ -34,8 +38,7 @@ class ServiceTile(Gtk.Box): css_classes=["card", "sovran-tile"], **kw, ) - # Force exact tile dimensions - self.set_size_request(TILE_SIZE, TILE_SIZE + 30) + self.set_size_request(TILE_W, TILE_H) self.set_hexpand(False) self.set_vexpand(False) @@ -46,38 +49,37 @@ class ServiceTile(Gtk.Box): # ── Icon ───────────────────────────────────────────────── self._logo = Gtk.Image( - pixel_size=36, - margin_top=14, + pixel_size=ICON_PX, + margin_top=18, halign=Gtk.Align.CENTER, ) self._set_logo(icon_name) self.append(self._logo) - # ── Name label (wraps within tile width) ───────────────── + # ── Name label ─────────────────────────────────────────── self._name_label = Gtk.Label( label=name, - css_classes=["heading", "tile-name"], + css_classes=["tile-name"], halign=Gtk.Align.CENTER, justify=Gtk.Justification.CENTER, wrap=True, wrap_mode=Pango.WrapMode.WORD_CHAR, lines=2, ellipsize=Pango.EllipsizeMode.END, - margin_start=6, - margin_end=6, - margin_top=4, + margin_start=12, + margin_end=12, + margin_top=6, ) - # Clamp the label width so it wraps inside the tile - self._name_label.set_size_request(TILE_SIZE - 16, -1) - self._name_label.set_max_width_chars(1) # forces natural width to be small + self._name_label.set_size_request(LABEL_W, -1) + self._name_label.set_max_width_chars(1) self.append(self._name_label) # ── Status label ───────────────────────────────────────── self._status_label = Gtk.Label( label="ā— …", - css_classes=["caption", "dim-label"], + css_classes=["caption", "tile-status", "dim-label"], halign=Gtk.Align.CENTER, - margin_top=1, + margin_top=2, ) self.append(self._status_label) @@ -85,12 +87,12 @@ class ServiceTile(Gtk.Box): spacer = Gtk.Box(vexpand=True) self.append(spacer) - # ── Controls ───────────────────────────────────────────── + # ── Controls ───────────────���───────────────────────────── controls = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, - spacing=8, + spacing=10, halign=Gtk.Align.CENTER, - margin_bottom=10, + margin_bottom=14, ) self._switch = Gtk.Switch(valign=Gtk.Align.CENTER) self._switch.connect("state-set", self._on_toggled) @@ -106,12 +108,11 @@ class ServiceTile(Gtk.Box): controls.append(restart_btn) self.append(controls) - # If the feature is disabled in custom.nix, lock the tile if not self._enabled: self._switch.set_active(False) self._switch.set_sensitive(False) self._status_label.set_label("ā—‹ disabled") - self._status_label.set_css_classes(["caption", "disabled-label"]) + self._status_label.set_css_classes(["caption", "tile-status", "disabled-label"]) self._logo.set_opacity(0.35) self.set_tooltip_text(f"{name} is not enabled in custom.nix") @@ -121,7 +122,8 @@ class ServiceTile(Gtk.Box): path = os.path.join(ICON_DIR, f"{icon_name}{ext}") if os.path.isfile(path): try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, 36, 36, True) + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + path, ICON_PX, ICON_PX, True) texture = Gdk.Texture.new_for_pixbuf(pixbuf) self._logo.set_from_paintable(texture) return @@ -145,16 +147,16 @@ class ServiceTile(Gtk.Box): if is_failed: self._status_label.set_label("ā— failed") - self._status_label.set_css_classes(["caption", "error"]) + self._status_label.set_css_classes(["caption", "tile-status", "error"]) elif is_on: self._status_label.set_label("ā— running") - self._status_label.set_css_classes(["caption", "success"]) + self._status_label.set_css_classes(["caption", "tile-status", "success"]) elif is_loading: self._status_label.set_label(f"ā— {active}") - self._status_label.set_css_classes(["caption", "warning"]) + self._status_label.set_css_classes(["caption", "tile-status", "warning"]) else: self._status_label.set_label(f"ā— {active}") - self._status_label.set_css_classes(["caption", "dim-label"]) + self._status_label.set_css_classes(["caption", "tile-status", "dim-label"]) def _on_toggled(self, switch, state): if not self._enabled: diff --git a/app/style.css b/app/style.css index 6fe1f51..2d4aaf9 100644 --- a/app/style.css +++ b/app/style.css @@ -1,50 +1,82 @@ +/* ── Tile (locked dimensions) ──────────────────────────────── */ .sovran-tile { - border-radius: 16px; + border-radius: 18px; padding: 0px; - min-width: 140px; - max-width: 140px; - min-height: 170px; - max-height: 170px; + min-width: 180px; + max-width: 180px; + min-height: 210px; + max-height: 210px; overflow: hidden; transition: box-shadow 200ms ease-in-out; } .sovran-tile:hover { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); } + +/* ── Tile text ─────────────────────────────────────────────── */ .tile-name { - font-size: 0.8em; + font-size: 1.0em; + font-weight: bold; } +.tile-status { + font-size: 0.9em; +} + +/* ── Section headers ───────────────────────────────────────── */ +.section-header { + font-size: 1.3em; + font-weight: bold; +} + +/* ── Status colors ─────────────────────────────────────────── */ .success { color: #2ec27e; } .warning { color: #e5a50a; } .error { color: #e01b24; } .disabled-label { color: #888888; font-style: italic; } + +/* ── Header / role ─────────────────────────────────────────── */ +.hub-title { + font-size: 1.2em; + font-weight: bold; +} .role-badge { padding: 2px 8px; border-radius: 4px; - font-size: 0.75em; + font-size: 0.85em; } + +/* ── Update indicator ──────────────────────────────────────── */ .update-badge { color: #2ec27e; - font-size: 1.2em; + font-size: 1.3em; font-weight: bold; } .update-available { background: #2ec27e; color: white; + font-size: 1.0em; } .update-available:hover { background: #26a269; } + +/* ── IP bar ────────────────────────────────────────────────── */ .ip-bar { - padding: 8px 16px; - border-radius: 8px; + padding: 10px 20px; + border-radius: 10px; background: alpha(@card_bg_color, 0.5); } +.ip-label { + font-size: 0.95em; +} .ip-value { font-family: monospace; font-weight: bold; + font-size: 1.0em; color: @accent_color; } + +/* ── Grid container ────────────────────────────────────────── */ .tiles-container { margin-left: auto; margin-right: auto; -- 2.53.0 From 25f07a601608b129767c3b8fec5c3f000b9c2ceb Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 20:58:13 -0500 Subject: [PATCH 138/857] Scale up all tiles and fonts, lock all dimensions proportionally --- app/sovran_systemsos_hub/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index 8851bc3..a42b6ce 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -577,7 +577,7 @@ class SovranHubWindow(Adw.ApplicationWindow): # ── Service tiles ──────────────────────────────────────────── - def _build_tiles(self): + def _build_tiles(self): method = self._config.get("command_method", "systemctl") services = self._config.get("services", []) -- 2.53.0 From be61de7a2dbd5cb560c05bbfb703458cb5d7a6ec Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 21:04:41 -0500 Subject: [PATCH 139/857] Fix CSS errors (remove unsupported properties), fix missing _fetch_ips_once method --- app/sovran_systemsos_hub/application.py | 23 ++++++++++++++++------- app/style.css | 16 +--------------- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index a42b6ce..e257b0b 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -66,7 +66,6 @@ REBOOT_COMMAND = [ ] UPDATE_CHECK_INTERVAL = 1800 - TILE_GRID_WIDTH = 820 @@ -374,8 +373,8 @@ class SovranHubWindow(Adw.ApplicationWindow): super().__init__( application=app, title="Sovran_SystemsOS Hub", - default_width=680, - default_height=780, + default_width=860, + default_height=800, ) self._config = config self._tiles = [] @@ -487,7 +486,6 @@ class SovranHubWindow(Adw.ApplicationWindow): GLib.timeout_add_seconds(5, self._check_for_updates_once) GLib.timeout_add_seconds(UPDATE_CHECK_INTERVAL, self._periodic_update_check) - GLib.timeout_add_seconds(1, self._fetch_ips_once) # ── IP Address Bar ─────────────────────────────────────────── @@ -515,7 +513,7 @@ class SovranHubWindow(Adw.ApplicationWindow): ) internal_label = Gtk.Label( label="Internal:", - css_classes=["ip-label", "dim-label"], + css_classes=["dim-label"], ) self._internal_ip_label = Gtk.Label( label="…", @@ -539,7 +537,7 @@ class SovranHubWindow(Adw.ApplicationWindow): ) external_label = Gtk.Label( label="External:", - css_classes=["ip-label", "dim-label"], + css_classes=["dim-label"], ) self._external_ip_label = Gtk.Label( label="…", @@ -556,6 +554,17 @@ class SovranHubWindow(Adw.ApplicationWindow): return bar + def _fetch_ips_once(self): + thread = threading.Thread(target=self._do_fetch_ips, daemon=True) + thread.start() + return False + + def _do_fetch_ips(self): + internal = _get_internal_ip() + GLib.idle_add(self._internal_ip_label.set_label, internal) + external = _get_external_ip() + GLib.idle_add(self._external_ip_label.set_label, external) + # ── Title box ──────────────────────────────────────────────── def _build_title_box(self): @@ -715,4 +724,4 @@ class SovranHubApp(Adw.Application): win = self.get_active_window() if not win: win = SovranHubWindow(self, self._config) - win.present() + win.present() \ No newline at end of file diff --git a/app/style.css b/app/style.css index 2d4aaf9..a5f1d74 100644 --- a/app/style.css +++ b/app/style.css @@ -1,12 +1,9 @@ -/* ── Tile (locked dimensions) ──────────────────────────────── */ +/* ── Tile (locked dimensions via GTK min-width/height only) ── */ .sovran-tile { border-radius: 18px; padding: 0px; min-width: 180px; - max-width: 180px; min-height: 210px; - max-height: 210px; - overflow: hidden; transition: box-shadow 200ms ease-in-out; } .sovran-tile:hover { @@ -15,16 +12,13 @@ /* ── Tile text ─────────────────────────────────────────────── */ .tile-name { - font-size: 1.0em; font-weight: bold; } .tile-status { - font-size: 0.9em; } /* ── Section headers ───────────────────────────────────────── */ .section-header { - font-size: 1.3em; font-weight: bold; } @@ -36,25 +30,21 @@ /* ── Header / role ─────────────────────────────────────────── */ .hub-title { - font-size: 1.2em; font-weight: bold; } .role-badge { padding: 2px 8px; border-radius: 4px; - font-size: 0.85em; } /* ── Update indicator ──────────────────────────────────────── */ .update-badge { color: #2ec27e; - font-size: 1.3em; font-weight: bold; } .update-available { background: #2ec27e; color: white; - font-size: 1.0em; } .update-available:hover { background: #26a269; @@ -66,13 +56,9 @@ border-radius: 10px; background: alpha(@card_bg_color, 0.5); } -.ip-label { - font-size: 0.95em; -} .ip-value { font-family: monospace; font-weight: bold; - font-size: 1.0em; color: @accent_color; } -- 2.53.0 From 89df4195f02210d7784c6539cbc9854e42aa8cde Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Wed, 1 Apr 2026 21:12:32 -0500 Subject: [PATCH 140/857] Nixpkgs Udpate --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index c33e993..67f1f82 100755 --- a/flake.lock +++ b/flake.lock @@ -222,11 +222,11 @@ }, "nixpkgs_4": { "locked": { - "lastModified": 1774709303, - "narHash": "sha256-D3Q07BbIA2KnTcSXIqqu9P586uWxN74zNoCH3h2ESHg=", + "lastModified": 1775036866, + "narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8110df5ad7abf5d4c0f6fb0f8f978390e77f9685", + "rev": "6201e203d09599479a3b3450ed24fa81537ebc4e", "type": "github" }, "original": { -- 2.53.0 From 92efd42d9e96bc9ac7c342bda7a814e3f9d54174 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 09:45:59 -0500 Subject: [PATCH 141/857] update --- staging_alpha.git/HEAD | 1 + staging_alpha.git/config | 12 ++ staging_alpha.git/description | 1 + staging_alpha.git/hooks/applypatch-msg.sample | 15 ++ staging_alpha.git/hooks/commit-msg.sample | 24 +++ .../hooks/fsmonitor-watchman.sample | 174 ++++++++++++++++++ staging_alpha.git/hooks/post-update.sample | 8 + staging_alpha.git/hooks/pre-applypatch.sample | 14 ++ staging_alpha.git/hooks/pre-commit.sample | 49 +++++ .../hooks/pre-merge-commit.sample | 13 ++ staging_alpha.git/hooks/pre-push.sample | 53 ++++++ staging_alpha.git/hooks/pre-rebase.sample | 169 +++++++++++++++++ staging_alpha.git/hooks/pre-receive.sample | 24 +++ .../hooks/prepare-commit-msg.sample | 42 +++++ .../hooks/push-to-checkout.sample | 78 ++++++++ .../hooks/sendemail-validate.sample | 77 ++++++++ staging_alpha.git/hooks/update.sample | 128 +++++++++++++ staging_alpha.git/info/exclude | 6 + staging_alpha.git/info/refs | 11 ++ staging_alpha.git/objects/info/commit-graph | Bin 0 -> 5972 bytes staging_alpha.git/objects/info/packs | 2 + ...75d7121e5aa8c885ea4a95f502e6a8f14e3.bitmap | Bin 0 -> 6058 bytes ...ab175d7121e5aa8c885ea4a95f502e6a8f14e3.idx | Bin 0 -> 10284 bytes ...b175d7121e5aa8c885ea4a95f502e6a8f14e3.pack | Bin 0 -> 247905 bytes ...ab175d7121e5aa8c885ea4a95f502e6a8f14e3.rev | Bin 0 -> 1368 bytes staging_alpha.git/packed-refs | 7 + 26 files changed, 908 insertions(+) create mode 100644 staging_alpha.git/HEAD create mode 100644 staging_alpha.git/config create mode 100644 staging_alpha.git/description create mode 100755 staging_alpha.git/hooks/applypatch-msg.sample create mode 100755 staging_alpha.git/hooks/commit-msg.sample create mode 100755 staging_alpha.git/hooks/fsmonitor-watchman.sample create mode 100755 staging_alpha.git/hooks/post-update.sample create mode 100755 staging_alpha.git/hooks/pre-applypatch.sample create mode 100755 staging_alpha.git/hooks/pre-commit.sample create mode 100755 staging_alpha.git/hooks/pre-merge-commit.sample create mode 100755 staging_alpha.git/hooks/pre-push.sample create mode 100755 staging_alpha.git/hooks/pre-rebase.sample create mode 100755 staging_alpha.git/hooks/pre-receive.sample create mode 100755 staging_alpha.git/hooks/prepare-commit-msg.sample create mode 100755 staging_alpha.git/hooks/push-to-checkout.sample create mode 100755 staging_alpha.git/hooks/sendemail-validate.sample create mode 100755 staging_alpha.git/hooks/update.sample create mode 100644 staging_alpha.git/info/exclude create mode 100644 staging_alpha.git/info/refs create mode 100644 staging_alpha.git/objects/info/commit-graph create mode 100644 staging_alpha.git/objects/info/packs create mode 100644 staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.bitmap create mode 100644 staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.idx create mode 100644 staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.pack create mode 100644 staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.rev create mode 100644 staging_alpha.git/packed-refs diff --git a/staging_alpha.git/HEAD b/staging_alpha.git/HEAD new file mode 100644 index 0000000..b870d82 --- /dev/null +++ b/staging_alpha.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/staging_alpha.git/config b/staging_alpha.git/config new file mode 100644 index 0000000..8568b31 --- /dev/null +++ b/staging_alpha.git/config @@ -0,0 +1,12 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true +[remote "origin"] + url = https://github.com/naturallaw777/staging_alpha.git + tagOpt = --no-tags + fetch = +refs/*:refs/* + mirror = true +[remote "gitea"] + url = https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS.git + fetch = +refs/heads/*:refs/remotes/gitea/* diff --git a/staging_alpha.git/description b/staging_alpha.git/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/staging_alpha.git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/staging_alpha.git/hooks/applypatch-msg.sample b/staging_alpha.git/hooks/applypatch-msg.sample new file mode 100755 index 0000000..43f271a --- /dev/null +++ b/staging_alpha.git/hooks/applypatch-msg.sample @@ -0,0 +1,15 @@ +#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash +# +# An example hook script to check the commit log message taken by +# applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. The hook is +# allowed to edit the commit message file. +# +# To enable this hook, rename this file to "applypatch-msg". + +. git-sh-setup +commitmsg="$(git rev-parse --git-path hooks/commit-msg)" +test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} +: diff --git a/staging_alpha.git/hooks/commit-msg.sample b/staging_alpha.git/hooks/commit-msg.sample new file mode 100755 index 0000000..3d3f504 --- /dev/null +++ b/staging_alpha.git/hooks/commit-msg.sample @@ -0,0 +1,24 @@ +#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash +# +# An example hook script to check the commit log message. +# Called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero +# status after issuing an appropriate message if it wants to stop the +# commit. The hook is allowed to edit the commit message file. +# +# To enable this hook, rename this file to "commit-msg". + +# Uncomment the below to add a Signed-off-by line to the message. +# Doing this in a hook is a bad idea in general, but the prepare-commit-msg +# hook is more suited to it. +# +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" + +# This example catches duplicate Signed-off-by lines. + +test "" = "$(grep '^Signed-off-by: ' "$1" | + sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { + echo >&2 Duplicate Signed-off-by lines. + exit 1 +} diff --git a/staging_alpha.git/hooks/fsmonitor-watchman.sample b/staging_alpha.git/hooks/fsmonitor-watchman.sample new file mode 100755 index 0000000..0abcce8 --- /dev/null +++ b/staging_alpha.git/hooks/fsmonitor-watchman.sample @@ -0,0 +1,174 @@ +#!/nix/store/phnk1lwy8xs0yrbrcs6l2mb9yr9c2knp-perl-5.42.0/bin/perl + +use strict; +use warnings; +use IPC::Open2; + +# An example hook script to integrate Watchman +# (https://facebook.github.io/watchman/) with git to speed up detecting +# new and modified files. +# +# The hook is passed a version (currently 2) and last update token +# formatted as a string and outputs to stdout a new update token and +# all files that have been modified since the update token. Paths must +# be relative to the root of the working tree and separated by a single NUL. +# +# To enable this hook, rename this file to "query-watchman" and set +# 'git config core.fsmonitor .git/hooks/query-watchman' +# +my ($version, $last_update_token) = @ARGV; + +# Uncomment for debugging +# print STDERR "$0 $version $last_update_token\n"; + +# Check the hook interface version +if ($version ne 2) { + die "Unsupported query-fsmonitor hook version '$version'.\n" . + "Falling back to scanning...\n"; +} + +my $git_work_tree = get_working_dir(); + +my $retry = 1; + +my $json_pkg; +eval { + require JSON::XS; + $json_pkg = "JSON::XS"; + 1; +} or do { + require JSON::PP; + $json_pkg = "JSON::PP"; +}; + +launch_watchman(); + +sub launch_watchman { + my $o = watchman_query(); + if (is_work_tree_watched($o)) { + output_result($o->{clock}, @{$o->{files}}); + } +} + +sub output_result { + my ($clockid, @files) = @_; + + # Uncomment for debugging watchman output + # open (my $fh, ">", ".git/watchman-output.out"); + # binmode $fh, ":utf8"; + # print $fh "$clockid\n@files\n"; + # close $fh; + + binmode STDOUT, ":utf8"; + print $clockid; + print "\0"; + local $, = "\0"; + print @files; +} + +sub watchman_clock { + my $response = qx/watchman clock "$git_work_tree"/; + die "Failed to get clock id on '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + + return $json_pkg->new->utf8->decode($response); +} + +sub watchman_query { + my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') + or die "open2() failed: $!\n" . + "Falling back to scanning...\n"; + + # In the query expression below we're asking for names of files that + # changed since $last_update_token but not from the .git folder. + # + # To accomplish this, we're using the "since" generator to use the + # recency index to select candidate nodes and "fields" to limit the + # output to file names only. Then we're using the "expression" term to + # further constrain the results. + my $last_update_line = ""; + if (substr($last_update_token, 0, 1) eq "c") { + $last_update_token = "\"$last_update_token\""; + $last_update_line = qq[\n"since": $last_update_token,]; + } + my $query = <<" END"; + ["query", "$git_work_tree", {$last_update_line + "fields": ["name"], + "expression": ["not", ["dirname", ".git"]] + }] + END + + # Uncomment for debugging the watchman query + # open (my $fh, ">", ".git/watchman-query.json"); + # print $fh $query; + # close $fh; + + print CHLD_IN $query; + close CHLD_IN; + my $response = do {local $/; }; + + # Uncomment for debugging the watch response + # open ($fh, ">", ".git/watchman-response.json"); + # print $fh $response; + # close $fh; + + die "Watchman: command returned no output.\n" . + "Falling back to scanning...\n" if $response eq ""; + die "Watchman: command returned invalid output: $response\n" . + "Falling back to scanning...\n" unless $response =~ /^\{/; + + return $json_pkg->new->utf8->decode($response); +} + +sub is_work_tree_watched { + my ($output) = @_; + my $error = $output->{error}; + if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) { + $retry--; + my $response = qx/watchman watch "$git_work_tree"/; + die "Failed to make watchman watch '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + $output = $json_pkg->new->utf8->decode($response); + $error = $output->{error}; + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + # Uncomment for debugging watchman output + # open (my $fh, ">", ".git/watchman-output.out"); + # close $fh; + + # Watchman will always return all files on the first query so + # return the fast "everything is dirty" flag to git and do the + # Watchman query just to get it over with now so we won't pay + # the cost in git to look up each individual file. + my $o = watchman_clock(); + $error = $output->{error}; + + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + output_result($o->{clock}, ("/")); + $last_update_token = $o->{clock}; + + eval { launch_watchman() }; + return 0; + } + + die "Watchman: $error.\n" . + "Falling back to scanning...\n" if $error; + + return 1; +} + +sub get_working_dir { + my $working_dir; + if ($^O =~ 'msys' || $^O =~ 'cygwin') { + $working_dir = Win32::GetCwd(); + $working_dir =~ tr/\\/\//; + } else { + require Cwd; + $working_dir = Cwd::cwd(); + } + + return $working_dir; +} diff --git a/staging_alpha.git/hooks/post-update.sample b/staging_alpha.git/hooks/post-update.sample new file mode 100755 index 0000000..620050f --- /dev/null +++ b/staging_alpha.git/hooks/post-update.sample @@ -0,0 +1,8 @@ +#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash +# +# An example hook script to prepare a packed repository for use over +# dumb transports. +# +# To enable this hook, rename this file to "post-update". + +exec git update-server-info diff --git a/staging_alpha.git/hooks/pre-applypatch.sample b/staging_alpha.git/hooks/pre-applypatch.sample new file mode 100755 index 0000000..b97e6cc --- /dev/null +++ b/staging_alpha.git/hooks/pre-applypatch.sample @@ -0,0 +1,14 @@ +#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash +# +# An example hook script to verify what is about to be committed +# by applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-applypatch". + +. git-sh-setup +precommit="$(git rev-parse --git-path hooks/pre-commit)" +test -x "$precommit" && exec "$precommit" ${1+"$@"} +: diff --git a/staging_alpha.git/hooks/pre-commit.sample b/staging_alpha.git/hooks/pre-commit.sample new file mode 100755 index 0000000..d64c24c --- /dev/null +++ b/staging_alpha.git/hooks/pre-commit.sample @@ -0,0 +1,49 @@ +#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=$(git hash-object -t tree /dev/null) +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --type=bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff-index --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# If there are whitespace errors, print the offending file names and fail. +exec git diff-index --check --cached $against -- diff --git a/staging_alpha.git/hooks/pre-merge-commit.sample b/staging_alpha.git/hooks/pre-merge-commit.sample new file mode 100755 index 0000000..1c5c145 --- /dev/null +++ b/staging_alpha.git/hooks/pre-merge-commit.sample @@ -0,0 +1,13 @@ +#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash +# +# An example hook script to verify what is about to be committed. +# Called by "git merge" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message to +# stderr if it wants to stop the merge commit. +# +# To enable this hook, rename this file to "pre-merge-commit". + +. git-sh-setup +test -x "$GIT_DIR/hooks/pre-commit" && + exec "$GIT_DIR/hooks/pre-commit" +: diff --git a/staging_alpha.git/hooks/pre-push.sample b/staging_alpha.git/hooks/pre-push.sample new file mode 100755 index 0000000..136692e --- /dev/null +++ b/staging_alpha.git/hooks/pre-push.sample @@ -0,0 +1,53 @@ +#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash + +# An example hook script to verify what is about to be pushed. Called by "git +# push" after it has checked the remote status, but before anything has been +# pushed. If this script exits with a non-zero status nothing will be pushed. +# +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# +# +# This sample shows how to prevent push of commits where the log message starts +# with "WIP" (work in progress). + +remote="$1" +url="$2" + +zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing" + exit 1 + fi + fi +done + +exit 0 diff --git a/staging_alpha.git/hooks/pre-rebase.sample b/staging_alpha.git/hooks/pre-rebase.sample new file mode 100755 index 0000000..92dc6b7 --- /dev/null +++ b/staging_alpha.git/hooks/pre-rebase.sample @@ -0,0 +1,169 @@ +#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash +# +# Copyright (c) 2006, 2008 Junio C Hamano +# +# The "pre-rebase" hook is run just before "git rebase" starts doing +# its job, and can prevent the command from running by exiting with +# non-zero status. +# +# The hook is called with the following parameters: +# +# $1 -- the upstream the series was forked from. +# $2 -- the branch being rebased (or empty when rebasing the current branch). +# +# This sample shows how to prevent topic branches that are already +# merged to 'next' branch from getting rebased, because allowing it +# would result in rebasing already published history. + +publish=next +basebranch="$1" +if test "$#" = 2 +then + topic="refs/heads/$2" +else + topic=`git symbolic-ref HEAD` || + exit 0 ;# we do not interrupt rebasing detached HEAD +fi + +case "$topic" in +refs/heads/??/*) + ;; +*) + exit 0 ;# we do not interrupt others. + ;; +esac + +# Now we are dealing with a topic branch being rebased +# on top of master. Is it OK to rebase it? + +# Does the topic really exist? +git show-ref -q "$topic" || { + echo >&2 "No such branch $topic" + exit 1 +} + +# Is topic fully merged to master? +not_in_master=`git rev-list --pretty=oneline ^master "$topic"` +if test -z "$not_in_master" +then + echo >&2 "$topic is fully merged to master; better remove it." + exit 1 ;# we could allow it, but there is no point. +fi + +# Is topic ever merged to next? If so you should not be rebasing it. +only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` +only_next_2=`git rev-list ^master ${publish} | sort` +if test "$only_next_1" = "$only_next_2" +then + not_in_topic=`git rev-list "^$topic" master` + if test -z "$not_in_topic" + then + echo >&2 "$topic is already up to date with master" + exit 1 ;# we could allow it, but there is no point. + else + exit 0 + fi +else + not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` + /nix/store/phnk1lwy8xs0yrbrcs6l2mb9yr9c2knp-perl-5.42.0/bin/perl -e ' + my $topic = $ARGV[0]; + my $msg = "* $topic has commits already merged to public branch:\n"; + my (%not_in_next) = map { + /^([0-9a-f]+) /; + ($1 => 1); + } split(/\n/, $ARGV[1]); + for my $elem (map { + /^([0-9a-f]+) (.*)$/; + [$1 => $2]; + } split(/\n/, $ARGV[2])) { + if (!exists $not_in_next{$elem->[0]}) { + if ($msg) { + print STDERR $msg; + undef $msg; + } + print STDERR " $elem->[1]\n"; + } + } + ' "$topic" "$not_in_next" "$not_in_master" + exit 1 +fi + +<<\DOC_END + +This sample hook safeguards topic branches that have been +published from being rewound. + +The workflow assumed here is: + + * Once a topic branch forks from "master", "master" is never + merged into it again (either directly or indirectly). + + * Once a topic branch is fully cooked and merged into "master", + it is deleted. If you need to build on top of it to correct + earlier mistakes, a new topic branch is created by forking at + the tip of the "master". This is not strictly necessary, but + it makes it easier to keep your history simple. + + * Whenever you need to test or publish your changes to topic + branches, merge them into "next" branch. + +The script, being an example, hardcodes the publish branch name +to be "next", but it is trivial to make it configurable via +$GIT_DIR/config mechanism. + +With this workflow, you would want to know: + +(1) ... if a topic branch has ever been merged to "next". Young + topic branches can have stupid mistakes you would rather + clean up before publishing, and things that have not been + merged into other branches can be easily rebased without + affecting other people. But once it is published, you would + not want to rewind it. + +(2) ... if a topic branch has been fully merged to "master". + Then you can delete it. More importantly, you should not + build on top of it -- other people may already want to + change things related to the topic as patches against your + "master", so if you need further changes, it is better to + fork the topic (perhaps with the same name) afresh from the + tip of "master". + +Let's look at this example: + + o---o---o---o---o---o---o---o---o---o "next" + / / / / + / a---a---b A / / + / / / / + / / c---c---c---c B / + / / / \ / + / / / b---b C \ / + / / / / \ / + ---o---o---o---o---o---o---o---o---o---o---o "master" + + +A, B and C are topic branches. + + * A has one fix since it was merged up to "next". + + * B has finished. It has been fully merged up to "master" and "next", + and is ready to be deleted. + + * C has not merged to "next" at all. + +We would want to allow C to be rebased, refuse A, and encourage +B to be deleted. + +To compute (1): + + git rev-list ^master ^topic next + git rev-list ^master next + + if these match, topic has not merged in next at all. + +To compute (2): + + git rev-list master..topic + + if this is empty, it is fully merged to "master". + +DOC_END diff --git a/staging_alpha.git/hooks/pre-receive.sample b/staging_alpha.git/hooks/pre-receive.sample new file mode 100755 index 0000000..63897dd --- /dev/null +++ b/staging_alpha.git/hooks/pre-receive.sample @@ -0,0 +1,24 @@ +#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash +# +# An example hook script to make use of push options. +# The example simply echoes all push options that start with 'echoback=' +# and rejects all pushes when the "reject" push option is used. +# +# To enable this hook, rename this file to "pre-receive". + +if test -n "$GIT_PUSH_OPTION_COUNT" +then + i=0 + while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" + do + eval "value=\$GIT_PUSH_OPTION_$i" + case "$value" in + echoback=*) + echo "echo from the pre-receive-hook: ${value#*=}" >&2 + ;; + reject) + exit 1 + esac + i=$((i + 1)) + done +fi diff --git a/staging_alpha.git/hooks/prepare-commit-msg.sample b/staging_alpha.git/hooks/prepare-commit-msg.sample new file mode 100755 index 0000000..35d6859 --- /dev/null +++ b/staging_alpha.git/hooks/prepare-commit-msg.sample @@ -0,0 +1,42 @@ +#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash +# +# An example hook script to prepare the commit log message. +# Called by "git commit" with the name of the file that has the +# commit message, followed by the description of the commit +# message's source. The hook's purpose is to edit the commit +# message file. If the hook fails with a non-zero status, +# the commit is aborted. +# +# To enable this hook, rename this file to "prepare-commit-msg". + +# This hook includes three examples. The first one removes the +# "# Please enter the commit message..." help message. +# +# The second includes the output of "git diff --name-status -r" +# into the message, just before the "git status" output. It is +# commented because it doesn't cope with --amend or with squashed +# commits. +# +# The third example adds a Signed-off-by line to the message, that can +# still be edited. This is rarely a good idea. + +COMMIT_MSG_FILE=$1 +COMMIT_SOURCE=$2 +SHA1=$3 + +/nix/store/phnk1lwy8xs0yrbrcs6l2mb9yr9c2knp-perl-5.42.0/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" + +# case "$COMMIT_SOURCE,$SHA1" in +# ,|template,) +# /nix/store/phnk1lwy8xs0yrbrcs6l2mb9yr9c2knp-perl-5.42.0/bin/perl -i.bak -pe ' +# print "\n" . `git diff --cached --name-status -r` +# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; +# *) ;; +# esac + +# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" +# if test -z "$COMMIT_SOURCE" +# then +# /nix/store/phnk1lwy8xs0yrbrcs6l2mb9yr9c2knp-perl-5.42.0/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" +# fi diff --git a/staging_alpha.git/hooks/push-to-checkout.sample b/staging_alpha.git/hooks/push-to-checkout.sample new file mode 100755 index 0000000..f680745 --- /dev/null +++ b/staging_alpha.git/hooks/push-to-checkout.sample @@ -0,0 +1,78 @@ +#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash + +# An example hook script to update a checked-out tree on a git push. +# +# This hook is invoked by git-receive-pack(1) when it reacts to git +# push and updates reference(s) in its repository, and when the push +# tries to update the branch that is currently checked out and the +# receive.denyCurrentBranch configuration variable is set to +# updateInstead. +# +# By default, such a push is refused if the working tree and the index +# of the remote repository has any difference from the currently +# checked out commit; when both the working tree and the index match +# the current commit, they are updated to match the newly pushed tip +# of the branch. This hook is to be used to override the default +# behaviour; however the code below reimplements the default behaviour +# as a starting point for convenient modification. +# +# The hook receives the commit with which the tip of the current +# branch is going to be updated: +commit=$1 + +# It can exit with a non-zero status to refuse the push (when it does +# so, it must not modify the index or the working tree). +die () { + echo >&2 "$*" + exit 1 +} + +# Or it can make any necessary changes to the working tree and to the +# index to bring them to the desired state when the tip of the current +# branch is updated to the new commit, and exit with a zero status. +# +# For example, the hook can simply run git read-tree -u -m HEAD "$1" +# in order to emulate git fetch that is run in the reverse direction +# with git push, as the two-tree form of git read-tree -u -m is +# essentially the same as git switch or git checkout that switches +# branches while keeping the local changes in the working tree that do +# not interfere with the difference between the branches. + +# The below is a more-or-less exact translation to shell of the C code +# for the default behaviour for git's push-to-checkout hook defined in +# the push_to_deploy() function in builtin/receive-pack.c. +# +# Note that the hook will be executed from the repository directory, +# not from the working tree, so if you want to perform operations on +# the working tree, you will have to adapt your code accordingly, e.g. +# by adding "cd .." or using relative paths. + +if ! git update-index -q --ignore-submodules --refresh +then + die "Up-to-date check failed" +fi + +if ! git diff-files --quiet --ignore-submodules -- +then + die "Working directory has unstaged changes" +fi + +# This is a rough translation of: +# +# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX +if git cat-file -e HEAD 2>/dev/null +then + head=HEAD +else + head=$(git hash-object -t tree --stdin &2 + exit 1 +} + +unset GIT_DIR GIT_WORK_TREE +cd "$worktree" && + +if grep -q "^diff --git " "$1" +then + validate_patch "$1" +else + validate_cover_letter "$1" +fi && + +if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL" +then + git config --unset-all sendemail.validateWorktree && + trap 'git worktree remove -ff "$worktree"' EXIT && + validate_series +fi diff --git a/staging_alpha.git/hooks/update.sample b/staging_alpha.git/hooks/update.sample new file mode 100755 index 0000000..8a31cba --- /dev/null +++ b/staging_alpha.git/hooks/update.sample @@ -0,0 +1,128 @@ +#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash +# +# An example hook script to block unannotated tags from entering. +# Called by "git receive-pack" with arguments: refname sha1-old sha1-new +# +# To enable this hook, rename this file to "update". +# +# Config +# ------ +# hooks.allowunannotated +# This boolean sets whether unannotated tags will be allowed into the +# repository. By default they won't be. +# hooks.allowdeletetag +# This boolean sets whether deleting tags will be allowed in the +# repository. By default they won't be. +# hooks.allowmodifytag +# This boolean sets whether a tag may be modified after creation. By default +# it won't be. +# hooks.allowdeletebranch +# This boolean sets whether deleting branches will be allowed in the +# repository. By default they won't be. +# hooks.denycreatebranch +# This boolean sets whether remotely creating branches will be denied +# in the repository. By default this is allowed. +# + +# --- Command line +refname="$1" +oldrev="$2" +newrev="$3" + +# --- Safety check +if [ -z "$GIT_DIR" ]; then + echo "Don't run this script from the command line." >&2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 )" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +# --- Config +allowunannotated=$(git config --type=bool hooks.allowunannotated) +allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch) +denycreatebranch=$(git config --type=bool hooks.denycreatebranch) +allowdeletetag=$(git config --type=bool hooks.allowdeletetag) +allowmodifytag=$(git config --type=bool hooks.allowmodifytag) + +# check for no description +projectdesc=$(sed -e '1q' "$GIT_DIR/description") +case "$projectdesc" in +"Unnamed repository"* | "") + echo "*** Project description file hasn't been set" >&2 + exit 1 + ;; +esac + +# --- Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero=$(git hash-object --stdin &2 + echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 + exit 1 + fi + ;; + refs/tags/*,delete) + # delete tag + if [ "$allowdeletetag" != "true" ]; then + echo "*** Deleting a tag is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/tags/*,tag) + # annotated tag + if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 + then + echo "*** Tag '$refname' already exists." >&2 + echo "*** Modifying a tag is not allowed in this repository." >&2 + exit 1 + fi + ;; + refs/heads/*,commit) + # branch + if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then + echo "*** Creating a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/heads/*,delete) + # delete branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/remotes/*,commit) + # tracking branch + ;; + refs/remotes/*,delete) + # delete tracking branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a tracking branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + *) + # Anything else (is there anything else?) + echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 + exit 1 + ;; +esac + +# --- Finished +exit 0 diff --git a/staging_alpha.git/info/exclude b/staging_alpha.git/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/staging_alpha.git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/staging_alpha.git/info/refs b/staging_alpha.git/info/refs new file mode 100644 index 0000000..6e7c528 --- /dev/null +++ b/staging_alpha.git/info/refs @@ -0,0 +1,11 @@ +fe8d30b1edc3008bbcd3e4278c24c06d32654c29 refs/heads/copilot/add-flakes-support-to-iso +8b028fae510b5ec3d276a439416e08674f37f444 refs/heads/copilot/create-gtk4-libadwaita-app +0acad658541753f618fba793698481c3724fa8d3 refs/heads/copilot/fix-gi-typelib-path-issue +12d3128b8970de89352947da4ed951584832c534 refs/heads/copilot/fix-installer-runtime-error +181ecfc9214ba43e3365f88416ca7468cd7838de refs/heads/copilot/fix-nix-installer-errors +3dda4b8f186aed482ec3a358512d59dae627dcdc refs/heads/main +0acad658541753f618fba793698481c3724fa8d3 refs/pull/1/head +181ecfc9214ba43e3365f88416ca7468cd7838de refs/pull/2/head +12d3128b8970de89352947da4ed951584832c534 refs/pull/3/head +fe8d30b1edc3008bbcd3e4278c24c06d32654c29 refs/pull/4/head +8b028fae510b5ec3d276a439416e08674f37f444 refs/pull/5/head diff --git a/staging_alpha.git/objects/info/commit-graph b/staging_alpha.git/objects/info/commit-graph new file mode 100644 index 0000000000000000000000000000000000000000..bb396be225870b13bd212385c6c30c3d35ef319e GIT binary patch literal 5972 zcmZ>E5Aa}QWMT04ba7*V02hBx7as_d#l_jhF$BWqn&Ix^Xar%4{(;aSQrv-ofq@YT zGchnQfG`UK0|P4>W@BJrz=zox7#NT-2Ll5GCj$cm7aHbfU|@h@9tH*mVlXcQ0|Pe9 z&%nTd4GS_bFo3WS0|Ns#EX=^bKr9wvU|;}YQ3eJEd{~TufdL(hGcYjV!V(M&43bD# zih+TFSS-!Jz(5I>VPIg8gQ}3*56>Q84-9#~(4w zhocleeo9}d6X;{EcC4$`$A!C{)x+oNMT2j0+!KFyYFe$yU#=blT*ojYVrvxDppId}ZI&pu4`)Wah zu$n`Cxu-=`6hrrHdGTL2t^K@YvqX-QZ*AfBp5QYb;+OcLIQhR+aU-8aMbVPa>;G?Wu5Xb% zarx5j)u(tq99xq6IzDRC!{!yyA7%=<#C`f2*|Nz(dXv-|Bd*q5ae2Lord(?p&Km8x zUZ|Ws%~hx^{SrS*Vg@^#sff;%#>2`Y*B$S*J$}C9%8``1`B!byjRLLepFG>KzMH9k zT_AVd;Y(#pEFJSW(*4c9xJ=G{C{`?U^hIsO9@`6{QA)1@XR%D~uUhsYaI@5%z59+o zo_oQ`;h>tul#H`Hg7$UcNi? z)QZ5zLVI>DNV_fIBk6oMeaB+n+2;3ZHu3pf8CMe{v@d|SHZa&Z4dXRug%#kxggL*?rZ zE!VkN^s;T8Vd8}>OB)FbV^!D7J2$(Y+t9T%?$3o=oiXQBrm59zdv$-yKa*b9bC<)e zPgGpi-+7?+u#%DFR_^vCeTTnAvRtqi-B&lGeqHTTn{Di$=Si+xIP;EoOp&UH#p)$e z`#HCJ|GvFl$K-lNKKK6?59Ry~p0#QF1h4;`AJO?nF5)(mEi-la}u_8mh(+?~cF7*F+a>9i6^@X8V zo+i}nxq0Eb3GW=W$&sQ{y~<)#EH+O4YjEyrM&nPf)xY{DE?@QfK!X0BxBsQ~T|00s zC*$eFl%hvWTl=1-E>qu;?0EC)mdzm#;$DlH%g$`r-Vy9yP%Ut)dwOB@Gqub)`q!UN z3d$4~c+Zvb;`;_e?xST%nHB5*r_6X-DrCB8@=KnxY{hIlT#~0AnDnB^B$HPxGVY~t z%Cv4KCbI{vb0zMdywM#k+F|#qO=m%q-JjX7qSq`sULPTNh-rHD+eOLKU6*HIl{e}&ZxfAxpo&EO8b8=e(s@>Q0#PgScbOvwCI^7+?m&#tiDt?>2h?BFE~2NN4? zWqz)^Yiu#ybw~U6+~TQAWfH8ps$;c^3i5u(FPp&mBmdaXKD9gdRb-gAlK(0}$kLLYL#r>>bUVL0|Y>M`VeYT1Tnuhm5b#eiy$;rTA zka_$*v(CQWOy!J~w}1FH8@f8Biof5?QrP z(=${Se5zq!U~q=0VN}RGBNOKHx^QYrY~qRC%k`BVd^}bhImvK|fq}sXqK08d=E>&v zX;GGrlUcQub2)B)tSzk^1X4eh)@8&FdI!(A*^itwsP41Ky5du8iCB?nkR7h9n{_c^$8ASo@_DP?K$~u zdD*_hTq{-!Ieyyqh5uF}sO{kfF^AD2^NbU3`t`UYCqAz9u>S6ne(j|y)ANEhP#aAO zqJ}{w^Z3O{vhTlMT$&bHQ6AMVGkK!kr#1F-Kw&5YRU?ymT!;CHyHx-8wZe;@XbNoo zQE#TXb*AAy1_lOeh#E$b%rlZ7cc}QkVszdy<Os{wWFEiYh9mu$L)A!R9@oC8QDG#YJT>QftNOp@c^g<38W-&bwOLFBPacEtx0fU&vQ* z@2c!G$xTU{SQoC6uk6$lcpcPVf~s-JJbr(1i-(ZsLZi&2_>7G%XQK+|9Q!Ym0qT=L z)s$qOlxTh>`ha(`_&zPaB`UvjE=6wJ=d%FRuP}qCVRXnm;}mf7>D_Yqgw{1j|8gw3 zE?*HB(swN=w`27pE`Q2-_G_~<>3V7kzx8&%r|K1uOK>cZ`8lKGK zYN@Bqi&ZoBh|cr-bira#q3_u}8&}>0^?9Ib5;9LLjK-0_VB-`^GEY{E1)TZ+tL}1A zMl-+Ls;d!?vUN-6gT^{6q2}0R9=~6_)%aP0+*X6%vpycZe2eqF-P^ra&x87kP&Eaa zCnd5s?$W(c(w4ONjn=JH#n@dBWFpu~Kz%u=nm3te#P;=_JbXXPa7A15%V%x8>(q`# zsjda}ePy9)zGR*edx9gs%R$v#$UHgC&d^c$(?44Y-aw15Yu2bV-E4Vb{~R1QpVxT31Qn&L)IRL9-fn!m4Aggps?o_je(|)0)BiPXoH;g^q&YtcCN9?Y z367q{z`$S(Rg;r>QX*7x2@BIg&MhBOA4i*-n-l>0|NseL=B@&<{2j?`>I2K z68|%Q%vk#~CfHFfS+sHCLeLluL=A&T=5g(X!42H^N4Y8tRb!EP{JzB-yRJjC)ddo@8dY^>i8aVJ?KlsTHUtq23=BUq&xrlu z3x2Or?A*HUXaDszZ?#_T+>yz=gn@y<6{3bwAoGmm5gchl7ovt?Mdr!6(>U^%JXB3b z=1Jub>-P8Z9r`s>dT;-}wXt5xzt^!gfa*6ts2ZQl9ePd zQg!>+GcYh{K-4fAWS%jx^H{%TlibD7yiAk&)n6AhT1MKhISU$BgQ`)b5t`1SdD3f_c#`^!4Q|kE<;&Ua7m z`zw`!fk6VQh9&ci_)Z_g`SN_tEJ@r`;-8p^Y3l4^Za&Dsz~B#6!;^VN{ARC8(gdeq zZ-r*=C8>wa{e&-hFOLR|hd|YsWS%kc#!((BL)0+DWS&@PheM4PR82_ciG@5@zy0Z+ z`sj?OHaEYKTFdOJ12Fgjm$GjMK(Mi53O}#`l|h? z(R|j7drZ@{*MZ7a1&A63kIduu*B|Jv+3KQF#eCzXXQZ*7{@Gabt1C>lYxs{ literal 0 HcmV?d00001 diff --git a/staging_alpha.git/objects/info/packs b/staging_alpha.git/objects/info/packs new file mode 100644 index 0000000..fb11bed --- /dev/null +++ b/staging_alpha.git/objects/info/packs @@ -0,0 +1,2 @@ +P pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.pack + diff --git a/staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.bitmap b/staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.bitmap new file mode 100644 index 0000000000000000000000000000000000000000..4615b98510b1e5465ac3dbd9486e896dde6b6113 GIT binary patch literal 6058 zcmZ?r4Dn@PWME}rU`bTEHq;7#J8p7@~lIfl&aY59E4?2*@oUKfZ$N0EG<5hY)#4 zNc{hw0OmS?NeBT_12WkZr%o0I20@5g1|BF4rl2|*)lqbULL4m3z`y`<6^PHoz~I2Z z(BTLZhv{TR(FqcTsSp6i9Eisb@+gP_#vnBy*Y3cnQvqx%Nbvy>0mo3CjP@uxL9T-- z0fh>P&%|K?69UsPojh=zAQ6yCurvb$1IURWS1~<+i9l$O1`rD*2Z|3CkN`M7(M$k| zgX5P;9HbtBVYWa`V3Yu<1e*YgZI~=H`GB>7(ga9+J1&(Vqfk^LLImV0xHKgCKv4)* z4Kfpi85kItVcrC(1^Md-+%6CgstTk_07QWJAeEpbECUh%F(8;x4VO*@kV1q`W>64= zgutQ1!oa}jj7ujd8o{XtVkZaO2OvI3rz$R;U_0P;GJ*|4wv(Y4hfa_%B!_|QU|?zh z`x~Sc#0TLrkP>jbp@tC1HYA-$`3s~Agulaef?@}v9%eG6On`_vfJ2NyK?EcT#vt7w z+wS5p0TM7ERUi`-n!uVEz>b74!C?(DVGfE3ARQ0|Aa61rfv_RI0GY(%zyK*`AaM*X zE6<~t08$0f1#vXUL?#9X1Be&{19Hv<$+0ppFp9xV0J#WZ0wk6|d=>_V0+^EBM~VR zQ9~kyDo8g>A>1DdAW^VCK=B3&MQ8>Cs|4{uX#m85)fgZ#P^h!Pbt2gTk^m_NW0<>l z!BryJ0S|tVc~CnTVf7hE3}n=DxK5A?kSGHK11K!P=7UtQz|8>*@qtOG7eKa&!%aYP zB}xW?x$+k_mB`Tm53_mLR3b+MT;(z>DnUXpSHfeA(E=n4jt7K0q5g#`hPso{5{pVu zNI;b#rRIeoA+VWw2r+k6F?0>W^U?t^)tU`Ln);xV9@)Nd;|Z!!Y| zg96N?pO_{o!9x_3Vj!UoH3^!MK&cPZat2um!XP!EFh2^{2~q(P0rg-&Dj1kRNe3(f zE|noHkaDmrSPWDuYlDQqAq%$~WH>|-SQW(N1yB)4?G7?+DO@K!#ejt2>JVwm5K|>; zu7Ri5y&&CS*TBOR8qExxphm$&P^rd%9;TmnhJf^eFf4W$K~|#0Gc=mfO`3!hJBzWH z?U{!fr1uf8zOeV@=zggsDXlFK1cv80}4ZM{z3D_1cco`ly|y-)Ppe07Ys8n z?1n`SNCL!WVPKE|X@Oy=7{dvW09Ly}O*P9}EoO zH~=XC7iItdGhj0z0^~@r39u9bavew)Ec!t-SPZ91P_}{T0I7TgR|$&^CTP0`_x0+1;T|Nk&BgOVFa0I8<51o5$&0Fvl8 z5u630AtszbYQ-YLv>l76NCVD zPC#~pGZ0uONC8L$gkggnAT}t)FfbruiD7!>Lxx%L%-~!GF$_XZjhevF6%e*$mg>Iz zp7)b0ryD+h2$E@@UdGV#W1%^SJu6w?y65`^*PegDl^{L{gY@)#x^Wo9hT&Q17hFO1 zfcUermVKX>U)0mKGl zsCp0^B-gY4CpesEF*of7@j)0QH!Iiv42Xug1tbphCx|^O*?SL|W}j>eqCt38u2U+A z2B`zNtLKw%2*{5h_AIR~Uoaide4_n|{_>uc)6Rn23$h2K2Znn-gyewOAhjTRR(ALy zFr9VbCoJrd-3pR}tBq$q0SZ5m{H%FKOn*dBn}G0 z8EYyXz;4ZoJkay!GdP|>a?CzKzwvOE7$%UOl($>HP{WXGzZcH3Qv%H0<=L6ieHdjATbc0 zRe2 zX=MPZ12N!kj(-N?GQj)@69>^?J948R_Wbyl3=1=`Vg?3CIhGX&PWLb|7!9&x7W-sy n=umx=%Ym+YcH literal 0 HcmV?d00001 diff --git a/staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.idx b/staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.idx new file mode 100644 index 0000000000000000000000000000000000000000..fac29ad9e140f70ed59940b250f7f222f9b42eea GIT binary patch literal 10284 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 Ur?E}y{UVc}`fS=F_OPR?0n5JxuK)l5 literal 0 HcmV?d00001 diff --git a/staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.pack b/staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.pack new file mode 100644 index 0000000000000000000000000000000000000000..a4f1e4bcd93ac4f9af66793d89b11a15c0f3a2a2 GIT binary patch literal 247905 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

hwC}{h?e~q=e7(ke{HtKg?XAn_pKJWuf1Z(V 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 literal 0 HcmV?d00001 diff --git a/staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.rev b/staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.rev new file mode 100644 index 0000000000000000000000000000000000000000..0ac575462fa74bef4f986d4d92da42876ec98ad2 GIT binary patch literal 1368 zcmWIYbctYKU|@t|cLoNAMGOoKuNW8@Oc@v$)fpHVSs54@b}%q7+A}aP@-Q$kurM$% zN-!`mY-eC#uxDUk_`$%ysK&s+=*+;tsLH^=P|U!pj!3`ZCk7$O-M7(n6jl!1X!nSp`fH3I_!F9QQZ2Q+NgF)%QI{2ju;z;KCy zfkB9Yfx(x7fgy^4f#EO%1EU)Q0|Ore14A1F0|OTW10yK>LE+HJz`!8Jz`(GLfq|iw zfq{XOfq`Ka0|P?^0|Ub~1_nka1_lO@-v0~?j3ECdGcYi4GcYiUGB7a6GB7acFfcH5 zF)%QK>^sE3z{thGz);D+z^KQ-!0?rUfnf~;1H(ZE28L-23=9Vt7#JoqFffAjZDC+w zc*wxOaFv09;VlCL!!ZU1Mn?t)h5`l#Mo{>dFfcH(GcYiK><8KPgn@x!0s{lXY6b>| zXABGsjSLJ7vlti{E-)}KoMK>L2xDMikYiwASi!)+aEpO~!G(c=VLAf?Lklqjr@);Ny{1_M*5*Zj6J~1#b7%?y~g7gM3 zFfe2@FfcSQFfhb0Ffhb3Ffe#CFfi zpzw-gU|{&bz`&5sz`)?oz`*Fiz`!WLz`)SMz`!t-fq`KI0|Nsn|L8I>F#KjFfgbyFfjNqFfi<7U|>{XU|^WSz`)SWz`$^afq|i(fq_Aqfq?-O zpMMw_82K0&7_}G}7|IzK7y=m>7(^Ht7$q4P7(r>)hJk_6gn@yfih+Sqih+Tl1(Nj{ zni&`vVi_11tQi;>xfvK3JQx@lKzSp9fq|ivfq`KU0|TQx0|O%m0|O%y0|SFD0|O(- zPLMsf85kIf7#J9h85kHr@v)eJff1DE9xyO4^fNFp*fKCMfc$%sfq`K+0|SEt0|O(d z3}I$qV7S7-z~ITizyK;2E;BGN^f53nTxVcl_{YG&2+GqSw=Q5{VA#aKz;J_sfdLfe zXBik6oEaDxKzZy10|Uc31_lODxs$}ezz9kg6B!s7MHmJq%kls+MZl39$TpRbX8AB+>({?0eV^eB9AAseO6@Dm|pb5y7ahRc*o_C2@H|| DZoZur literal 0 HcmV?d00001 diff --git a/staging_alpha.git/packed-refs b/staging_alpha.git/packed-refs new file mode 100644 index 0000000..c9bed10 --- /dev/null +++ b/staging_alpha.git/packed-refs @@ -0,0 +1,7 @@ +# pack-refs with: peeled fully-peeled sorted +3dda4b8f186aed482ec3a358512d59dae627dcdc refs/heads/main +0acad658541753f618fba793698481c3724fa8d3 refs/pull/1/head +181ecfc9214ba43e3365f88416ca7468cd7838de refs/pull/2/head +12d3128b8970de89352947da4ed951584832c534 refs/pull/3/head +fe8d30b1edc3008bbcd3e4278c24c06d32654c29 refs/pull/4/head +8b028fae510b5ec3d276a439416e08674f37f444 refs/pull/5/head -- 2.53.0 From c685bca80dbfb8d20fa73711dd62a46646c245ba Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 11:33:49 -0500 Subject: [PATCH 142/857] update --- .gitignore.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore.txt b/.gitignore.txt index e608e23..5ba3a39 100644 --- a/.gitignore.txt +++ b/.gitignore.txt @@ -1,2 +1,5 @@ custom.nix role-state.nix +*.iso +*.zip +*.pma -- 2.53.0 From f8336ff9959504dd245da488581c82b0f4d77727 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 11:35:53 -0500 Subject: [PATCH 143/857] update --- staging_alpha.git/HEAD | 1 - staging_alpha.git/config | 12 -- staging_alpha.git/description | 1 - staging_alpha.git/hooks/applypatch-msg.sample | 15 -- staging_alpha.git/hooks/commit-msg.sample | 24 --- .../hooks/fsmonitor-watchman.sample | 174 ------------------ staging_alpha.git/hooks/post-update.sample | 8 - staging_alpha.git/hooks/pre-applypatch.sample | 14 -- staging_alpha.git/hooks/pre-commit.sample | 49 ----- .../hooks/pre-merge-commit.sample | 13 -- staging_alpha.git/hooks/pre-push.sample | 53 ------ staging_alpha.git/hooks/pre-rebase.sample | 169 ----------------- staging_alpha.git/hooks/pre-receive.sample | 24 --- .../hooks/prepare-commit-msg.sample | 42 ----- .../hooks/push-to-checkout.sample | 78 -------- .../hooks/sendemail-validate.sample | 77 -------- staging_alpha.git/hooks/update.sample | 128 ------------- staging_alpha.git/info/exclude | 6 - staging_alpha.git/info/refs | 11 -- staging_alpha.git/objects/info/commit-graph | Bin 5972 -> 0 bytes staging_alpha.git/objects/info/packs | 2 - ...75d7121e5aa8c885ea4a95f502e6a8f14e3.bitmap | Bin 6058 -> 0 bytes ...ab175d7121e5aa8c885ea4a95f502e6a8f14e3.idx | Bin 10284 -> 0 bytes ...b175d7121e5aa8c885ea4a95f502e6a8f14e3.pack | Bin 247905 -> 0 bytes ...ab175d7121e5aa8c885ea4a95f502e6a8f14e3.rev | Bin 1368 -> 0 bytes staging_alpha.git/packed-refs | 7 - 26 files changed, 908 deletions(-) delete mode 100644 staging_alpha.git/HEAD delete mode 100644 staging_alpha.git/config delete mode 100644 staging_alpha.git/description delete mode 100755 staging_alpha.git/hooks/applypatch-msg.sample delete mode 100755 staging_alpha.git/hooks/commit-msg.sample delete mode 100755 staging_alpha.git/hooks/fsmonitor-watchman.sample delete mode 100755 staging_alpha.git/hooks/post-update.sample delete mode 100755 staging_alpha.git/hooks/pre-applypatch.sample delete mode 100755 staging_alpha.git/hooks/pre-commit.sample delete mode 100755 staging_alpha.git/hooks/pre-merge-commit.sample delete mode 100755 staging_alpha.git/hooks/pre-push.sample delete mode 100755 staging_alpha.git/hooks/pre-rebase.sample delete mode 100755 staging_alpha.git/hooks/pre-receive.sample delete mode 100755 staging_alpha.git/hooks/prepare-commit-msg.sample delete mode 100755 staging_alpha.git/hooks/push-to-checkout.sample delete mode 100755 staging_alpha.git/hooks/sendemail-validate.sample delete mode 100755 staging_alpha.git/hooks/update.sample delete mode 100644 staging_alpha.git/info/exclude delete mode 100644 staging_alpha.git/info/refs delete mode 100644 staging_alpha.git/objects/info/commit-graph delete mode 100644 staging_alpha.git/objects/info/packs delete mode 100644 staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.bitmap delete mode 100644 staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.idx delete mode 100644 staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.pack delete mode 100644 staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.rev delete mode 100644 staging_alpha.git/packed-refs diff --git a/staging_alpha.git/HEAD b/staging_alpha.git/HEAD deleted file mode 100644 index b870d82..0000000 --- a/staging_alpha.git/HEAD +++ /dev/null @@ -1 +0,0 @@ -ref: refs/heads/main diff --git a/staging_alpha.git/config b/staging_alpha.git/config deleted file mode 100644 index 8568b31..0000000 --- a/staging_alpha.git/config +++ /dev/null @@ -1,12 +0,0 @@ -[core] - repositoryformatversion = 0 - filemode = true - bare = true -[remote "origin"] - url = https://github.com/naturallaw777/staging_alpha.git - tagOpt = --no-tags - fetch = +refs/*:refs/* - mirror = true -[remote "gitea"] - url = https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS.git - fetch = +refs/heads/*:refs/remotes/gitea/* diff --git a/staging_alpha.git/description b/staging_alpha.git/description deleted file mode 100644 index 498b267..0000000 --- a/staging_alpha.git/description +++ /dev/null @@ -1 +0,0 @@ -Unnamed repository; edit this file 'description' to name the repository. diff --git a/staging_alpha.git/hooks/applypatch-msg.sample b/staging_alpha.git/hooks/applypatch-msg.sample deleted file mode 100755 index 43f271a..0000000 --- a/staging_alpha.git/hooks/applypatch-msg.sample +++ /dev/null @@ -1,15 +0,0 @@ -#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash -# -# An example hook script to check the commit log message taken by -# applypatch from an e-mail message. -# -# The hook should exit with non-zero status after issuing an -# appropriate message if it wants to stop the commit. The hook is -# allowed to edit the commit message file. -# -# To enable this hook, rename this file to "applypatch-msg". - -. git-sh-setup -commitmsg="$(git rev-parse --git-path hooks/commit-msg)" -test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} -: diff --git a/staging_alpha.git/hooks/commit-msg.sample b/staging_alpha.git/hooks/commit-msg.sample deleted file mode 100755 index 3d3f504..0000000 --- a/staging_alpha.git/hooks/commit-msg.sample +++ /dev/null @@ -1,24 +0,0 @@ -#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash -# -# An example hook script to check the commit log message. -# Called by "git commit" with one argument, the name of the file -# that has the commit message. The hook should exit with non-zero -# status after issuing an appropriate message if it wants to stop the -# commit. The hook is allowed to edit the commit message file. -# -# To enable this hook, rename this file to "commit-msg". - -# Uncomment the below to add a Signed-off-by line to the message. -# Doing this in a hook is a bad idea in general, but the prepare-commit-msg -# hook is more suited to it. -# -# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') -# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" - -# This example catches duplicate Signed-off-by lines. - -test "" = "$(grep '^Signed-off-by: ' "$1" | - sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { - echo >&2 Duplicate Signed-off-by lines. - exit 1 -} diff --git a/staging_alpha.git/hooks/fsmonitor-watchman.sample b/staging_alpha.git/hooks/fsmonitor-watchman.sample deleted file mode 100755 index 0abcce8..0000000 --- a/staging_alpha.git/hooks/fsmonitor-watchman.sample +++ /dev/null @@ -1,174 +0,0 @@ -#!/nix/store/phnk1lwy8xs0yrbrcs6l2mb9yr9c2knp-perl-5.42.0/bin/perl - -use strict; -use warnings; -use IPC::Open2; - -# An example hook script to integrate Watchman -# (https://facebook.github.io/watchman/) with git to speed up detecting -# new and modified files. -# -# The hook is passed a version (currently 2) and last update token -# formatted as a string and outputs to stdout a new update token and -# all files that have been modified since the update token. Paths must -# be relative to the root of the working tree and separated by a single NUL. -# -# To enable this hook, rename this file to "query-watchman" and set -# 'git config core.fsmonitor .git/hooks/query-watchman' -# -my ($version, $last_update_token) = @ARGV; - -# Uncomment for debugging -# print STDERR "$0 $version $last_update_token\n"; - -# Check the hook interface version -if ($version ne 2) { - die "Unsupported query-fsmonitor hook version '$version'.\n" . - "Falling back to scanning...\n"; -} - -my $git_work_tree = get_working_dir(); - -my $retry = 1; - -my $json_pkg; -eval { - require JSON::XS; - $json_pkg = "JSON::XS"; - 1; -} or do { - require JSON::PP; - $json_pkg = "JSON::PP"; -}; - -launch_watchman(); - -sub launch_watchman { - my $o = watchman_query(); - if (is_work_tree_watched($o)) { - output_result($o->{clock}, @{$o->{files}}); - } -} - -sub output_result { - my ($clockid, @files) = @_; - - # Uncomment for debugging watchman output - # open (my $fh, ">", ".git/watchman-output.out"); - # binmode $fh, ":utf8"; - # print $fh "$clockid\n@files\n"; - # close $fh; - - binmode STDOUT, ":utf8"; - print $clockid; - print "\0"; - local $, = "\0"; - print @files; -} - -sub watchman_clock { - my $response = qx/watchman clock "$git_work_tree"/; - die "Failed to get clock id on '$git_work_tree'.\n" . - "Falling back to scanning...\n" if $? != 0; - - return $json_pkg->new->utf8->decode($response); -} - -sub watchman_query { - my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') - or die "open2() failed: $!\n" . - "Falling back to scanning...\n"; - - # In the query expression below we're asking for names of files that - # changed since $last_update_token but not from the .git folder. - # - # To accomplish this, we're using the "since" generator to use the - # recency index to select candidate nodes and "fields" to limit the - # output to file names only. Then we're using the "expression" term to - # further constrain the results. - my $last_update_line = ""; - if (substr($last_update_token, 0, 1) eq "c") { - $last_update_token = "\"$last_update_token\""; - $last_update_line = qq[\n"since": $last_update_token,]; - } - my $query = <<" END"; - ["query", "$git_work_tree", {$last_update_line - "fields": ["name"], - "expression": ["not", ["dirname", ".git"]] - }] - END - - # Uncomment for debugging the watchman query - # open (my $fh, ">", ".git/watchman-query.json"); - # print $fh $query; - # close $fh; - - print CHLD_IN $query; - close CHLD_IN; - my $response = do {local $/; }; - - # Uncomment for debugging the watch response - # open ($fh, ">", ".git/watchman-response.json"); - # print $fh $response; - # close $fh; - - die "Watchman: command returned no output.\n" . - "Falling back to scanning...\n" if $response eq ""; - die "Watchman: command returned invalid output: $response\n" . - "Falling back to scanning...\n" unless $response =~ /^\{/; - - return $json_pkg->new->utf8->decode($response); -} - -sub is_work_tree_watched { - my ($output) = @_; - my $error = $output->{error}; - if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) { - $retry--; - my $response = qx/watchman watch "$git_work_tree"/; - die "Failed to make watchman watch '$git_work_tree'.\n" . - "Falling back to scanning...\n" if $? != 0; - $output = $json_pkg->new->utf8->decode($response); - $error = $output->{error}; - die "Watchman: $error.\n" . - "Falling back to scanning...\n" if $error; - - # Uncomment for debugging watchman output - # open (my $fh, ">", ".git/watchman-output.out"); - # close $fh; - - # Watchman will always return all files on the first query so - # return the fast "everything is dirty" flag to git and do the - # Watchman query just to get it over with now so we won't pay - # the cost in git to look up each individual file. - my $o = watchman_clock(); - $error = $output->{error}; - - die "Watchman: $error.\n" . - "Falling back to scanning...\n" if $error; - - output_result($o->{clock}, ("/")); - $last_update_token = $o->{clock}; - - eval { launch_watchman() }; - return 0; - } - - die "Watchman: $error.\n" . - "Falling back to scanning...\n" if $error; - - return 1; -} - -sub get_working_dir { - my $working_dir; - if ($^O =~ 'msys' || $^O =~ 'cygwin') { - $working_dir = Win32::GetCwd(); - $working_dir =~ tr/\\/\//; - } else { - require Cwd; - $working_dir = Cwd::cwd(); - } - - return $working_dir; -} diff --git a/staging_alpha.git/hooks/post-update.sample b/staging_alpha.git/hooks/post-update.sample deleted file mode 100755 index 620050f..0000000 --- a/staging_alpha.git/hooks/post-update.sample +++ /dev/null @@ -1,8 +0,0 @@ -#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash -# -# An example hook script to prepare a packed repository for use over -# dumb transports. -# -# To enable this hook, rename this file to "post-update". - -exec git update-server-info diff --git a/staging_alpha.git/hooks/pre-applypatch.sample b/staging_alpha.git/hooks/pre-applypatch.sample deleted file mode 100755 index b97e6cc..0000000 --- a/staging_alpha.git/hooks/pre-applypatch.sample +++ /dev/null @@ -1,14 +0,0 @@ -#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash -# -# An example hook script to verify what is about to be committed -# by applypatch from an e-mail message. -# -# The hook should exit with non-zero status after issuing an -# appropriate message if it wants to stop the commit. -# -# To enable this hook, rename this file to "pre-applypatch". - -. git-sh-setup -precommit="$(git rev-parse --git-path hooks/pre-commit)" -test -x "$precommit" && exec "$precommit" ${1+"$@"} -: diff --git a/staging_alpha.git/hooks/pre-commit.sample b/staging_alpha.git/hooks/pre-commit.sample deleted file mode 100755 index d64c24c..0000000 --- a/staging_alpha.git/hooks/pre-commit.sample +++ /dev/null @@ -1,49 +0,0 @@ -#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash -# -# An example hook script to verify what is about to be committed. -# Called by "git commit" with no arguments. The hook should -# exit with non-zero status after issuing an appropriate message if -# it wants to stop the commit. -# -# To enable this hook, rename this file to "pre-commit". - -if git rev-parse --verify HEAD >/dev/null 2>&1 -then - against=HEAD -else - # Initial commit: diff against an empty tree object - against=$(git hash-object -t tree /dev/null) -fi - -# If you want to allow non-ASCII filenames set this variable to true. -allownonascii=$(git config --type=bool hooks.allownonascii) - -# Redirect output to stderr. -exec 1>&2 - -# Cross platform projects tend to avoid non-ASCII filenames; prevent -# them from being added to the repository. We exploit the fact that the -# printable range starts at the space character and ends with tilde. -if [ "$allownonascii" != "true" ] && - # Note that the use of brackets around a tr range is ok here, (it's - # even required, for portability to Solaris 10's /usr/bin/tr), since - # the square bracket bytes happen to fall in the designated range. - test $(git diff-index --cached --name-only --diff-filter=A -z $against | - LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 -then - cat <<\EOF -Error: Attempt to add a non-ASCII file name. - -This can cause problems if you want to work with people on other platforms. - -To be portable it is advisable to rename the file. - -If you know what you are doing you can disable this check using: - - git config hooks.allownonascii true -EOF - exit 1 -fi - -# If there are whitespace errors, print the offending file names and fail. -exec git diff-index --check --cached $against -- diff --git a/staging_alpha.git/hooks/pre-merge-commit.sample b/staging_alpha.git/hooks/pre-merge-commit.sample deleted file mode 100755 index 1c5c145..0000000 --- a/staging_alpha.git/hooks/pre-merge-commit.sample +++ /dev/null @@ -1,13 +0,0 @@ -#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash -# -# An example hook script to verify what is about to be committed. -# Called by "git merge" with no arguments. The hook should -# exit with non-zero status after issuing an appropriate message to -# stderr if it wants to stop the merge commit. -# -# To enable this hook, rename this file to "pre-merge-commit". - -. git-sh-setup -test -x "$GIT_DIR/hooks/pre-commit" && - exec "$GIT_DIR/hooks/pre-commit" -: diff --git a/staging_alpha.git/hooks/pre-push.sample b/staging_alpha.git/hooks/pre-push.sample deleted file mode 100755 index 136692e..0000000 --- a/staging_alpha.git/hooks/pre-push.sample +++ /dev/null @@ -1,53 +0,0 @@ -#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash - -# An example hook script to verify what is about to be pushed. Called by "git -# push" after it has checked the remote status, but before anything has been -# pushed. If this script exits with a non-zero status nothing will be pushed. -# -# This hook is called with the following parameters: -# -# $1 -- Name of the remote to which the push is being done -# $2 -- URL to which the push is being done -# -# If pushing without using a named remote those arguments will be equal. -# -# Information about the commits which are being pushed is supplied as lines to -# the standard input in the form: -# -# -# -# This sample shows how to prevent push of commits where the log message starts -# with "WIP" (work in progress). - -remote="$1" -url="$2" - -zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing" - exit 1 - fi - fi -done - -exit 0 diff --git a/staging_alpha.git/hooks/pre-rebase.sample b/staging_alpha.git/hooks/pre-rebase.sample deleted file mode 100755 index 92dc6b7..0000000 --- a/staging_alpha.git/hooks/pre-rebase.sample +++ /dev/null @@ -1,169 +0,0 @@ -#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash -# -# Copyright (c) 2006, 2008 Junio C Hamano -# -# The "pre-rebase" hook is run just before "git rebase" starts doing -# its job, and can prevent the command from running by exiting with -# non-zero status. -# -# The hook is called with the following parameters: -# -# $1 -- the upstream the series was forked from. -# $2 -- the branch being rebased (or empty when rebasing the current branch). -# -# This sample shows how to prevent topic branches that are already -# merged to 'next' branch from getting rebased, because allowing it -# would result in rebasing already published history. - -publish=next -basebranch="$1" -if test "$#" = 2 -then - topic="refs/heads/$2" -else - topic=`git symbolic-ref HEAD` || - exit 0 ;# we do not interrupt rebasing detached HEAD -fi - -case "$topic" in -refs/heads/??/*) - ;; -*) - exit 0 ;# we do not interrupt others. - ;; -esac - -# Now we are dealing with a topic branch being rebased -# on top of master. Is it OK to rebase it? - -# Does the topic really exist? -git show-ref -q "$topic" || { - echo >&2 "No such branch $topic" - exit 1 -} - -# Is topic fully merged to master? -not_in_master=`git rev-list --pretty=oneline ^master "$topic"` -if test -z "$not_in_master" -then - echo >&2 "$topic is fully merged to master; better remove it." - exit 1 ;# we could allow it, but there is no point. -fi - -# Is topic ever merged to next? If so you should not be rebasing it. -only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` -only_next_2=`git rev-list ^master ${publish} | sort` -if test "$only_next_1" = "$only_next_2" -then - not_in_topic=`git rev-list "^$topic" master` - if test -z "$not_in_topic" - then - echo >&2 "$topic is already up to date with master" - exit 1 ;# we could allow it, but there is no point. - else - exit 0 - fi -else - not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` - /nix/store/phnk1lwy8xs0yrbrcs6l2mb9yr9c2knp-perl-5.42.0/bin/perl -e ' - my $topic = $ARGV[0]; - my $msg = "* $topic has commits already merged to public branch:\n"; - my (%not_in_next) = map { - /^([0-9a-f]+) /; - ($1 => 1); - } split(/\n/, $ARGV[1]); - for my $elem (map { - /^([0-9a-f]+) (.*)$/; - [$1 => $2]; - } split(/\n/, $ARGV[2])) { - if (!exists $not_in_next{$elem->[0]}) { - if ($msg) { - print STDERR $msg; - undef $msg; - } - print STDERR " $elem->[1]\n"; - } - } - ' "$topic" "$not_in_next" "$not_in_master" - exit 1 -fi - -<<\DOC_END - -This sample hook safeguards topic branches that have been -published from being rewound. - -The workflow assumed here is: - - * Once a topic branch forks from "master", "master" is never - merged into it again (either directly or indirectly). - - * Once a topic branch is fully cooked and merged into "master", - it is deleted. If you need to build on top of it to correct - earlier mistakes, a new topic branch is created by forking at - the tip of the "master". This is not strictly necessary, but - it makes it easier to keep your history simple. - - * Whenever you need to test or publish your changes to topic - branches, merge them into "next" branch. - -The script, being an example, hardcodes the publish branch name -to be "next", but it is trivial to make it configurable via -$GIT_DIR/config mechanism. - -With this workflow, you would want to know: - -(1) ... if a topic branch has ever been merged to "next". Young - topic branches can have stupid mistakes you would rather - clean up before publishing, and things that have not been - merged into other branches can be easily rebased without - affecting other people. But once it is published, you would - not want to rewind it. - -(2) ... if a topic branch has been fully merged to "master". - Then you can delete it. More importantly, you should not - build on top of it -- other people may already want to - change things related to the topic as patches against your - "master", so if you need further changes, it is better to - fork the topic (perhaps with the same name) afresh from the - tip of "master". - -Let's look at this example: - - o---o---o---o---o---o---o---o---o---o "next" - / / / / - / a---a---b A / / - / / / / - / / c---c---c---c B / - / / / \ / - / / / b---b C \ / - / / / / \ / - ---o---o---o---o---o---o---o---o---o---o---o "master" - - -A, B and C are topic branches. - - * A has one fix since it was merged up to "next". - - * B has finished. It has been fully merged up to "master" and "next", - and is ready to be deleted. - - * C has not merged to "next" at all. - -We would want to allow C to be rebased, refuse A, and encourage -B to be deleted. - -To compute (1): - - git rev-list ^master ^topic next - git rev-list ^master next - - if these match, topic has not merged in next at all. - -To compute (2): - - git rev-list master..topic - - if this is empty, it is fully merged to "master". - -DOC_END diff --git a/staging_alpha.git/hooks/pre-receive.sample b/staging_alpha.git/hooks/pre-receive.sample deleted file mode 100755 index 63897dd..0000000 --- a/staging_alpha.git/hooks/pre-receive.sample +++ /dev/null @@ -1,24 +0,0 @@ -#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash -# -# An example hook script to make use of push options. -# The example simply echoes all push options that start with 'echoback=' -# and rejects all pushes when the "reject" push option is used. -# -# To enable this hook, rename this file to "pre-receive". - -if test -n "$GIT_PUSH_OPTION_COUNT" -then - i=0 - while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" - do - eval "value=\$GIT_PUSH_OPTION_$i" - case "$value" in - echoback=*) - echo "echo from the pre-receive-hook: ${value#*=}" >&2 - ;; - reject) - exit 1 - esac - i=$((i + 1)) - done -fi diff --git a/staging_alpha.git/hooks/prepare-commit-msg.sample b/staging_alpha.git/hooks/prepare-commit-msg.sample deleted file mode 100755 index 35d6859..0000000 --- a/staging_alpha.git/hooks/prepare-commit-msg.sample +++ /dev/null @@ -1,42 +0,0 @@ -#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash -# -# An example hook script to prepare the commit log message. -# Called by "git commit" with the name of the file that has the -# commit message, followed by the description of the commit -# message's source. The hook's purpose is to edit the commit -# message file. If the hook fails with a non-zero status, -# the commit is aborted. -# -# To enable this hook, rename this file to "prepare-commit-msg". - -# This hook includes three examples. The first one removes the -# "# Please enter the commit message..." help message. -# -# The second includes the output of "git diff --name-status -r" -# into the message, just before the "git status" output. It is -# commented because it doesn't cope with --amend or with squashed -# commits. -# -# The third example adds a Signed-off-by line to the message, that can -# still be edited. This is rarely a good idea. - -COMMIT_MSG_FILE=$1 -COMMIT_SOURCE=$2 -SHA1=$3 - -/nix/store/phnk1lwy8xs0yrbrcs6l2mb9yr9c2knp-perl-5.42.0/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" - -# case "$COMMIT_SOURCE,$SHA1" in -# ,|template,) -# /nix/store/phnk1lwy8xs0yrbrcs6l2mb9yr9c2knp-perl-5.42.0/bin/perl -i.bak -pe ' -# print "\n" . `git diff --cached --name-status -r` -# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; -# *) ;; -# esac - -# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') -# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" -# if test -z "$COMMIT_SOURCE" -# then -# /nix/store/phnk1lwy8xs0yrbrcs6l2mb9yr9c2knp-perl-5.42.0/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" -# fi diff --git a/staging_alpha.git/hooks/push-to-checkout.sample b/staging_alpha.git/hooks/push-to-checkout.sample deleted file mode 100755 index f680745..0000000 --- a/staging_alpha.git/hooks/push-to-checkout.sample +++ /dev/null @@ -1,78 +0,0 @@ -#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash - -# An example hook script to update a checked-out tree on a git push. -# -# This hook is invoked by git-receive-pack(1) when it reacts to git -# push and updates reference(s) in its repository, and when the push -# tries to update the branch that is currently checked out and the -# receive.denyCurrentBranch configuration variable is set to -# updateInstead. -# -# By default, such a push is refused if the working tree and the index -# of the remote repository has any difference from the currently -# checked out commit; when both the working tree and the index match -# the current commit, they are updated to match the newly pushed tip -# of the branch. This hook is to be used to override the default -# behaviour; however the code below reimplements the default behaviour -# as a starting point for convenient modification. -# -# The hook receives the commit with which the tip of the current -# branch is going to be updated: -commit=$1 - -# It can exit with a non-zero status to refuse the push (when it does -# so, it must not modify the index or the working tree). -die () { - echo >&2 "$*" - exit 1 -} - -# Or it can make any necessary changes to the working tree and to the -# index to bring them to the desired state when the tip of the current -# branch is updated to the new commit, and exit with a zero status. -# -# For example, the hook can simply run git read-tree -u -m HEAD "$1" -# in order to emulate git fetch that is run in the reverse direction -# with git push, as the two-tree form of git read-tree -u -m is -# essentially the same as git switch or git checkout that switches -# branches while keeping the local changes in the working tree that do -# not interfere with the difference between the branches. - -# The below is a more-or-less exact translation to shell of the C code -# for the default behaviour for git's push-to-checkout hook defined in -# the push_to_deploy() function in builtin/receive-pack.c. -# -# Note that the hook will be executed from the repository directory, -# not from the working tree, so if you want to perform operations on -# the working tree, you will have to adapt your code accordingly, e.g. -# by adding "cd .." or using relative paths. - -if ! git update-index -q --ignore-submodules --refresh -then - die "Up-to-date check failed" -fi - -if ! git diff-files --quiet --ignore-submodules -- -then - die "Working directory has unstaged changes" -fi - -# This is a rough translation of: -# -# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX -if git cat-file -e HEAD 2>/dev/null -then - head=HEAD -else - head=$(git hash-object -t tree --stdin &2 - exit 1 -} - -unset GIT_DIR GIT_WORK_TREE -cd "$worktree" && - -if grep -q "^diff --git " "$1" -then - validate_patch "$1" -else - validate_cover_letter "$1" -fi && - -if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL" -then - git config --unset-all sendemail.validateWorktree && - trap 'git worktree remove -ff "$worktree"' EXIT && - validate_series -fi diff --git a/staging_alpha.git/hooks/update.sample b/staging_alpha.git/hooks/update.sample deleted file mode 100755 index 8a31cba..0000000 --- a/staging_alpha.git/hooks/update.sample +++ /dev/null @@ -1,128 +0,0 @@ -#!/nix/store/v8sa6r6q037ihghxfbwzjj4p59v2x0pv-bash-5.3p9/bin/bash -# -# An example hook script to block unannotated tags from entering. -# Called by "git receive-pack" with arguments: refname sha1-old sha1-new -# -# To enable this hook, rename this file to "update". -# -# Config -# ------ -# hooks.allowunannotated -# This boolean sets whether unannotated tags will be allowed into the -# repository. By default they won't be. -# hooks.allowdeletetag -# This boolean sets whether deleting tags will be allowed in the -# repository. By default they won't be. -# hooks.allowmodifytag -# This boolean sets whether a tag may be modified after creation. By default -# it won't be. -# hooks.allowdeletebranch -# This boolean sets whether deleting branches will be allowed in the -# repository. By default they won't be. -# hooks.denycreatebranch -# This boolean sets whether remotely creating branches will be denied -# in the repository. By default this is allowed. -# - -# --- Command line -refname="$1" -oldrev="$2" -newrev="$3" - -# --- Safety check -if [ -z "$GIT_DIR" ]; then - echo "Don't run this script from the command line." >&2 - echo " (if you want, you could supply GIT_DIR then run" >&2 - echo " $0 )" >&2 - exit 1 -fi - -if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then - echo "usage: $0 " >&2 - exit 1 -fi - -# --- Config -allowunannotated=$(git config --type=bool hooks.allowunannotated) -allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch) -denycreatebranch=$(git config --type=bool hooks.denycreatebranch) -allowdeletetag=$(git config --type=bool hooks.allowdeletetag) -allowmodifytag=$(git config --type=bool hooks.allowmodifytag) - -# check for no description -projectdesc=$(sed -e '1q' "$GIT_DIR/description") -case "$projectdesc" in -"Unnamed repository"* | "") - echo "*** Project description file hasn't been set" >&2 - exit 1 - ;; -esac - -# --- Check types -# if $newrev is 0000...0000, it's a commit to delete a ref. -zero=$(git hash-object --stdin &2 - echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 - exit 1 - fi - ;; - refs/tags/*,delete) - # delete tag - if [ "$allowdeletetag" != "true" ]; then - echo "*** Deleting a tag is not allowed in this repository" >&2 - exit 1 - fi - ;; - refs/tags/*,tag) - # annotated tag - if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 - then - echo "*** Tag '$refname' already exists." >&2 - echo "*** Modifying a tag is not allowed in this repository." >&2 - exit 1 - fi - ;; - refs/heads/*,commit) - # branch - if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then - echo "*** Creating a branch is not allowed in this repository" >&2 - exit 1 - fi - ;; - refs/heads/*,delete) - # delete branch - if [ "$allowdeletebranch" != "true" ]; then - echo "*** Deleting a branch is not allowed in this repository" >&2 - exit 1 - fi - ;; - refs/remotes/*,commit) - # tracking branch - ;; - refs/remotes/*,delete) - # delete tracking branch - if [ "$allowdeletebranch" != "true" ]; then - echo "*** Deleting a tracking branch is not allowed in this repository" >&2 - exit 1 - fi - ;; - *) - # Anything else (is there anything else?) - echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 - exit 1 - ;; -esac - -# --- Finished -exit 0 diff --git a/staging_alpha.git/info/exclude b/staging_alpha.git/info/exclude deleted file mode 100644 index a5196d1..0000000 --- a/staging_alpha.git/info/exclude +++ /dev/null @@ -1,6 +0,0 @@ -# git ls-files --others --exclude-from=.git/info/exclude -# Lines that start with '#' are comments. -# For a project mostly in C, the following would be a good set of -# exclude patterns (uncomment them if you want to use them): -# *.[oa] -# *~ diff --git a/staging_alpha.git/info/refs b/staging_alpha.git/info/refs deleted file mode 100644 index 6e7c528..0000000 --- a/staging_alpha.git/info/refs +++ /dev/null @@ -1,11 +0,0 @@ -fe8d30b1edc3008bbcd3e4278c24c06d32654c29 refs/heads/copilot/add-flakes-support-to-iso -8b028fae510b5ec3d276a439416e08674f37f444 refs/heads/copilot/create-gtk4-libadwaita-app -0acad658541753f618fba793698481c3724fa8d3 refs/heads/copilot/fix-gi-typelib-path-issue -12d3128b8970de89352947da4ed951584832c534 refs/heads/copilot/fix-installer-runtime-error -181ecfc9214ba43e3365f88416ca7468cd7838de refs/heads/copilot/fix-nix-installer-errors -3dda4b8f186aed482ec3a358512d59dae627dcdc refs/heads/main -0acad658541753f618fba793698481c3724fa8d3 refs/pull/1/head -181ecfc9214ba43e3365f88416ca7468cd7838de refs/pull/2/head -12d3128b8970de89352947da4ed951584832c534 refs/pull/3/head -fe8d30b1edc3008bbcd3e4278c24c06d32654c29 refs/pull/4/head -8b028fae510b5ec3d276a439416e08674f37f444 refs/pull/5/head diff --git a/staging_alpha.git/objects/info/commit-graph b/staging_alpha.git/objects/info/commit-graph deleted file mode 100644 index bb396be225870b13bd212385c6c30c3d35ef319e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5972 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 diff --git a/staging_alpha.git/objects/info/packs b/staging_alpha.git/objects/info/packs deleted file mode 100644 index fb11bed..0000000 --- a/staging_alpha.git/objects/info/packs +++ /dev/null @@ -1,2 +0,0 @@ -P pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.pack - diff --git a/staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.bitmap b/staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.bitmap deleted file mode 100644 index 4615b98510b1e5465ac3dbd9486e896dde6b6113..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6058 zcmZ?r4Dn@PWME}rU`bTEHq;7#J8p7@~lIfl&aY59E4?2*@oUKfZ$N0EG<5hY)#4 zNc{hw0OmS?NeBT_12WkZr%o0I20@5g1|BF4rl2|*)lqbULL4m3z`y`<6^PHoz~I2Z z(BTLZhv{TR(FqcTsSp6i9Eisb@+gP_#vnBy*Y3cnQvqx%Nbvy>0mo3CjP@uxL9T-- z0fh>P&%|K?69UsPojh=zAQ6yCurvb$1IURWS1~<+i9l$O1`rD*2Z|3CkN`M7(M$k| zgX5P;9HbtBVYWa`V3Yu<1e*YgZI~=H`GB>7(ga9+J1&(Vqfk^LLImV0xHKgCKv4)* z4Kfpi85kItVcrC(1^Md-+%6CgstTk_07QWJAeEpbECUh%F(8;x4VO*@kV1q`W>64= zgutQ1!oa}jj7ujd8o{XtVkZaO2OvI3rz$R;U_0P;GJ*|4wv(Y4hfa_%B!_|QU|?zh z`x~Sc#0TLrkP>jbp@tC1HYA-$`3s~Agulaef?@}v9%eG6On`_vfJ2NyK?EcT#vt7w z+wS5p0TM7ERUi`-n!uVEz>b74!C?(DVGfE3ARQ0|Aa61rfv_RI0GY(%zyK*`AaM*X zE6<~t08$0f1#vXUL?#9X1Be&{19Hv<$+0ppFp9xV0J#WZ0wk6|d=>_V0+^EBM~VR zQ9~kyDo8g>A>1DdAW^VCK=B3&MQ8>Cs|4{uX#m85)fgZ#P^h!Pbt2gTk^m_NW0<>l z!BryJ0S|tVc~CnTVf7hE3}n=DxK5A?kSGHK11K!P=7UtQz|8>*@qtOG7eKa&!%aYP zB}xW?x$+k_mB`Tm53_mLR3b+MT;(z>DnUXpSHfeA(E=n4jt7K0q5g#`hPso{5{pVu zNI;b#rRIeoA+VWw2r+k6F?0>W^U?t^)tU`Ln);xV9@)Nd;|Z!!Y| zg96N?pO_{o!9x_3Vj!UoH3^!MK&cPZat2um!XP!EFh2^{2~q(P0rg-&Dj1kRNe3(f zE|noHkaDmrSPWDuYlDQqAq%$~WH>|-SQW(N1yB)4?G7?+DO@K!#ejt2>JVwm5K|>; zu7Ri5y&&CS*TBOR8qExxphm$&P^rd%9;TmnhJf^eFf4W$K~|#0Gc=mfO`3!hJBzWH z?U{!fr1uf8zOeV@=zggsDXlFK1cv80}4ZM{z3D_1cco`ly|y-)Ppe07Ys8n z?1n`SNCL!WVPKE|X@Oy=7{dvW09Ly}O*P9}EoO zH~=XC7iItdGhj0z0^~@r39u9bavew)Ec!t-SPZ91P_}{T0I7TgR|$&^CTP0`_x0+1;T|Nk&BgOVFa0I8<51o5$&0Fvl8 z5u630AtszbYQ-YLv>l76NCVD zPC#~pGZ0uONC8L$gkggnAT}t)FfbruiD7!>Lxx%L%-~!GF$_XZjhevF6%e*$mg>Iz zp7)b0ryD+h2$E@@UdGV#W1%^SJu6w?y65`^*PegDl^{L{gY@)#x^Wo9hT&Q17hFO1 zfcUermVKX>U)0mKGl zsCp0^B-gY4CpesEF*of7@j)0QH!Iiv42Xug1tbphCx|^O*?SL|W}j>eqCt38u2U+A z2B`zNtLKw%2*{5h_AIR~Uoaide4_n|{_>uc)6Rn23$h2K2Znn-gyewOAhjTRR(ALy zFr9VbCoJrd-3pR}tBq$q0SZ5m{H%FKOn*dBn}G0 z8EYyXz;4ZoJkay!GdP|>a?CzKzwvOE7$%UOl($>HP{WXGzZcH3Qv%H0<=L6ieHdjATbc0 zRe2 zX=MPZ12N!kj(-N?GQj)@69>^?J948R_Wbyl3=1=`Vg?3CIhGX&PWLb|7!9&x7W-sy n=umx=%Ym+YcH diff --git a/staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.idx b/staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.idx deleted file mode 100644 index fac29ad9e140f70ed59940b250f7f222f9b42eea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10284 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 Ur?E}y{UVc}`fS=F_OPR?0n5JxuK)l5 diff --git a/staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.pack b/staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.pack deleted file mode 100644 index a4f1e4bcd93ac4f9af66793d89b11a15c0f3a2a2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 247905 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

    hwC}{h?e~q=e7(ke{HtKg?XAn_pKJWuf1Z(V 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 +
      +
      + `; + + const grid = section.querySelector(".tiles-grid"); + for (const svc of entries) { + grid.appendChild(buildTile(svc)); + } + + $tilesArea.appendChild(section); + } + + if ($tilesArea.children.length === 0) { + $tilesArea.innerHTML = `

      No services configured.

      `; + } +} + +function buildTile(svc) { + const sc = statusClass(svc.status); + const st = statusText(svc.status, svc.enabled); + const dis = !svc.enabled; + const isOn = svc.status === "active"; + + const tile = document.createElement("div"); + tile.className = "service-tile" + (dis ? " disabled" : ""); + tile.dataset.unit = svc.unit; + if (dis) tile.title = `${svc.name} is not enabled in custom.nix`; + + tile.innerHTML = ` + ${escHtml(svc.name)} + +
      ${escHtml(svc.name)}
      +
      + + ${escHtml(st)} +
      +
      +
      + + +
      + `; + + // Toggle handler + const chk = tile.querySelector(".tile-toggle"); + if (!dis) { + chk.addEventListener("change", async (e) => { + const action = e.target.checked ? "start" : "stop"; + chk.disabled = true; + try { + await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/${action}`, { method: "POST" }); + } catch (_) {} + setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY); + }); + } + + // Restart handler + const restartBtn = tile.querySelector(".tile-restart-btn"); + if (!dis) { + restartBtn.addEventListener("click", async () => { + restartBtn.disabled = true; + try { + await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/restart`, { method: "POST" }); + } catch (_) {} + setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY); + }); + } + + return tile; +} + +// ── Render: live update (no DOM rebuild) ────────────────────────── + +function updateTiles(services) { + _servicesCache = services; + + for (const svc of services) { + const tile = $tilesArea.querySelector(`.service-tile[data-unit="${CSS.escape(svc.unit)}"]`); + if (!tile) continue; + + const sc = statusClass(svc.status); + const st = statusText(svc.status, svc.enabled); + + const dot = tile.querySelector(".status-dot"); + const text = tile.querySelector(".status-text"); + const chk = tile.querySelector(".tile-toggle"); + + if (dot) { dot.className = `status-dot ${sc}`; } + if (text) { text.textContent = st; } + if (chk && !chk.disabled) { + chk.checked = svc.status === "active"; + } + } +} + +// ── HTML escape ─────────────────────────────────────────────────── + +function escHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// ── Service polling ─────────────────────────────────────────────── + +let _firstLoad = true; + +async function refreshServices() { + try { + const services = await apiFetch("/api/services"); + if (_firstLoad) { + buildTiles(services, _categoryLabels); + _firstLoad = false; + } else { + updateTiles(services); + } + } catch (err) { + console.warn("Failed to fetch services:", err); + } +} + +// ── Network IPs ─────────────────────────────────────────────────── + +async function loadNetwork() { + try { + const data = await apiFetch("/api/network"); + if ($internalIp) $internalIp.textContent = data.internal_ip || "—"; + if ($externalIp) $externalIp.textContent = data.external_ip || "—"; + } catch (_) { + if ($internalIp) $internalIp.textContent = "—"; + if ($externalIp) $externalIp.textContent = "—"; + } +} + +// ── Update check ────────────────────────────────────────────────── + +async function checkUpdates() { + try { + const data = await apiFetch("/api/updates/check"); + if ($updateBadge) { + $updateBadge.classList.toggle("visible", !!data.available); + } + } catch (_) {} +} + +// ── Update modal ────────────────────────────────────────────────── + +function openUpdateModal() { + if (!$modal) return; + _updateLog = ""; + if ($modalLog) $modalLog.textContent = ""; + if ($modalStatus) $modalStatus.textContent = "Updating…"; + if ($modalSpinner) $modalSpinner.classList.add("spinning"); + if ($btnReboot) { $btnReboot.style.display = "none"; } + if ($btnSave) { $btnSave.style.display = "none"; } + if ($btnCloseModal) { $btnCloseModal.disabled = true; } + + $modal.classList.add("open"); + startUpdateStream(); +} + +function closeUpdateModal() { + if (!$modal) return; + $modal.classList.remove("open"); + if (_updateSource) { + _updateSource.close(); + _updateSource = null; + } +} + +function appendLog(text) { + _updateLog += text + "\n"; + if ($modalLog) { + $modalLog.textContent += text + "\n"; + $modalLog.scrollTop = $modalLog.scrollHeight; + } +} + +function startUpdateStream() { + // Trigger the update via POST first, then listen via SSE + fetch("/api/updates/run", { method: "POST" }).then(response => { + if (!response.ok || !response.body) { + const detail = response.ok ? "no body" : `HTTP ${response.status} ${response.statusText}`; + appendLog(`[Error: failed to start update — ${detail}]`); + onUpdateDone(false); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + function read() { + reader.read().then(({ done, value }) => { + if (done) { + onUpdateDone(true); + return; + } + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop(); // keep incomplete line + for (const line of lines) { + if (line.startsWith("data: ")) { + appendLog(line.slice(6)); + } else if (line.startsWith("event: done")) { + // success event will follow in data: + } else if (line.startsWith("event: error")) { + // error event will follow in data: + } + } + read(); + }).catch(err => { + appendLog(`[Stream error: ${err}]`); + onUpdateDone(false); + }); + } + read(); + }).catch(err => { + appendLog(`[Request error: ${err}]`); + onUpdateDone(false); + }); +} + +function onUpdateDone(success) { + if ($modalSpinner) $modalSpinner.classList.remove("spinning"); + if ($btnCloseModal) $btnCloseModal.disabled = false; + + if (success) { + if ($modalStatus) $modalStatus.textContent = "āœ“ Update complete"; + if ($btnReboot) $btnReboot.style.display = "inline-flex"; + } else { + if ($modalStatus) $modalStatus.textContent = "āœ— Update failed"; + if ($btnSave) $btnSave.style.display = "inline-flex"; + } +} + +function saveErrorReport() { + const blob = new Blob([_updateLog], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `sovran-update-error-${new Date().toISOString().split('.')[0].replace(/:/g, '-')}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +function doReboot() { + fetch("/api/reboot", { method: "POST" }).catch(() => {}); + if ($modalStatus) $modalStatus.textContent = "Rebooting…"; +} + +// ── Event listeners ─────────────────────────────────────────────── + +if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); +if ($refreshBtn) $refreshBtn.addEventListener("click", () => refreshServices()); +if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal); +if ($btnReboot) $btnReboot.addEventListener("click", doReboot); +if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); + +// Close modal on overlay click +if ($modal) { + $modal.addEventListener("click", (e) => { + if (e.target === $modal) closeUpdateModal(); + }); +} + +// ── Init ────────────────────────────────────────────────────────── + +async function init() { + // Load config to get category labels + try { + const cfg = await apiFetch("/api/config"); + if (cfg.category_order) { + for (const [key, label] of cfg.category_order) { + _categoryLabels[key] = label; + } + } + // Update role badge + const badge = document.getElementById("role-badge"); + if (badge && cfg.role_label) badge.textContent = cfg.role_label; + } catch (_) {} + + // Initial data loads + await refreshServices(); + loadNetwork(); + checkUpdates(); + + // Polling + setInterval(refreshServices, POLL_INTERVAL_SERVICES); + setInterval(checkUpdates, POLL_INTERVAL_UPDATES); +} + +document.addEventListener("DOMContentLoaded", init); diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css new file mode 100644 index 0000000..2031d70 --- /dev/null +++ b/app/sovran_systemsos_web/static/style.css @@ -0,0 +1,530 @@ +/* Sovran_SystemsOS Hub — Web UI Stylesheet + Dark theme matching the Adwaita dark aesthetic */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg-color: #1e1e2e; + --surface-color: #2a2a3c; + --card-color: #313244; + --border-color: #45475a; + --text-primary: #cdd6f4; + --text-secondary: #a6adc8; + --text-dim: #6c7086; + --accent-color: #89b4fa; + --green: #2ec27e; + --yellow: #e5a50a; + --red: #e01b24; + --grey: #888888; + --radius-card: 18px; + --radius-btn: 8px; + --shadow-card: 0 2px 8px rgba(0,0,0,0.4); + --shadow-hover: 0 6px 20px rgba(0,0,0,0.6); +} + +html, body { + height: 100%; +} + +body { + font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif; + background-color: var(--bg-color); + color: var(--text-primary); + line-height: 1.5; + min-height: 100vh; +} + +/* ── Header bar ─────────────────────────────────────────────────── */ + +.header-bar { + background-color: var(--surface-color); + border-bottom: 1px solid var(--border-color); + padding: 10px 24px; + display: flex; + align-items: center; + gap: 16px; + position: sticky; + top: 0; + z-index: 100; +} + +.header-bar .title { + font-size: 1.15rem; + font-weight: 700; + color: var(--text-primary); + flex: 1; +} + +.role-badge { + background-color: var(--accent-color); + color: #1e1e2e; + font-size: 0.72rem; + font-weight: 700; + padding: 3px 10px; + border-radius: 20px; + letter-spacing: 0.03em; +} + +/* ── Buttons ────────────────────────────────────────────────────── */ + +button { + font-family: inherit; + cursor: pointer; + border: none; + outline: none; + transition: opacity 0.15s, box-shadow 0.15s, background-color 0.15s; +} + +button:disabled { + opacity: 0.45; + cursor: default; +} + +.btn { + padding: 7px 16px; + border-radius: var(--radius-btn); + font-size: 0.88rem; + font-weight: 600; +} + +.btn-primary { + background-color: var(--accent-color); + color: #1e1e2e; +} + +.btn-primary:hover:not(:disabled) { + opacity: 0.88; +} + +.btn-update { + background-color: var(--green); + color: #fff; + position: relative; + display: flex; + align-items: center; + gap: 8px; +} + +.btn-update:hover:not(:disabled) { + background-color: #27ae6e; +} + +.update-badge { + display: none; + width: 10px; + height: 10px; + background-color: var(--yellow); + border-radius: 50%; + animation: pulse-badge 1.4s ease-in-out infinite; +} + +.update-badge.visible { + display: inline-block; +} + +@keyframes pulse-badge { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(1.35); } +} + +.btn-icon { + background: none; + color: var(--text-secondary); + padding: 6px; + border-radius: 50%; + font-size: 1.1rem; + line-height: 1; +} + +.btn-icon:hover:not(:disabled) { + background-color: var(--border-color); + color: var(--text-primary); +} + +/* ── IP bar ─────────────────────────────────────────────────────── */ + +.ip-bar { + background-color: var(--surface-color); + border-bottom: 1px solid var(--border-color); + padding: 8px 24px; + display: flex; + align-items: center; + justify-content: center; + gap: 32px; + font-size: 0.82rem; + color: var(--text-secondary); +} + +.ip-bar .ip-label { + color: var(--text-dim); + margin-right: 6px; +} + +.ip-bar .ip-value { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + color: var(--accent-color); + font-weight: 600; +} + +.ip-separator { + color: var(--border-color); +} + +/* ── Main content ───────────────────────────────────────────────── */ + +.main-content { + max-width: 980px; + margin: 0 auto; + padding: 24px 16px 48px; +} + +/* ── Category sections ──────────────────────────────────────────── */ + +.category-section { + margin-bottom: 32px; +} + +.section-header { + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 4px; + padding-left: 4px; +} + +.section-divider { + border: none; + border-top: 1px solid var(--border-color); + margin-bottom: 16px; +} + +.tiles-grid { + display: flex; + flex-wrap: wrap; + gap: 14px; +} + +/* ── Service tile card ──────────────────────────────────────────── */ + +.service-tile { + width: 180px; + min-height: 210px; + background-color: var(--card-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + align-items: center; + padding: 18px 12px 14px; + gap: 0; + transition: box-shadow 0.2s, border-color 0.2s; + position: relative; +} + +.service-tile:hover { + box-shadow: var(--shadow-hover); + border-color: #6c7086; +} + +.service-tile.disabled { + opacity: 0.45; +} + +.tile-icon { + width: 48px; + height: 48px; + object-fit: contain; + margin-bottom: 8px; +} + +.tile-icon-fallback { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--border-color); + border-radius: 12px; + color: var(--text-dim); + font-size: 1.5rem; + margin-bottom: 8px; +} + +.tile-name { + font-size: 0.88rem; + font-weight: 600; + text-align: center; + color: var(--text-primary); + line-height: 1.3; + max-width: 156px; + word-break: break-word; + hyphens: auto; + min-height: 2.6em; + display: flex; + align-items: center; + justify-content: center; +} + +.tile-status { + font-size: 0.75rem; + margin-top: 6px; + display: flex; + align-items: center; + gap: 5px; + color: var(--text-secondary); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background-color: var(--grey); +} + +.status-dot.active { background-color: var(--green); } +.status-dot.inactive { background-color: var(--red); } +.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; } +.status-dot.failed { background-color: var(--red); } +.status-dot.disabled { background-color: var(--grey); } + +.tile-spacer { + flex: 1; +} + +/* ── Tile controls ──────────────────────────────────────────────── */ + +.tile-controls { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; +} + +/* CSS-only toggle switch */ +.toggle-label { + display: flex; + align-items: center; + cursor: pointer; +} + +.toggle-label input[type="checkbox"] { + display: none; +} + +.toggle-track { + width: 40px; + height: 22px; + background-color: var(--border-color); + border-radius: 11px; + position: relative; + transition: background-color 0.2s; +} + +.toggle-label input:checked + .toggle-track { + background-color: var(--green); +} + +.toggle-label.disabled-toggle { + cursor: not-allowed; + opacity: 0.5; +} + +.toggle-thumb { + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + background-color: #fff; + border-radius: 50%; + transition: transform 0.2s; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); +} + +.toggle-label input:checked + .toggle-track .toggle-thumb { + transform: translateX(18px); +} + +.tile-restart-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px 6px; + border-radius: 50%; + font-size: 0.95rem; + line-height: 1; + transition: background-color 0.15s, color 0.15s; +} + +.tile-restart-btn:hover:not(:disabled) { + background-color: var(--border-color); + color: var(--text-primary); +} + +.tile-restart-btn:disabled { + opacity: 0.35; + cursor: default; +} + +/* ── Update modal ───────────────────────────────────────────────── */ + +.modal-overlay { + display: none; + position: fixed; + inset: 0; + background-color: rgba(0,0,0,0.65); + z-index: 200; + align-items: center; + justify-content: center; +} + +.modal-overlay.open { + display: flex; +} + +.modal-dialog { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 16px; + width: 90vw; + max-width: 900px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 16px 48px rgba(0,0,0,0.7); +} + +.modal-header { + display: flex; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); + gap: 12px; +} + +.modal-title { + font-size: 1rem; + font-weight: 700; + flex: 1; +} + +.modal-status { + font-size: 0.85rem; + color: var(--text-secondary); +} + +.modal-spinner { + width: 18px; + height: 18px; + border: 2.5px solid var(--border-color); + border-top-color: var(--accent-color); + border-radius: 50%; + animation: spin 0.75s linear infinite; + display: none; +} + +.modal-spinner.spinning { + display: block; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.modal-log { + flex: 1; + overflow-y: auto; + padding: 12px 16px; + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.78rem; + line-height: 1.6; + color: var(--text-primary); + background-color: #12121c; + white-space: pre-wrap; + word-break: break-all; +} + +.modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 12px 20px; + border-top: 1px solid var(--border-color); +} + +.btn-reboot { + background-color: var(--red); + color: #fff; +} + +.btn-reboot:hover:not(:disabled) { + background-color: #c0181f; +} + +.btn-save { + background-color: var(--yellow); + color: #1e1e2e; +} + +.btn-save:hover:not(:disabled) { + background-color: #c98d08; +} + +.btn-close-modal { + background-color: var(--border-color); + color: var(--text-primary); +} + +.btn-close-modal:hover:not(:disabled) { + background-color: #5a5c72; +} + +/* ── Empty state ────────────────────────────────────────────────── */ + +.empty-state { + text-align: center; + padding: 64px 24px; + color: var(--text-dim); +} + +.empty-state p { + font-size: 1rem; + margin-bottom: 8px; +} + +/* ── Responsive ─────────────────────────────────────────────────── */ + +@media (max-width: 600px) { + .header-bar { + padding: 10px 14px; + gap: 10px; + } + .header-bar .title { + font-size: 0.95rem; + } + .ip-bar { + gap: 16px; + flex-wrap: wrap; + padding: 8px 14px; + } + .main-content { + padding: 16px 12px 40px; + } + .tiles-grid { + justify-content: center; + } + .service-tile { + width: 160px; + min-height: 200px; + } +} diff --git a/app/sovran_systemsos_hub/systemctl.py b/app/sovran_systemsos_web/systemctl.py similarity index 88% rename from app/sovran_systemsos_hub/systemctl.py rename to app/sovran_systemsos_web/systemctl.py index 651da84..17e490c 100644 --- a/app/sovran_systemsos_hub/systemctl.py +++ b/app/sovran_systemsos_web/systemctl.py @@ -34,7 +34,7 @@ def run_action( else: cmd = base_cmd try: - subprocess.Popen(cmd) - return True + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + return result.returncode == 0 except Exception: - return False \ No newline at end of file + return False diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html new file mode 100644 index 0000000..d6614e2 --- /dev/null +++ b/app/sovran_systemsos_web/templates/index.html @@ -0,0 +1,59 @@ + + + + + + Sovran_SystemsOS Hub + + + + + +
      + Sovran_SystemsOS Hub + Loading… + + +
      + + +
      + + Internal IP: + … + + | + + External IP: + … + +
      + + +
      +
      +
      + + + + + + + diff --git a/app/style.css b/app/style.css deleted file mode 100644 index a5f1d74..0000000 --- a/app/style.css +++ /dev/null @@ -1,69 +0,0 @@ -/* ── Tile (locked dimensions via GTK min-width/height only) ── */ -.sovran-tile { - border-radius: 18px; - padding: 0px; - min-width: 180px; - min-height: 210px; - transition: box-shadow 200ms ease-in-out; -} -.sovran-tile:hover { - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); -} - -/* ── Tile text ─────────────────────────────────────────────── */ -.tile-name { - font-weight: bold; -} -.tile-status { -} - -/* ── Section headers ───────────────────────────────────────── */ -.section-header { - font-weight: bold; -} - -/* ── Status colors ─────────────────────────────────────────── */ -.success { color: #2ec27e; } -.warning { color: #e5a50a; } -.error { color: #e01b24; } -.disabled-label { color: #888888; font-style: italic; } - -/* ── Header / role ─────────────────────────────────────────── */ -.hub-title { - font-weight: bold; -} -.role-badge { - padding: 2px 8px; - border-radius: 4px; -} - -/* ── Update indicator ──────────────────────────────────────── */ -.update-badge { - color: #2ec27e; - font-weight: bold; -} -.update-available { - background: #2ec27e; - color: white; -} -.update-available:hover { - background: #26a269; -} - -/* ── IP bar ────────────────────────────────────────────────── */ -.ip-bar { - padding: 10px 20px; - border-radius: 10px; - background: alpha(@card_bg_color, 0.5); -} -.ip-value { - font-family: monospace; - font-weight: bold; - color: @accent_color; -} - -/* ── Grid container ────────────────────────────────────────── */ -.tiles-container { - margin-left: auto; - margin-right: auto; -} \ No newline at end of file diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index f6aeed3..8765722 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -52,27 +52,18 @@ let services = monitoredServices; }); - sovran-hub = pkgs.python3Packages.buildPythonApplication { - pname = "sovran-systemsos-hub"; + sovran-hub-web = pkgs.python3Packages.buildPythonApplication { + pname = "sovran-systemsos-hub-web"; version = "1.0.0"; format = "other"; src = ../../app; - nativeBuildInputs = with pkgs; [ - wrapGAppsHook4 - gobject-introspection - ]; - - buildInputs = with pkgs; [ - gtk4 - libadwaita - gdk-pixbuf - librsvg - ]; - propagatedBuildInputs = with pkgs.python3Packages; [ - pygobject3 + fastapi + uvicorn + jinja2 + python-multipart ]; dontBuild = true; @@ -81,84 +72,64 @@ let runHook preInstall # ── Python source ───────────────────────────────────────── - install -d $out/lib/sovran-hub - cp -r sovran_systemsos_hub $out/lib/sovran-hub/ - - # ── CSS ──────────────────────────────────────────────────── - cp style.css $out/lib/sovran-hub/style.css + install -d $out/lib/sovran-hub-web + cp -r sovran_systemsos_web $out/lib/sovran-hub-web/ # ── Generated config ─────────────────────────────────────── - cp ${generatedConfig} $out/lib/sovran-hub/config.json + cp ${generatedConfig} $out/lib/sovran-hub-web/config.json - # ── Icons (SVG + PNG) ───────────────���────────────────────── + # ── Icons (SVG) ──────────────────────────────────────────── install -d $out/share/sovran-hub/icons cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true # ── Launcher script ──────────────────────────────────────── install -d $out/bin - cat > $out/bin/sovran-hub < $out/bin/sovran-hub-web < $out/share/applications/sovran-hub.desktop < $out/etc/xdg/autostart/sovran-hub.desktop < Date: Thu, 2 Apr 2026 17:02:00 +0000 Subject: [PATCH 146/857] Remove __pycache__ from tracking, add to .gitignore Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5c173acb-776f-4cd2-bc89-bb7675e38677 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- .gitignore.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore.txt b/.gitignore.txt index 5ba3a39..b7757c0 100644 --- a/.gitignore.txt +++ b/.gitignore.txt @@ -3,3 +3,6 @@ role-state.nix *.iso *.zip *.pma +__pycache__/ +*.pyc +*.pyo -- 2.53.0 From 92a9d2cfa153fa01b0576e1b92431fc29b0cfa5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:02:28 +0000 Subject: [PATCH 147/857] Add .gitignore to exclude __pycache__, remove pyc files Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5c173acb-776f-4cd2-bc89-bb7675e38677 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- .gitignore | 4 ++++ .../__pycache__/__init__.cpython-312.pyc | Bin 179 -> 0 bytes .../__pycache__/config.cpython-312.pyc | Bin 1399 -> 0 bytes .../__pycache__/server.cpython-312.pyc | Bin 15868 -> 0 bytes .../__pycache__/systemctl.cpython-312.pyc | Bin 1976 -> 0 bytes 5 files changed, 4 insertions(+) create mode 100644 .gitignore delete mode 100644 app/sovran_systemsos_web/__pycache__/__init__.cpython-312.pyc delete mode 100644 app/sovran_systemsos_web/__pycache__/config.cpython-312.pyc delete mode 100644 app/sovran_systemsos_web/__pycache__/server.cpython-312.pyc delete mode 100644 app/sovran_systemsos_web/__pycache__/systemctl.cpython-312.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d702d14 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.pyo + diff --git a/app/sovran_systemsos_web/__pycache__/__init__.cpython-312.pyc b/app/sovran_systemsos_web/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 497e0a0d5cd4bae4aeff8e3ae78edf1a19d94adc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 179 zcmX@j%ge>Uz`(F~-nmQ={TM``Uz`(F~-nqi?AiMl-u@$A3losVBgLp8^!@$76%)r3#`5hz386_}r zhAfx^;UWwSS*%DLC^Ll-Ne>%T5Q)m-MB+f11axpil_617*TIWqIFwn!2W2p(nnX-p~%MVyQbDXb}M z=?p3CbC{EvA{l}i${8RgX>wFq2BjtFS9}+ zCABECEU_drKTq$LV6cB!kfUF`N2pW0v%jC4r+XDQBpvEy73b&OVl47|2}&-vIE%q) zF}Wm1llhiFQEFOIYH>z)-r@$iCLY2sl4f9FU}s=pC|<|Fz|g?(gqyeDs?)09 zuG8)Yhx8o|u737T_I{pDo;y5z{qCLa{r;W)bDZY8&vc*fKhuAW(|Y%n?(6+m`tR|$ ztmArF#_ckX`wbq+&#cUxT%TFlc-R_TKJc-yhB4mZ6`Y_lgSk8Y0|O(c;sT~S!s62{ zCtA)3nr!=>nUPcRJ4pBwi1_`4M|Muhb$R29^2QgqO+GO&vW79eIY31B7f*5 zj?fRxAhBP?1`G@ghopI&g%}R0i#oG0AF|UyVB=*BW1JB3g#knt$ulr8007SlJ97X4 diff --git a/app/sovran_systemsos_web/__pycache__/server.cpython-312.pyc b/app/sovran_systemsos_web/__pycache__/server.cpython-312.pyc deleted file mode 100644 index 3ac7409534f3a54cacad1f4b8b53dbf46916652d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15868 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 Date: Thu, 2 Apr 2026 12:15:53 -0500 Subject: [PATCH 148/857] changed port --- modules/core/sovran-hub.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 8765722..ead8070 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -95,7 +95,7 @@ import uvicorn uvicorn.run( "sovran_systemsos_web.server:app", host="0.0.0.0", - port=8080, + port=8937, log_level="info", ) LAUNCHER @@ -130,6 +130,6 @@ in }; # ── Open firewall port ───────────────────────────────────── - networking.firewall.allowedTCPPorts = [ 8080 ]; + networking.firewall.allowedTCPPorts = [ 8937 ]; }; } -- 2.53.0 From fb940b055d59e9d6049467fc2bc3d6ccd88247f9 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 12:31:04 -0500 Subject: [PATCH 149/857] fixed for update button in web --- app/sovran_systemsos_web/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 8453630..b1ed08f 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -293,6 +293,7 @@ async def api_reboot(): return {"ok": True} +@app.post("/api/updates/run") async def api_updates_run(): async def event_stream() -> AsyncIterator[str]: yield "data: $ ssh root@localhost 'cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y'\n\n" -- 2.53.0 From ad688a1d293970070842d93033e046b15477dfaf Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 12:37:43 -0500 Subject: [PATCH 150/857] fixed ssh access --- app/sovran_systemsos_web/server.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index b1ed08f..ccf6cab 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -27,14 +27,11 @@ FLAKE_INPUT_NAME = "Sovran_Systems" GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovran_SystemsOS/commits" REBOOT_COMMAND = [ - "ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes", - "root@localhost", "reboot", ] UPDATE_COMMAND = [ - "ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes", - "root@localhost", + "bash", "-c", "cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y", ] -- 2.53.0 From 08492cef947c3c4e726cef632d7fad5f6da53cdb Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 12:54:32 -0500 Subject: [PATCH 151/857] added new systemd update unit --- app/sovran_systemsos_web/server.py | 115 +++++++++++++++++-------- app/sovran_systemsos_web/static/app.js | 108 +++++++++++++---------- modules/core/sovran-hub.nix | 14 ++- 3 files changed, 155 insertions(+), 82 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index ccf6cab..b454b0a 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -7,7 +7,6 @@ import json import os import socket import subprocess -import threading import urllib.request from typing import AsyncIterator @@ -26,15 +25,13 @@ FLAKE_LOCK_PATH = "/etc/nixos/flake.lock" FLAKE_INPUT_NAME = "Sovran_Systems" GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovran_SystemsOS/commits" +UPDATE_UNIT = "sovran-hub-update.service" +UPDATE_LOG = "/var/log/sovran-hub-update.log" + REBOOT_COMMAND = [ "reboot", ] -UPDATE_COMMAND = [ - "bash", "-c", - "cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y", -] - CATEGORY_ORDER = [ ("infrastructure", "Infrastructure"), ("bitcoin-base", "Bitcoin Base"), @@ -163,6 +160,42 @@ def _get_external_ip() -> str: return "unavailable" +# ── Update unit helpers ────────────────────────────────────────── + +def _update_is_active() -> bool: + """Return True if the update unit is currently running.""" + r = subprocess.run( + ["systemctl", "is-active", "--quiet", UPDATE_UNIT], + capture_output=True, + ) + return r.returncode == 0 + + +def _update_result() -> str: + """Return 'success', 'failed', or 'inactive'.""" + r = subprocess.run( + ["systemctl", "show", "-p", "Result", "--value", UPDATE_UNIT], + capture_output=True, text=True, + ) + val = r.stdout.strip() + if val == "success": + return "success" + elif val: + return "failed" + return "inactive" + + +def _read_update_log(offset: int = 0) -> tuple[str, int]: + """Read update log from offset. Return (new_text, new_offset).""" + try: + with open(UPDATE_LOG, "r") as f: + f.seek(offset) + text = f.read() + return text, f.tell() + except FileNotFoundError: + return "", 0 + + # ── Routes ─────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) @@ -292,36 +325,48 @@ async def api_reboot(): @app.post("/api/updates/run") async def api_updates_run(): - async def event_stream() -> AsyncIterator[str]: - yield "data: $ ssh root@localhost 'cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y'\n\n" - yield "data: \n\n" + """Kick off the detached update systemd unit.""" + loop = asyncio.get_event_loop() - process = await asyncio.create_subprocess_exec( - *UPDATE_COMMAND, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - ) + # Check if already running + running = await loop.run_in_executor(None, _update_is_active) + if running: + return {"ok": True, "status": "already_running"} - assert process.stdout is not None - try: - async for raw_line in process.stdout: - line = raw_line.decode(errors="replace").rstrip("\n") - # SSE requires data: prefix; escape newlines within a line - yield f"data: {line}\n\n" - except Exception: - yield "data: [stream error: output read interrupted]\n\n" + # Clear the old log + try: + open(UPDATE_LOG, "w").close() + except OSError: + pass - await process.wait() - if process.returncode == 0: - yield "event: done\ndata: success\n\n" - else: - yield f"event: error\ndata: exit code {process.returncode}\n\n" - - return StreamingResponse( - event_stream(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "X-Accel-Buffering": "no", - }, + # Reset the failed state (if any) and start the unit + await asyncio.create_subprocess_exec( + "systemctl", "reset-failed", UPDATE_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, ) + proc = await asyncio.create_subprocess_exec( + "systemctl", "start", UPDATE_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + + return {"ok": True, "status": "started"} + + +@app.get("/api/updates/status") +async def api_updates_status(offset: int = 0): + """Poll endpoint: returns running state, result, and new log lines.""" + loop = asyncio.get_event_loop() + + running = await loop.run_in_executor(None, _update_is_active) + result = await loop.run_in_executor(None, _update_result) + new_log, new_offset = await loop.run_in_executor(None, _read_update_log, offset) + + return { + "running": running, + "result": result, + "log": new_log, + "offset": new_offset, + } \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 79e88bd..f1b2e3e 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -4,6 +4,7 @@ const POLL_INTERVAL_SERVICES = 5000; // 5 s const POLL_INTERVAL_UPDATES = 1800000; // 30 min const ACTION_REFRESH_DELAY = 1500; // 1.5 s after start/stop/restart +const UPDATE_POLL_INTERVAL = 2000; // 2 s while update is running const CATEGORY_ORDER = [ "infrastructure", @@ -24,6 +25,8 @@ let _servicesCache = []; let _categoryLabels = {}; let _updateSource = null; let _updateLog = ""; +let _updatePollTimer = null; +let _updateLogOffset = 0; // ── DOM refs ────────────────────────────────────────────────────── @@ -261,6 +264,7 @@ async function checkUpdates() { function openUpdateModal() { if (!$modal) return; _updateLog = ""; + _updateLogOffset = 0; if ($modalLog) $modalLog.textContent = ""; if ($modalStatus) $modalStatus.textContent = "Updating…"; if ($modalSpinner) $modalSpinner.classList.add("spinning"); @@ -269,69 +273,81 @@ function openUpdateModal() { if ($btnCloseModal) { $btnCloseModal.disabled = true; } $modal.classList.add("open"); - startUpdateStream(); + startUpdate(); } function closeUpdateModal() { if (!$modal) return; $modal.classList.remove("open"); - if (_updateSource) { - _updateSource.close(); - _updateSource = null; - } + stopUpdatePoll(); } function appendLog(text) { - _updateLog += text + "\n"; + if (!text) return; + _updateLog += text; if ($modalLog) { - $modalLog.textContent += text + "\n"; + $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; } } -function startUpdateStream() { - // Trigger the update via POST first, then listen via SSE - fetch("/api/updates/run", { method: "POST" }).then(response => { - if (!response.ok || !response.body) { - const detail = response.ok ? "no body" : `HTTP ${response.status} ${response.statusText}`; - appendLog(`[Error: failed to start update — ${detail}]`); +function startUpdate() { + appendLog("$ cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y\n\n"); + + // Trigger the systemd unit via POST + fetch("/api/updates/run", { method: "POST" }) + .then(response => { + if (!response.ok) { + return response.text().then(t => { throw new Error(t); }); + } + return response.json(); + }) + .then(data => { + // Start polling for status + log lines + startUpdatePoll(); + }) + .catch(err => { + appendLog(`[Error: failed to start update — ${err}]\n`); onUpdateDone(false); - return; + }); +} + +function startUpdatePoll() { + // Poll immediately, then on interval + pollUpdateStatus(); + _updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL); +} + +function stopUpdatePoll() { + if (_updatePollTimer) { + clearInterval(_updatePollTimer); + _updatePollTimer = null; + } +} + +async function pollUpdateStatus() { + try { + const data = await apiFetch(`/api/updates/status?offset=${_updateLogOffset}`); + + // Append new log text + if (data.log) { + appendLog(data.log); } + _updateLogOffset = data.offset; - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - function read() { - reader.read().then(({ done, value }) => { - if (done) { - onUpdateDone(true); - return; - } - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop(); // keep incomplete line - for (const line of lines) { - if (line.startsWith("data: ")) { - appendLog(line.slice(6)); - } else if (line.startsWith("event: done")) { - // success event will follow in data: - } else if (line.startsWith("event: error")) { - // error event will follow in data: - } - } - read(); - }).catch(err => { - appendLog(`[Stream error: ${err}]`); + // Check if finished + if (!data.running) { + stopUpdatePoll(); + if (data.result === "success") { + onUpdateDone(true); + } else { onUpdateDone(false); - }); + } } - read(); - }).catch(err => { - appendLog(`[Request error: ${err}]`); - onUpdateDone(false); - }); + } catch (err) { + // Server may be restarting during nixos-rebuild switch — keep polling + console.warn("Update poll failed (server may be restarting):", err); + } } function onUpdateDone(success) { @@ -405,4 +421,4 @@ async function init() { setInterval(checkUpdates, POLL_INTERVAL_UPDATES); } -document.addEventListener("DOMContentLoaded", init); +document.addEventListener("DOMContentLoaded", init); \ No newline at end of file diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index ead8070..8f403e1 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -129,7 +129,19 @@ in }; }; + # ── System update as a detached oneshot ───────────────────── + systemd.services.sovran-hub-update = { + description = "Sovran_SystemsOS System Update"; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pkgs.bash}/bin/bash -c 'cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y'"; + StandardOutput = "file:/var/log/sovran-hub-update.log"; + StandardError = "file:/var/log/sovran-hub-update.log"; + }; + path = [ pkgs.nix pkgs.nixos-rebuild pkgs.git pkgs.flatpak ]; + }; + # ── Open firewall port ───────────────────────────────────── networking.firewall.allowedTCPPorts = [ 8937 ]; }; -} +} \ No newline at end of file -- 2.53.0 From 38733daffc3367f802674a16e0d3d631efe23ea5 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 13:09:07 -0500 Subject: [PATCH 152/857] updated logging --- app/sovran_systemsos_web/server.py | 70 +++++++++++++++++--------- app/sovran_systemsos_web/static/app.js | 57 +++++++++++++++------ modules/core/sovran-hub.nix | 7 +-- 3 files changed, 93 insertions(+), 41 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index b454b0a..c5f301c 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -26,7 +26,6 @@ FLAKE_INPUT_NAME = "Sovran_Systems" GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovran_SystemsOS/commits" UPDATE_UNIT = "sovran-hub-update.service" -UPDATE_LOG = "/var/log/sovran-hub-update.log" REBOOT_COMMAND = [ "reboot", @@ -172,7 +171,7 @@ def _update_is_active() -> bool: def _update_result() -> str: - """Return 'success', 'failed', or 'inactive'.""" + """Return 'success', 'failed', or 'unknown'.""" r = subprocess.run( ["systemctl", "show", "-p", "Result", "--value", UPDATE_UNIT], capture_output=True, text=True, @@ -182,18 +181,47 @@ def _update_result() -> str: return "success" elif val: return "failed" - return "inactive" + return "unknown" -def _read_update_log(offset: int = 0) -> tuple[str, int]: - """Read update log from offset. Return (new_text, new_offset).""" - try: - with open(UPDATE_LOG, "r") as f: - f.seek(offset) - text = f.read() - return text, f.tell() - except FileNotFoundError: - return "", 0 +def _get_update_invocation_id() -> str: + """Get the current InvocationID of the update unit.""" + r = subprocess.run( + ["systemctl", "show", "-p", "InvocationID", "--value", UPDATE_UNIT], + capture_output=True, text=True, + ) + return r.stdout.strip() + + +def _read_journal_logs(since_cursor: str = "") -> tuple[list[str], str]: + """ + Read journal logs for the update unit. + Returns (lines, last_cursor). + Uses cursors so we never miss lines even if the server restarts. + """ + cmd = [ + "journalctl", "-u", UPDATE_UNIT, + "--no-pager", "-o", "cat", + "--show-cursor", + ] + if since_cursor: + cmd += ["--after-cursor", since_cursor] + else: + # Only get logs from the most recent invocation + cmd += ["-n", "10000"] + + r = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + output = r.stdout + + lines = [] + cursor = since_cursor + for raw_line in output.split("\n"): + if raw_line.startswith("-- cursor: "): + cursor = raw_line[len("-- cursor: "):] + elif raw_line: + lines.append(raw_line) + + return lines, cursor # ── Routes ─────────────────────────────────────────────────────── @@ -333,12 +361,6 @@ async def api_updates_run(): if running: return {"ok": True, "status": "already_running"} - # Clear the old log - try: - open(UPDATE_LOG, "w").close() - except OSError: - pass - # Reset the failed state (if any) and start the unit await asyncio.create_subprocess_exec( "systemctl", "reset-failed", UPDATE_UNIT, @@ -356,17 +378,19 @@ async def api_updates_run(): @app.get("/api/updates/status") -async def api_updates_status(offset: int = 0): - """Poll endpoint: returns running state, result, and new log lines.""" +async def api_updates_status(cursor: str = ""): + """Poll endpoint: returns running state, result, and new journal lines.""" loop = asyncio.get_event_loop() running = await loop.run_in_executor(None, _update_is_active) result = await loop.run_in_executor(None, _update_result) - new_log, new_offset = await loop.run_in_executor(None, _read_update_log, offset) + lines, new_cursor = await loop.run_in_executor( + None, _read_journal_logs, cursor, + ) return { "running": running, "result": result, - "log": new_log, - "offset": new_offset, + "lines": lines, + "cursor": new_cursor, } \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index f1b2e3e..afb2842 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -26,7 +26,8 @@ let _categoryLabels = {}; let _updateSource = null; let _updateLog = ""; let _updatePollTimer = null; -let _updateLogOffset = 0; +let _updateCursor = ""; +let _serverDownSince = 0; // ── DOM refs ────────────────────────────────────────────────────── @@ -264,7 +265,8 @@ async function checkUpdates() { function openUpdateModal() { if (!$modal) return; _updateLog = ""; - _updateLogOffset = 0; + _updateCursor = ""; + _serverDownSince = 0; if ($modalLog) $modalLog.textContent = ""; if ($modalStatus) $modalStatus.textContent = "Updating…"; if ($modalSpinner) $modalSpinner.classList.add("spinning"); @@ -284,15 +286,16 @@ function closeUpdateModal() { function appendLog(text) { if (!text) return; - _updateLog += text; + _updateLog += text + "\n"; if ($modalLog) { - $modalLog.textContent += text; + $modalLog.textContent += text + "\n"; $modalLog.scrollTop = $modalLog.scrollHeight; } } function startUpdate() { - appendLog("$ cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y\n\n"); + appendLog("$ cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y"); + appendLog(""); // Trigger the systemd unit via POST fetch("/api/updates/run", { method: "POST" }) @@ -303,11 +306,14 @@ function startUpdate() { return response.json(); }) .then(data => { - // Start polling for status + log lines + if (data.status === "already_running") { + appendLog("[Update already in progress, attaching…]"); + } + // Start polling for journal output startUpdatePoll(); }) .catch(err => { - appendLog(`[Error: failed to start update — ${err}]\n`); + appendLog(`[Error: failed to start update — ${err}]`); onUpdateDone(false); }); } @@ -327,16 +333,31 @@ function stopUpdatePoll() { async function pollUpdateStatus() { try { - const data = await apiFetch(`/api/updates/status?offset=${_updateLogOffset}`); + const data = await apiFetch( + `/api/updates/status?cursor=${encodeURIComponent(_updateCursor)}` + ); - // Append new log text - if (data.log) { - appendLog(data.log); + // Server is back — reset the down counter + if (_serverDownSince > 0) { + appendLog("[Server reconnected, resuming…]"); + _serverDownSince = 0; } - _updateLogOffset = data.offset; - // Check if finished - if (!data.running) { + // Append new journal lines + if (data.lines && data.lines.length > 0) { + for (const line of data.lines) { + appendLog(line); + } + } + if (data.cursor) { + _updateCursor = data.cursor; + } + + // Update status text while running + if (data.running) { + if ($modalStatus) $modalStatus.textContent = "Updating…"; + } else { + // Finished stopUpdatePoll(); if (data.result === "success") { onUpdateDone(true); @@ -345,7 +366,12 @@ async function pollUpdateStatus() { } } } catch (err) { - // Server may be restarting during nixos-rebuild switch — keep polling + // Server is likely restarting during nixos-rebuild switch + if (_serverDownSince === 0) { + _serverDownSince = Date.now(); + appendLog("[Server restarting — waiting for it to come back…]"); + if ($modalStatus) $modalStatus.textContent = "Server restarting…"; + } console.warn("Update poll failed (server may be restarting):", err); } } @@ -360,6 +386,7 @@ function onUpdateDone(success) { } else { if ($modalStatus) $modalStatus.textContent = "āœ— Update failed"; if ($btnSave) $btnSave.style.display = "inline-flex"; + if ($btnReboot) $btnReboot.style.display = "inline-flex"; } } diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 8f403e1..96cb817 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -134,9 +134,10 @@ in description = "Sovran_SystemsOS System Update"; serviceConfig = { Type = "oneshot"; - ExecStart = "${pkgs.bash}/bin/bash -c 'cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y'"; - StandardOutput = "file:/var/log/sovran-hub-update.log"; - StandardError = "file:/var/log/sovran-hub-update.log"; + ExecStart = "${pkgs.bash}/bin/bash -c 'cd /etc/nixos && nix flake update 2>&1 && nixos-rebuild switch 2>&1 && flatpak update -y 2>&1'"; + StandardOutput = "journal"; + StandardError = "journal"; + SyslogIdentifier = "sovran-hub-update"; }; path = [ pkgs.nix pkgs.nixos-rebuild pkgs.git pkgs.flatpak ]; }; -- 2.53.0 From 150666d7c35fcc1c70dfaed40cda50277376a984 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 13:15:19 -0500 Subject: [PATCH 153/857] updated logging --- app/sovran_systemsos_web/server.py | 84 ++++++----------- app/sovran_systemsos_web/static/app.js | 124 ++++++++++--------------- modules/core/sovran-hub.nix | 63 +++++++++++-- 3 files changed, 134 insertions(+), 137 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index c5f301c..c0d3bbc 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -8,10 +8,9 @@ import os import socket import subprocess import urllib.request -from typing import AsyncIterator -from fastapi import FastAPI, HTTPException, Response -from fastapi.responses import HTMLResponse, StreamingResponse +from fastapi import FastAPI, HTTPException +from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.requests import Request @@ -26,10 +25,9 @@ FLAKE_INPUT_NAME = "Sovran_Systems" GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovran_SystemsOS/commits" UPDATE_UNIT = "sovran-hub-update.service" +UPDATE_LOG = "/var/log/sovran-hub-update.log" -REBOOT_COMMAND = [ - "reboot", -] +REBOOT_COMMAND = ["reboot"] CATEGORY_ORDER = [ ("infrastructure", "Infrastructure"), @@ -58,7 +56,6 @@ app.mount( name="static", ) -# Also serve icons from the app/icons directory (set via env or adjacent folder) _ICONS_DIR = os.environ.get( "SOVRAN_HUB_ICONS", os.path.join(os.path.dirname(_BASE_DIR), "icons"), @@ -141,7 +138,6 @@ def _get_internal_ip() -> str: def _get_external_ip() -> str: - # Max length 46 covers the longest valid IPv6 address (45 chars) plus a newline MAX_IP_LENGTH = 46 for url in [ "https://api.ipify.org", @@ -184,44 +180,21 @@ def _update_result() -> str: return "unknown" -def _get_update_invocation_id() -> str: - """Get the current InvocationID of the update unit.""" - r = subprocess.run( - ["systemctl", "show", "-p", "InvocationID", "--value", UPDATE_UNIT], - capture_output=True, text=True, - ) - return r.stdout.strip() - - -def _read_journal_logs(since_cursor: str = "") -> tuple[list[str], str]: - """ - Read journal logs for the update unit. - Returns (lines, last_cursor). - Uses cursors so we never miss lines even if the server restarts. - """ - cmd = [ - "journalctl", "-u", UPDATE_UNIT, - "--no-pager", "-o", "cat", - "--show-cursor", - ] - if since_cursor: - cmd += ["--after-cursor", since_cursor] - else: - # Only get logs from the most recent invocation - cmd += ["-n", "10000"] - - r = subprocess.run(cmd, capture_output=True, text=True, timeout=10) - output = r.stdout - - lines = [] - cursor = since_cursor - for raw_line in output.split("\n"): - if raw_line.startswith("-- cursor: "): - cursor = raw_line[len("-- cursor: "):] - elif raw_line: - lines.append(raw_line) - - return lines, cursor +def _read_log(offset: int = 0) -> tuple[str, int]: + """Read the update log file from the given byte offset. + Returns (new_text, new_offset).""" + try: + with open(UPDATE_LOG, "rb") as f: + f.seek(0, 2) # seek to end + size = f.tell() + if offset > size: + # Log was truncated (new run), start over + offset = 0 + f.seek(offset) + chunk = f.read() + return chunk.decode(errors="replace"), offset + len(chunk) + except FileNotFoundError: + return "", 0 # ── Routes ─────────────────────────────────────────────────────── @@ -275,7 +248,6 @@ async def api_services(): def _get_allowed_units() -> set[str]: - """Return the set of unit names from the current config (whitelist).""" cfg = load_config() return {s.get("unit", "") for s in cfg.get("services", []) if s.get("unit")} @@ -356,19 +328,19 @@ async def api_updates_run(): """Kick off the detached update systemd unit.""" loop = asyncio.get_event_loop() - # Check if already running running = await loop.run_in_executor(None, _update_is_active) if running: return {"ok": True, "status": "already_running"} - # Reset the failed state (if any) and start the unit + # Reset failed state if any await asyncio.create_subprocess_exec( "systemctl", "reset-failed", UPDATE_UNIT, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) + proc = await asyncio.create_subprocess_exec( - "systemctl", "start", UPDATE_UNIT, + "systemctl", "start", "--no-block", UPDATE_UNIT, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) @@ -378,19 +350,17 @@ async def api_updates_run(): @app.get("/api/updates/status") -async def api_updates_status(cursor: str = ""): - """Poll endpoint: returns running state, result, and new journal lines.""" +async def api_updates_status(offset: int = 0): + """Poll endpoint: returns running state, result, and new log content.""" loop = asyncio.get_event_loop() running = await loop.run_in_executor(None, _update_is_active) result = await loop.run_in_executor(None, _update_result) - lines, new_cursor = await loop.run_in_executor( - None, _read_journal_logs, cursor, - ) + new_log, new_offset = await loop.run_in_executor(None, _read_log, offset) return { "running": running, "result": result, - "lines": lines, - "cursor": new_cursor, + "log": new_log, + "offset": new_offset, } \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index afb2842..e62d49d 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -4,7 +4,7 @@ const POLL_INTERVAL_SERVICES = 5000; // 5 s const POLL_INTERVAL_UPDATES = 1800000; // 30 min const ACTION_REFRESH_DELAY = 1500; // 1.5 s after start/stop/restart -const UPDATE_POLL_INTERVAL = 2000; // 2 s while update is running +const UPDATE_POLL_INTERVAL = 1500; // 1.5 s while update is running const CATEGORY_ORDER = [ "infrastructure", @@ -21,29 +21,28 @@ const STATUS_LOADING_STATES = new Set([ // ── State ───────────────────────────────────────────────────────── -let _servicesCache = []; -let _categoryLabels = {}; -let _updateSource = null; -let _updateLog = ""; +let _servicesCache = []; +let _categoryLabels = {}; +let _updateLog = ""; let _updatePollTimer = null; -let _updateCursor = ""; -let _serverDownSince = 0; +let _updateLogOffset = 0; +let _serverWasDown = false; -// ── DOM refs ────────────────────────────────────────────────────── +// ── DOM refs ────────────────────────────────────────────���───────── -const $tilesArea = document.getElementById("tiles-area"); -const $updateBtn = document.getElementById("btn-update"); -const $updateBadge = document.getElementById("update-badge"); -const $refreshBtn = document.getElementById("btn-refresh"); -const $internalIp = document.getElementById("ip-internal"); -const $externalIp = document.getElementById("ip-external"); +const $tilesArea = document.getElementById("tiles-area"); +const $updateBtn = document.getElementById("btn-update"); +const $updateBadge = document.getElementById("update-badge"); +const $refreshBtn = document.getElementById("btn-refresh"); +const $internalIp = document.getElementById("ip-internal"); +const $externalIp = document.getElementById("ip-external"); -const $modal = document.getElementById("update-modal"); -const $modalSpinner = document.getElementById("modal-spinner"); -const $modalStatus = document.getElementById("modal-status"); -const $modalLog = document.getElementById("modal-log"); -const $btnReboot = document.getElementById("btn-reboot"); -const $btnSave = document.getElementById("btn-save-report"); +const $modal = document.getElementById("update-modal"); +const $modalSpinner = document.getElementById("modal-spinner"); +const $modalStatus = document.getElementById("modal-status"); +const $modalLog = document.getElementById("modal-log"); +const $btnReboot = document.getElementById("btn-reboot"); +const $btnSave = document.getElementById("btn-save-report"); const $btnCloseModal = document.getElementById("btn-close-modal"); // ── Helpers ─────────────────────────────────────────────────────── @@ -77,7 +76,6 @@ async function apiFetch(path, options = {}) { function buildTiles(services, categoryLabels) { _servicesCache = services; - // Group by category const grouped = {}; for (const svc of services) { const cat = svc.category || "other"; @@ -155,7 +153,6 @@ function buildTile(svc) { `; - // Toggle handler const chk = tile.querySelector(".tile-toggle"); if (!dis) { chk.addEventListener("change", async (e) => { @@ -168,7 +165,6 @@ function buildTile(svc) { }); } - // Restart handler const restartBtn = tile.querySelector(".tile-restart-btn"); if (!dis) { restartBtn.addEventListener("click", async () => { @@ -265,14 +261,14 @@ async function checkUpdates() { function openUpdateModal() { if (!$modal) return; _updateLog = ""; - _updateCursor = ""; - _serverDownSince = 0; - if ($modalLog) $modalLog.textContent = ""; - if ($modalStatus) $modalStatus.textContent = "Updating…"; - if ($modalSpinner) $modalSpinner.classList.add("spinning"); - if ($btnReboot) { $btnReboot.style.display = "none"; } - if ($btnSave) { $btnSave.style.display = "none"; } - if ($btnCloseModal) { $btnCloseModal.disabled = true; } + _updateLogOffset = 0; + _serverWasDown = false; + if ($modalLog) $modalLog.textContent = ""; + if ($modalStatus) $modalStatus.textContent = "Starting update…"; + if ($modalSpinner) $modalSpinner.classList.add("spinning"); + if ($btnReboot) { $btnReboot.style.display = "none"; } + if ($btnSave) { $btnSave.style.display = "none"; } + if ($btnCloseModal) { $btnCloseModal.disabled = true; } $modal.classList.add("open"); startUpdate(); @@ -286,18 +282,14 @@ function closeUpdateModal() { function appendLog(text) { if (!text) return; - _updateLog += text + "\n"; + _updateLog += text; if ($modalLog) { - $modalLog.textContent += text + "\n"; + $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; } } function startUpdate() { - appendLog("$ cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y"); - appendLog(""); - - // Trigger the systemd unit via POST fetch("/api/updates/run", { method: "POST" }) .then(response => { if (!response.ok) { @@ -307,19 +299,18 @@ function startUpdate() { }) .then(data => { if (data.status === "already_running") { - appendLog("[Update already in progress, attaching…]"); + appendLog("[Update already in progress, attaching…]\n\n"); } - // Start polling for journal output + if ($modalStatus) $modalStatus.textContent = "Updating…"; startUpdatePoll(); }) .catch(err => { - appendLog(`[Error: failed to start update — ${err}]`); + appendLog(`[Error: failed to start update — ${err}]\n`); onUpdateDone(false); }); } function startUpdatePoll() { - // Poll immediately, then on interval pollUpdateStatus(); _updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL); } @@ -333,31 +324,22 @@ function stopUpdatePoll() { async function pollUpdateStatus() { try { - const data = await apiFetch( - `/api/updates/status?cursor=${encodeURIComponent(_updateCursor)}` - ); + const data = await apiFetch(`/api/updates/status?offset=${_updateLogOffset}`); - // Server is back — reset the down counter - if (_serverDownSince > 0) { - appendLog("[Server reconnected, resuming…]"); - _serverDownSince = 0; - } - - // Append new journal lines - if (data.lines && data.lines.length > 0) { - for (const line of data.lines) { - appendLog(line); - } - } - if (data.cursor) { - _updateCursor = data.cursor; - } - - // Update status text while running - if (data.running) { + // Server came back after being down + if (_serverWasDown) { + _serverWasDown = false; if ($modalStatus) $modalStatus.textContent = "Updating…"; - } else { - // Finished + } + + // Append any new log content + if (data.log) { + appendLog(data.log); + } + _updateLogOffset = data.offset; + + // Check if finished + if (!data.running) { stopUpdatePoll(); if (data.result === "success") { onUpdateDone(true); @@ -366,13 +348,12 @@ async function pollUpdateStatus() { } } } catch (err) { - // Server is likely restarting during nixos-rebuild switch - if (_serverDownSince === 0) { - _serverDownSince = Date.now(); - appendLog("[Server restarting — waiting for it to come back…]"); + // Server is likely restarting during nixos-rebuild switch — keep polling + if (!_serverWasDown) { + _serverWasDown = true; + appendLog("\n[Server restarting — waiting for it to come back…]\n\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; } - console.warn("Update poll failed (server may be restarting):", err); } } @@ -415,7 +396,6 @@ if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal); if ($btnReboot) $btnReboot.addEventListener("click", doReboot); if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); -// Close modal on overlay click if ($modal) { $modal.addEventListener("click", (e) => { if (e.target === $modal) closeUpdateModal(); @@ -425,7 +405,6 @@ if ($modal) { // ── Init ────────────────────────────────────────────────────────── async function init() { - // Load config to get category labels try { const cfg = await apiFetch("/api/config"); if (cfg.category_order) { @@ -433,17 +412,14 @@ async function init() { _categoryLabels[key] = label; } } - // Update role badge const badge = document.getElementById("role-badge"); if (badge && cfg.role_label) badge.textContent = cfg.role_label; } catch (_) {} - // Initial data loads await refreshServices(); loadNetwork(); checkUpdates(); - // Polling setInterval(refreshServices, POLL_INTERVAL_SERVICES); setInterval(checkUpdates, POLL_INTERVAL_UPDATES); } diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 96cb817..04bc0ad 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -52,6 +52,61 @@ let services = monitoredServices; }); + # ── Update wrapper script ────────────────────────────────────── + update-script = pkgs.writeShellScript "sovran-hub-update.sh" '' + set -uo pipefail + export PATH="${lib.makeBinPath [ pkgs.nix pkgs.nixos-rebuild pkgs.git pkgs.flatpak pkgs.coreutils ]}:$PATH" + + LOG="/var/log/sovran-hub-update.log" + + # Truncate the log and redirect ALL output (stdout + stderr) into it + : > "$LOG" + exec > >(tee -a "$LOG") 2>&1 + + echo "══════════════════════════════════════════════════" + echo " Sovran_SystemsOS Update — $(date)" + echo "══════════════════════════════════════════════════" + echo "" + + RC=0 + + echo "── Step 1/3: nix flake update ────────────────────" + if ! nix flake update --flake /etc/nixos --print-build-logs 2>&1; then + echo "[ERROR] nix flake update failed" + RC=1 + fi + echo "" + + if [ "$RC" -eq 0 ]; then + echo "── Step 2/3: nixos-rebuild switch ──────────────────" + if ! nixos-rebuild switch --flake /etc/nixos --print-build-logs 2>&1; then + echo "[ERROR] nixos-rebuild switch failed" + RC=1 + fi + echo "" + fi + + if [ "$RC" -eq 0 ]; then + echo "── Step 3/3: flatpak update ────────────────────────" + if ! flatpak update -y 2>&1; then + echo "[WARNING] flatpak update failed (non-fatal)" + fi + echo "" + fi + + if [ "$RC" -eq 0 ]; then + echo "══════════════════════════════════════════════════" + echo " āœ“ Update completed successfully" + echo "══════════════════════════════════════════════════" + else + echo "══════════════════════════════════════════════════" + echo " āœ— Update failed — see errors above" + echo "══════════════════════════════════════════════════" + fi + + exit "$RC" + ''; + sovran-hub-web = pkgs.python3Packages.buildPythonApplication { pname = "sovran-systemsos-hub-web"; version = "1.0.0"; @@ -133,13 +188,9 @@ in systemd.services.sovran-hub-update = { description = "Sovran_SystemsOS System Update"; serviceConfig = { - Type = "oneshot"; - ExecStart = "${pkgs.bash}/bin/bash -c 'cd /etc/nixos && nix flake update 2>&1 && nixos-rebuild switch 2>&1 && flatpak update -y 2>&1'"; - StandardOutput = "journal"; - StandardError = "journal"; - SyslogIdentifier = "sovran-hub-update"; + Type = "oneshot"; + ExecStart = "${update-script}"; }; - path = [ pkgs.nix pkgs.nixos-rebuild pkgs.git pkgs.flatpak ]; }; # ── Open firewall port ───────────────────────────────────── -- 2.53.0 From eb11231e348df2dff3514123f72df4fee2de74cb Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 13:21:33 -0500 Subject: [PATCH 154/857] updated logging --- app/sovran_systemsos_web/static/app.js | 17 ++++++++++++++--- app/sovran_systemsos_web/static/style.css | 22 +++++++++++++++++----- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index e62d49d..3fab03e 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -27,8 +27,9 @@ let _updateLog = ""; let _updatePollTimer = null; let _updateLogOffset = 0; let _serverWasDown = false; +let _updateFinished = false; -// ── DOM refs ────────────────────────────────────────────���───────── +// ── DOM refs ────────────────────────────────────────────────────── const $tilesArea = document.getElementById("tiles-area"); const $updateBtn = document.getElementById("btn-update"); @@ -250,8 +251,13 @@ async function loadNetwork() { async function checkUpdates() { try { const data = await apiFetch("/api/updates/check"); + const hasUpdates = !!data.available; if ($updateBadge) { - $updateBadge.classList.toggle("visible", !!data.available); + $updateBadge.classList.toggle("visible", hasUpdates); + } + // Toggle button color: blue (default) → green (updates available) + if ($updateBtn) { + $updateBtn.classList.toggle("has-updates", hasUpdates); } } catch (_) {} } @@ -263,6 +269,7 @@ function openUpdateModal() { _updateLog = ""; _updateLogOffset = 0; _serverWasDown = false; + _updateFinished = false; if ($modalLog) $modalLog.textContent = ""; if ($modalStatus) $modalStatus.textContent = "Starting update…"; if ($modalSpinner) $modalSpinner.classList.add("spinning"); @@ -323,6 +330,9 @@ function stopUpdatePoll() { } async function pollUpdateStatus() { + // Don't poll if we already know it's done + if (_updateFinished) return; + try { const data = await apiFetch(`/api/updates/status?offset=${_updateLogOffset}`); @@ -340,6 +350,7 @@ async function pollUpdateStatus() { // Check if finished if (!data.running) { + _updateFinished = true; stopUpdatePoll(); if (data.result === "success") { onUpdateDone(true); @@ -402,7 +413,7 @@ if ($modal) { }); } -// ── Init ────────────────────────────────────────────────────────── +// ── Init ──────��─────────────────────────────────────────────────── async function init() { try { diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 2031d70..6263d99 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -100,9 +100,10 @@ button:disabled { opacity: 0.88; } +/* Update button: blue by default, green when updates available */ .btn-update { - background-color: var(--green); - color: #fff; + background-color: var(--accent-color); + color: #1e1e2e; position: relative; display: flex; align-items: center; @@ -110,6 +111,15 @@ button:disabled { } .btn-update:hover:not(:disabled) { + opacity: 0.88; +} + +.btn-update.has-updates { + background-color: var(--green); + color: #fff; +} + +.btn-update.has-updates:hover:not(:disabled) { background-color: #27ae6e; } @@ -451,6 +461,7 @@ button:disabled { background-color: #12121c; white-space: pre-wrap; word-break: break-all; + min-height: 200px; } .modal-footer { @@ -462,13 +473,14 @@ button:disabled { border-top: 1px solid var(--border-color); } +/* Reboot button: green */ .btn-reboot { - background-color: var(--red); + background-color: var(--green); color: #fff; } .btn-reboot:hover:not(:disabled) { - background-color: #c0181f; + background-color: #27ae6e; } .btn-save { @@ -527,4 +539,4 @@ button:disabled { width: 160px; min-height: 200px; } -} +} \ No newline at end of file -- 2.53.0 From 3e1f672c004fd585b838a94c8d2de0e57e38b8e6 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 13:27:25 -0500 Subject: [PATCH 155/857] updated logging --- app/sovran_systemsos_web/server.py | 43 ++++++++++++++++++++++++-- app/sovran_systemsos_web/static/app.js | 25 ++++++++++----- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index c0d3bbc..cb60627 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -7,6 +7,7 @@ import json import os import socket import subprocess +import time import urllib.request from fastapi import FastAPI, HTTPException @@ -69,6 +70,12 @@ if os.path.isdir(_ICONS_DIR): templates = Jinja2Templates(directory=os.path.join(_BASE_DIR, "templates")) +# ── Track when we started an update ────────────────────────────── +# This timestamp lets us know that an update was recently kicked off, +# so we don't prematurely declare it finished if the unit hasn't +# transitioned to "active" yet. +_update_started_at: float = 0.0 + # ── Update check helpers ───────────────────────────────────────── def _get_locked_info(): @@ -166,6 +173,15 @@ def _update_is_active() -> bool: return r.returncode == 0 +def _update_state() -> str: + """Return the ActiveState of the update unit.""" + r = subprocess.run( + ["systemctl", "show", "-p", "ActiveState", "--value", UPDATE_UNIT], + capture_output=True, text=True, + ) + return r.stdout.strip() + + def _update_result() -> str: """Return 'success', 'failed', or 'unknown'.""" r = subprocess.run( @@ -326,6 +342,7 @@ async def api_reboot(): @app.post("/api/updates/run") async def api_updates_run(): """Kick off the detached update systemd unit.""" + global _update_started_at loop = asyncio.get_event_loop() running = await loop.run_in_executor(None, _update_is_active) @@ -339,6 +356,9 @@ async def api_updates_run(): stderr=asyncio.subprocess.DEVNULL, ) + # Record the start time so we can handle the race condition + _update_started_at = time.monotonic() + proc = await asyncio.create_subprocess_exec( "systemctl", "start", "--no-block", UPDATE_UNIT, stdout=asyncio.subprocess.DEVNULL, @@ -352,14 +372,33 @@ async def api_updates_run(): @app.get("/api/updates/status") async def api_updates_status(offset: int = 0): """Poll endpoint: returns running state, result, and new log content.""" + global _update_started_at loop = asyncio.get_event_loop() - running = await loop.run_in_executor(None, _update_is_active) + active = await loop.run_in_executor(None, _update_is_active) + state = await loop.run_in_executor(None, _update_state) result = await loop.run_in_executor(None, _update_result) new_log, new_offset = await loop.run_in_executor(None, _read_log, offset) + # Race condition guard: if we just started the unit and it hasn't + # transitioned to "activating"/"active" yet, report it as still running. + # Give it up to 10 seconds to appear as active. + if not active and _update_started_at > 0: + elapsed = time.monotonic() - _update_started_at + if elapsed < 10 and state in ("inactive", ""): + # Unit hasn't started yet — tell the frontend it's still running + return { + "running": True, + "result": "pending", + "log": new_log, + "offset": new_offset, + } + else: + # Either it finished or the grace period expired + _update_started_at = 0.0 + return { - "running": running, + "running": active, "result": result, "log": new_log, "offset": new_offset, diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 3fab03e..bfc2778 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -5,6 +5,7 @@ const POLL_INTERVAL_SERVICES = 5000; // 5 s const POLL_INTERVAL_UPDATES = 1800000; // 30 min const ACTION_REFRESH_DELAY = 1500; // 1.5 s after start/stop/restart const UPDATE_POLL_INTERVAL = 1500; // 1.5 s while update is running +const UPDATE_POLL_DELAY = 3000; // 3 s before first poll (let unit start) const CATEGORY_ORDER = [ "infrastructure", @@ -28,6 +29,7 @@ let _updatePollTimer = null; let _updateLogOffset = 0; let _serverWasDown = false; let _updateFinished = false; +let _sawRunning = false; // ── DOM refs ────────────────────────────────────────────────────── @@ -255,7 +257,6 @@ async function checkUpdates() { if ($updateBadge) { $updateBadge.classList.toggle("visible", hasUpdates); } - // Toggle button color: blue (default) → green (updates available) if ($updateBtn) { $updateBtn.classList.toggle("has-updates", hasUpdates); } @@ -270,6 +271,7 @@ function openUpdateModal() { _updateLogOffset = 0; _serverWasDown = false; _updateFinished = false; + _sawRunning = false; if ($modalLog) $modalLog.textContent = ""; if ($modalStatus) $modalStatus.textContent = "Starting update…"; if ($modalSpinner) $modalSpinner.classList.add("spinning"); @@ -307,9 +309,11 @@ function startUpdate() { .then(data => { if (data.status === "already_running") { appendLog("[Update already in progress, attaching…]\n\n"); + _sawRunning = true; } if ($modalStatus) $modalStatus.textContent = "Updating…"; - startUpdatePoll(); + // Delay the first poll to give the systemd unit time to start + setTimeout(startUpdatePoll, UPDATE_POLL_DELAY); }) .catch(err => { appendLog(`[Error: failed to start update — ${err}]\n`); @@ -330,7 +334,6 @@ function stopUpdatePoll() { } async function pollUpdateStatus() { - // Don't poll if we already know it's done if (_updateFinished) return; try { @@ -348,8 +351,14 @@ async function pollUpdateStatus() { } _updateLogOffset = data.offset; - // Check if finished - if (!data.running) { + // Track if we ever saw the unit as running + if (data.running) { + _sawRunning = true; + } + + // Only declare finished if we previously saw it running (or server says so) + // This prevents the race where the unit hasn't started yet + if (!data.running && _sawRunning) { _updateFinished = true; stopUpdatePoll(); if (data.result === "success") { @@ -359,7 +368,9 @@ async function pollUpdateStatus() { } } } catch (err) { - // Server is likely restarting during nixos-rebuild switch — keep polling + // Server is likely restarting during nixos-rebuild switch + // This counts as "saw running" since it was running before it died + _sawRunning = true; if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n\n"); @@ -413,7 +424,7 @@ if ($modal) { }); } -// ── Init ──────��─────────────────────────────────────────────────── +// ── Init ────────────────────────────────────────────────────────── async function init() { try { -- 2.53.0 From a66e8e736f8d529430da8d941992d8399872fd2c Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 13:34:29 -0500 Subject: [PATCH 156/857] updated logging --- app/sovran_systemsos_web/server.py | 86 ++++++++++++++------------ app/sovran_systemsos_web/static/app.js | 28 +++------ modules/core/sovran-hub.nix | 9 ++- 3 files changed, 64 insertions(+), 59 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index cb60627..90532dc 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -7,7 +7,6 @@ import json import os import socket import subprocess -import time import urllib.request from fastapi import FastAPI, HTTPException @@ -27,6 +26,7 @@ GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovr UPDATE_UNIT = "sovran-hub-update.service" UPDATE_LOG = "/var/log/sovran-hub-update.log" +UPDATE_LOCK = "/run/sovran-hub-update.lock" REBOOT_COMMAND = ["reboot"] @@ -70,12 +70,6 @@ if os.path.isdir(_ICONS_DIR): templates = Jinja2Templates(directory=os.path.join(_BASE_DIR, "templates")) -# ── Track when we started an update ────────────────────────────── -# This timestamp lets us know that an update was recently kicked off, -# so we don't prematurely declare it finished if the unit hasn't -# transitioned to "active" yet. -_update_started_at: float = 0.0 - # ── Update check helpers ───────────────────────────────────────── def _get_locked_info(): @@ -173,15 +167,6 @@ def _update_is_active() -> bool: return r.returncode == 0 -def _update_state() -> str: - """Return the ActiveState of the update unit.""" - r = subprocess.run( - ["systemctl", "show", "-p", "ActiveState", "--value", UPDATE_UNIT], - capture_output=True, text=True, - ) - return r.stdout.strip() - - def _update_result() -> str: """Return 'success', 'failed', or 'unknown'.""" r = subprocess.run( @@ -196,6 +181,28 @@ def _update_result() -> str: return "unknown" +def _update_lock_exists() -> bool: + """Check if the file-based update lock exists (survives server restart).""" + return os.path.exists(UPDATE_LOCK) + + +def _create_update_lock(): + """Create the lock file to indicate an update is in progress.""" + try: + with open(UPDATE_LOCK, "w") as f: + f.write(str(os.getpid())) + except OSError: + pass + + +def _remove_update_lock(): + """Remove the lock file.""" + try: + os.unlink(UPDATE_LOCK) + except FileNotFoundError: + pass + + def _read_log(offset: int = 0) -> tuple[str, int]: """Read the update log file from the given byte offset. Returns (new_text, new_offset).""" @@ -342,7 +349,6 @@ async def api_reboot(): @app.post("/api/updates/run") async def api_updates_run(): """Kick off the detached update systemd unit.""" - global _update_started_at loop = asyncio.get_event_loop() running = await loop.run_in_executor(None, _update_is_active) @@ -356,8 +362,8 @@ async def api_updates_run(): stderr=asyncio.subprocess.DEVNULL, ) - # Record the start time so we can handle the race condition - _update_started_at = time.monotonic() + # Create a file-based lock that survives server restarts + _create_update_lock() proc = await asyncio.create_subprocess_exec( "systemctl", "start", "--no-block", UPDATE_UNIT, @@ -372,33 +378,37 @@ async def api_updates_run(): @app.get("/api/updates/status") async def api_updates_status(offset: int = 0): """Poll endpoint: returns running state, result, and new log content.""" - global _update_started_at loop = asyncio.get_event_loop() active = await loop.run_in_executor(None, _update_is_active) - state = await loop.run_in_executor(None, _update_state) result = await loop.run_in_executor(None, _update_result) + lock_exists = _update_lock_exists() new_log, new_offset = await loop.run_in_executor(None, _read_log, offset) - # Race condition guard: if we just started the unit and it hasn't - # transitioned to "activating"/"active" yet, report it as still running. - # Give it up to 10 seconds to appear as active. - if not active and _update_started_at > 0: - elapsed = time.monotonic() - _update_started_at - if elapsed < 10 and state in ("inactive", ""): - # Unit hasn't started yet — tell the frontend it's still running - return { - "running": True, - "result": "pending", - "log": new_log, - "offset": new_offset, - } - else: - # Either it finished or the grace period expired - _update_started_at = 0.0 + # If the unit is active, it's definitely still running + if active: + return { + "running": True, + "result": "pending", + "log": new_log, + "offset": new_offset, + } + # If the lock file exists but the unit is not active, the update + # finished (or the server just restarted after nixos-rebuild switch). + # The lock file persists across server restarts because it's on disk. + if lock_exists: + _remove_update_lock() + return { + "running": False, + "result": result, + "log": new_log, + "offset": new_offset, + } + + # No lock, not active — nothing happening return { - "running": active, + "running": False, "result": result, "log": new_log, "offset": new_offset, diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index bfc2778..f39293b 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -4,8 +4,7 @@ const POLL_INTERVAL_SERVICES = 5000; // 5 s const POLL_INTERVAL_UPDATES = 1800000; // 30 min const ACTION_REFRESH_DELAY = 1500; // 1.5 s after start/stop/restart -const UPDATE_POLL_INTERVAL = 1500; // 1.5 s while update is running -const UPDATE_POLL_DELAY = 3000; // 3 s before first poll (let unit start) +const UPDATE_POLL_INTERVAL = 2000; // 2 s while update is running const CATEGORY_ORDER = [ "infrastructure", @@ -29,7 +28,6 @@ let _updatePollTimer = null; let _updateLogOffset = 0; let _serverWasDown = false; let _updateFinished = false; -let _sawRunning = false; // ── DOM refs ────────────────────────────────────────────────────── @@ -74,7 +72,7 @@ async function apiFetch(path, options = {}) { return res.json(); } -// ── Render: initial build ───────────────────────────────────────── +// ── Render: initial build ────────────────────────��──────────────── function buildTiles(services, categoryLabels) { _servicesCache = services; @@ -271,7 +269,6 @@ function openUpdateModal() { _updateLogOffset = 0; _serverWasDown = false; _updateFinished = false; - _sawRunning = false; if ($modalLog) $modalLog.textContent = ""; if ($modalStatus) $modalStatus.textContent = "Starting update…"; if ($modalSpinner) $modalSpinner.classList.add("spinning"); @@ -309,11 +306,9 @@ function startUpdate() { .then(data => { if (data.status === "already_running") { appendLog("[Update already in progress, attaching…]\n\n"); - _sawRunning = true; } if ($modalStatus) $modalStatus.textContent = "Updating…"; - // Delay the first poll to give the systemd unit time to start - setTimeout(startUpdatePoll, UPDATE_POLL_DELAY); + startUpdatePoll(); }) .catch(err => { appendLog(`[Error: failed to start update — ${err}]\n`); @@ -342,6 +337,7 @@ async function pollUpdateStatus() { // Server came back after being down if (_serverWasDown) { _serverWasDown = false; + appendLog("[Server reconnected]\n"); if ($modalStatus) $modalStatus.textContent = "Updating…"; } @@ -351,14 +347,8 @@ async function pollUpdateStatus() { } _updateLogOffset = data.offset; - // Track if we ever saw the unit as running - if (data.running) { - _sawRunning = true; - } - - // Only declare finished if we previously saw it running (or server says so) - // This prevents the race where the unit hasn't started yet - if (!data.running && _sawRunning) { + // Check if finished + if (!data.running) { _updateFinished = true; stopUpdatePoll(); if (data.result === "success") { @@ -368,12 +358,10 @@ async function pollUpdateStatus() { } } } catch (err) { - // Server is likely restarting during nixos-rebuild switch - // This counts as "saw running" since it was running before it died - _sawRunning = true; + // Server is likely restarting during nixos-rebuild switch — keep polling if (!_serverWasDown) { _serverWasDown = true; - appendLog("\n[Server restarting — waiting for it to come back…]\n\n"); + appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; } } diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 04bc0ad..121032f 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -28,7 +28,7 @@ let { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; category = "communication"; } { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; enabled = cfg.features.element-calling; category = "communication"; } ] - # ── Self-Hosted Apps ─────────────────────────────────────── + # ── Self-Hosted Apps ────────────────��────────────────────── ++ [ { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; enabled = cfg.services.vaultwarden; category = "apps"; } { name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; icon = "nextcloud"; enabled = cfg.services.nextcloud; category = "apps"; } @@ -58,11 +58,18 @@ let export PATH="${lib.makeBinPath [ pkgs.nix pkgs.nixos-rebuild pkgs.git pkgs.flatpak pkgs.coreutils ]}:$PATH" LOG="/var/log/sovran-hub-update.log" + LOCK="/run/sovran-hub-update.lock" + + # Create lock file (survives server restarts, cleared on reboot since /run is tmpfs) + echo $$ > "$LOCK" # Truncate the log and redirect ALL output (stdout + stderr) into it : > "$LOG" exec > >(tee -a "$LOG") 2>&1 + # Ensure lock is removed on exit (success or failure) + trap 'rm -f "$LOCK"' EXIT + echo "══════════════════════════════════════════════════" echo " Sovran_SystemsOS Update — $(date)" echo "══════════════════════════════════════════════════" -- 2.53.0 From bb7db0693a224707f50eec9dac298f5b456ec308 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 13:42:24 -0500 Subject: [PATCH 157/857] fixed updater --- app/sovran_systemsos_web/server.py | 95 ++++------------------- app/sovran_systemsos_web/static/app.js | 28 ++++--- app/sovran_systemsos_web/static/style.css | 12 +-- modules/core/sovran-hub.nix | 13 ++-- 4 files changed, 45 insertions(+), 103 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 90532dc..037e6af 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -24,9 +24,9 @@ FLAKE_LOCK_PATH = "/etc/nixos/flake.lock" FLAKE_INPUT_NAME = "Sovran_Systems" GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovran_SystemsOS/commits" -UPDATE_UNIT = "sovran-hub-update.service" -UPDATE_LOG = "/var/log/sovran-hub-update.log" -UPDATE_LOCK = "/run/sovran-hub-update.lock" +UPDATE_LOG = "/var/log/sovran-hub-update.log" +UPDATE_STATUS = "/var/log/sovran-hub-update.status" +UPDATE_UNIT = "sovran-hub-update.service" REBOOT_COMMAND = ["reboot"] @@ -156,51 +156,15 @@ def _get_external_ip() -> str: return "unavailable" -# ── Update unit helpers ────────────────────────────────────────── +# ── Update helpers (file-based, no systemctl) ──────────────────── -def _update_is_active() -> bool: - """Return True if the update unit is currently running.""" - r = subprocess.run( - ["systemctl", "is-active", "--quiet", UPDATE_UNIT], - capture_output=True, - ) - return r.returncode == 0 - - -def _update_result() -> str: - """Return 'success', 'failed', or 'unknown'.""" - r = subprocess.run( - ["systemctl", "show", "-p", "Result", "--value", UPDATE_UNIT], - capture_output=True, text=True, - ) - val = r.stdout.strip() - if val == "success": - return "success" - elif val: - return "failed" - return "unknown" - - -def _update_lock_exists() -> bool: - """Check if the file-based update lock exists (survives server restart).""" - return os.path.exists(UPDATE_LOCK) - - -def _create_update_lock(): - """Create the lock file to indicate an update is in progress.""" +def _read_update_status() -> str: + """Read the status file. Returns RUNNING, SUCCESS, FAILED, or IDLE.""" try: - with open(UPDATE_LOCK, "w") as f: - f.write(str(os.getpid())) - except OSError: - pass - - -def _remove_update_lock(): - """Remove the lock file.""" - try: - os.unlink(UPDATE_LOCK) + with open(UPDATE_STATUS, "r") as f: + return f.read().strip() except FileNotFoundError: - pass + return "IDLE" def _read_log(offset: int = 0) -> tuple[str, int]: @@ -208,10 +172,9 @@ def _read_log(offset: int = 0) -> tuple[str, int]: Returns (new_text, new_offset).""" try: with open(UPDATE_LOG, "rb") as f: - f.seek(0, 2) # seek to end + f.seek(0, 2) size = f.tell() if offset > size: - # Log was truncated (new run), start over offset = 0 f.seek(offset) chunk = f.read() @@ -351,8 +314,8 @@ async def api_updates_run(): """Kick off the detached update systemd unit.""" loop = asyncio.get_event_loop() - running = await loop.run_in_executor(None, _update_is_active) - if running: + status = await loop.run_in_executor(None, _read_update_status) + if status == "RUNNING": return {"ok": True, "status": "already_running"} # Reset failed state if any @@ -362,9 +325,6 @@ async def api_updates_run(): stderr=asyncio.subprocess.DEVNULL, ) - # Create a file-based lock that survives server restarts - _create_update_lock() - proc = await asyncio.create_subprocess_exec( "systemctl", "start", "--no-block", UPDATE_UNIT, stdout=asyncio.subprocess.DEVNULL, @@ -377,38 +337,17 @@ async def api_updates_run(): @app.get("/api/updates/status") async def api_updates_status(offset: int = 0): - """Poll endpoint: returns running state, result, and new log content.""" + """Poll endpoint: reads status file + log file. No systemctl needed.""" loop = asyncio.get_event_loop() - active = await loop.run_in_executor(None, _update_is_active) - result = await loop.run_in_executor(None, _update_result) - lock_exists = _update_lock_exists() + status = await loop.run_in_executor(None, _read_update_status) new_log, new_offset = await loop.run_in_executor(None, _read_log, offset) - # If the unit is active, it's definitely still running - if active: - return { - "running": True, - "result": "pending", - "log": new_log, - "offset": new_offset, - } + running = (status == "RUNNING") + result = "pending" if running else status.lower() - # If the lock file exists but the unit is not active, the update - # finished (or the server just restarted after nixos-rebuild switch). - # The lock file persists across server restarts because it's on disk. - if lock_exists: - _remove_update_lock() - return { - "running": False, - "result": result, - "log": new_log, - "offset": new_offset, - } - - # No lock, not active — nothing happening return { - "running": False, + "running": running, "result": result, "log": new_log, "offset": new_offset, diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index f39293b..cc59588 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -72,7 +72,7 @@ async function apiFetch(path, options = {}) { return res.json(); } -// ── Render: initial build ────────────────────────��──────────────── +// ── Render: initial build ───────────────────────────────────────── function buildTiles(services, categoryLabels) { _servicesCache = services; @@ -341,24 +341,28 @@ async function pollUpdateStatus() { if ($modalStatus) $modalStatus.textContent = "Updating…"; } - // Append any new log content + // Append new log content if (data.log) { appendLog(data.log); } _updateLogOffset = data.offset; - // Check if finished - if (!data.running) { - _updateFinished = true; - stopUpdatePoll(); - if (data.result === "success") { - onUpdateDone(true); - } else { - onUpdateDone(false); - } + // RUNNING → keep polling + if (data.running) { + return; + } + + // Finished — check result + _updateFinished = true; + stopUpdatePoll(); + + if (data.result === "success") { + onUpdateDone(true); + } else { + onUpdateDone(false); } } catch (err) { - // Server is likely restarting during nixos-rebuild switch — keep polling + // Server is restarting during nixos-rebuild switch — keep polling if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 6263d99..bd0ef60 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -473,14 +473,14 @@ button:disabled { border-top: 1px solid var(--border-color); } -/* Reboot button: green */ -.btn-reboot { - background-color: var(--green); - color: #fff; +/* Reboot button */ +#btn-reboot { + background-color: #2ec27e !important; + color: #fff !important; } -.btn-reboot:hover:not(:disabled) { - background-color: #27ae6e; +#btn-reboot:hover:not(:disabled) { + background-color: #27ae6e !important; } .btn-save { diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 121032f..3586db2 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -28,7 +28,7 @@ let { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; category = "communication"; } { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; enabled = cfg.features.element-calling; category = "communication"; } ] - # ── Self-Hosted Apps ────────────────��────────────────────── + # ── Self-Hosted Apps ─────────────────────────────────────── ++ [ { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; enabled = cfg.services.vaultwarden; category = "apps"; } { name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; icon = "nextcloud"; enabled = cfg.services.nextcloud; category = "apps"; } @@ -58,18 +58,15 @@ let export PATH="${lib.makeBinPath [ pkgs.nix pkgs.nixos-rebuild pkgs.git pkgs.flatpak pkgs.coreutils ]}:$PATH" LOG="/var/log/sovran-hub-update.log" - LOCK="/run/sovran-hub-update.lock" + STATUS="/var/log/sovran-hub-update.status" - # Create lock file (survives server restarts, cleared on reboot since /run is tmpfs) - echo $$ > "$LOCK" + # Mark as RUNNING + echo "RUNNING" > "$STATUS" # Truncate the log and redirect ALL output (stdout + stderr) into it : > "$LOG" exec > >(tee -a "$LOG") 2>&1 - # Ensure lock is removed on exit (success or failure) - trap 'rm -f "$LOCK"' EXIT - echo "══════════════════════════════════════════════════" echo " Sovran_SystemsOS Update — $(date)" echo "══════════════════════════════════════════════════" @@ -105,10 +102,12 @@ let echo "══════════════════════════════════════════════════" echo " āœ“ Update completed successfully" echo "══════════════════════════════════════════════════" + echo "SUCCESS" > "$STATUS" else echo "══════════════════════════════════════════════════" echo " āœ— Update failed — see errors above" echo "══════════════════════════════════════════════════" + echo "FAILED" > "$STATUS" fi exit "$RC" -- 2.53.0 From 868e6e3315292acebc1b3af8019e82e50f2fda74 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 13:54:03 -0500 Subject: [PATCH 158/857] fixed the color of buttons --- app/sovran_systemsos_web/static/style.css | 28 ++++++++++++------- app/sovran_systemsos_web/templates/index.html | 6 ++-- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index bd0ef60..e4b0b5b 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -1,5 +1,6 @@ /* Sovran_SystemsOS Hub — Web UI Stylesheet - Dark theme matching the Adwaita dark aesthetic */ + Dark theme matching the Adwaita dark aesthetic + v2 — cache bust */ *, *::before, *::after { box-sizing: border-box; @@ -100,9 +101,9 @@ button:disabled { opacity: 0.88; } -/* Update button: blue by default, green when updates available */ +/* Update System button: BLUE (#89b4fa) by default */ .btn-update { - background-color: var(--accent-color); + background-color: #89b4fa; color: #1e1e2e; position: relative; display: flex; @@ -114,8 +115,9 @@ button:disabled { opacity: 0.88; } +/* Update System button: GREEN when updates are available */ .btn-update.has-updates { - background-color: var(--green); + background-color: #2ec27e; color: #fff; } @@ -473,14 +475,20 @@ button:disabled { border-top: 1px solid var(--border-color); } -/* Reboot button */ -#btn-reboot { - background-color: #2ec27e !important; - color: #fff !important; +/* ── Modal footer buttons ──────────────────────────────────────── + Reboot = GREEN, Save = YELLOW, Close = GREY */ + +.modal-footer .btn-reboot, +.modal-footer button.btn-reboot, +button#btn-reboot { + background-color: #2ec27e; + color: #fff; } -#btn-reboot:hover:not(:disabled) { - background-color: #27ae6e !important; +.modal-footer .btn-reboot:hover:not(:disabled), +.modal-footer button.btn-reboot:hover:not(:disabled), +button#btn-reboot:hover:not(:disabled) { + background-color: #27ae6e; } .btn-save { diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index d6614e2..0f02e50 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -4,7 +4,7 @@ Sovran_SystemsOS Hub - + @@ -54,6 +54,6 @@ - + - + \ No newline at end of file -- 2.53.0 From 9a61994ddebe6a953abb0cb604b8150d4613ca14 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 14:01:08 -0500 Subject: [PATCH 159/857] fixed reboot menu --- app/sovran_systemsos_web/static/app.js | 66 +++++++---- app/sovran_systemsos_web/static/style.css | 109 ++++++++++++++++-- app/sovran_systemsos_web/templates/index.html | 22 +++- 3 files changed, 164 insertions(+), 33 deletions(-) diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index cc59588..1bd498b 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -5,6 +5,7 @@ const POLL_INTERVAL_SERVICES = 5000; // 5 s const POLL_INTERVAL_UPDATES = 1800000; // 30 min const ACTION_REFRESH_DELAY = 1500; // 1.5 s after start/stop/restart const UPDATE_POLL_INTERVAL = 2000; // 2 s while update is running +const REBOOT_CHECK_INTERVAL = 5000; // 5 s between reconnect attempts const CATEGORY_ORDER = [ "infrastructure", @@ -19,7 +20,7 @@ const STATUS_LOADING_STATES = new Set([ "reloading", "activating", "deactivating", "maintenance", ]); -// ── State ───────────────────────────────────────────────────────── +// ── State ──────────────────────────────────────────────���────────── let _servicesCache = []; let _categoryLabels = {}; @@ -31,20 +32,22 @@ let _updateFinished = false; // ── DOM refs ────────────────────────────────────────────────────── -const $tilesArea = document.getElementById("tiles-area"); -const $updateBtn = document.getElementById("btn-update"); -const $updateBadge = document.getElementById("update-badge"); -const $refreshBtn = document.getElementById("btn-refresh"); -const $internalIp = document.getElementById("ip-internal"); -const $externalIp = document.getElementById("ip-external"); +const $tilesArea = document.getElementById("tiles-area"); +const $updateBtn = document.getElementById("btn-update"); +const $updateBadge = document.getElementById("update-badge"); +const $refreshBtn = document.getElementById("btn-refresh"); +const $internalIp = document.getElementById("ip-internal"); +const $externalIp = document.getElementById("ip-external"); -const $modal = document.getElementById("update-modal"); -const $modalSpinner = document.getElementById("modal-spinner"); -const $modalStatus = document.getElementById("modal-status"); -const $modalLog = document.getElementById("modal-log"); -const $btnReboot = document.getElementById("btn-reboot"); -const $btnSave = document.getElementById("btn-save-report"); -const $btnCloseModal = document.getElementById("btn-close-modal"); +const $modal = document.getElementById("update-modal"); +const $modalSpinner = document.getElementById("modal-spinner"); +const $modalStatus = document.getElementById("modal-status"); +const $modalLog = document.getElementById("modal-log"); +const $btnReboot = document.getElementById("btn-reboot"); +const $btnSave = document.getElementById("btn-save-report"); +const $btnCloseModal = document.getElementById("btn-close-modal"); + +const $rebootOverlay = document.getElementById("reboot-overlay"); // ── Helpers ─────────────────────────────────────────────────────── @@ -334,25 +337,21 @@ async function pollUpdateStatus() { try { const data = await apiFetch(`/api/updates/status?offset=${_updateLogOffset}`); - // Server came back after being down if (_serverWasDown) { _serverWasDown = false; appendLog("[Server reconnected]\n"); if ($modalStatus) $modalStatus.textContent = "Updating…"; } - // Append new log content if (data.log) { appendLog(data.log); } _updateLogOffset = data.offset; - // RUNNING → keep polling if (data.running) { return; } - // Finished — check result _updateFinished = true; stopUpdatePoll(); @@ -362,7 +361,6 @@ async function pollUpdateStatus() { onUpdateDone(false); } } catch (err) { - // Server is restarting during nixos-rebuild switch — keep polling if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); @@ -397,9 +395,37 @@ function saveErrorReport() { URL.revokeObjectURL(url); } +// ── Reboot with confirmation overlay ────────────────────────────── + function doReboot() { + // Close the update modal + if ($modal) $modal.classList.remove("open"); + stopUpdatePoll(); + + // Show the reboot overlay + if ($rebootOverlay) $rebootOverlay.classList.add("visible"); + + // Send the reboot command fetch("/api/reboot", { method: "POST" }).catch(() => {}); - if ($modalStatus) $modalStatus.textContent = "Rebooting…"; + + // Start polling to detect when the server comes back + setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); +} + +function waitForServerReboot() { + fetch("/api/config", { cache: "no-store" }) + .then(res => { + if (res.ok) { + // Server is back — reload the page to get the fresh state + window.location.reload(); + } else { + setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); + } + }) + .catch(() => { + // Still down — keep trying + setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); + }); } // ── Event listeners ─────────────────────────────────────────────── diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index e4b0b5b..fcc8606 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -1,6 +1,6 @@ /* Sovran_SystemsOS Hub — Web UI Stylesheet Dark theme matching the Adwaita dark aesthetic - v2 — cache bust */ + v3 — reboot overlay */ *, *::before, *::after { box-sizing: border-box; @@ -70,7 +70,7 @@ body { letter-spacing: 0.03em; } -/* ── Buttons ────────────────────────────────────────────────────── */ +/* ── Buttons ────────────────────────────────────────────────────��─ */ button { font-family: inherit; @@ -101,7 +101,7 @@ button:disabled { opacity: 0.88; } -/* Update System button: BLUE (#89b4fa) by default */ +/* Update System button: BLUE by default */ .btn-update { background-color: #89b4fa; color: #1e1e2e; @@ -320,7 +320,6 @@ button:disabled { margin-top: 10px; } -/* CSS-only toggle switch */ .toggle-label { display: flex; align-items: center; @@ -475,19 +474,15 @@ button:disabled { border-top: 1px solid var(--border-color); } -/* ── Modal footer buttons ──────────────────────────────────────── - Reboot = GREEN, Save = YELLOW, Close = GREY */ - +/* Reboot = GREEN */ .modal-footer .btn-reboot, -.modal-footer button.btn-reboot, -button#btn-reboot { +button.btn-reboot { background-color: #2ec27e; color: #fff; } .modal-footer .btn-reboot:hover:not(:disabled), -.modal-footer button.btn-reboot:hover:not(:disabled), -button#btn-reboot:hover:not(:disabled) { +button.btn-reboot:hover:not(:disabled) { background-color: #27ae6e; } @@ -509,6 +504,94 @@ button#btn-reboot:hover:not(:disabled) { background-color: #5a5c72; } +/* ── Reboot overlay ─────────────────────────────────────────────── */ + +.reboot-overlay { + display: none; + position: fixed; + inset: 0; + background-color: rgba(15, 15, 25, 0.92); + z-index: 999; + align-items: center; + justify-content: center; +} + +.reboot-overlay.visible { + display: flex; +} + +.reboot-card { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 48px 56px; + text-align: center; + max-width: 480px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8); + animation: reboot-fade-in 0.4s ease-out; +} + +@keyframes reboot-fade-in { + from { opacity: 0; transform: scale(0.92) translateY(12px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.reboot-icon { + font-size: 3rem; + color: var(--accent-color); + margin-bottom: 16px; + animation: reboot-spin 2s linear infinite; + display: inline-block; +} + +@keyframes reboot-spin { + to { transform: rotate(360deg); } +} + +.reboot-title { + font-size: 1.35rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 12px; +} + +.reboot-message { + font-size: 0.92rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 24px; +} + +.reboot-dots { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: 16px; +} + +.reboot-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: var(--accent-color); + animation: reboot-bounce 1.4s ease-in-out infinite; +} + +.reboot-dot:nth-child(2) { animation-delay: 0.2s; } +.reboot-dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes reboot-bounce { + 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } + 40% { opacity: 1; transform: scale(1.2); } +} + +.reboot-submessage { + font-size: 0.82rem; + color: var(--text-dim); + font-style: italic; +} + /* ── Empty state ────────────────────────────────────────────────── */ .empty-state { @@ -547,4 +630,8 @@ button#btn-reboot:hover:not(:disabled) { width: 160px; min-height: 200px; } + .reboot-card { + padding: 36px 28px; + margin: 0 16px; + } } \ No newline at end of file diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index 0f02e50..e9144cd 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -4,7 +4,7 @@ Sovran_SystemsOS Hub - + @@ -54,6 +54,24 @@ - + +
      +
      +
      ↻
      +

      System Rebooting

      +

      + Sovran_SystemsOS is now restarting.
      + This page will automatically reconnect once the system is back online. +

      +
      + + + +
      +

      Stay tuned…

      +
      +
      + + \ No newline at end of file -- 2.53.0 From d9a5416012bff856a2653166479783bd87c42e90 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 14:07:47 -0500 Subject: [PATCH 160/857] fixed stale info --- app/sovran_systemsos_web/server.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 037e6af..238e5c3 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -167,6 +167,15 @@ def _read_update_status() -> str: return "IDLE" +def _write_update_status(status: str): + """Write to the status file.""" + try: + with open(UPDATE_STATUS, "w") as f: + f.write(status) + except OSError: + pass + + def _read_log(offset: int = 0) -> tuple[str, int]: """Read the update log file from the given byte offset. Returns (new_text, new_offset).""" @@ -318,6 +327,14 @@ async def api_updates_run(): if status == "RUNNING": return {"ok": True, "status": "already_running"} + # Clear stale status and log BEFORE starting the unit + _write_update_status("RUNNING") + try: + with open(UPDATE_LOG, "w") as f: + f.write("") + except OSError: + pass + # Reset failed state if any await asyncio.create_subprocess_exec( "systemctl", "reset-failed", UPDATE_UNIT, -- 2.53.0 From 64c32a7f53dabd966ab21c9f8ab4218c39cc6299 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 14:20:06 -0500 Subject: [PATCH 161/857] added info dialog for each tile --- app/sovran_systemsos_web/server.py | 74 ++++ app/sovran_systemsos_web/static/app.js | 371 +----------------- app/sovran_systemsos_web/static/style.css | 160 +++++++- app/sovran_systemsos_web/templates/index.html | 17 +- modules/core/sovran-hub.nix | 59 ++- 5 files changed, 296 insertions(+), 385 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 238e5c3..2034a0f 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import json import os +import re import socket import subprocess import urllib.request @@ -192,6 +193,43 @@ def _read_log(offset: int = 0) -> tuple[str, int]: return "", 0 +# ── Credentials helpers ────────────────────────────────────────── + +def _resolve_credential(cred: dict) -> dict | None: + """Resolve a single credential entry to {label, value}.""" + label = cred.get("label", "") + prefix = cred.get("prefix", "") + suffix = cred.get("suffix", "") + extract = cred.get("extract", "") + multiline = cred.get("multiline", False) + + # Static value + if "value" in cred: + return {"label": label, "value": prefix + cred["value"] + suffix, "multiline": multiline} + + # File-based value + filepath = cred.get("file", "") + if not filepath: + return None + + try: + with open(filepath, "r") as f: + raw = f.read().strip() + except (FileNotFoundError, PermissionError): + return None + + if extract: + # Extract a key=value from an env file (e.g., ADMIN_TOKEN=...) + match = re.search(rf'{re.escape(extract)}=(.*)', raw) + if match: + raw = match.group(1).strip() + else: + return None + + value = prefix + raw + suffix + return {"label": label, "value": value, "multiline": multiline} + + # ── Routes ─────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) @@ -228,6 +266,10 @@ async def api_services(): ) else: status = "disabled" + + creds = entry.get("credentials", []) + has_credentials = len(creds) > 0 + return { "name": entry.get("name", ""), "unit": unit, @@ -236,12 +278,44 @@ async def api_services(): "enabled": enabled, "category": entry.get("category", "other"), "status": status, + "has_credentials": has_credentials, } results = await asyncio.gather(*[get_status(s) for s in services]) return list(results) +@app.get("/api/credentials/{unit}") +async def api_credentials(unit: str): + """Return resolved credentials for a given service unit.""" + cfg = load_config() + services = cfg.get("services", []) + + # Find the service entry matching this unit + entry = None + for s in services: + if s.get("unit") == unit: + creds = s.get("credentials", []) + if creds: + entry = s + break + + if not entry: + raise HTTPException(status_code=404, detail="No credentials for this service") + + loop = asyncio.get_event_loop() + resolved = [] + for cred in entry.get("credentials", []): + result = await loop.run_in_executor(None, _resolve_credential, cred) + if result: + resolved.append(result) + + return { + "name": entry.get("name", ""), + "credentials": resolved, + } + + def _get_allowed_units() -> set[str]: cfg = load_config() return {s.get("unit", "") for s in cfg.get("services", []) if s.get("unit")} diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 1bd498b..3120df6 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -20,7 +20,7 @@ const STATUS_LOADING_STATES = new Set([ "reloading", "activating", "deactivating", "maintenance", ]); -// ── State ──────────────────────────────────────────────���────────── +// ── State ───────────────────────────────────────────────────────── let _servicesCache = []; let _categoryLabels = {}; @@ -49,6 +49,11 @@ const $btnCloseModal = document.getElementById("btn-close-modal"); const $rebootOverlay = document.getElementById("reboot-overlay"); +const $credsModal = document.getElementById("creds-modal"); +const $credsTitle = document.getElementById("creds-modal-title"); +const $credsBody = document.getElementById("creds-body"); +const $credsCloseBtn = document.getElementById("creds-close-btn"); + // ── Helpers ─────────────────────────────────────────────────────── function statusClass(status) { @@ -102,366 +107,4 @@ function buildTiles(services, categoryLabels) { const section = document.createElement("div"); section.className = "category-section"; - section.dataset.category = catKey; - - section.innerHTML = ` -
      ${escHtml(label)}
      -
      -
      - `; - - const grid = section.querySelector(".tiles-grid"); - for (const svc of entries) { - grid.appendChild(buildTile(svc)); - } - - $tilesArea.appendChild(section); - } - - if ($tilesArea.children.length === 0) { - $tilesArea.innerHTML = `

      No services configured.

      `; - } -} - -function buildTile(svc) { - const sc = statusClass(svc.status); - const st = statusText(svc.status, svc.enabled); - const dis = !svc.enabled; - const isOn = svc.status === "active"; - - const tile = document.createElement("div"); - tile.className = "service-tile" + (dis ? " disabled" : ""); - tile.dataset.unit = svc.unit; - if (dis) tile.title = `${svc.name} is not enabled in custom.nix`; - - tile.innerHTML = ` - ${escHtml(svc.name)} - -
      ${escHtml(svc.name)}
      -
      - - ${escHtml(st)} -
      -
      -
      - - -
      - `; - - const chk = tile.querySelector(".tile-toggle"); - if (!dis) { - chk.addEventListener("change", async (e) => { - const action = e.target.checked ? "start" : "stop"; - chk.disabled = true; - try { - await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/${action}`, { method: "POST" }); - } catch (_) {} - setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY); - }); - } - - const restartBtn = tile.querySelector(".tile-restart-btn"); - if (!dis) { - restartBtn.addEventListener("click", async () => { - restartBtn.disabled = true; - try { - await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/restart`, { method: "POST" }); - } catch (_) {} - setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY); - }); - } - - return tile; -} - -// ── Render: live update (no DOM rebuild) ────────────────────────── - -function updateTiles(services) { - _servicesCache = services; - - for (const svc of services) { - const tile = $tilesArea.querySelector(`.service-tile[data-unit="${CSS.escape(svc.unit)}"]`); - if (!tile) continue; - - const sc = statusClass(svc.status); - const st = statusText(svc.status, svc.enabled); - - const dot = tile.querySelector(".status-dot"); - const text = tile.querySelector(".status-text"); - const chk = tile.querySelector(".tile-toggle"); - - if (dot) { dot.className = `status-dot ${sc}`; } - if (text) { text.textContent = st; } - if (chk && !chk.disabled) { - chk.checked = svc.status === "active"; - } - } -} - -// ── HTML escape ─────────────────────────────────────────────────── - -function escHtml(str) { - return String(str) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -// ── Service polling ─────────────────────────────────────────────── - -let _firstLoad = true; - -async function refreshServices() { - try { - const services = await apiFetch("/api/services"); - if (_firstLoad) { - buildTiles(services, _categoryLabels); - _firstLoad = false; - } else { - updateTiles(services); - } - } catch (err) { - console.warn("Failed to fetch services:", err); - } -} - -// ── Network IPs ─────────────────────────────────────────────────── - -async function loadNetwork() { - try { - const data = await apiFetch("/api/network"); - if ($internalIp) $internalIp.textContent = data.internal_ip || "—"; - if ($externalIp) $externalIp.textContent = data.external_ip || "—"; - } catch (_) { - if ($internalIp) $internalIp.textContent = "—"; - if ($externalIp) $externalIp.textContent = "—"; - } -} - -// ── Update check ────────────────────────────────────────────────── - -async function checkUpdates() { - try { - const data = await apiFetch("/api/updates/check"); - const hasUpdates = !!data.available; - if ($updateBadge) { - $updateBadge.classList.toggle("visible", hasUpdates); - } - if ($updateBtn) { - $updateBtn.classList.toggle("has-updates", hasUpdates); - } - } catch (_) {} -} - -// ── Update modal ────────────────────────────────────────────────── - -function openUpdateModal() { - if (!$modal) return; - _updateLog = ""; - _updateLogOffset = 0; - _serverWasDown = false; - _updateFinished = false; - if ($modalLog) $modalLog.textContent = ""; - if ($modalStatus) $modalStatus.textContent = "Starting update…"; - if ($modalSpinner) $modalSpinner.classList.add("spinning"); - if ($btnReboot) { $btnReboot.style.display = "none"; } - if ($btnSave) { $btnSave.style.display = "none"; } - if ($btnCloseModal) { $btnCloseModal.disabled = true; } - - $modal.classList.add("open"); - startUpdate(); -} - -function closeUpdateModal() { - if (!$modal) return; - $modal.classList.remove("open"); - stopUpdatePoll(); -} - -function appendLog(text) { - if (!text) return; - _updateLog += text; - if ($modalLog) { - $modalLog.textContent += text; - $modalLog.scrollTop = $modalLog.scrollHeight; - } -} - -function startUpdate() { - fetch("/api/updates/run", { method: "POST" }) - .then(response => { - if (!response.ok) { - return response.text().then(t => { throw new Error(t); }); - } - return response.json(); - }) - .then(data => { - if (data.status === "already_running") { - appendLog("[Update already in progress, attaching…]\n\n"); - } - if ($modalStatus) $modalStatus.textContent = "Updating…"; - startUpdatePoll(); - }) - .catch(err => { - appendLog(`[Error: failed to start update — ${err}]\n`); - onUpdateDone(false); - }); -} - -function startUpdatePoll() { - pollUpdateStatus(); - _updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL); -} - -function stopUpdatePoll() { - if (_updatePollTimer) { - clearInterval(_updatePollTimer); - _updatePollTimer = null; - } -} - -async function pollUpdateStatus() { - if (_updateFinished) return; - - try { - const data = await apiFetch(`/api/updates/status?offset=${_updateLogOffset}`); - - if (_serverWasDown) { - _serverWasDown = false; - appendLog("[Server reconnected]\n"); - if ($modalStatus) $modalStatus.textContent = "Updating…"; - } - - if (data.log) { - appendLog(data.log); - } - _updateLogOffset = data.offset; - - if (data.running) { - return; - } - - _updateFinished = true; - stopUpdatePoll(); - - if (data.result === "success") { - onUpdateDone(true); - } else { - onUpdateDone(false); - } - } catch (err) { - if (!_serverWasDown) { - _serverWasDown = true; - appendLog("\n[Server restarting — waiting for it to come back…]\n"); - if ($modalStatus) $modalStatus.textContent = "Server restarting…"; - } - } -} - -function onUpdateDone(success) { - if ($modalSpinner) $modalSpinner.classList.remove("spinning"); - if ($btnCloseModal) $btnCloseModal.disabled = false; - - if (success) { - if ($modalStatus) $modalStatus.textContent = "āœ“ Update complete"; - if ($btnReboot) $btnReboot.style.display = "inline-flex"; - } else { - if ($modalStatus) $modalStatus.textContent = "āœ— Update failed"; - if ($btnSave) $btnSave.style.display = "inline-flex"; - if ($btnReboot) $btnReboot.style.display = "inline-flex"; - } -} - -function saveErrorReport() { - const blob = new Blob([_updateLog], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `sovran-update-error-${new Date().toISOString().split('.')[0].replace(/:/g, '-')}.txt`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -// ── Reboot with confirmation overlay ────────────────────────────── - -function doReboot() { - // Close the update modal - if ($modal) $modal.classList.remove("open"); - stopUpdatePoll(); - - // Show the reboot overlay - if ($rebootOverlay) $rebootOverlay.classList.add("visible"); - - // Send the reboot command - fetch("/api/reboot", { method: "POST" }).catch(() => {}); - - // Start polling to detect when the server comes back - setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); -} - -function waitForServerReboot() { - fetch("/api/config", { cache: "no-store" }) - .then(res => { - if (res.ok) { - // Server is back — reload the page to get the fresh state - window.location.reload(); - } else { - setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); - } - }) - .catch(() => { - // Still down — keep trying - setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); - }); -} - -// ── Event listeners ─────────────────────────────────────────────── - -if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); -if ($refreshBtn) $refreshBtn.addEventListener("click", () => refreshServices()); -if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal); -if ($btnReboot) $btnReboot.addEventListener("click", doReboot); -if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); - -if ($modal) { - $modal.addEventListener("click", (e) => { - if (e.target === $modal) closeUpdateModal(); - }); -} - -// ── Init ────────────────────────────────────────────────────────── - -async function init() { - try { - const cfg = await apiFetch("/api/config"); - if (cfg.category_order) { - for (const [key, label] of cfg.category_order) { - _categoryLabels[key] = label; - } - } - const badge = document.getElementById("role-badge"); - if (badge && cfg.role_label) badge.textContent = cfg.role_label; - } catch (_) {} - - await refreshServices(); - loadNetwork(); - checkUpdates(); - - setInterval(refreshServices, POLL_INTERVAL_SERVICES); - setInterval(checkUpdates, POLL_INTERVAL_UPDATES); -} - -document.addEventListener("DOMContentLoaded", init); \ No newline at end of file + section.dataset.category \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index fcc8606..62eeee2 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -1,6 +1,6 @@ /* Sovran_SystemsOS Hub — Web UI Stylesheet Dark theme matching the Adwaita dark aesthetic - v3 — reboot overlay */ + v4 — credentials info modal */ *, *::before, *::after { box-sizing: border-box; @@ -70,7 +70,7 @@ body { letter-spacing: 0.03em; } -/* ── Buttons ────────────────────────────────────────────────────��─ */ +/* ── Buttons ────────────────────────────────────────────────────── */ button { font-family: inherit; @@ -249,6 +249,32 @@ button:disabled { opacity: 0.45; } +/* Info badge on tiles with credentials */ +.tile-info-btn { + position: absolute; + top: 8px; + right: 8px; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: var(--accent-color); + color: #1e1e2e; + font-size: 0.75rem; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: none; + transition: transform 0.15s, background-color 0.15s; + line-height: 1; +} + +.tile-info-btn:hover { + transform: scale(1.15); + background-color: #a8c8ff; +} + .tile-icon { width: 48px; height: 48px; @@ -504,6 +530,133 @@ button.btn-reboot:hover:not(:disabled) { background-color: #5a5c72; } +/* ── Credentials info modal ──────────────────────────────────────── */ + +.creds-dialog { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 16px; + width: 90vw; + max-width: 520px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 16px 48px rgba(0,0,0,0.7); + animation: creds-fade-in 0.2s ease-out; +} + +@keyframes creds-fade-in { + from { opacity: 0; transform: scale(0.95) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.creds-header { + display: flex; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); +} + +.creds-title { + font-size: 1rem; + font-weight: 700; + flex: 1; +} + +.creds-close-btn { + background: none; + color: var(--text-secondary); + font-size: 1.1rem; + padding: 4px 8px; + border-radius: 6px; + cursor: pointer; + border: none; +} + +.creds-close-btn:hover { + background-color: var(--border-color); + color: var(--text-primary); +} + +.creds-body { + padding: 16px 20px; + overflow-y: auto; +} + +.creds-loading { + color: var(--text-dim); + text-align: center; + padding: 24px 0; +} + +.creds-row { + margin-bottom: 14px; +} + +.creds-row:last-child { + margin-bottom: 0; +} + +.creds-label { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + margin-bottom: 4px; +} + +.creds-value-wrap { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.creds-value { + flex: 1; + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.82rem; + color: var(--accent-color); + background-color: #12121c; + padding: 8px 12px; + border-radius: 8px; + word-break: break-all; + white-space: pre-wrap; + line-height: 1.5; + border: 1px solid var(--border-color); +} + +.creds-copy-btn { + background-color: var(--border-color); + color: var(--text-primary); + font-size: 0.72rem; + font-weight: 600; + padding: 6px 10px; + border-radius: 6px; + cursor: pointer; + border: none; + white-space: nowrap; + flex-shrink: 0; + align-self: flex-start; + margin-top: 6px; +} + +.creds-copy-btn:hover { + background-color: #5a5c72; +} + +.creds-copy-btn.copied { + background-color: var(--green); + color: #fff; +} + +.creds-empty { + color: var(--text-dim); + text-align: center; + padding: 24px 0; + font-size: 0.88rem; +} + /* ── Reboot overlay ─────────────────────────────────────────────── */ .reboot-overlay { @@ -634,4 +787,7 @@ button.btn-reboot:hover:not(:disabled) { padding: 36px 28px; margin: 0 16px; } + .creds-dialog { + margin: 0 12px; + } } \ No newline at end of file diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index e9144cd..e7f0f7e 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -4,7 +4,7 @@ Sovran_SystemsOS Hub - + @@ -54,6 +54,19 @@ + + +
      @@ -72,6 +85,6 @@
      - + \ No newline at end of file diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 3586db2..1e3ddd4 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -6,37 +6,62 @@ let monitoredServices = # ── Infrastructure (always present) ──────────────────────── [ - { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; category = "infrastructure"; } - { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; category = "infrastructure"; } + { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; category = "infrastructure"; credentials = []; } + { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; category = "infrastructure"; credentials = []; } ] # ── Bitcoin Base (node implementations) ──────────────────── ++ [ - { name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; } - { name = "Bitcoin Knots"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core && !cfg.features.bip110; category = "bitcoin-base"; } - { name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; } + { name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; credentials = [ + { label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; } + ]; } + { name = "Bitcoin Knots"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core && !cfg.features.bip110; category = "bitcoin-base"; credentials = [ + { label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; } + ]; } + { name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [ + { label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; } + ]; } ] # ── Bitcoin Apps (services on top of the node) ───────────── ++ [ - { name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; } - { name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; } - { name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; } - { name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; } - { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; } + { name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ + { label = "Tor Address"; file = "/var/lib/tor/onion/electrs/hostname"; } + { label = "Port"; value = "50001"; } + ]; } + { name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = []; } + { name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ + { label = "URL"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; } + { label = "Password"; file = "/etc/nix-bitcoin-secrets/rtl-password"; } + ]; } + { name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ + { label = "URL"; file = "/var/lib/domains/btcpayserver"; prefix = "https://"; } + { label = "Note"; value = "Create your admin account on first visit"; } + ]; } + { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = []; } ] # ── Communication ────────────────────────────────────────── ++ [ - { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; category = "communication"; } - { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; enabled = cfg.features.element-calling; category = "communication"; } + { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; category = "communication"; credentials = [ + { label = "Users"; file = "/var/lib/secrets/matrix-users"; multiline = true; } + ]; } + { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; enabled = cfg.features.element-calling; category = "communication"; credentials = []; } ] # ── Self-Hosted Apps ─────────────────────────────────────── ++ [ - { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; enabled = cfg.services.vaultwarden; category = "apps"; } - { name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; icon = "nextcloud"; enabled = cfg.services.nextcloud; category = "apps"; } - { name = "WordPress"; unit = "phpfpm-wordpress.service"; type = "system"; icon = "wordpress"; enabled = cfg.services.wordpress; category = "apps"; } + { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; enabled = cfg.services.vaultwarden; category = "apps"; credentials = [ + { label = "URL"; file = "/var/lib/domains/vaultwarden"; prefix = "https://"; } + { label = "Admin Panel"; file = "/var/lib/domains/vaultwarden"; prefix = "https://"; suffix = "/admin"; } + { label = "Admin Token"; file = "/var/lib/secrets/vaultwarden/vaultwarden.env"; extract = "ADMIN_TOKEN"; } + ]; } + { name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; icon = "nextcloud"; enabled = cfg.services.nextcloud; category = "apps"; credentials = [ + { label = "Credentials"; file = "/var/lib/secrets/nextcloud-admin"; multiline = true; } + ]; } + { name = "WordPress"; unit = "phpfpm-wordpress.service"; type = "system"; icon = "wordpress"; enabled = cfg.services.wordpress; category = "apps"; credentials = [ + { label = "Credentials"; file = "/var/lib/secrets/wordpress-admin"; multiline = true; } + ]; } ] # ── Nostr / Relay ────────────────────────────────────────── ++ [ - { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; enabled = cfg.features.haven; category = "nostr"; } + { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; enabled = cfg.features.haven; category = "nostr"; credentials = []; } ]; activeRole = @@ -52,7 +77,7 @@ let services = monitoredServices; }); - # ── Update wrapper script ────────────────────────────────────── + # ── Update wrapper script ─────────────────────────────────────�� update-script = pkgs.writeShellScript "sovran-hub-update.sh" '' set -uo pipefail export PATH="${lib.makeBinPath [ pkgs.nix pkgs.nixos-rebuild pkgs.git pkgs.flatpak pkgs.coreutils ]}:$PATH" -- 2.53.0 From 9f179295d8d9f328a2718f35eb1956cb2c509023 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 14:25:04 -0500 Subject: [PATCH 162/857] fixed app.js --- app/sovran_systemsos_web/static/app.js | 435 ++++++++++++++++++++++++- 1 file changed, 434 insertions(+), 1 deletion(-) diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 3120df6..01c38b5 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -72,6 +72,15 @@ function statusText(status, enabled) { return status; } +function escHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + // ── Fetch wrappers ──────────────────────────────────────────────── async function apiFetch(path, options = {}) { @@ -107,4 +116,428 @@ function buildTiles(services, categoryLabels) { const section = document.createElement("div"); section.className = "category-section"; - section.dataset.category \ No newline at end of file + section.dataset.category = catKey; + + section.innerHTML = ` +
      ${escHtml(label)}
      +
      +
      + `; + + const grid = section.querySelector(".tiles-grid"); + for (const svc of entries) { + grid.appendChild(buildTile(svc)); + } + + $tilesArea.appendChild(section); + } + + if ($tilesArea.children.length === 0) { + $tilesArea.innerHTML = `

      No services configured.

      `; + } +} + +function buildTile(svc) { + const sc = statusClass(svc.status); + const st = statusText(svc.status, svc.enabled); + const dis = !svc.enabled; + const isOn = svc.status === "active"; + const hasCreds = svc.has_credentials; + + const tile = document.createElement("div"); + tile.className = "service-tile" + (dis ? " disabled" : ""); + tile.dataset.unit = svc.unit; + if (dis) tile.title = `${svc.name} is not enabled in custom.nix`; + + // Info button (only if service has credentials) + const infoBtn = hasCreds + ? `` + : ""; + + tile.innerHTML = ` + ${infoBtn} + ${escHtml(svc.name)} + +
      ${escHtml(svc.name)}
      +
      + + ${escHtml(st)} +
      +
      +
      + + +
      + `; + + // Info button click handler + const infoBtnEl = tile.querySelector(".tile-info-btn"); + if (infoBtnEl) { + infoBtnEl.addEventListener("click", (e) => { + e.stopPropagation(); + openCredsModal(svc.unit, svc.name); + }); + } + + const chk = tile.querySelector(".tile-toggle"); + if (!dis) { + chk.addEventListener("change", async (e) => { + const action = e.target.checked ? "start" : "stop"; + chk.disabled = true; + try { + await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/${action}`, { method: "POST" }); + } catch (_) {} + setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY); + }); + } + + const restartBtn = tile.querySelector(".tile-restart-btn"); + if (!dis) { + restartBtn.addEventListener("click", async () => { + restartBtn.disabled = true; + try { + await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/restart`, { method: "POST" }); + } catch (_) {} + setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY); + }); + } + + return tile; +} + +// ── Render: live update (no DOM rebuild) ────────────────────────── + +function updateTiles(services) { + _servicesCache = services; + + for (const svc of services) { + const tile = $tilesArea.querySelector(`.service-tile[data-unit="${CSS.escape(svc.unit)}"]`); + if (!tile) continue; + + const sc = statusClass(svc.status); + const st = statusText(svc.status, svc.enabled); + + const dot = tile.querySelector(".status-dot"); + const text = tile.querySelector(".status-text"); + const chk = tile.querySelector(".tile-toggle"); + + if (dot) { dot.className = `status-dot ${sc}`; } + if (text) { text.textContent = st; } + if (chk && !chk.disabled) { + chk.checked = svc.status === "active"; + } + } +} + +// ── Service polling ─────────────────────────────────────────────── + +let _firstLoad = true; + +async function refreshServices() { + try { + const services = await apiFetch("/api/services"); + if (_firstLoad) { + buildTiles(services, _categoryLabels); + _firstLoad = false; + } else { + updateTiles(services); + } + } catch (err) { + console.warn("Failed to fetch services:", err); + } +} + +// ── Network IPs ─────────────────────────────────────────────────── + +async function loadNetwork() { + try { + const data = await apiFetch("/api/network"); + if ($internalIp) $internalIp.textContent = data.internal_ip || "—"; + if ($externalIp) $externalIp.textContent = data.external_ip || "—"; + } catch (_) { + if ($internalIp) $internalIp.textContent = "—"; + if ($externalIp) $externalIp.textContent = "—"; + } +} + +// ── Update check ────────────────────────────────────────────────── + +async function checkUpdates() { + try { + const data = await apiFetch("/api/updates/check"); + const hasUpdates = !!data.available; + if ($updateBadge) { + $updateBadge.classList.toggle("visible", hasUpdates); + } + if ($updateBtn) { + $updateBtn.classList.toggle("has-updates", hasUpdates); + } + } catch (_) {} +} + +// ── Credentials info modal ──────────────────────────────────────── + +async function openCredsModal(unit, name) { + if (!$credsModal) return; + + if ($credsTitle) $credsTitle.textContent = name + " — Connection Info"; + if ($credsBody) $credsBody.innerHTML = '

      Loading…

      '; + + $credsModal.classList.add("open"); + + try { + const data = await apiFetch(`/api/credentials/${encodeURIComponent(unit)}`); + + if (!data.credentials || data.credentials.length === 0) { + $credsBody.innerHTML = '

      No connection info available yet.

      '; + return; + } + + let html = ""; + for (const cred of data.credentials) { + const id = "cred-" + Math.random().toString(36).substring(2, 8); + html += ` +
      +
      ${escHtml(cred.label)}
      +
      +
      ${escHtml(cred.value)}
      + +
      +
      + `; + } + $credsBody.innerHTML = html; + + // Attach copy handlers + $credsBody.querySelectorAll(".creds-copy-btn").forEach(btn => { + btn.addEventListener("click", () => { + const target = document.getElementById(btn.dataset.target); + if (target) { + navigator.clipboard.writeText(target.textContent).then(() => { + btn.textContent = "Copied!"; + btn.classList.add("copied"); + setTimeout(() => { + btn.textContent = "Copy"; + btn.classList.remove("copied"); + }, 1500); + }).catch(() => {}); + } + }); + }); + + } catch (err) { + $credsBody.innerHTML = '

      Could not load credentials.

      '; + } +} + +function closeCredsModal() { + if ($credsModal) $credsModal.classList.remove("open"); +} + +// ── Update modal ────────────────────────────────────────────────── + +function openUpdateModal() { + if (!$modal) return; + _updateLog = ""; + _updateLogOffset = 0; + _serverWasDown = false; + _updateFinished = false; + if ($modalLog) $modalLog.textContent = ""; + if ($modalStatus) $modalStatus.textContent = "Starting update…"; + if ($modalSpinner) $modalSpinner.classList.add("spinning"); + if ($btnReboot) { $btnReboot.style.display = "none"; } + if ($btnSave) { $btnSave.style.display = "none"; } + if ($btnCloseModal) { $btnCloseModal.disabled = true; } + + $modal.classList.add("open"); + startUpdate(); +} + +function closeUpdateModal() { + if (!$modal) return; + $modal.classList.remove("open"); + stopUpdatePoll(); +} + +function appendLog(text) { + if (!text) return; + _updateLog += text; + if ($modalLog) { + $modalLog.textContent += text; + $modalLog.scrollTop = $modalLog.scrollHeight; + } +} + +function startUpdate() { + fetch("/api/updates/run", { method: "POST" }) + .then(response => { + if (!response.ok) { + return response.text().then(t => { throw new Error(t); }); + } + return response.json(); + }) + .then(data => { + if (data.status === "already_running") { + appendLog("[Update already in progress, attaching…]\n\n"); + } + if ($modalStatus) $modalStatus.textContent = "Updating…"; + startUpdatePoll(); + }) + .catch(err => { + appendLog(`[Error: failed to start update — ${err}]\n`); + onUpdateDone(false); + }); +} + +function startUpdatePoll() { + pollUpdateStatus(); + _updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL); +} + +function stopUpdatePoll() { + if (_updatePollTimer) { + clearInterval(_updatePollTimer); + _updatePollTimer = null; + } +} + +async function pollUpdateStatus() { + if (_updateFinished) return; + + try { + const data = await apiFetch(`/api/updates/status?offset=${_updateLogOffset}`); + + if (_serverWasDown) { + _serverWasDown = false; + appendLog("[Server reconnected]\n"); + if ($modalStatus) $modalStatus.textContent = "Updating…"; + } + + if (data.log) { + appendLog(data.log); + } + _updateLogOffset = data.offset; + + if (data.running) { + return; + } + + _updateFinished = true; + stopUpdatePoll(); + + if (data.result === "success") { + onUpdateDone(true); + } else { + onUpdateDone(false); + } + } catch (err) { + if (!_serverWasDown) { + _serverWasDown = true; + appendLog("\n[Server restarting — waiting for it to come back…]\n"); + if ($modalStatus) $modalStatus.textContent = "Server restarting…"; + } + } +} + +function onUpdateDone(success) { + if ($modalSpinner) $modalSpinner.classList.remove("spinning"); + if ($btnCloseModal) $btnCloseModal.disabled = false; + + if (success) { + if ($modalStatus) $modalStatus.textContent = "āœ“ Update complete"; + if ($btnReboot) $btnReboot.style.display = "inline-flex"; + } else { + if ($modalStatus) $modalStatus.textContent = "āœ— Update failed"; + if ($btnSave) $btnSave.style.display = "inline-flex"; + if ($btnReboot) $btnReboot.style.display = "inline-flex"; + } +} + +function saveErrorReport() { + const blob = new Blob([_updateLog], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `sovran-update-error-${new Date().toISOString().split('.')[0].replace(/:/g, '-')}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// ── Reboot with confirmation overlay ────────────────────────────── + +function doReboot() { + if ($modal) $modal.classList.remove("open"); + stopUpdatePoll(); + if ($rebootOverlay) $rebootOverlay.classList.add("visible"); + fetch("/api/reboot", { method: "POST" }).catch(() => {}); + setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); +} + +function waitForServerReboot() { + fetch("/api/config", { cache: "no-store" }) + .then(res => { + if (res.ok) { + window.location.reload(); + } else { + setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); + } + }) + .catch(() => { + setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); + }); +} + +// ── Event listeners ─────────────────────────────────────────────── + +if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); +if ($refreshBtn) $refreshBtn.addEventListener("click", () => refreshServices()); +if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal); +if ($btnReboot) $btnReboot.addEventListener("click", doReboot); +if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); +if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal); + +if ($modal) { + $modal.addEventListener("click", (e) => { + if (e.target === $modal) closeUpdateModal(); + }); +} + +if ($credsModal) { + $credsModal.addEventListener("click", (e) => { + if (e.target === $credsModal) closeCredsModal(); + }); +} + +// ── Init ────────────────────────────────────────────────────────── + +async function init() { + try { + const cfg = await apiFetch("/api/config"); + if (cfg.category_order) { + for (const [key, label] of cfg.category_order) { + _categoryLabels[key] = label; + } + } + const badge = document.getElementById("role-badge"); + if (badge && cfg.role_label) badge.textContent = cfg.role_label; + } catch (_) {} + + await refreshServices(); + loadNetwork(); + checkUpdates(); + + setInterval(refreshServices, POLL_INTERVAL_SERVICES); + setInterval(checkUpdates, POLL_INTERVAL_UPDATES); +} + +document.addEventListener("DOMContentLoaded", init); \ No newline at end of file -- 2.53.0 From a7d3af4ddd9e9680aff3999c70a1d1f91514bf44 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 14:50:07 -0500 Subject: [PATCH 163/857] fixed cache --- app/sovran_systemsos_web/server.py | 23 +++++++++++++++++-- app/sovran_systemsos_web/templates/index.html | 4 ++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 2034a0f..8233497 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import hashlib import json import os import re @@ -71,6 +72,20 @@ if os.path.isdir(_ICONS_DIR): templates = Jinja2Templates(directory=os.path.join(_BASE_DIR, "templates")) +# ── Static asset cache-busting ──────────────────────────────────── + +def _file_hash(filename: str) -> str: + """Return first 8 chars of the MD5 hex digest for a static file.""" + path = os.path.join(_BASE_DIR, "static", filename) + try: + with open(path, "rb") as f: + return hashlib.md5(f.read()).hexdigest()[:8] + except FileNotFoundError: + return "0" + +_APP_JS_HASH = _file_hash("app.js") +_STYLE_CSS_HASH = _file_hash("style.css") + # ── Update check helpers ───────────────────────────────────────── def _get_locked_info(): @@ -230,11 +245,15 @@ def _resolve_credential(cred: dict) -> dict | None: return {"label": label, "value": value, "multiline": multiline} -# ── Routes ─────────────────────────────────────────────────────── +# ── Routes ───────────��─────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) async def index(request: Request): - return templates.TemplateResponse("index.html", {"request": request}) + return templates.TemplateResponse("index.html", { + "request": request, + "app_js_hash": _APP_JS_HASH, + "style_css_hash": _STYLE_CSS_HASH, + }) @app.get("/api/config") diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index e7f0f7e..94970ed 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -4,7 +4,7 @@ Sovran_SystemsOS Hub - + @@ -85,6 +85,6 @@ - + \ No newline at end of file -- 2.53.0 From f1e79d64083421ee538029ffd9e55136d472762e Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 14:55:32 -0500 Subject: [PATCH 164/857] added password tile --- modules/core/sovran-hub.nix | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 1e3ddd4..e9b23e3 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -8,6 +8,11 @@ let [ { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; category = "infrastructure"; credentials = []; } { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; category = "infrastructure"; credentials = []; } + { name = "System Passwords"; unit = "root-password-setup.service"; type = "system"; icon = "system"; enabled = true; category = "infrastructure"; credentials = [ + { label = "Free Account"; value = "Username: free / Password: free"; } + { label = "Root Password"; file = "/var/lib/secrets/root-password"; } + { label = "SSH Local Access"; value = "ssh root@localhost / Passphrase: gosovransystems"; } + ]; } ] # ── Bitcoin Base (node implementations) ──────────────────── ++ [ @@ -77,7 +82,7 @@ let services = monitoredServices; }); - # ── Update wrapper script ─────────────────────────────────────�� + # ── Update wrapper script ────────────────────────────────────── update-script = pkgs.writeShellScript "sovran-hub-update.sh" '' set -uo pipefail export PATH="${lib.makeBinPath [ pkgs.nix pkgs.nixos-rebuild pkgs.git pkgs.flatpak pkgs.coreutils ]}:$PATH" -- 2.53.0 From bb2c66a4dcbeeeee94697f4f01c7c4286f9a8a97 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 15:09:04 -0500 Subject: [PATCH 165/857] added passwd fix for user account --- modules/core/sovran-hub.nix | 3 +- modules/credentials-pdf.nix | 71 +++++++++++++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index e9b23e3..9e60227 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -9,7 +9,8 @@ let { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; category = "infrastructure"; credentials = []; } { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; category = "infrastructure"; credentials = []; } { name = "System Passwords"; unit = "root-password-setup.service"; type = "system"; icon = "system"; enabled = true; category = "infrastructure"; credentials = [ - { label = "Free Account"; value = "Username: free / Password: free"; } + { label = "Free Account — Username"; value = "free"; } + { label = "Free Account — Password"; file = "/var/lib/secrets/free-password"; } { label = "Root Password"; file = "/var/lib/secrets/root-password"; } { label = "SSH Local Access"; value = "ssh root@localhost / Passphrase: gosovransystems"; } ]; } diff --git a/modules/credentials-pdf.nix b/modules/credentials-pdf.nix index 86e84f0..80110b9 100644 --- a/modules/credentials-pdf.nix +++ b/modules/credentials-pdf.nix @@ -2,8 +2,54 @@ let fonts = pkgs.liberation_ttf; + + # ── Helper: change 'free' password and save it ───────────── + change-free-password = pkgs.writeShellScriptBin "change-free-password" '' + set -euo pipefail + SECRET_FILE="/var/lib/secrets/free-password" + + if [ -z "''${1:-}" ]; then + echo -n "New password for free: " + read -rs NEW_PASS + echo + else + NEW_PASS="$1" + fi + + echo "free:$NEW_PASS" | ${pkgs.shadow}/bin/chpasswd + mkdir -p /var/lib/secrets + echo "$NEW_PASS" > "$SECRET_FILE" + chmod 600 "$SECRET_FILE" + echo "Password for 'free' updated and saved." + ''; + + # ── Wrapper: intercept 'passwd free' ─────────────────────── + passwd-wrapper = pkgs.writeShellScriptBin "passwd" '' + # If the target user is 'free', redirect to the proper tool + TARGET="''${1:-}" + + if [ "$TARGET" = "free" ]; then + echo "" + echo "╔══════════════════════════════════════════════════════╗" + echo "ā•‘ ⚠ Use 'change-free-password' instead of 'passwd' ā•‘" + echo "ā•‘ ā•‘" + echo "ā•‘ 'passwd free' only updates /etc/shadow. ā•‘" + echo "ā•‘ The Hub and Magic Keys PDF will NOT be updated. ā•‘" + echo "ā•‘ ā•‘" + echo "ā•‘ Redirecting to change-free-password now... ā•‘" + echo "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" + echo "" + exec ${change-free-password}/bin/change-free-password + fi + + # For all other users, pass through to the real passwd + exec ${pkgs.shadow}/bin/passwd "$@" + ''; in { + # ── Make helpers available system-wide ────────────────────── + environment.systemPackages = [ change-free-password passwd-wrapper ]; + # ── 1. Auto-Generate Root Password (Runs once) ───────────── systemd.services.root-password-setup = { description = "Generate and set a random root password"; @@ -25,6 +71,25 @@ in ''; }; + # ── 1b. Save 'free' password on first boot ───────────────── + systemd.services.free-password-setup = { + description = "Save the initial 'free' user password"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ pkgs.coreutils ]; + script = '' + SECRET_FILE="/var/lib/secrets/free-password" + if [ ! -f "$SECRET_FILE" ]; then + mkdir -p /var/lib/secrets + echo "free" > "$SECRET_FILE" + chmod 600 "$SECRET_FILE" + fi + ''; + }; + # ── 2. Timer: Check every 5 minutes ──────────────────────── systemd.timers.generate-credentials-pdf = { description = "Periodically check if Magic Keys PDF needs regenerating"; @@ -70,6 +135,7 @@ in SECRET_SOURCES="" for f in \ /var/lib/secrets/root-password \ + /var/lib/secrets/free-password \ /etc/nix-bitcoin-secrets/rtl-password \ /var/lib/tor/onion/rtl/hostname \ /var/lib/tor/onion/electrs/hostname \ @@ -112,6 +178,7 @@ in read_secret() { if [ -f "$1" ]; then cat "$1"; else echo "$2"; fi; } ROOT_PASS=$(read_secret /var/lib/secrets/root-password "Generating...") + FREE_PASS=$(read_secret /var/lib/secrets/free-password "free") RTL_PASS=$(read_secret /etc/nix-bitcoin-secrets/rtl-password "Not found") RTL_ONION=$(read_secret /var/lib/tor/onion/rtl/hostname "Not generated yet") ELECTRS_ONION=$(read_secret /var/lib/tor/onion/electrs/hostname "Not generated yet") @@ -150,7 +217,7 @@ These are the master keys to the actual machine. ### 1. Main Screen Unlock (The 'free' account) When you turn the computer on, it usually logs you in automatically. However, if the screen goes to sleep, or **if you enable Remote Desktop (RDP)**, you will need this to log in: - **Username:** \`free\` -- **Password:** \`free\` +- **Password:** \`$FREE_PASS\` 🚨 **VERY IMPORTANT:** You MUST write this password down and keep it safe! If you lose it, you will be locked out of your computer! @@ -306,4 +373,4 @@ BITCOIN echo "PDF generated successfully." ''; }; -} +} \ No newline at end of file -- 2.53.0 From 987d62ce4d4f3b6ad226cb10190d687d683e3851 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 15:14:15 -0500 Subject: [PATCH 166/857] updated security --- modules/credentials-pdf.nix | 88 ++++++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 31 deletions(-) diff --git a/modules/credentials-pdf.nix b/modules/credentials-pdf.nix index 80110b9..739604d 100644 --- a/modules/credentials-pdf.nix +++ b/modules/credentials-pdf.nix @@ -8,12 +8,26 @@ let set -euo pipefail SECRET_FILE="/var/lib/secrets/free-password" - if [ -z "''${1:-}" ]; then - echo -n "New password for free: " - read -rs NEW_PASS - echo - else - NEW_PASS="$1" + if [ "$(id -u)" -ne 0 ]; then + echo "Error: must be run as root (use sudo)." >&2 + exit 1 + fi + + echo -n "New password for free: " + read -rs NEW_PASS + echo + echo -n "Confirm password: " + read -rs CONFIRM + echo + + if [ "$NEW_PASS" != "$CONFIRM" ]; then + echo "Passwords do not match." >&2 + exit 1 + fi + + if [ -z "$NEW_PASS" ]; then + echo "Password cannot be empty." >&2 + exit 1 fi echo "free:$NEW_PASS" | ${pkgs.shadow}/bin/chpasswd @@ -22,33 +36,45 @@ let chmod 600 "$SECRET_FILE" echo "Password for 'free' updated and saved." ''; - - # ── Wrapper: intercept 'passwd free' ─────────────────────── - passwd-wrapper = pkgs.writeShellScriptBin "passwd" '' - # If the target user is 'free', redirect to the proper tool - TARGET="''${1:-}" - - if [ "$TARGET" = "free" ]; then - echo "" - echo "╔══════════════════════════════════════════════════════╗" - echo "ā•‘ ⚠ Use 'change-free-password' instead of 'passwd' ā•‘" - echo "ā•‘ ā•‘" - echo "ā•‘ 'passwd free' only updates /etc/shadow. ā•‘" - echo "ā•‘ The Hub and Magic Keys PDF will NOT be updated. ā•‘" - echo "ā•‘ ā•‘" - echo "ā•‘ Redirecting to change-free-password now... ā•‘" - echo "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" - echo "" - exec ${change-free-password}/bin/change-free-password - fi - - # For all other users, pass through to the real passwd - exec ${pkgs.shadow}/bin/passwd "$@" - ''; in { - # ── Make helpers available system-wide ────────────────────── - environment.systemPackages = [ change-free-password passwd-wrapper ]; + # ── Make helper available system-wide ─────────────────────── + environment.systemPackages = [ change-free-password ]; + + # ── Shell aliases: intercept 'passwd free' ───────────────── + programs.bash.interactiveShellInit = '' + passwd() { + if [ "$1" = "free" ]; then + echo "" + echo "╔══════════════════════════════════════════════════════╗" + echo "ā•‘ ⚠ Use 'sudo change-free-password' instead. ā•‘" + echo "ā•‘ ā•‘" + echo "ā•‘ 'passwd free' only updates /etc/shadow. ā•‘" + echo "ā•‘ The Hub and Magic Keys PDF will NOT be updated. ā•‘" + echo "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" + echo "" + return 1 + fi + command passwd "$@" + } + ''; + + programs.fish.interactiveShellInit = '' + function passwd --wraps passwd + if test "$argv[1]" = "free" + echo "" + echo "╔══════════════════════════════════════════════════════╗" + echo "ā•‘ ⚠ Use 'sudo change-free-password' instead. ā•‘" + echo "ā•‘ ā•‘" + echo "ā•‘ 'passwd free' only updates /etc/shadow. ā•‘" + echo "ā•‘ The Hub and Magic Keys PDF will NOT be updated. ā•‘" + echo "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ļæ½ļæ½ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" + echo "" + return 1 + end + command passwd $argv + end + ''; # ── 1. Auto-Generate Root Password (Runs once) ───────────── systemd.services.root-password-setup = { -- 2.53.0 From ae1f39f0c8b470af6a48bfcdd553cf35968b165f Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 15:24:24 -0500 Subject: [PATCH 167/857] updated BIP110 --- app/sovran_systemsos_web/static/app.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 01c38b5..2f039ab 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -56,6 +56,10 @@ const $credsCloseBtn = document.getElementById("creds-close-btn"); // ── Helpers ─────────────────────────────────────────────────────── +function tileId(svc) { + return svc.unit + "::" + svc.name; +} + function statusClass(status) { if (!status) return "unknown"; if (status === "active") return "active"; @@ -147,6 +151,7 @@ function buildTile(svc) { const tile = document.createElement("div"); tile.className = "service-tile" + (dis ? " disabled" : ""); tile.dataset.unit = svc.unit; + tile.dataset.tileId = tileId(svc); if (dis) tile.title = `${svc.name} is not enabled in custom.nix`; // Info button (only if service has credentials) @@ -219,7 +224,8 @@ function updateTiles(services) { _servicesCache = services; for (const svc of services) { - const tile = $tilesArea.querySelector(`.service-tile[data-unit="${CSS.escape(svc.unit)}"]`); + const id = CSS.escape(tileId(svc)); + const tile = $tilesArea.querySelector(`.service-tile[data-tile-id="${id}"]`); if (!tile) continue; const sc = statusClass(svc.status); @@ -263,7 +269,7 @@ async function loadNetwork() { if ($internalIp) $internalIp.textContent = data.internal_ip || "—"; if ($externalIp) $externalIp.textContent = data.external_ip || "—"; } catch (_) { - if ($internalIp) $internalIp.textContent = "—"; + if ($internalIp) $internalIp.textContent = "��"; if ($externalIp) $externalIp.textContent = "—"; } } -- 2.53.0 From b6dfbc4a5643f4d0e32835acc4ba620997d6ade8 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 15:37:07 -0500 Subject: [PATCH 168/857] frontend uses weblinks --- app/sovran_systemsos_web/static/app.js | 13 ++++++-- app/sovran_systemsos_web/static/style.css | 18 +++++++++-- modules/core/sovran-hub.nix | 39 +++++++++++++++++++++-- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 2f039ab..72b0160 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -85,6 +85,14 @@ function escHtml(str) { .replace(/'/g, "'"); } +function linkify(str) { + const escaped = escHtml(str); + return escaped.replace( + /(https?:\/\/[^\s<]+)/g, + '
      $1' + ); +} + // ── Fetch wrappers ──────────────────────────────────────────────── async function apiFetch(path, options = {}) { @@ -269,7 +277,7 @@ async function loadNetwork() { if ($internalIp) $internalIp.textContent = data.internal_ip || "—"; if ($externalIp) $externalIp.textContent = data.external_ip || "—"; } catch (_) { - if ($internalIp) $internalIp.textContent = "��"; + if ($internalIp) $internalIp.textContent = "—"; if ($externalIp) $externalIp.textContent = "—"; } } @@ -310,11 +318,12 @@ async function openCredsModal(unit, name) { let html = ""; for (const cred of data.credentials) { const id = "cred-" + Math.random().toString(36).substring(2, 8); + const displayValue = linkify(cred.value); html += `
      ${escHtml(cred.label)}
      -
      ${escHtml(cred.value)}
      +
      ${displayValue}
      diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 62eeee2..e78c96c 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -780,7 +780,7 @@ button.btn-reboot:hover:not(:disabled) { justify-content: center; } .service-tile { - width: 160px; + : width: 160px; min-height: 200px; } .reboot-card { @@ -790,4 +790,18 @@ button.btn-reboot:hover:not(:disabled) { .creds-dialog { margin: 0 12px; } -} \ No newline at end of file + +/* ── Credential links ─────────────────────────────────────── */ +.creds-link { + color: #58a6ff; + text-decoration: none; + word-break: break-all; +} +.creds-link:hover { + text-decoration: underline; + color: #79c0ff; +} + + + +} diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 9e60227..a0f1d1f 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -35,14 +35,18 @@ let ]; } { name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = []; } { name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ - { label = "URL"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; } + { label = "Tor Access"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; } + { label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3050"; } { label = "Password"; file = "/etc/nix-bitcoin-secrets/rtl-password"; } ]; } { name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ { label = "URL"; file = "/var/lib/domains/btcpayserver"; prefix = "https://"; } { label = "Note"; value = "Create your admin account on first visit"; } ]; } - { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = []; } + { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [ + { label = "Tor Access"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; } + { label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; } + ]; } ] # ── Communication ────────────────────────────────────────── ++ [ @@ -205,6 +209,37 @@ LAUNCHER in { config = { + # ── Save internal IP for hub credentials ──────────────────── + systemd.services.save-internal-ip = { + description = "Save internal IP address for hub credentials"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ pkgs.iproute2 pkgs.coreutils pkgs.hostname ]; + script = '' + mkdir -p /var/lib/secrets + IP=$(hostname -I | awk '{print $1}') + if [ -n "$IP" ]; then + echo "$IP" > /var/lib/secrets/internal-ip + chmod 644 /var/lib/secrets/internal-ip + fi + ''; + }; + + # ── Refresh IP periodically (in case DHCP changes it) ────── + systemd.timers.save-internal-ip = { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "30s"; + OnUnitActiveSec = "15min"; + Unit = "save-internal-ip.service"; + }; + }; + # ── Web server as a systemd service ──────────────────────── systemd.services.sovran-hub-web = { description = "Sovran_SystemsOS Hub Web Interface"; -- 2.53.0 From ad0f04c0bffbe2533ca88a496ce52576f1d96f39 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 15:43:32 -0500 Subject: [PATCH 169/857] frontend link fix --- app/sovran_systemsos_web/server.py | 29 +++++++++++++++++++-- app/sovran_systemsos_web/static/style.css | 6 +++-- modules/core/sovran-hub.nix | 31 ----------------------- 3 files changed, 31 insertions(+), 35 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 8233497..aaaf9b9 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -30,6 +30,8 @@ UPDATE_LOG = "/var/log/sovran-hub-update.log" UPDATE_STATUS = "/var/log/sovran-hub-update.status" UPDATE_UNIT = "sovran-hub-update.service" +INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip" + REBOOT_COMMAND = ["reboot"] CATEGORY_ORDER = [ @@ -154,6 +156,17 @@ def _get_internal_ip() -> str: return "unavailable" +def _save_internal_ip(ip: str): + """Write the internal IP to a file so credentials can reference it.""" + if ip and ip != "unavailable": + try: + os.makedirs(os.path.dirname(INTERNAL_IP_FILE), exist_ok=True) + with open(INTERNAL_IP_FILE, "w") as f: + f.write(ip) + except OSError: + pass + + def _get_external_ip() -> str: MAX_IP_LENGTH = 46 for url in [ @@ -245,7 +258,7 @@ def _resolve_credential(cred: dict) -> dict | None: return {"label": label, "value": value, "multiline": multiline} -# ── Routes ───────────��─────────────────────────────────────────── +# ── Routes ─────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) async def index(request: Request): @@ -392,6 +405,8 @@ async def api_network(): loop.run_in_executor(None, _get_internal_ip), loop.run_in_executor(None, _get_external_ip), ) + # Keep the internal-ip file in sync for credential lookups + _save_internal_ip(internal) return {"internal_ip": internal, "external_ip": external} @@ -461,4 +476,14 @@ async def api_updates_status(offset: int = 0): "result": result, "log": new_log, "offset": new_offset, - } \ No newline at end of file + } + + +# ── Startup: seed the internal IP file immediately ─────────────── + +@app.on_event("startup") +async def _startup_save_ip(): + """Write internal IP to file on server start so credentials work immediately.""" + loop = asyncio.get_event_loop() + ip = await loop.run_in_executor(None, _get_internal_ip) + _save_internal_ip(ip) \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index e78c96c..5db74e6 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -793,13 +793,15 @@ button.btn-reboot:hover:not(:disabled) { /* ── Credential links ─────────────────────────────────────── */ .creds-link { - color: #58a6ff; + color: #7ee787; text-decoration: none; word-break: break-all; } .creds-link:hover { text-decoration: underline; - color: #79c0ff; + color: #a5f0b0; +} + } diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index a0f1d1f..fda649b 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -209,37 +209,6 @@ LAUNCHER in { config = { - # ── Save internal IP for hub credentials ──────────────────── - systemd.services.save-internal-ip = { - description = "Save internal IP address for hub credentials"; - wantedBy = [ "multi-user.target" ]; - after = [ "network-online.target" ]; - wants = [ "network-online.target" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - }; - path = [ pkgs.iproute2 pkgs.coreutils pkgs.hostname ]; - script = '' - mkdir -p /var/lib/secrets - IP=$(hostname -I | awk '{print $1}') - if [ -n "$IP" ]; then - echo "$IP" > /var/lib/secrets/internal-ip - chmod 644 /var/lib/secrets/internal-ip - fi - ''; - }; - - # ── Refresh IP periodically (in case DHCP changes it) ────── - systemd.timers.save-internal-ip = { - wantedBy = [ "timers.target" ]; - timerConfig = { - OnBootSec = "30s"; - OnUnitActiveSec = "15min"; - Unit = "save-internal-ip.service"; - }; - }; - # ── Web server as a systemd service ──────────────────────── systemd.services.sovran-hub-web = { description = "Sovran_SystemsOS Hub Web Interface"; -- 2.53.0 From 31539510d5d3fbd6bf90cb3baf052d34da26173e Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 15:46:23 -0500 Subject: [PATCH 170/857] frontend link fix --- app/sovran_systemsos_web/static/style.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 5db74e6..bcf401d 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -803,7 +803,3 @@ button.btn-reboot:hover:not(:disabled) { } } - - - -} -- 2.53.0 From 78c1d2f2bcee18a0ee4a429a3bf36c980588352a Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 15:50:31 -0500 Subject: [PATCH 171/857] frontend link fix --- app/sovran_systemsos_web/static/style.css | 63 ++++++++++++----------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index bcf401d..7dc40d0 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -537,8 +537,8 @@ button.btn-reboot:hover:not(:disabled) { border: 1px solid var(--border-color); border-radius: 16px; width: 90vw; - max-width: 520px; - max-height: 80vh; + max-width: 700px; + max-height: 85vh; display: flex; flex-direction: column; box-shadow: 0 16px 48px rgba(0,0,0,0.7); @@ -553,12 +553,12 @@ button.btn-reboot:hover:not(:disabled) { .creds-header { display: flex; align-items: center; - padding: 16px 20px; + padding: 20px 28px; border-bottom: 1px solid var(--border-color); } .creds-title { - font-size: 1rem; + font-size: 1.15rem; font-weight: 700; flex: 1; } @@ -566,7 +566,7 @@ button.btn-reboot:hover:not(:disabled) { .creds-close-btn { background: none; color: var(--text-secondary); - font-size: 1.1rem; + font-size: 1.3rem; padding: 4px 8px; border-radius: 6px; cursor: pointer; @@ -579,7 +579,7 @@ button.btn-reboot:hover:not(:disabled) { } .creds-body { - padding: 16px 20px; + padding: 24px 28px; overflow-y: auto; } @@ -590,7 +590,7 @@ button.btn-reboot:hover:not(:disabled) { } .creds-row { - margin-bottom: 14px; + margin-bottom: 20px; } .creds-row:last-child { @@ -598,47 +598,47 @@ button.btn-reboot:hover:not(:disabled) { } .creds-label { - font-size: 0.72rem; + font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); - margin-bottom: 4px; + margin-bottom: 6px; } .creds-value-wrap { display: flex; align-items: flex-start; - gap: 8px; + gap: 10px; } .creds-value { flex: 1; font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.82rem; - color: var(--accent-color); + font-size: 0.92rem; + color: var(--text-primary); background-color: #12121c; - padding: 8px 12px; + padding: 12px 16px; border-radius: 8px; word-break: break-all; white-space: pre-wrap; - line-height: 1.5; + line-height: 1.6; border: 1px solid var(--border-color); } .creds-copy-btn { background-color: var(--border-color); color: var(--text-primary); - font-size: 0.72rem; + font-size: 0.78rem; font-weight: 600; - padding: 6px 10px; + padding: 8px 14px; border-radius: 6px; cursor: pointer; border: none; white-space: nowrap; flex-shrink: 0; align-self: flex-start; - margin-top: 6px; + margin-top: 10px; } .creds-copy-btn:hover { @@ -657,6 +657,19 @@ button.btn-reboot:hover:not(:disabled) { font-size: 0.88rem; } +/* ── Credential links ────────────────────────────────────────────── */ + +.creds-link { + color: #b8f0c0; + text-decoration: none; + word-break: break-all; +} + +.creds-link:hover { + text-decoration: underline; + color: #defce6; +} + /* ── Reboot overlay ─────────────────────────────────────────────── */ .reboot-overlay { @@ -780,7 +793,7 @@ button.btn-reboot:hover:not(:disabled) { justify-content: center; } .service-tile { - : width: 160px; + width: 160px; min-height: 200px; } .reboot-card { @@ -790,16 +803,4 @@ button.btn-reboot:hover:not(:disabled) { .creds-dialog { margin: 0 12px; } - -/* ── Credential links ─────────────────────────────────────── */ -.creds-link { - color: #7ee787; - text-decoration: none; - word-break: break-all; -} -.creds-link:hover { - text-decoration: underline; - color: #a5f0b0; -} - -} +} \ No newline at end of file -- 2.53.0 From 195f616ca88fe0984c4853ffe0604f4105461adc Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 15:55:57 -0500 Subject: [PATCH 172/857] frontend onion links --- modules/core/sovran-hub.nix | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index fda649b..fa51754 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -18,19 +18,19 @@ let # ── Bitcoin Base (node implementations) ──────────────────── ++ [ { name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; credentials = [ - { label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; } + { label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; } ]; } { name = "Bitcoin Knots"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core && !cfg.features.bip110; category = "bitcoin-base"; credentials = [ - { label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; } + { label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; } ]; } { name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [ - { label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; } + { label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; } ]; } ] # ── Bitcoin Apps (services on top of the node) ───────────── ++ [ { name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ - { label = "Tor Address"; file = "/var/lib/tor/onion/electrs/hostname"; } + { label = "Tor Address"; file = "/var/lib/tor/onion/electrs/hostname"; prefix = "http://"; } { label = "Port"; value = "50001"; } ]; } { name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = []; } @@ -237,4 +237,4 @@ in # ── Open firewall port ───────────────────────────────────── networking.firewall.allowedTCPPorts = [ 8937 ]; }; -} \ No newline at end of file +} -- 2.53.0 From 13f38f62547f19de2a6943b44921d94092e4d62d Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 16:03:55 -0500 Subject: [PATCH 173/857] added lndconnet --- modules/core/sovran-hub.nix | 6 ++++- modules/credentials-pdf.nix | 46 +++++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index fa51754..e7ccb59 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -43,6 +43,10 @@ let { label = "URL"; file = "/var/lib/domains/btcpayserver"; prefix = "https://"; } { label = "Note"; value = "Create your admin account on first visit"; } ]; } + { name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ + { label = "Connection URL"; file = "/var/lib/secrets/zeus-connect-url"; } + { label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus → Scan Node Config\n3. Copy and paste the Connection URL above"; } + ]; } { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [ { label = "Tor Access"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; } { label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; } @@ -237,4 +241,4 @@ in # ── Open firewall port ───────────────────────────────────── networking.firewall.allowedTCPPorts = [ 8937 ]; }; -} +} \ No newline at end of file diff --git a/modules/credentials-pdf.nix b/modules/credentials-pdf.nix index 739604d..09aa04b 100644 --- a/modules/credentials-pdf.nix +++ b/modules/credentials-pdf.nix @@ -68,7 +68,7 @@ in echo "ā•‘ ā•‘" echo "ā•‘ 'passwd free' only updates /etc/shadow. ā•‘" echo "ā•‘ The Hub and Magic Keys PDF will NOT be updated. ā•‘" - echo "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ļæ½ļæ½ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" + echo "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ļæ½ļæ½ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" echo "" return 1 end @@ -116,6 +116,47 @@ in ''; }; + # ── 1c. Save Zeus/lndconnect URL for hub credentials ──────── + systemd.services.zeus-connect-setup = { + description = "Save Zeus lndconnect URL"; + wantedBy = [ "multi-user.target" ]; + after = [ "lnd.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ pkgs.coreutils "/run/current-system/sw" ]; + script = '' + SECRET_FILE="/var/lib/secrets/zeus-connect-url" + mkdir -p /var/lib/secrets + + URL="" + if command -v lndconnect >/dev/null 2>&1; then + URL=$(lndconnect --url 2>/dev/null || true) + elif command -v lnconnect-clnrest >/dev/null 2>&1; then + URL=$(lnconnect-clnrest --url 2>/dev/null || true) + fi + + if [ -n "$URL" ]; then + echo "$URL" > "$SECRET_FILE" + chmod 600 "$SECRET_FILE" + echo "Zeus connect URL saved." + else + echo "No lndconnect URL available yet." + fi + ''; + }; + + # ── Refresh Zeus URL periodically (certs/macaroons may rotate) + systemd.timers.zeus-connect-setup = { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "2min"; + OnUnitActiveSec = "30min"; + Unit = "zeus-connect-setup.service"; + }; + }; + # ── 2. Timer: Check every 5 minutes ──────────────────────── systemd.timers.generate-credentials-pdf = { description = "Periodically check if Magic Keys PDF needs regenerating"; @@ -172,7 +213,8 @@ in /var/lib/secrets/wordpress-admin \ /var/lib/secrets/vaultwarden/vaultwarden.env \ /var/lib/domains/vaultwarden \ - /var/lib/domains/btcpayserver; do + /var/lib/domains/btcpayserver \ + /var/lib/secrets/zeus-connect-url; do if [ -f "$f" ]; then SECRET_SOURCES="$SECRET_SOURCES$(cat "$f")" fi -- 2.53.0 From d391905e92b68f3b5950b6f3a52b2304d978437f Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 16:10:44 -0500 Subject: [PATCH 174/857] added qr code --- app/sovran_systemsos_web/server.py | 40 ++++++- app/sovran_systemsos_web/static/app.js | 15 ++- app/sovran_systemsos_web/static/style.css | 139 ++-------------------- 3 files changed, 59 insertions(+), 135 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index aaaf9b9..d228f68 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -3,7 +3,9 @@ from __future__ import annotations import asyncio +import base64 import hashlib +import io import json import os import re @@ -31,6 +33,7 @@ UPDATE_STATUS = "/var/log/sovran-hub-update.status" UPDATE_UNIT = "sovran-hub-update.service" INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip" +ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url" REBOOT_COMMAND = ["reboot"] @@ -185,6 +188,24 @@ def _get_external_ip() -> str: return "unavailable" +# ── QR code helper ──────────────────────────────────────────────── + +def _generate_qr_base64(data: str) -> str | None: + """Generate a QR code PNG and return it as a base64-encoded data URI. + Uses qrencode CLI (available on the system via credentials-pdf.nix).""" + try: + result = subprocess.run( + ["qrencode", "-o", "-", "-t", "PNG", "-s", "6", "-m", "2", "-l", "H", data], + capture_output=True, timeout=10, + ) + if result.returncode == 0 and result.stdout: + b64 = base64.b64encode(result.stdout).decode("ascii") + return f"data:image/png;base64,{b64}" + except Exception: + pass + return None + + # ── Update helpers (file-based, no systemctl) ──────────────────── def _read_update_status() -> str: @@ -224,16 +245,22 @@ def _read_log(offset: int = 0) -> tuple[str, int]: # ── Credentials helpers ────────────────────────────────────────── def _resolve_credential(cred: dict) -> dict | None: - """Resolve a single credential entry to {label, value}.""" + """Resolve a single credential entry to {label, value, ...}.""" label = cred.get("label", "") prefix = cred.get("prefix", "") suffix = cred.get("suffix", "") extract = cred.get("extract", "") multiline = cred.get("multiline", False) + qrcode = cred.get("qrcode", False) # Static value if "value" in cred: - return {"label": label, "value": prefix + cred["value"] + suffix, "multiline": multiline} + result = {"label": label, "value": prefix + cred["value"] + suffix, "multiline": multiline} + if qrcode: + qr_data = _generate_qr_base64(result["value"]) + if qr_data: + result["qrcode"] = qr_data + return result # File-based value filepath = cred.get("file", "") @@ -255,7 +282,14 @@ def _resolve_credential(cred: dict) -> dict | None: return None value = prefix + raw + suffix - return {"label": label, "value": value, "multiline": multiline} + result = {"label": label, "value": value, "multiline": multiline} + + if qrcode: + qr_data = _generate_qr_base64(value) + if qr_data: + result["qrcode"] = qr_data + + return result # ── Routes ─────────────────────────────────────────────────────── diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 72b0160..616cef6 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -269,7 +269,7 @@ async function refreshServices() { } } -// ── Network IPs ─────────────────────────────────────────────────── +// ── Network IPs ──────────────────────────────────��──────────────── async function loadNetwork() { try { @@ -319,9 +319,22 @@ async function openCredsModal(unit, name) { for (const cred of data.credentials) { const id = "cred-" + Math.random().toString(36).substring(2, 8); const displayValue = linkify(cred.value); + + // QR code block (if present) + let qrBlock = ""; + if (cred.qrcode) { + qrBlock = ` +
      + QR Code for ${escHtml(cred.label)} +
      Scan with Zeus app on your phone
      +
      + `; + } + html += `
      ${escHtml(cred.label)}
      + ${qrBlock}
      ${displayValue}
      diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 7dc40d0..dc655a2 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -1,6 +1,6 @@ /* Sovran_SystemsOS Hub — Web UI Stylesheet Dark theme matching the Adwaita dark aesthetic - v4 — credentials info modal */ + v5 — QR code support in credentials modal */ *, *::before, *::after { box-sizing: border-box; @@ -530,7 +530,7 @@ button.btn-reboot:hover:not(:disabled) { background-color: #5a5c72; } -/* ── Credentials info modal ──────────────────────────────────────── */ +/* ── Credentials info modal ──────────────────────────────────────��─ */ .creds-dialog { background-color: var(--surface-color); @@ -670,137 +670,14 @@ button.btn-reboot:hover:not(:disabled) { color: #defce6; } -/* ── Reboot overlay ─────────────────────────────────────────────── */ +/* ── QR code in credentials modal ────────────────────────────────── */ -.reboot-overlay { - display: none; - position: fixed; - inset: 0; - background-color: rgba(15, 15, 25, 0.92); - z-index: 999; - align-items: center; - justify-content: center; -} - -.reboot-overlay.visible { - display: flex; -} - -.reboot-card { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: 20px; - padding: 48px 56px; - text-align: center; - max-width: 480px; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8); - animation: reboot-fade-in 0.4s ease-out; -} - -@keyframes reboot-fade-in { - from { opacity: 0; transform: scale(0.92) translateY(12px); } - to { opacity: 1; transform: scale(1) translateY(0); } -} - -.reboot-icon { - font-size: 3rem; - color: var(--accent-color); - margin-bottom: 16px; - animation: reboot-spin 2s linear infinite; - display: inline-block; -} - -@keyframes reboot-spin { - to { transform: rotate(360deg); } -} - -.reboot-title { - font-size: 1.35rem; - font-weight: 700; - color: var(--text-primary); - margin-bottom: 12px; -} - -.reboot-message { - font-size: 0.92rem; - color: var(--text-secondary); - line-height: 1.6; - margin-bottom: 24px; -} - -.reboot-dots { +.creds-qr-wrap { display: flex; + flex-direction: column; align-items: center; - justify-content: center; - gap: 8px; - margin-bottom: 16px; + padding: 20px 0; + margin-bottom: 10px; } -.reboot-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background-color: var(--accent-color); - animation: reboot-bounce 1.4s ease-in-out infinite; -} - -.reboot-dot:nth-child(2) { animation-delay: 0.2s; } -.reboot-dot:nth-child(3) { animation-delay: 0.4s; } - -@keyframes reboot-bounce { - 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } - 40% { opacity: 1; transform: scale(1.2); } -} - -.reboot-submessage { - font-size: 0.82rem; - color: var(--text-dim); - font-style: italic; -} - -/* ── Empty state ────────────────────────────────────────────────── */ - -.empty-state { - text-align: center; - padding: 64px 24px; - color: var(--text-dim); -} - -.empty-state p { - font-size: 1rem; - margin-bottom: 8px; -} - -/* ── Responsive ─────────────────────────────────────────────────── */ - -@media (max-width: 600px) { - .header-bar { - padding: 10px 14px; - gap: 10px; - } - .header-bar .title { - font-size: 0.95rem; - } - .ip-bar { - gap: 16px; - flex-wrap: wrap; - padding: 8px 14px; - } - .main-content { - padding: 16px 12px 40px; - } - .tiles-grid { - justify-content: center; - } - .service-tile { - width: 160px; - min-height: 200px; - } - .reboot-card { - padding: 36px 28px; - margin: 0 16px; - } - .creds-dialog { - margin: 0 12px; - } -} \ No newline at end of file +. \ No newline at end of file -- 2.53.0 From 2fefbf47e403137a02edd5fd2fdcf656df881a25 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 16:17:39 -0500 Subject: [PATCH 175/857] added qr code --- app/sovran_systemsos_web/static/style.css | 166 +++++++++++++++++++++- modules/core/sovran-hub.nix | 6 +- 2 files changed, 168 insertions(+), 4 deletions(-) diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index dc655a2..e9cf6d9 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -680,4 +680,168 @@ button.btn-reboot:hover:not(:disabled) { margin-bottom: 10px; } -. \ No newline at end of file +/* ── QR code in credentials modal ────────────────────────────────── */ + +.creds-qr-wrap { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + margin-bottom: 10px; +} + +.creds-qr-img { + width: 240px; + height: 240px; + border-radius: 12px; + border: 4px solid #fff; + background-color: #fff; + image-rendering: pixelated; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); +} + +.creds-qr-hint { + margin-top: 10px; + font-size: 0.82rem; + color: var(--text-secondary); + font-style: italic; +} + +/* ── Reboot overlay ─────────────────────────────────────────────── */ + +.reboot-overlay { + display: none; + position: fixed; + inset: 0; + background-color: rgba(15, 15, 25, 0.92); + z-index: 999; + align-items: center; + justify-content: center; +} + +.reboot-overlay.visible { + display: flex; +} + +.reboot-card { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 48px 56px; + text-align: center; + max-width: 480px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8); + animation: reboot-fade-in 0.4s ease-out; +} + +@keyframes reboot-fade-in { + from { opacity: 0; transform: scale(0.92) translateY(12px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.reboot-icon { + font-size: 3rem; + color: var(--accent-color); + margin-bottom: 16px; + animation: reboot-spin 2s linear infinite; + display: inline-block; +} + +@keyframes reboot-spin { + to { transform: rotate(360deg); } +} + +.reboot-title { + font-size: 1.35rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 12px; +} + +.reboot-message { + font-size: 0.92rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 24px; +} + +.reboot-dots { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: 16px; +} + +.reboot-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: var(--accent-color); + animation: reboot-bounce 1.4s ease-in-out infinite; +} + +.reboot-dot:nth-child(2) { animation-delay: 0.2s; } +.reboot-dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes reboot-bounce { + 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } + 40% { opacity: 1; transform: scale(1.2); } +} + +.reboot-submessage { + font-size: 0.82rem; + color: var(--text-dim); + font-style: italic; +} + +/* ── Empty state ────────────────────────────────────────────────── */ + +.empty-state { + text-align: center; + padding: 64px 24px; + color: var(--text-dim); +} + +.empty-state p { + font-size: 1rem; + margin-bottom: 8px; +} + +/* ── Responsive ─────────────────────────────────────────────────── */ + +@media (max-width: 600px) { + .header-bar { + padding: 10px 14px; + gap: 10px; + } + .header-bar .title { + font-size: 0.95rem; + } + .ip-bar { + gap: 16px; + flex-wrap: wrap; + padding: 8px 14px; + } + .main-content { + padding: 16px 12px 40px; + } + .tiles-grid { + justify-content: center; + } + .service-tile { + width: 160px; + min-height: 200px; + } + .reboot-card { + padding: 36px 28px; + margin: 0 16px; + } + .creds-dialog { + margin: 0 12px; + } + .creds-qr-img { + width: 200px; + height: 200px; + } +} diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index e7ccb59..9f49bde 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -44,8 +44,8 @@ let { label = "Note"; value = "Create your admin account on first visit"; } ]; } { name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ - { label = "Connection URL"; file = "/var/lib/secrets/zeus-connect-url"; } - { label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus → Scan Node Config\n3. Copy and paste the Connection URL above"; } + { label = "Connection URL"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; } + { label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus → Scan Node Config\n3. Scan the QR code above or paste the Connection URL"; } ]; } { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [ { label = "Tor Access"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; } @@ -241,4 +241,4 @@ in # ── Open firewall port ───────────────────────────────────── networking.firewall.allowedTCPPorts = [ 8937 ]; }; -} \ No newline at end of file +} -- 2.53.0 From 4d23197aa3373de1cd4d3b58b73acdbb412ab00a Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 16:23:45 -0500 Subject: [PATCH 176/857] added qr code --- modules/core/sovran-hub.nix | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 9f49bde..c5fca89 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -227,6 +227,9 @@ in StandardOutput = "journal"; StandardError = "journal"; }; + + # ── Make qrencode available for QR code generation ──────── + path = [ pkgs.qrencode ]; }; # ── System update as a detached oneshot ───────────────────── @@ -241,4 +244,4 @@ in # ── Open firewall port ───────────────────────────────────── networking.firewall.allowedTCPPorts = [ 8937 ]; }; -} +} \ No newline at end of file -- 2.53.0 From 8546ff073aaa30850aa9e46d1cd831b8c43bec8c Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 16:35:18 -0500 Subject: [PATCH 177/857] removed start stop toggles --- app/sovran_systemsos_web/server.py | 52 ---------- app/sovran_systemsos_web/static/app.js | 48 +-------- app/sovran_systemsos_web/static/style.css | 120 +++------------------- 3 files changed, 21 insertions(+), 199 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index d228f68..bb3e412 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio import base64 import hashlib -import io import json import os import re @@ -317,7 +316,6 @@ async def api_config(): @app.get("/api/services") async def api_services(): cfg = load_config() - method = cfg.get("command_method", "systemctl") services = cfg.get("services", []) loop = asyncio.get_event_loop() @@ -382,56 +380,6 @@ async def api_credentials(unit: str): } -def _get_allowed_units() -> set[str]: - cfg = load_config() - return {s.get("unit", "") for s in cfg.get("services", []) if s.get("unit")} - - -@app.post("/api/services/{unit}/start") -async def service_start(unit: str): - if unit not in _get_allowed_units(): - raise HTTPException(status_code=403, detail=f"Unit {unit!r} is not in the allowed service list") - cfg = load_config() - method = cfg.get("command_method", "systemctl") - loop = asyncio.get_event_loop() - ok = await loop.run_in_executor( - None, lambda: sysctl.run_action("start", unit, "system", method) - ) - if not ok: - raise HTTPException(status_code=500, detail=f"Failed to start {unit}") - return {"ok": True} - - -@app.post("/api/services/{unit}/stop") -async def service_stop(unit: str): - if unit not in _get_allowed_units(): - raise HTTPException(status_code=403, detail=f"Unit {unit!r} is not in the allowed service list") - cfg = load_config() - method = cfg.get("command_method", "systemctl") - loop = asyncio.get_event_loop() - ok = await loop.run_in_executor( - None, lambda: sysctl.run_action("stop", unit, "system", method) - ) - if not ok: - raise HTTPException(status_code=500, detail=f"Failed to stop {unit}") - return {"ok": True} - - -@app.post("/api/services/{unit}/restart") -async def service_restart(unit: str): - if unit not in _get_allowed_units(): - raise HTTPException(status_code=403, detail=f"Unit {unit!r} is not in the allowed service list") - cfg = load_config() - method = cfg.get("command_method", "systemctl") - loop = asyncio.get_event_loop() - ok = await loop.run_in_executor( - None, lambda: sysctl.run_action("restart", unit, "system", method) - ) - if not ok: - raise HTTPException(status_code=500, detail=f"Failed to restart {unit}") - return {"ok": True} - - @app.get("/api/network") async def api_network(): loop = asyncio.get_event_loop() diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 616cef6..7cce369 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -1,9 +1,9 @@ -/* Sovran_SystemsOS Hub — Vanilla JS Frontend */ +/* Sovran_SystemsOS Hub — Vanilla JS Frontend + v6 — Status-only dashboard (no start/stop/restart controls) */ "use strict"; const POLL_INTERVAL_SERVICES = 5000; // 5 s const POLL_INTERVAL_UPDATES = 1800000; // 30 min -const ACTION_REFRESH_DELAY = 1500; // 1.5 s after start/stop/restart const UPDATE_POLL_INTERVAL = 2000; // 2 s while update is running const REBOOT_CHECK_INTERVAL = 5000; // 5 s between reconnect attempts @@ -153,8 +153,7 @@ function buildTile(svc) { const sc = statusClass(svc.status); const st = statusText(svc.status, svc.enabled); const dis = !svc.enabled; - const isOn = svc.status === "active"; - const hasCreds = svc.has_credentials; + const hasCreds = svc.has_credentials && svc.enabled; const tile = document.createElement("div"); tile.className = "service-tile" + (dis ? " disabled" : ""); @@ -162,7 +161,7 @@ function buildTile(svc) { tile.dataset.tileId = tileId(svc); if (dis) tile.title = `${svc.name} is not enabled in custom.nix`; - // Info button (only if service has credentials) + // Info button (only if service has credentials and is enabled) const infoBtn = hasCreds ? `` : ""; @@ -179,16 +178,6 @@ function buildTile(svc) { ${escHtml(st)}
      -
      -
      - - -
      `; // Info button click handler @@ -200,29 +189,6 @@ function buildTile(svc) { }); } - const chk = tile.querySelector(".tile-toggle"); - if (!dis) { - chk.addEventListener("change", async (e) => { - const action = e.target.checked ? "start" : "stop"; - chk.disabled = true; - try { - await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/${action}`, { method: "POST" }); - } catch (_) {} - setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY); - }); - } - - const restartBtn = tile.querySelector(".tile-restart-btn"); - if (!dis) { - restartBtn.addEventListener("click", async () => { - restartBtn.disabled = true; - try { - await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/restart`, { method: "POST" }); - } catch (_) {} - setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY); - }); - } - return tile; } @@ -241,13 +207,9 @@ function updateTiles(services) { const dot = tile.querySelector(".status-dot"); const text = tile.querySelector(".status-text"); - const chk = tile.querySelector(".tile-toggle"); if (dot) { dot.className = `status-dot ${sc}`; } if (text) { text.textContent = st; } - if (chk && !chk.disabled) { - chk.checked = svc.status === "active"; - } } } @@ -269,7 +231,7 @@ async function refreshServices() { } } -// ── Network IPs ──────────────────────────────────��──────────────── +// ── Network IPs ─────────────────────────────────────────────────── async function loadNetwork() { try { diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index e9cf6d9..a2dbec6 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -1,6 +1,6 @@ /* Sovran_SystemsOS Hub — Web UI Stylesheet Dark theme matching the Adwaita dark aesthetic - v5 — QR code support in credentials modal */ + v6 — Status-only tiles (no controls) */ *, *::before, *::after { box-sizing: border-box; @@ -222,11 +222,11 @@ button:disabled { gap: 14px; } -/* ── Service tile card ──────────────────────────────────────────── */ +/* ── Service tile card (status-only) ─────────────────────────────── */ .service-tile { - width: 180px; - min-height: 210px; + width: 160px; + min-height: 150px; background-color: var(--card-color); border: 1px solid var(--border-color); border-radius: var(--radius-card); @@ -234,7 +234,8 @@ button:disabled { display: flex; flex-direction: column; align-items: center; - padding: 18px 12px 14px; + justify-content: center; + padding: 20px 12px 18px; gap: 0; transition: box-shadow 0.2s, border-color 0.2s; position: relative; @@ -279,7 +280,7 @@ button:disabled { width: 48px; height: 48px; object-fit: contain; - margin-bottom: 8px; + margin-bottom: 10px; } .tile-icon-fallback { @@ -292,7 +293,7 @@ button:disabled { border-radius: 12px; color: var(--text-dim); font-size: 1.5rem; - margin-bottom: 8px; + margin-bottom: 10px; } .tile-name { @@ -301,10 +302,10 @@ button:disabled { text-align: center; color: var(--text-primary); line-height: 1.3; - max-width: 156px; + max-width: 140px; word-break: break-word; hyphens: auto; - min-height: 2.6em; + min-height: 1.3em; display: flex; align-items: center; justify-content: center; @@ -312,7 +313,7 @@ button:disabled { .tile-status { font-size: 0.75rem; - margin-top: 6px; + margin-top: 8px; display: flex; align-items: center; gap: 5px; @@ -333,86 +334,7 @@ button:disabled { .status-dot.failed { background-color: var(--red); } .status-dot.disabled { background-color: var(--grey); } -.tile-spacer { - flex: 1; -} - -/* ── Tile controls ──────────────────────────────────────────────── */ - -.tile-controls { - display: flex; - align-items: center; - gap: 10px; - margin-top: 10px; -} - -.toggle-label { - display: flex; - align-items: center; - cursor: pointer; -} - -.toggle-label input[type="checkbox"] { - display: none; -} - -.toggle-track { - width: 40px; - height: 22px; - background-color: var(--border-color); - border-radius: 11px; - position: relative; - transition: background-color 0.2s; -} - -.toggle-label input:checked + .toggle-track { - background-color: var(--green); -} - -.toggle-label.disabled-toggle { - cursor: not-allowed; - opacity: 0.5; -} - -.toggle-thumb { - position: absolute; - top: 3px; - left: 3px; - width: 16px; - height: 16px; - background-color: #fff; - border-radius: 50%; - transition: transform 0.2s; - box-shadow: 0 1px 3px rgba(0,0,0,0.4); -} - -.toggle-label input:checked + .toggle-track .toggle-thumb { - transform: translateX(18px); -} - -.tile-restart-btn { - background: none; - border: none; - color: var(--text-secondary); - cursor: pointer; - padding: 4px 6px; - border-radius: 50%; - font-size: 0.95rem; - line-height: 1; - transition: background-color 0.15s, color 0.15s; -} - -.tile-restart-btn:hover:not(:disabled) { - background-color: var(--border-color); - color: var(--text-primary); -} - -.tile-restart-btn:disabled { - opacity: 0.35; - cursor: default; -} - -/* ── Update modal ───────────────────────────────────────────────── */ +/* ── Update modal ─────────────────────────────────���─────────────── */ .modal-overlay { display: none; @@ -530,7 +452,7 @@ button.btn-reboot:hover:not(:disabled) { background-color: #5a5c72; } -/* ── Credentials info modal ──────────────────────────────────────��─ */ +/* ── Credentials info modal ──────────────────────────────────────── */ .creds-dialog { background-color: var(--surface-color); @@ -680,16 +602,6 @@ button.btn-reboot:hover:not(:disabled) { margin-bottom: 10px; } -/* ── QR code in credentials modal ────────────────────────────────── */ - -.creds-qr-wrap { - display: flex; - flex-direction: column; - align-items: center; - padding: 20px 0; - margin-bottom: 10px; -} - .creds-qr-img { width: 240px; height: 240px; @@ -830,8 +742,8 @@ button.btn-reboot:hover:not(:disabled) { justify-content: center; } .service-tile { - width: 160px; - min-height: 200px; + width: 140px; + min-height: 130px; } .reboot-card { padding: 36px 28px; @@ -844,4 +756,4 @@ button.btn-reboot:hover:not(:disabled) { width: 200px; height: 200px; } -} +} \ No newline at end of file -- 2.53.0 From 51c458c33a0ecaa8ec43252215be7153b9e77c48 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 16:39:01 -0500 Subject: [PATCH 178/857] added RDP --- modules/core/sovran-hub.nix | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index c5fca89..29534cf 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -14,6 +14,10 @@ let { label = "Root Password"; file = "/var/lib/secrets/root-password"; } { label = "SSH Local Access"; value = "ssh root@localhost / Passphrase: gosovransystems"; } ]; } + + { name = "Remote Desktop (RDP)"; unit = "gnome-remote-desktop.service"; type = "system"; icon = "rdp"; enabled = cfg.features.rdp; category = "apps"; credentials = [ + { label = "Credentials"; file = "/var/lib/gnome-remote-desktop/rdp-credentials"; multiline = true; } + ]; } ] # ── Bitcoin Base (node implementations) ──────────────────── ++ [ @@ -244,4 +248,4 @@ in # ── Open firewall port ───────────────────────────────────── networking.firewall.allowedTCPPorts = [ 8937 ]; }; -} \ No newline at end of file +} -- 2.53.0 From b63ad15cc1ebe8e2f161fa6040a46abcf73d60af Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 16:45:27 -0500 Subject: [PATCH 179/857] fixed RDP layout --- app/icons/rdp.svg | 1 + modules/core/sovran-hub.nix | 12 +++++++----- modules/rdp.nix | 27 +++++++++++++-------------- 3 files changed, 21 insertions(+), 19 deletions(-) create mode 100644 app/icons/rdp.svg diff --git a/app/icons/rdp.svg b/app/icons/rdp.svg new file mode 100644 index 0000000..4982929 --- /dev/null +++ b/app/icons/rdp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 29534cf..884a94f 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -14,9 +14,11 @@ let { label = "Root Password"; file = "/var/lib/secrets/root-password"; } { label = "SSH Local Access"; value = "ssh root@localhost / Passphrase: gosovransystems"; } ]; } - - { name = "Remote Desktop (RDP)"; unit = "gnome-remote-desktop.service"; type = "system"; icon = "rdp"; enabled = cfg.features.rdp; category = "apps"; credentials = [ - { label = "Credentials"; file = "/var/lib/gnome-remote-desktop/rdp-credentials"; multiline = true; } + { name = "Remote Desktop"; unit = "gnome-remote-desktop.service"; type = "system"; icon = "rdp"; enabled = cfg.features.rdp; category = "infrastructure"; credentials = [ + { label = "Username"; file = "/var/lib/gnome-remote-desktop/rdp-username"; } + { label = "Password"; file = "/var/lib/gnome-remote-desktop/rdp-password"; } + { label = "Address"; file = "/var/lib/secrets/internal-ip"; suffix = ":3389"; } + { label = "How to Connect"; value = "1. Install an RDP client (e.g. Remmina, Microsoft Remote Desktop)\n2. Create a new RDP connection\n3. Enter the Address above as the host\n4. Enter the Username and Password above\n5. Connect — you will see your desktop remotely"; } ]; } ] # ── Bitcoin Base (node implementations) ──────────────────── @@ -182,7 +184,7 @@ let # ── Generated config ─────────────────────────────────────── cp ${generatedConfig} $out/lib/sovran-hub-web/config.json - # ── Icons (SVG) ──────────────────────────────────────────── + # ── Icons (SVG) ──────────────────��───────────────────────── install -d $out/share/sovran-hub/icons cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true @@ -248,4 +250,4 @@ in # ── Open firewall port ───────────────────────────────────── networking.firewall.allowedTCPPorts = [ 8937 ]; }; -} +} \ No newline at end of file diff --git a/modules/rdp.nix b/modules/rdp.nix index 67b4c34..d3354bc 100755 --- a/modules/rdp.nix +++ b/modules/rdp.nix @@ -1,21 +1,16 @@ -{ config, pkgs, lib, ... }: +{ config, lib, pkgs, ... }: lib.mkIf config.sovran_systemsOS.features.rdp { - services.gnome.gnome-remote-desktop.enable = true; + services.gnome-remote-desktop.enable = true; - networking.firewall.allowedTCPPorts = [ 3389 ]; - - environment.systemPackages = with pkgs; [ - freerdp - ]; - - # The NixOS module installs the unit but doesn't enable it — we just need to start it and order it - systemd.services.gnome-remote-desktop = { - wantedBy = [ "graphical.target" ]; - after = [ "gnome-remote-desktop-setup.service" ]; - wants = [ "gnome-remote-desktop-setup.service" ]; + users.users.gnome-remote-desktop = { + isSystemUser = true; + group = "gnome-remote-desktop"; + home = "/var/lib/gnome-remote-desktop"; + createHome = true; }; + users.groups.gnome-remote-desktop = {}; systemd.tmpfiles.rules = [ "d /var/lib/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -" @@ -77,6 +72,10 @@ lib.mkIf config.sovran_systemsOS.features.rdp { PASSWORD=$(cat /var/lib/gnome-remote-desktop/rdp-password) fi + # Write username to a separate file for the hub + echo "sovran" > /var/lib/gnome-remote-desktop/rdp-username + chmod 600 /var/lib/gnome-remote-desktop/rdp-username + # Get current IP address LOCAL_IP=$(hostname -I | awk '{print $1}') @@ -104,4 +103,4 @@ lib.mkIf config.sovran_systemsOS.features.rdp { echo "GNOME Remote Desktop RDP configured successfully" ''; }; -} +} \ No newline at end of file -- 2.53.0 From c4307f358c0627a2db2471387cda6377c89840e1 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 16:48:01 -0500 Subject: [PATCH 180/857] fixed RDP layout --- modules/rdp.nix | 2 -- 1 file changed, 2 deletions(-) diff --git a/modules/rdp.nix b/modules/rdp.nix index d3354bc..e89e8c9 100755 --- a/modules/rdp.nix +++ b/modules/rdp.nix @@ -2,8 +2,6 @@ lib.mkIf config.sovran_systemsOS.features.rdp { - services.gnome-remote-desktop.enable = true; - users.users.gnome-remote-desktop = { isSystemUser = true; group = "gnome-remote-desktop"; -- 2.53.0 From e436b2f7a6cf1815cf01667bf0d07d812e82ca3c Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 17:08:20 -0500 Subject: [PATCH 181/857] added service feature --- app/sovran_systemsos_web/server.py | 154 +++++++++- app/sovran_systemsos_web/static/app.js | 379 +++++++++++++++---------- modules/core/sovran-hub.nix | 17 +- 3 files changed, 386 insertions(+), 164 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index bb3e412..3143eaa 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -21,7 +21,7 @@ from fastapi.requests import Request from .config import load_config from . import systemctl as sysctl -# ── Constants ──────────────────────────────────────────────────── +# ── Constants ──────────────────────────────��───────────────────── FLAKE_LOCK_PATH = "/etc/nixos/flake.lock" FLAKE_INPUT_NAME = "Sovran_Systems" @@ -36,6 +36,17 @@ ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url" REBOOT_COMMAND = ["reboot"] +# ── Tech Support constants ──────────────────────────────────────── + +SUPPORT_KEY_FILE = "/root/.ssh/sovran_support_authorized" +AUTHORIZED_KEYS = "/root/.ssh/authorized_keys" +SUPPORT_STATUS_FILE = "/var/lib/secrets/support-session-status" + +# Sovran Systems tech support public key +SOVRAN_SUPPORT_PUBKEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyReplaceMeWithYourRealPublicKey sovran-support" + +SUPPORT_KEY_COMMENT = "sovran-support" + CATEGORY_ORDER = [ ("infrastructure", "Infrastructure"), ("bitcoin-base", "Bitcoin Base"), @@ -43,6 +54,7 @@ CATEGORY_ORDER = [ ("communication", "Communication"), ("apps", "Self-Hosted Apps"), ("nostr", "Nostr"), + ("support", "Support"), ] ROLE_LABELS = { @@ -90,7 +102,7 @@ def _file_hash(filename: str) -> str: _APP_JS_HASH = _file_hash("app.js") _STYLE_CSS_HASH = _file_hash("style.css") -# ── Update check helpers ───────────────────────────────────────── +# ── Update check helpers ──────────────────��────────────────────── def _get_locked_info(): try: @@ -291,6 +303,106 @@ def _resolve_credential(cred: dict) -> dict | None: return result +# ── Tech Support helpers ────────────────────────────────────────── + +def _is_support_active() -> bool: + """Check if the support key is currently in authorized_keys.""" + try: + with open(AUTHORIZED_KEYS, "r") as f: + content = f.read() + return SUPPORT_KEY_COMMENT in content + except FileNotFoundError: + return False + + +def _get_support_session_info() -> dict: + """Read support session metadata.""" + try: + with open(SUPPORT_STATUS_FILE, "r") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _enable_support() -> bool: + """Add the Sovran support public key to root's authorized_keys.""" + try: + os.makedirs("/root/.ssh", mode=0o700, exist_ok=True) + + # Write the key to the dedicated support key file + with open(SUPPORT_KEY_FILE, "w") as f: + f.write(SOVRAN_SUPPORT_PUBKEY + "\n") + os.chmod(SUPPORT_KEY_FILE, 0o600) + + # Append to authorized_keys if not already present + existing = "" + try: + with open(AUTHORIZED_KEYS, "r") as f: + existing = f.read() + except FileNotFoundError: + pass + + if SUPPORT_KEY_COMMENT not in existing: + with open(AUTHORIZED_KEYS, "a") as f: + f.write(SOVRAN_SUPPORT_PUBKEY + "\n") + os.chmod(AUTHORIZED_KEYS, 0o600) + + # Write session metadata + import time + session_info = { + "enabled_at": time.time(), + "enabled_at_human": time.strftime("%Y-%m-%d %H:%M:%S %Z"), + } + os.makedirs(os.path.dirname(SUPPORT_STATUS_FILE), exist_ok=True) + with open(SUPPORT_STATUS_FILE, "w") as f: + json.dump(session_info, f) + + return True + except Exception: + return False + + +def _disable_support() -> bool: + """Remove the Sovran support public key from authorized_keys.""" + try: + # Remove from authorized_keys + try: + with open(AUTHORIZED_KEYS, "r") as f: + lines = f.readlines() + filtered = [l for l in lines if SUPPORT_KEY_COMMENT not in l] + with open(AUTHORIZED_KEYS, "w") as f: + f.writelines(filtered) + os.chmod(AUTHORIZED_KEYS, 0o600) + except FileNotFoundError: + pass + + # Remove the dedicated key file + try: + os.remove(SUPPORT_KEY_FILE) + except FileNotFoundError: + pass + + # Remove session metadata + try: + os.remove(SUPPORT_STATUS_FILE) + except FileNotFoundError: + pass + + return True + except Exception: + return False + + +def _verify_support_removed() -> bool: + """Verify the support key is truly gone from authorized_keys.""" + try: + with open(AUTHORIZED_KEYS, "r") as f: + content = f.read() + return SUPPORT_KEY_COMMENT not in content + except FileNotFoundError: + return True # No file = no key = removed + + # ── Routes ─────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) @@ -461,6 +573,44 @@ async def api_updates_status(offset: int = 0): } +# ── Tech Support endpoints ──────────────────────────────────────── + +@app.get("/api/support/status") +async def api_support_status(): + """Check if tech support SSH access is currently enabled.""" + loop = asyncio.get_event_loop() + active = await loop.run_in_executor(None, _is_support_active) + session = await loop.run_in_executor(None, _get_support_session_info) + return { + "active": active, + "enabled_at": session.get("enabled_at"), + "enabled_at_human": session.get("enabled_at_human"), + } + + +@app.post("/api/support/enable") +async def api_support_enable(): + """Add the Sovran support SSH key to allow remote tech support.""" + loop = asyncio.get_event_loop() + ok = await loop.run_in_executor(None, _enable_support) + if not ok: + raise HTTPException(status_code=500, detail="Failed to enable support access") + return {"ok": True, "message": "Support access enabled"} + + +@app.post("/api/support/disable") +async def api_support_disable(): + """Remove the Sovran support SSH key and end the session.""" + loop = asyncio.get_event_loop() + ok = await loop.run_in_executor(None, _disable_support) + if not ok: + raise HTTPException(status_code=500, detail="Failed to disable support access") + + # Verify it's actually gone + verified = await loop.run_in_executor(None, _verify_support_removed) + return {"ok": True, "verified": verified, "message": "Support access removed and verified"} + + # ── Startup: seed the internal IP file immediately ─────────────── @app.on_event("startup") diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 7cce369..b6bd0f2 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -1,11 +1,12 @@ /* Sovran_SystemsOS Hub — Vanilla JS Frontend - v6 — Status-only dashboard (no start/stop/restart controls) */ + v7 — Status-only dashboard + Tech Support */ "use strict"; const POLL_INTERVAL_SERVICES = 5000; // 5 s const POLL_INTERVAL_UPDATES = 1800000; // 30 min const UPDATE_POLL_INTERVAL = 2000; // 2 s while update is running const REBOOT_CHECK_INTERVAL = 5000; // 5 s between reconnect attempts +const SUPPORT_TIMER_INTERVAL = 1000; // 1 s for session timer const CATEGORY_ORDER = [ "infrastructure", @@ -14,6 +15,7 @@ const CATEGORY_ORDER = [ "communication", "apps", "nostr", + "support", ]; const STATUS_LOADING_STATES = new Set([ @@ -22,13 +24,16 @@ const STATUS_LOADING_STATES = new Set([ // ── State ───────────────────────────────────────────────────────── -let _servicesCache = []; -let _categoryLabels = {}; -let _updateLog = ""; -let _updatePollTimer = null; -let _updateLogOffset = 0; -let _serverWasDown = false; -let _updateFinished = false; +let _servicesCache = []; +let _categoryLabels = {}; +let _updateLog = ""; +let _updatePollTimer = null; +let _updateLogOffset = 0; +let _serverWasDown = false; +let _updateFinished = false; +let _supportTimerInt = null; +let _supportEnabledAt = null; +let _cachedExternalIp = null; // ── DOM refs ────────────────────────────────────────────────────── @@ -54,6 +59,10 @@ const $credsTitle = document.getElementById("creds-modal-title"); const $credsBody = document.getElementById("creds-body"); const $credsCloseBtn = document.getElementById("creds-close-btn"); +const $supportModal = document.getElementById("support-modal"); +const $supportBody = document.getElementById("support-body"); +const $supportCloseBtn = document.getElementById("support-close-btn"); + // ── Helpers ─────────────────────────────────────────────────────── function tileId(svc) { @@ -93,6 +102,15 @@ function linkify(str) { ); } +function formatDuration(seconds) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}h ${m}m ${s}s`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + // ── Fetch wrappers ──────────────────────────────────────────────── async function apiFetch(path, options = {}) { @@ -150,18 +168,37 @@ function buildTiles(services, categoryLabels) { } function buildTile(svc) { + const isSupport = svc.type === "support"; const sc = statusClass(svc.status); const st = statusText(svc.status, svc.enabled); const dis = !svc.enabled; const hasCreds = svc.has_credentials && svc.enabled; const tile = document.createElement("div"); - tile.className = "service-tile" + (dis ? " disabled" : ""); + tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : ""); tile.dataset.unit = svc.unit; tile.dataset.tileId = tileId(svc); if (dis) tile.title = `${svc.name} is not enabled in custom.nix`; - // Info button (only if service has credentials and is enabled) + if (isSupport) { + // Support tile — clickable, no info button, no status dot + tile.innerHTML = ` + ${escHtml(svc.name)} + +
      ${escHtml(svc.name)}
      +
      + Click to manage +
      + `; + tile.style.cursor = "pointer"; + tile.addEventListener("click", () => openSupportModal()); + return tile; + } + + // Normal tile const infoBtn = hasCreds ? `` : ""; @@ -180,7 +217,6 @@ function buildTile(svc) {
      `; - // Info button click handler const infoBtnEl = tile.querySelector(".tile-info-btn"); if (infoBtnEl) { infoBtnEl.addEventListener("click", (e) => { @@ -202,6 +238,8 @@ function updateTiles(services) { const tile = $tilesArea.querySelector(`.service-tile[data-tile-id="${id}"]`); if (!tile) continue; + if (svc.type === "support") continue; // Support tile doesn't have a systemd status + const sc = statusClass(svc.status); const st = statusText(svc.status, svc.enabled); @@ -238,6 +276,7 @@ async function loadNetwork() { const data = await apiFetch("/api/network"); if ($internalIp) $internalIp.textContent = data.internal_ip || "—"; if ($externalIp) $externalIp.textContent = data.external_ip || "—"; + _cachedExternalIp = data.external_ip || "unavailable"; } catch (_) { if ($internalIp) $internalIp.textContent = "—"; if ($externalIp) $externalIp.textContent = "—"; @@ -282,7 +321,6 @@ async function openCredsModal(unit, name) { const id = "cred-" + Math.random().toString(36).substring(2, 8); const displayValue = linkify(cred.value); - // QR code block (if present) let qrBlock = ""; if (cred.qrcode) { qrBlock = ` @@ -306,7 +344,6 @@ async function openCredsModal(unit, name) { } $credsBody.innerHTML = html; - // Attach copy handlers $credsBody.querySelectorAll(".creds-copy-btn").forEach(btn => { btn.addEventListener("click", () => { const target = document.getElementById(btn.dataset.target); @@ -332,6 +369,184 @@ function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); } +// ── Tech Support modal ──────────────────────────────────────────── + +async function openSupportModal() { + if (!$supportModal) return; + $supportModal.classList.add("open"); + $supportBody.innerHTML = '

      Checking support status…

      '; + + try { + const status = await apiFetch("/api/support/status"); + if (status.active) { + _supportEnabledAt = status.enabled_at; + renderSupportActive(); + } else { + renderSupportInactive(); + } + } catch (err) { + $supportBody.innerHTML = '

      Could not check support status.

      '; + } +} + +function renderSupportInactive() { + stopSupportTimer(); + const ip = _cachedExternalIp || "loading…"; + $supportBody.innerHTML = ` +
      +
      šŸ›Ÿ
      +

      Need help from Sovran Systems?

      +

      + This will temporarily give Sovran Systems secure SSH access to your machine + so we can diagnose and fix issues for you. +

      + +
      +
      + Your External IP + ${escHtml(ip)} +
      +

      + Give this IP to your Sovran Systems technician when asked. +

      +
      + +
      +

      What happens when you click Enable:

      +
        +
      1. A Sovran Systems SSH key is added to this machine
      2. +
      3. You give us your External IP shown above
      4. +
      5. We connect and help you remotely
      6. +
      7. When done, you click End Support Session to remove the key
      8. +
      +
      + + +

      + You can end the session at any time. The access key will be completely removed. +

      +
      + `; + + document.getElementById("btn-support-enable").addEventListener("click", enableSupport); +} + +function renderSupportActive() { + const ip = _cachedExternalIp || "loading…"; + $supportBody.innerHTML = ` +
      +
      šŸ”“
      +

      Support Access is Active

      +

      + Sovran Systems can currently connect to your machine via SSH. +

      + +
      +
      + Your External IP + ${escHtml(ip)} +
      +
      + Session Duration + — +
      +
      + +

      + When your support session is complete, click the button below to + immediately remove the access key. +

      + + +
      + `; + + document.getElementById("btn-support-disable").addEventListener("click", disableSupport); + startSupportTimer(); +} + +function renderSupportRemoved(verified) { + stopSupportTimer(); + const icon = verified ? "āœ…" : "āš ļø"; + const msg = verified + ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." + : "The key removal was requested but could not be fully verified. Please reboot your machine to be sure."; + + $supportBody.innerHTML = ` +
      +
      ${icon}
      +

      Support Session Ended

      +

      ${escHtml(msg)}

      + +
      + SSH Key Status: + + ${verified ? "āœ“ Removed — No access" : "⚠ Verify by rebooting"} + +
      + + +
      + `; + + document.getElementById("btn-support-done").addEventListener("click", closeSupportModal); +} + +async function enableSupport() { + const btn = document.getElementById("btn-support-enable"); + if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; } + try { + await apiFetch("/api/support/enable", { method: "POST" }); + const status = await apiFetch("/api/support/status"); + _supportEnabledAt = status.enabled_at; + renderSupportActive(); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; } + alert("Failed to enable support access. Please try again."); + } +} + +async function disableSupport() { + const btn = document.getElementById("btn-support-disable"); + if (btn) { btn.disabled = true; btn.textContent = "Removing key…"; } + try { + const result = await apiFetch("/api/support/disable", { method: "POST" }); + renderSupportRemoved(result.verified); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "End Support Session"; } + alert("Failed to disable support access. Please try again."); + } +} + +function startSupportTimer() { + stopSupportTimer(); + updateSupportTimer(); + _supportTimerInt = setInterval(updateSupportTimer, SUPPORT_TIMER_INTERVAL); +} + +function stopSupportTimer() { + if (_supportTimerInt) { + clearInterval(_supportTimerInt); + _supportTimerInt = null; + } +} + +function updateSupportTimer() { + const el = document.getElementById("support-timer"); + if (!el || !_supportEnabledAt) return; + const elapsed = (Date.now() / 1000) - _supportEnabledAt; + el.textContent = formatDuration(Math.max(0, elapsed)); +} + +function closeSupportModal() { + if ($supportModal) $supportModal.classList.remove("open"); + stopSupportTimer(); +} + // ── Update modal ────────────────────────────────────────────────── function openUpdateModal() { @@ -394,140 +609,4 @@ function startUpdatePoll() { function stopUpdatePoll() { if (_updatePollTimer) { - clearInterval(_updatePollTimer); - _updatePollTimer = null; - } -} - -async function pollUpdateStatus() { - if (_updateFinished) return; - - try { - const data = await apiFetch(`/api/updates/status?offset=${_updateLogOffset}`); - - if (_serverWasDown) { - _serverWasDown = false; - appendLog("[Server reconnected]\n"); - if ($modalStatus) $modalStatus.textContent = "Updating…"; - } - - if (data.log) { - appendLog(data.log); - } - _updateLogOffset = data.offset; - - if (data.running) { - return; - } - - _updateFinished = true; - stopUpdatePoll(); - - if (data.result === "success") { - onUpdateDone(true); - } else { - onUpdateDone(false); - } - } catch (err) { - if (!_serverWasDown) { - _serverWasDown = true; - appendLog("\n[Server restarting — waiting for it to come back…]\n"); - if ($modalStatus) $modalStatus.textContent = "Server restarting…"; - } - } -} - -function onUpdateDone(success) { - if ($modalSpinner) $modalSpinner.classList.remove("spinning"); - if ($btnCloseModal) $btnCloseModal.disabled = false; - - if (success) { - if ($modalStatus) $modalStatus.textContent = "āœ“ Update complete"; - if ($btnReboot) $btnReboot.style.display = "inline-flex"; - } else { - if ($modalStatus) $modalStatus.textContent = "āœ— Update failed"; - if ($btnSave) $btnSave.style.display = "inline-flex"; - if ($btnReboot) $btnReboot.style.display = "inline-flex"; - } -} - -function saveErrorReport() { - const blob = new Blob([_updateLog], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `sovran-update-error-${new Date().toISOString().split('.')[0].replace(/:/g, '-')}.txt`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -// ── Reboot with confirmation overlay ────────────────────────────── - -function doReboot() { - if ($modal) $modal.classList.remove("open"); - stopUpdatePoll(); - if ($rebootOverlay) $rebootOverlay.classList.add("visible"); - fetch("/api/reboot", { method: "POST" }).catch(() => {}); - setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); -} - -function waitForServerReboot() { - fetch("/api/config", { cache: "no-store" }) - .then(res => { - if (res.ok) { - window.location.reload(); - } else { - setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); - } - }) - .catch(() => { - setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); - }); -} - -// ── Event listeners ─────────────────────────────────────────────── - -if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); -if ($refreshBtn) $refreshBtn.addEventListener("click", () => refreshServices()); -if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal); -if ($btnReboot) $btnReboot.addEventListener("click", doReboot); -if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); -if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal); - -if ($modal) { - $modal.addEventListener("click", (e) => { - if (e.target === $modal) closeUpdateModal(); - }); -} - -if ($credsModal) { - $credsModal.addEventListener("click", (e) => { - if (e.target === $credsModal) closeCredsModal(); - }); -} - -// ── Init ────────────────────────────────────────────────────────── - -async function init() { - try { - const cfg = await apiFetch("/api/config"); - if (cfg.category_order) { - for (const [key, label] of cfg.category_order) { - _categoryLabels[key] = label; - } - } - const badge = document.getElementById("role-badge"); - if (badge && cfg.role_label) badge.textContent = cfg.role_label; - } catch (_) {} - - await refreshServices(); - loadNetwork(); - checkUpdates(); - - setInterval(refreshServices, POLL_INTERVAL_SERVICES); - setInterval(checkUpdates, POLL_INTERVAL_UPDATES); -} - -document.addEventListener("DOMContentLoaded", init); \ No newline at end of file + clearInterval(_ \ No newline at end of file diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 884a94f..f8f7923 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -51,7 +51,7 @@ let ]; } { name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ { label = "Connection URL"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; } - { label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus → Scan Node Config\n3. Scan the QR code above or paste the Connection URL"; } + { label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus �� Scan Node Config\n3. Scan the QR code above or paste the Connection URL"; } ]; } { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [ { label = "Tor Access"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; } @@ -82,6 +82,10 @@ let # ── Nostr / Relay ────────────────────────────────────────── ++ [ { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; enabled = cfg.features.haven; category = "nostr"; credentials = []; } + ] + # ── Support ──────────────────────────────────────────────── + ++ [ + { name = "Tech Support"; unit = "sovran-tech-support"; type = "support"; icon = "support"; enabled = true; category = "support"; credentials = []; } ]; activeRole = @@ -105,10 +109,7 @@ let LOG="/var/log/sovran-hub-update.log" STATUS="/var/log/sovran-hub-update.status" - # Mark as RUNNING echo "RUNNING" > "$STATUS" - - # Truncate the log and redirect ALL output (stdout + stderr) into it : > "$LOG" exec > >(tee -a "$LOG") 2>&1 @@ -177,18 +178,14 @@ let installPhase = '' runHook preInstall - # ── Python source ───────────────────────────────────────── install -d $out/lib/sovran-hub-web cp -r sovran_systemsos_web $out/lib/sovran-hub-web/ - # ── Generated config ─────────────────────────────────────── cp ${generatedConfig} $out/lib/sovran-hub-web/config.json - # ── Icons (SVG) ──────────────────��───────────────────────── install -d $out/share/sovran-hub/icons cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true - # ── Launcher script ──────────────────────────────────────── install -d $out/bin cat > $out/bin/sovran-hub-web < Date: Thu, 2 Apr 2026 17:20:14 -0500 Subject: [PATCH 182/857] fixed service layout --- app/sovran_systemsos_web/static/app.js | 527 ++++++++---------- app/sovran_systemsos_web/static/style.css | 205 ++++++- app/sovran_systemsos_web/templates/index.html | 13 + 3 files changed, 434 insertions(+), 311 deletions(-) diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index b6bd0f2..3f39249 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -2,11 +2,11 @@ v7 — Status-only dashboard + Tech Support */ "use strict"; -const POLL_INTERVAL_SERVICES = 5000; // 5 s -const POLL_INTERVAL_UPDATES = 1800000; // 30 min -const UPDATE_POLL_INTERVAL = 2000; // 2 s while update is running -const REBOOT_CHECK_INTERVAL = 5000; // 5 s between reconnect attempts -const SUPPORT_TIMER_INTERVAL = 1000; // 1 s for session timer +const POLL_INTERVAL_SERVICES = 5000; +const POLL_INTERVAL_UPDATES = 1800000; +const UPDATE_POLL_INTERVAL = 2000; +const REBOOT_CHECK_INTERVAL = 5000; +const SUPPORT_TIMER_INTERVAL = 1000; const CATEGORY_ORDER = [ "infrastructure", @@ -65,9 +65,7 @@ const $supportCloseBtn = document.getElementById("support-close-btn"); // ── Helpers ─────────────────────────────────────────────────────── -function tileId(svc) { - return svc.unit + "::" + svc.name; -} +function tileId(svc) { return svc.unit + "::" + svc.name; } function statusClass(status) { if (!status) return "unknown"; @@ -86,36 +84,27 @@ function statusText(status, enabled) { } function escHtml(str) { - return String(str) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + return String(str).replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'"); } function linkify(str) { - const escaped = escHtml(str); - return escaped.replace( - /(https?:\/\/[^\s<]+)/g, - '$1' - ); + return escHtml(str).replace(/(https?:\/\/[^\s<]+)/g, '$1'); } function formatDuration(seconds) { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); - if (h > 0) return `${h}h ${m}m ${s}s`; - if (m > 0) return `${m}m ${s}s`; - return `${s}s`; + if (h > 0) return h + "h " + m + "m " + s + "s"; + if (m > 0) return m + "m " + s + "s"; + return s + "s"; } // ── Fetch wrappers ──────────────────────────────────────────────── -async function apiFetch(path, options = {}) { - const res = await fetch(path, options); - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); +async function apiFetch(path, options) { + const res = await fetch(path, options || {}); + if (!res.ok) throw new Error(res.status + " " + res.statusText); return res.json(); } @@ -123,157 +112,106 @@ async function apiFetch(path, options = {}) { function buildTiles(services, categoryLabels) { _servicesCache = services; - - const grouped = {}; - for (const svc of services) { - const cat = svc.category || "other"; + var grouped = {}; + for (var i = 0; i < services.length; i++) { + var cat = services[i].category || "other"; if (!grouped[cat]) grouped[cat] = []; - grouped[cat].push(svc); + grouped[cat].push(services[i]); } - $tilesArea.innerHTML = ""; - - const orderedKeys = [ - ...CATEGORY_ORDER.filter(k => grouped[k]), - ...Object.keys(grouped).filter(k => !CATEGORY_ORDER.includes(k)), - ]; - - for (const catKey of orderedKeys) { - const entries = grouped[catKey]; + var orderedKeys = CATEGORY_ORDER.filter(function(k) { return grouped[k]; }); + Object.keys(grouped).forEach(function(k) { + if (orderedKeys.indexOf(k) === -1) orderedKeys.push(k); + }); + for (var j = 0; j < orderedKeys.length; j++) { + var catKey = orderedKeys[j]; + var entries = grouped[catKey]; if (!entries || entries.length === 0) continue; - - const label = categoryLabels[catKey] || catKey; - - const section = document.createElement("div"); + var label = categoryLabels[catKey] || catKey; + var section = document.createElement("div"); section.className = "category-section"; section.dataset.category = catKey; - - section.innerHTML = ` -
      ${escHtml(label)}
      -
      -
      - `; - - const grid = section.querySelector(".tiles-grid"); - for (const svc of entries) { - grid.appendChild(buildTile(svc)); + section.innerHTML = '
      ' + escHtml(label) + '

      '; + var grid = section.querySelector(".tiles-grid"); + for (var k = 0; k < entries.length; k++) { + grid.appendChild(buildTile(entries[k])); } - $tilesArea.appendChild(section); } - if ($tilesArea.children.length === 0) { - $tilesArea.innerHTML = `

      No services configured.

      `; + $tilesArea.innerHTML = '

      No services configured.

      '; } } function buildTile(svc) { - const isSupport = svc.type === "support"; - const sc = statusClass(svc.status); - const st = statusText(svc.status, svc.enabled); - const dis = !svc.enabled; - const hasCreds = svc.has_credentials && svc.enabled; + var isSupport = svc.type === "support"; + var sc = statusClass(svc.status); + var st = statusText(svc.status, svc.enabled); + var dis = !svc.enabled; + var hasCreds = svc.has_credentials && svc.enabled; - const tile = document.createElement("div"); + var tile = document.createElement("div"); tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : ""); tile.dataset.unit = svc.unit; tile.dataset.tileId = tileId(svc); - if (dis) tile.title = `${svc.name} is not enabled in custom.nix`; + if (dis) tile.title = svc.name + " is not enabled in custom.nix"; if (isSupport) { - // Support tile — clickable, no info button, no status dot - tile.innerHTML = ` - ${escHtml(svc.name)} - -
      ${escHtml(svc.name)}
      -
      - Click to manage -
      - `; + tile.innerHTML = '' + escHtml(svc.name) + '
      ' + escHtml(svc.name) + '
      Click to manage
      '; tile.style.cursor = "pointer"; - tile.addEventListener("click", () => openSupportModal()); + tile.addEventListener("click", function() { openSupportModal(); }); return tile; } - // Normal tile - const infoBtn = hasCreds - ? `` - : ""; + var infoBtn = hasCreds ? '' : ""; + tile.innerHTML = infoBtn + '' + escHtml(svc.name) + '
      ' + escHtml(svc.name) + '
      ' + escHtml(st) + '
      '; - tile.innerHTML = ` - ${infoBtn} - ${escHtml(svc.name)} - -
      ${escHtml(svc.name)}
      -
      - - ${escHtml(st)} -
      - `; - - const infoBtnEl = tile.querySelector(".tile-info-btn"); + var infoBtnEl = tile.querySelector(".tile-info-btn"); if (infoBtnEl) { - infoBtnEl.addEventListener("click", (e) => { + infoBtnEl.addEventListener("click", function(e) { e.stopPropagation(); openCredsModal(svc.unit, svc.name); }); } - return tile; } -// ── Render: live update (no DOM rebuild) ────────────────────────── +// ── Render: live update ─────────────────────────────────────────── function updateTiles(services) { _servicesCache = services; - - for (const svc of services) { - const id = CSS.escape(tileId(svc)); - const tile = $tilesArea.querySelector(`.service-tile[data-tile-id="${id}"]`); + for (var i = 0; i < services.length; i++) { + var svc = services[i]; + if (svc.type === "support") continue; + var id = CSS.escape(tileId(svc)); + var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]'); if (!tile) continue; - - if (svc.type === "support") continue; // Support tile doesn't have a systemd status - - const sc = statusClass(svc.status); - const st = statusText(svc.status, svc.enabled); - - const dot = tile.querySelector(".status-dot"); - const text = tile.querySelector(".status-text"); - - if (dot) { dot.className = `status-dot ${sc}`; } - if (text) { text.textContent = st; } + var sc = statusClass(svc.status); + var st = statusText(svc.status, svc.enabled); + var dot = tile.querySelector(".status-dot"); + var text = tile.querySelector(".status-text"); + if (dot) dot.className = "status-dot " + sc; + if (text) text.textContent = st; } } // ── Service polling ─────────────────────────────────────────────── -let _firstLoad = true; +var _firstLoad = true; async function refreshServices() { try { - const services = await apiFetch("/api/services"); - if (_firstLoad) { - buildTiles(services, _categoryLabels); - _firstLoad = false; - } else { - updateTiles(services); - } - } catch (err) { - console.warn("Failed to fetch services:", err); - } + var services = await apiFetch("/api/services"); + if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; } + else { updateTiles(services); } + } catch (err) { console.warn("Failed to fetch services:", err); } } // ── Network IPs ─────────────────────────────────────────────────── async function loadNetwork() { try { - const data = await apiFetch("/api/network"); + var data = await apiFetch("/api/network"); if ($internalIp) $internalIp.textContent = data.internal_ip || "—"; if ($externalIp) $externalIp.textContent = data.external_ip || "—"; _cachedExternalIp = data.external_ip || "unavailable"; @@ -287,14 +225,10 @@ async function loadNetwork() { async function checkUpdates() { try { - const data = await apiFetch("/api/updates/check"); - const hasUpdates = !!data.available; - if ($updateBadge) { - $updateBadge.classList.toggle("visible", hasUpdates); - } - if ($updateBtn) { - $updateBtn.classList.toggle("has-updates", hasUpdates); - } + var data = await apiFetch("/api/updates/check"); + var hasUpdates = !!data.available; + if ($updateBadge) $updateBadge.classList.toggle("visible", hasUpdates); + if ($updateBtn) $updateBtn.classList.toggle("has-updates", hasUpdates); } catch (_) {} } @@ -302,72 +236,45 @@ async function checkUpdates() { async function openCredsModal(unit, name) { if (!$credsModal) return; - if ($credsTitle) $credsTitle.textContent = name + " — Connection Info"; - if ($credsBody) $credsBody.innerHTML = '

      Loading…

      '; - + if ($credsBody) $credsBody.innerHTML = '

      Loading…

      '; $credsModal.classList.add("open"); - try { - const data = await apiFetch(`/api/credentials/${encodeURIComponent(unit)}`); - + var data = await apiFetch("/api/credentials/" + encodeURIComponent(unit)); if (!data.credentials || data.credentials.length === 0) { $credsBody.innerHTML = '

      No connection info available yet.

      '; return; } - - let html = ""; - for (const cred of data.credentials) { - const id = "cred-" + Math.random().toString(36).substring(2, 8); - const displayValue = linkify(cred.value); - - let qrBlock = ""; + var html = ""; + for (var i = 0; i < data.credentials.length; i++) { + var cred = data.credentials[i]; + var id = "cred-" + Math.random().toString(36).substring(2, 8); + var displayValue = linkify(cred.value); + var qrBlock = ""; if (cred.qrcode) { - qrBlock = ` -
      - QR Code for ${escHtml(cred.label)} -
      Scan with Zeus app on your phone
      -
      - `; + qrBlock = '
      QR Code for ' + escHtml(cred.label) + '
      Scan with Zeus app on your phone
      '; } - - html += ` -
      -
      ${escHtml(cred.label)}
      - ${qrBlock} -
      -
      ${displayValue}
      - -
      -
      - `; + html += '
      ' + escHtml(cred.label) + '
      ' + qrBlock + '
      ' + displayValue + '
      '; } $credsBody.innerHTML = html; - - $credsBody.querySelectorAll(".creds-copy-btn").forEach(btn => { - btn.addEventListener("click", () => { - const target = document.getElementById(btn.dataset.target); + $credsBody.querySelectorAll(".creds-copy-btn").forEach(function(btn) { + btn.addEventListener("click", function() { + var target = document.getElementById(btn.dataset.target); if (target) { - navigator.clipboard.writeText(target.textContent).then(() => { + navigator.clipboard.writeText(target.textContent).then(function() { btn.textContent = "Copied!"; btn.classList.add("copied"); - setTimeout(() => { - btn.textContent = "Copy"; - btn.classList.remove("copied"); - }, 1500); - }).catch(() => {}); + setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1500); + }).catch(function() {}); } }); }); - } catch (err) { $credsBody.innerHTML = '

      Could not load credentials.

      '; } } -function closeCredsModal() { - if ($credsModal) $credsModal.classList.remove("open"); -} +function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); } // ── Tech Support modal ──────────────────────────────────────────── @@ -375,15 +282,10 @@ async function openSupportModal() { if (!$supportModal) return; $supportModal.classList.add("open"); $supportBody.innerHTML = '

      Checking support status…

      '; - try { - const status = await apiFetch("/api/support/status"); - if (status.active) { - _supportEnabledAt = status.enabled_at; - renderSupportActive(); - } else { - renderSupportInactive(); - } + var status = await apiFetch("/api/support/status"); + if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(); } + else { renderSupportInactive(); } } catch (err) { $supportBody.innerHTML = '

      Could not check support status.

      '; } @@ -391,117 +293,34 @@ async function openSupportModal() { function renderSupportInactive() { stopSupportTimer(); - const ip = _cachedExternalIp || "loading…"; - $supportBody.innerHTML = ` -
      -
      šŸ›Ÿ
      -

      Need help from Sovran Systems?

      -

      - This will temporarily give Sovran Systems secure SSH access to your machine - so we can diagnose and fix issues for you. -

      - -
      -
      - Your External IP - ${escHtml(ip)} -
      -

      - Give this IP to your Sovran Systems technician when asked. -

      -
      - -
      -

      What happens when you click Enable:

      -
        -
      1. A Sovran Systems SSH key is added to this machine
      2. -
      3. You give us your External IP shown above
      4. -
      5. We connect and help you remotely
      6. -
      7. When done, you click End Support Session to remove the key
      8. -
      -
      - - -

      - You can end the session at any time. The access key will be completely removed. -

      -
      - `; - + var ip = _cachedExternalIp || "loading…"; + $supportBody.innerHTML = '
      šŸ›Ÿ

      Need help from Sovran Systems?

      This will temporarily give Sovran Systems secure SSH access to your machine so we can diagnose and fix issues for you.

      Your External IP' + escHtml(ip) + '

      Give this IP to your Sovran Systems technician when asked.

      What happens when you click Enable:

      1. A Sovran Systems SSH key is added to this machine
      2. You give us your External IP shown above
      3. We connect and help you remotely
      4. When done, you click End Support Session to remove the key

      You can end the session at any time. The access key will be completely removed.

      '; document.getElementById("btn-support-enable").addEventListener("click", enableSupport); } function renderSupportActive() { - const ip = _cachedExternalIp || "loading…"; - $supportBody.innerHTML = ` -
      -
      šŸ”“
      -

      Support Access is Active

      -

      - Sovran Systems can currently connect to your machine via SSH. -

      - -
      -
      - Your External IP - ${escHtml(ip)} -
      -
      - Session Duration - — -
      -
      - -

      - When your support session is complete, click the button below to - immediately remove the access key. -

      - - -
      - `; - + var ip = _cachedExternalIp || "loading…"; + $supportBody.innerHTML = '
      šŸ”“

      Support Access is Active

      Sovran Systems can currently connect to your machine via SSH.

      Your External IP' + escHtml(ip) + '
      Session Duration—

      When your support session is complete, click the button below to immediately remove the access key.

      '; document.getElementById("btn-support-disable").addEventListener("click", disableSupport); startSupportTimer(); } function renderSupportRemoved(verified) { stopSupportTimer(); - const icon = verified ? "āœ…" : "āš ļø"; - const msg = verified - ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." - : "The key removal was requested but could not be fully verified. Please reboot your machine to be sure."; - - $supportBody.innerHTML = ` -
      -
      ${icon}
      -

      Support Session Ended

      -

      ${escHtml(msg)}

      - -
      - SSH Key Status: - - ${verified ? "āœ“ Removed — No access" : "⚠ Verify by rebooting"} - -
      - - -
      - `; - + var icon = verified ? "āœ…" : "āš ļø"; + var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot your machine to be sure."; + var vclass = verified ? "verified-gone" : "verify-warning"; + var vlabel = verified ? "āœ“ Removed — No access" : "⚠ Verify by rebooting"; + $supportBody.innerHTML = '
      ' + icon + '

      Support Session Ended

      ' + escHtml(msg) + '

      SSH Key Status:' + vlabel + '
      '; document.getElementById("btn-support-done").addEventListener("click", closeSupportModal); } async function enableSupport() { - const btn = document.getElementById("btn-support-enable"); + var btn = document.getElementById("btn-support-enable"); if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; } try { await apiFetch("/api/support/enable", { method: "POST" }); - const status = await apiFetch("/api/support/status"); + var status = await apiFetch("/api/support/status"); _supportEnabledAt = status.enabled_at; renderSupportActive(); } catch (err) { @@ -511,10 +330,10 @@ async function enableSupport() { } async function disableSupport() { - const btn = document.getElementById("btn-support-disable"); + var btn = document.getElementById("btn-support-disable"); if (btn) { btn.disabled = true; btn.textContent = "Removing key…"; } try { - const result = await apiFetch("/api/support/disable", { method: "POST" }); + var result = await apiFetch("/api/support/disable", { method: "POST" }); renderSupportRemoved(result.verified); } catch (err) { if (btn) { btn.disabled = false; btn.textContent = "End Support Session"; } @@ -529,16 +348,13 @@ function startSupportTimer() { } function stopSupportTimer() { - if (_supportTimerInt) { - clearInterval(_supportTimerInt); - _supportTimerInt = null; - } + if (_supportTimerInt) { clearInterval(_supportTimerInt); _supportTimerInt = null; } } function updateSupportTimer() { - const el = document.getElementById("support-timer"); + var el = document.getElementById("support-timer"); if (!el || !_supportEnabledAt) return; - const elapsed = (Date.now() / 1000) - _supportEnabledAt; + var elapsed = (Date.now() / 1000) - _supportEnabledAt; el.textContent = formatDuration(Math.max(0, elapsed)); } @@ -555,13 +371,12 @@ function openUpdateModal() { _updateLogOffset = 0; _serverWasDown = false; _updateFinished = false; - if ($modalLog) $modalLog.textContent = ""; - if ($modalStatus) $modalStatus.textContent = "Starting update…"; - if ($modalSpinner) $modalSpinner.classList.add("spinning"); - if ($btnReboot) { $btnReboot.style.display = "none"; } - if ($btnSave) { $btnSave.style.display = "none"; } - if ($btnCloseModal) { $btnCloseModal.disabled = true; } - + if ($modalLog) $modalLog.textContent = ""; + if ($modalStatus) $modalStatus.textContent = "Starting update…"; + if ($modalSpinner) $modalSpinner.classList.add("spinning"); + if ($btnReboot) $btnReboot.style.display = "none"; + if ($btnSave) $btnSave.style.display = "none"; + if ($btnCloseModal) $btnCloseModal.disabled = true; $modal.classList.add("open"); startUpdate(); } @@ -575,29 +390,22 @@ function closeUpdateModal() { function appendLog(text) { if (!text) return; _updateLog += text; - if ($modalLog) { - $modalLog.textContent += text; - $modalLog.scrollTop = $modalLog.scrollHeight; - } + if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; } } function startUpdate() { fetch("/api/updates/run", { method: "POST" }) - .then(response => { - if (!response.ok) { - return response.text().then(t => { throw new Error(t); }); - } + .then(function(response) { + if (!response.ok) return response.text().then(function(t) { throw new Error(t); }); return response.json(); }) - .then(data => { - if (data.status === "already_running") { - appendLog("[Update already in progress, attaching…]\n\n"); - } + .then(function(data) { + if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n"); if ($modalStatus) $modalStatus.textContent = "Updating…"; startUpdatePoll(); }) - .catch(err => { - appendLog(`[Error: failed to start update — ${err}]\n`); + .catch(function(err) { + appendLog("[Error: failed to start update — " + err + "]\n"); onUpdateDone(false); }); } @@ -608,5 +416,104 @@ function startUpdatePoll() { } function stopUpdatePoll() { - if (_updatePollTimer) { - clearInterval(_ \ No newline at end of file + if (_updatePollTimer) { clearInterval(_updatePollTimer); _updatePollTimer = null; } +} + +async function pollUpdateStatus() { + if (_updateFinished) return; + try { + var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset); + if (_serverWasDown) { _serverWasDown = false; appendLog("[Server reconnected]\n"); if ($modalStatus) $modalStatus.textContent = "Updating…"; } + if (data.log) appendLog(data.log); + _updateLogOffset = data.offset; + if (data.running) return; + _updateFinished = true; + stopUpdatePoll(); + if (data.result === "success") onUpdateDone(true); + else onUpdateDone(false); + } catch (err) { + if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; } + } +} + +function onUpdateDone(success) { + if ($modalSpinner) $modalSpinner.classList.remove("spinning"); + if ($btnCloseModal) $btnCloseModal.disabled = false; + if (success) { + if ($modalStatus) $modalStatus.textContent = "āœ“ Update complete"; + if ($btnReboot) $btnReboot.style.display = "inline-flex"; + } else { + if ($modalStatus) $modalStatus.textContent = "āœ— Update failed"; + if ($btnSave) $btnSave.style.display = "inline-flex"; + if ($btnReboot) $btnReboot.style.display = "inline-flex"; + } +} + +function saveErrorReport() { + var blob = new Blob([_updateLog], { type: "text/plain" }); + var url = URL.createObjectURL(blob); + var a = document.createElement("a"); + a.href = url; + a.download = "sovran-update-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// ── Reboot ──────────────────────────────────────────────────────── + +function doReboot() { + if ($modal) $modal.classList.remove("open"); + stopUpdatePoll(); + if ($rebootOverlay) $rebootOverlay.classList.add("visible"); + fetch("/api/reboot", { method: "POST" }).catch(function() {}); + setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); +} + +function waitForServerReboot() { + fetch("/api/config", { cache: "no-store" }) + .then(function(res) { + if (res.ok) window.location.reload(); + else setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); + }) + .catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); }); +} + +// ── Event listeners ─────────────────────────────────────────────── + +if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); +if ($refreshBtn) $refreshBtn.addEventListener("click", function() { refreshServices(); }); +if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal); +if ($btnReboot) $btnReboot.addEventListener("click", doReboot); +if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); +if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal); +if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal); + +if ($modal) $modal.addEventListener("click", function(e) { if (e.target === $modal) closeUpdateModal(); }); +if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); }); +if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); }); + +// ── Init ────────────────────────────────────────────────────────── + +async function init() { + try { + var cfg = await apiFetch("/api/config"); + if (cfg.category_order) { + for (var i = 0; i < cfg.category_order.length; i++) { + _categoryLabels[cfg.category_order[i][0]] = cfg.category_order[i][1]; + } + } + var badge = document.getElementById("role-badge"); + if (badge && cfg.role_label) badge.textContent = cfg.role_label; + } catch (_) {} + + await refreshServices(); + loadNetwork(); + checkUpdates(); + + setInterval(refreshServices, POLL_INTERVAL_SERVICES); + setInterval(checkUpdates, POLL_INTERVAL_UPDATES); +} + +document.addEventListener("DOMContentLoaded", init); \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index a2dbec6..cbfa0e1 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -756,4 +756,207 @@ button.btn-reboot:hover:not(:disabled) { width: 200px; height: 200px; } -} \ No newline at end of file + +/* ── Tech Support tile ───────────────────────────────────────────── */ + +.support-tile { + border-color: var(--accent-color); + border-width: 2px; + border-style: dashed; +} + +.support-tile:hover { + border-color: #a8c8ff; + border-style: solid; +} + +.support-status-label { + font-size: 0.75rem; + color: var(--accent-color); + font-weight: 600; +} + +/* ── Tech Support modal content ──────────────────────────────────── */ + +.support-section { + text-align: center; + padding: 8px 0; +} + +.support-icon-big { + font-size: 3rem; + margin-bottom: 12px; +} + +.support-heading { + font-size: 1.15rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; +} + +.support-active-heading { + color: var(--yellow); +} + +.support-desc { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 20px; + max-width: 480px; + margin-left: auto; + margin-right: auto; +} + +.support-info-box { + background-color: #12121c; + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 16px 20px; + margin: 0 auto 20px; + max-width: 400px; +} + +.support-active-box { + border-color: var(--yellow); +} + +.support-info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; +} + +.support-info-label { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); +} + +.support-info-value { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.92rem; + color: var(--accent-color); + font-weight: 600; +} + +.support-info-hint { + font-size: 0.78rem; + color: var(--text-dim); + margin-top: 8px; + font-style: italic; +} + +.support-steps { + text-align: left; + max-width: 420px; + margin: 0 auto 24px; +} + +.support-steps-title { + font-size: 0.82rem; + font-weight: 700; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.support-steps ol { + padding-left: 20px; + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.8; +} + +.support-btn-enable { + background-color: var(--green); + color: #fff; + padding: 12px 32px; + font-size: 1rem; + font-weight: 700; + border-radius: 10px; +} + +.support-btn-enable:hover:not(:disabled) { + background-color: #27ae6e; +} + +.support-btn-disable { + background-color: var(--red); + color: #fff; + padding: 12px 32px; + font-size: 1rem; + font-weight: 700; + border-radius: 10px; +} + +.support-btn-disable:hover:not(:disabled) { + background-color: #c41520; +} + +.support-active-note { + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 20px; + max-width: 420px; + margin-left: auto; + margin-right: auto; +} + +.support-fine-print { + font-size: 0.75rem; + color: var(--text-dim); + margin-top: 12px; + font-style: italic; +} + +.support-verify-box { + background-color: #12121c; + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 16px 20px; + margin: 20px auto; + max-width: 400px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.support-verify-label { + font-size: 0.82rem; + font-weight: 700; + color: var(--text-dim); +} + +.support-verify-value { + font-size: 0.88rem; + font-weight: 700; +} + +.support-verify-value.verified-gone { + color: var(--green); +} + +.support-verify-value.verify-warning { + color: var(--yellow); +} + +.support-btn-done { + background-color: var(--border-color); + color: var(--text-primary); + padding: 10px 28px; + font-size: 0.92rem; + font-weight: 600; + border-radius: 10px; +} + +.support-btn-done:hover:not(:disabled) { + background-color: #5a5c72; + + +} + +} diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index 94970ed..1b7455b 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -67,6 +67,19 @@ + + +
      -- 2.53.0 From f8c15bdaa49c4aaf7d096cc84fb730841a87d35f Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 17:25:21 -0500 Subject: [PATCH 183/857] added public key --- app/sovran_systemsos_web/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 3143eaa..485a127 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -43,7 +43,7 @@ AUTHORIZED_KEYS = "/root/.ssh/authorized_keys" SUPPORT_STATUS_FILE = "/var/lib/secrets/support-session-status" # Sovran Systems tech support public key -SOVRAN_SUPPORT_PUBKEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyReplaceMeWithYourRealPublicKey sovran-support" +SOVRAN_SUPPORT_PUBKEY = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCQa3DEhx9RUtV0WopfFuL3cjQt2fBzp5wOg/hkj0FXyZXpp+F47Td1B9mKMNvucINaMQB6T0mW6c70fyT92gZO2OqCff6aeWovtTd9ynRgtJbny/qvVSShDbJcR7nSMeVPoDRaYs18fuA50guYnfoYAkaXyXPmVQ0uK84HwIB5j8gq6GMji7vv+TTNhDP8qOceUzt1DYPo9Z2JSnkFey+Z/fmxWJGsu+MSrA0/PPENEmf6L0ZSgxnu3gHEtdyX2hrFzjE16y3G0wSQzbWJb8MJO0KRSMcyvz6AzOSW4RYdXR1c+4JiciKRdnIAYYHfg7tnZT9wC9AzHjdEbmmrlF05mtjXKnxbPgGY0tlRSYo7B5E0k2zfi30MkIJ6kIE9TMM2z/+1KstrQN4OKBTGomBTYQaRQCT6dGpRTR+b8lOvUcnCSuat1sUC2M2VGFcBbDbKD0FyXy/vOk1pgA4I7GoESWQClnl+ntRg8HrW4oVTX2KpqR2CXjlF956HJGqHW6k= free@nixos" SUPPORT_KEY_COMMENT = "sovran-support" @@ -618,4 +618,4 @@ async def _startup_save_ip(): """Write internal IP to file on server start so credentials work immediately.""" loop = asyncio.get_event_loop() ip = await loop.run_in_executor(None, _get_internal_ip) - _save_internal_ip(ip) \ No newline at end of file + _save_internal_ip(ip) -- 2.53.0 From 19c7e01b2aadf2d1698254249307c8f553ed932f Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 17:39:51 -0500 Subject: [PATCH 184/857] updated public key --- app/sovran_systemsos_web/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 485a127..5b79a79 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -43,9 +43,9 @@ AUTHORIZED_KEYS = "/root/.ssh/authorized_keys" SUPPORT_STATUS_FILE = "/var/lib/secrets/support-session-status" # Sovran Systems tech support public key -SOVRAN_SUPPORT_PUBKEY = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCQa3DEhx9RUtV0WopfFuL3cjQt2fBzp5wOg/hkj0FXyZXpp+F47Td1B9mKMNvucINaMQB6T0mW6c70fyT92gZO2OqCff6aeWovtTd9ynRgtJbny/qvVSShDbJcR7nSMeVPoDRaYs18fuA50guYnfoYAkaXyXPmVQ0uK84HwIB5j8gq6GMji7vv+TTNhDP8qOceUzt1DYPo9Z2JSnkFey+Z/fmxWJGsu+MSrA0/PPENEmf6L0ZSgxnu3gHEtdyX2hrFzjE16y3G0wSQzbWJb8MJO0KRSMcyvz6AzOSW4RYdXR1c+4JiciKRdnIAYYHfg7tnZT9wC9AzHjdEbmmrlF05mtjXKnxbPgGY0tlRSYo7B5E0k2zfi30MkIJ6kIE9TMM2z/+1KstrQN4OKBTGomBTYQaRQCT6dGpRTR+b8lOvUcnCSuat1sUC2M2VGFcBbDbKD0FyXy/vOk1pgA4I7GoESWQClnl+ntRg8HrW4oVTX2KpqR2CXjlF956HJGqHW6k= free@nixos" +SOVRAN_SUPPORT_PUBKEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFLY8hjksaWzQmIQVutTBLkTYuXQbnPF03dFQnUV+PJF sovransystemsos-support" -SUPPORT_KEY_COMMENT = "sovran-support" +SUPPORT_KEY_COMMENT = "sovransystemsos-support" CATEGORY_ORDER = [ ("infrastructure", "Infrastructure"), -- 2.53.0 From 971b0797df9bae68f7002023a5d2a92c06425905 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:15:35 +0000 Subject: [PATCH 185/857] Initial plan -- 2.53.0 From b9c8c20347d1b4d985a2ecc6c913ef2d1ec9a710 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:24:17 +0000 Subject: [PATCH 186/857] feat: add Feature Manager to Sovran Hub dashboard - flake.nix: import /etc/nixos/hub-overrides.nix alongside custom.nix - sovran-hub.nix: add hub-overrides-init service (seeds file if missing), sovran-hub-rebuild service (nixos-rebuild switch only), and feature_manager=true in generated config.json - server.py: add FEATURE_REGISTRY with 6 features (rdp, haven, element-calling, mempool, bip110, bitcoin-core); add hub-overrides.nix read/write helpers; add /api/features, /api/features/toggle, /api/rebuild/status, /api/domains/set, /api/domains/set-email, /api/domains/status endpoints; update /api/config to expose feature_manager - index.html: add domain setup modal, SSL email modal, feature confirm modal, and rebuild modal HTML - app.js: add Feature Manager rendering with sub-category layout, feature toggle cards with sliding toggles, domain setup flow, SSL email collection, conflict confirmation, rebuild polling - style.css: add Feature Manager styles (feature cards, toggle switch, domain badge, conflict warning, domain input fields)" Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/9088415a-efc3-4dd1-9c22-877a543af47b Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 389 +++++++++++++- app/sovran_systemsos_web/static/app.js | 503 +++++++++++++++++- app/sovran_systemsos_web/static/style.css | 217 ++++++++ app/sovran_systemsos_web/templates/index.html | 66 +++ flake.nix | 1 + modules/core/sovran-hub.nix | 60 +++ 6 files changed, 1228 insertions(+), 8 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 5b79a79..f4de31c 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -13,10 +13,11 @@ import subprocess import urllib.request from fastapi import FastAPI, HTTPException -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.requests import Request +from pydantic import BaseModel from .config import load_config from . import systemctl as sysctl @@ -31,6 +32,15 @@ UPDATE_LOG = "/var/log/sovran-hub-update.log" UPDATE_STATUS = "/var/log/sovran-hub-update.status" UPDATE_UNIT = "sovran-hub-update.service" +REBUILD_LOG = "/var/log/sovran-hub-rebuild.log" +REBUILD_STATUS = "/var/log/sovran-hub-rebuild.status" +REBUILD_UNIT = "sovran-hub-rebuild.service" + +HUB_OVERRIDES_NIX = "/etc/nixos/hub-overrides.nix" +DOMAINS_DIR = "/var/lib/domains" +NOSTR_NPUB_FILE = "/var/lib/secrets/nostr_npub" +NJALLA_SCRIPT = "/var/lib/njalla/njalla.sh" + INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip" ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url" @@ -55,6 +65,85 @@ CATEGORY_ORDER = [ ("apps", "Self-Hosted Apps"), ("nostr", "Nostr"), ("support", "Support"), + ("feature-manager", "Feature Manager"), +] + +FEATURE_REGISTRY = [ + { + "id": "rdp", + "name": "Remote Desktop (RDP)", + "description": "Access your desktop remotely via RDP client", + "category": "infrastructure", + "needs_domain": False, + "domain_name": None, + "needs_ddns": False, + "extra_fields": [], + "conflicts_with": [], + }, + { + "id": "haven", + "name": "Haven NOSTR Relay", + "description": "Run your own private Nostr relay", + "category": "nostr", + "needs_domain": True, + "domain_name": "haven", + "needs_ddns": True, + "extra_fields": [ + { + "id": "nostr_npub", + "label": "Nostr Public Key (npub1...)", + "type": "text", + "required": True, + "current_value": "", + }, + ], + "conflicts_with": [], + }, + { + "id": "element-calling", + "name": "Element Video & Audio Calling", + "description": "Add video/audio calling to Matrix via LiveKit", + "category": "communication", + "needs_domain": True, + "domain_name": "element-calling", + "needs_ddns": True, + "extra_fields": [], + "conflicts_with": [], + "requires": ["matrix_domain"], + }, + { + "id": "mempool", + "name": "Mempool Explorer", + "description": "Bitcoin mempool visualization and explorer", + "category": "bitcoin", + "needs_domain": False, + "domain_name": None, + "needs_ddns": False, + "extra_fields": [], + "conflicts_with": [], + }, + { + "id": "bip110", + "name": "BIP-110 (Bitcoin Better Money)", + "description": "Bitcoin Knots with BIP-110 consensus changes", + "category": "bitcoin", + "needs_domain": False, + "domain_name": None, + "needs_ddns": False, + "extra_fields": [], + "conflicts_with": ["bitcoin-core"], + }, + { + "id": "bitcoin-core", + "name": "Bitcoin Core", + "description": "Use Bitcoin Core instead of Bitcoin Knots", + "category": "bitcoin", + "needs_domain": False, + "domain_name": None, + "needs_ddns": False, + "extra_fields": [], + "conflicts_with": ["bip110"], + }, ] ROLE_LABELS = { @@ -303,6 +392,77 @@ def _resolve_credential(cred: dict) -> dict | None: return result +# ── Rebuild helpers (file-based, no systemctl) ─────────────────── + +def _read_rebuild_status() -> str: + """Read the rebuild status file. Returns RUNNING, SUCCESS, FAILED, or IDLE.""" + try: + with open(REBUILD_STATUS, "r") as f: + return f.read().strip() + except FileNotFoundError: + return "IDLE" + + +def _read_rebuild_log(offset: int = 0) -> tuple[str, int]: + """Read the rebuild log file from the given byte offset.""" + try: + with open(REBUILD_LOG, "rb") as f: + f.seek(0, 2) + size = f.tell() + if offset > size: + offset = 0 + f.seek(offset) + chunk = f.read() + return chunk.decode(errors="replace"), offset + len(chunk) + except FileNotFoundError: + return "", 0 + + +# ── hub-overrides.nix helpers ───────────────────────────────────── + +def _read_hub_overrides() -> tuple[dict, str | None]: + """Parse hub-overrides.nix. Returns (features_dict, nostr_npub_or_none).""" + features: dict[str, bool] = {} + nostr_npub = None + try: + with open(HUB_OVERRIDES_NIX, "r") as f: + content = f.read() + for m in re.finditer( + r'sovran_systemsOS\.features\.([a-zA-Z0-9_-]+)\s*=\s*(true|false)\s*;', + content, + ): + features[m.group(1)] = m.group(2) == "true" + m2 = re.search(r'sovran_systemsOS\.nostr_npub\s*=\s*"([^"]*)"', content) + if m2: + nostr_npub = m2.group(1) + except FileNotFoundError: + pass + return features, nostr_npub + + +def _write_hub_overrides(features: dict, nostr_npub: str | None) -> None: + """Write a complete hub-overrides.nix from the given state.""" + lines = [] + for feat_id, enabled in features.items(): + val = "true" if enabled else "false" + lines.append(f" sovran_systemsOS.features.{feat_id} = {val};") + if nostr_npub: + lines.append(f' sovran_systemsOS.nostr_npub = "{nostr_npub}";') + body = "\n".join(lines) + "\n" if lines else "" + content = ( + "# Auto-generated by Sovran Hub — do not edit manually\n" + "{ ... }:\n" + "{\n" + + body + + "}\n" + ) + nix_dir = os.path.dirname(HUB_OVERRIDES_NIX) + if nix_dir: + os.makedirs(nix_dir, exist_ok=True) + with open(HUB_OVERRIDES_NIX, "w") as f: + f.write(content) + + # ── Tech Support helpers ────────────────────────────────────────── def _is_support_active() -> bool: @@ -422,6 +582,7 @@ async def api_config(): "role": role, "role_label": ROLE_LABELS.get(role, role), "category_order": CATEGORY_ORDER, + "feature_manager": cfg.get("feature_manager", False), } @@ -611,6 +772,232 @@ async def api_support_disable(): return {"ok": True, "verified": verified, "message": "Support access removed and verified"} +# ── Feature Manager endpoints ───────────────────────────────────── + +@app.get("/api/features") +async def api_features(): + """Return all toggleable features with current state and domain requirements.""" + loop = asyncio.get_event_loop() + overrides, nostr_npub = await loop.run_in_executor(None, _read_hub_overrides) + + ssl_email_path = os.path.join(DOMAINS_DIR, "sslemail") + ssl_email_configured = os.path.exists(ssl_email_path) + + features = [] + for feat in FEATURE_REGISTRY: + feat_id = feat["id"] + enabled = overrides.get(feat_id, False) + + domain_name = feat.get("domain_name") + domain_configured = True + if domain_name: + domain_configured = os.path.exists(os.path.join(DOMAINS_DIR, domain_name)) + + extra_fields = [] + for ef in feat.get("extra_fields", []): + ef_copy = dict(ef) + if ef["id"] == "nostr_npub": + ef_copy["current_value"] = nostr_npub or "" + extra_fields.append(ef_copy) + + entry: dict = { + "id": feat_id, + "name": feat["name"], + "description": feat["description"], + "category": feat["category"], + "enabled": enabled, + "needs_domain": feat.get("needs_domain", False), + "domain_configured": domain_configured, + "domain_name": domain_name, + "needs_ddns": feat.get("needs_ddns", False), + "extra_fields": extra_fields, + "conflicts_with": feat.get("conflicts_with", []), + } + if "requires" in feat: + entry["requires"] = feat["requires"] + features.append(entry) + + return {"features": features, "ssl_email_configured": ssl_email_configured} + + +class FeatureToggleRequest(BaseModel): + feature: str + enabled: bool + extra: dict = {} + + +@app.post("/api/features/toggle") +async def api_features_toggle(req: FeatureToggleRequest): + """Enable or disable a feature and trigger a system rebuild.""" + feat_meta = next((f for f in FEATURE_REGISTRY if f["id"] == req.feature), None) + if not feat_meta: + raise HTTPException(status_code=404, detail="Feature not found") + + loop = asyncio.get_event_loop() + features, nostr_npub = await loop.run_in_executor(None, _read_hub_overrides) + + if req.enabled: + # Element-calling requires matrix domain + if req.feature == "element-calling": + if not os.path.exists(os.path.join(DOMAINS_DIR, "matrix")): + raise HTTPException( + status_code=400, + detail=( + "Element Calling requires a Matrix domain to be configured. " + "Please run `sovran-setup-domains` first or configure the Matrix domain." + ), + ) + + # Domain requirement check + if feat_meta.get("needs_domain") and feat_meta.get("domain_name"): + domain_path = os.path.join(DOMAINS_DIR, feat_meta["domain_name"]) + if not os.path.exists(domain_path): + return JSONResponse( + status_code=400, + content={ + "error": "domain_required", + "domain_name": feat_meta["domain_name"], + }, + ) + + # Haven requires nostr_npub + if req.feature == "haven": + npub = (req.extra or {}).get("nostr_npub", "").strip() + if npub: + nostr_npub = npub + elif not nostr_npub: + raise HTTPException(status_code=400, detail="nostr_npub is required for Haven") + + # Auto-disable conflicting features + for conflict_id in feat_meta.get("conflicts_with", []): + features[conflict_id] = False + + features[req.feature] = True + else: + features[req.feature] = False + + # Persist any extra fields (nostr_npub) + new_npub = (req.extra or {}).get("nostr_npub", "").strip() + if new_npub: + nostr_npub = new_npub + try: + os.makedirs(os.path.dirname(NOSTR_NPUB_FILE), exist_ok=True) + with open(NOSTR_NPUB_FILE, "w") as f: + f.write(nostr_npub) + except OSError: + pass + + await loop.run_in_executor(None, _write_hub_overrides, features, nostr_npub) + + # Start the rebuild service + await asyncio.create_subprocess_exec( + "systemctl", "reset-failed", REBUILD_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + proc = await asyncio.create_subprocess_exec( + "systemctl", "start", "--no-block", REBUILD_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + + return {"ok": True, "status": "rebuilding"} + + +@app.get("/api/rebuild/status") +async def api_rebuild_status(offset: int = 0): + """Poll endpoint for rebuild progress.""" + loop = asyncio.get_event_loop() + status = await loop.run_in_executor(None, _read_rebuild_status) + new_log, new_offset = await loop.run_in_executor(None, _read_rebuild_log, offset) + running = status == "RUNNING" + result = "pending" if running else status.lower() + return { + "running": running, + "result": result, + "log": new_log, + "offset": new_offset, + } + + +# ── Domain endpoints ────────────────────────────────────────────── + +class DomainSetRequest(BaseModel): + domain_name: str + domain: str + ddns_url: str = "" + + +@app.post("/api/domains/set") +async def api_domains_set(req: DomainSetRequest): + """Save a domain and optionally register a DDNS URL.""" + os.makedirs(DOMAINS_DIR, exist_ok=True) + domain_path = os.path.join(DOMAINS_DIR, req.domain_name) + with open(domain_path, "w") as f: + f.write(req.domain.strip()) + + if req.ddns_url: + ddns_url = req.ddns_url.strip() + # Strip leading "curl " if present + if ddns_url.lower().startswith("curl "): + ddns_url = ddns_url[5:].strip() + # Strip surrounding quotes + if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]: + ddns_url = ddns_url[1:-1] + # Replace trailing &auto with &a=${IP} + if ddns_url.endswith("&auto"): + ddns_url = ddns_url[:-5] + "&a=${IP}" + # Append curl line to njalla.sh + njalla_dir = os.path.dirname(NJALLA_SCRIPT) + if njalla_dir: + os.makedirs(njalla_dir, exist_ok=True) + with open(NJALLA_SCRIPT, "a") as f: + f.write(f'curl "{ddns_url}"\n') + try: + os.chmod(NJALLA_SCRIPT, 0o755) + except OSError: + pass + # Run njalla.sh immediately to update DNS + try: + subprocess.run([NJALLA_SCRIPT], timeout=30, check=False) + except Exception: + pass + + return {"ok": True} + + +class DomainSetEmailRequest(BaseModel): + email: str + + +@app.post("/api/domains/set-email") +async def api_domains_set_email(req: DomainSetEmailRequest): + """Save the SSL certificate email address.""" + os.makedirs(DOMAINS_DIR, exist_ok=True) + with open(os.path.join(DOMAINS_DIR, "sslemail"), "w") as f: + f.write(req.email.strip()) + return {"ok": True} + + +@app.get("/api/domains/status") +async def api_domains_status(): + """Return the value of each known domain file (or null if missing).""" + known = [ + "matrix", "haven", "element-calling", "sslemail", + "vaultwarden", "btcpayserver", "nextcloud", "wordpress", + ] + domains: dict[str, str | None] = {} + for name in known: + path = os.path.join(DOMAINS_DIR, name) + try: + with open(path, "r") as f: + domains[name] = f.read().strip() + except FileNotFoundError: + domains[name] = None + return {"domains": domains} + + # ── Startup: seed the internal IP file immediately ─────────────── @app.on_event("startup") diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 3f39249..179c0ed 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -1,5 +1,5 @@ /* Sovran_SystemsOS Hub — Vanilla JS Frontend - v7 — Status-only dashboard + Tech Support */ + v7 — Status-only dashboard + Tech Support + Feature Manager */ "use strict"; const POLL_INTERVAL_SERVICES = 5000; @@ -16,8 +16,18 @@ const CATEGORY_ORDER = [ "apps", "nostr", "support", + "feature-manager", ]; +const FEATURE_SUBCATEGORY_LABELS = { + "infrastructure": "šŸ”§ Infrastructure", + "bitcoin": "₿ Bitcoin", + "communication": "šŸ’¬ Communication", + "nostr": "šŸ“” Nostr", +}; + +const FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"]; + const STATUS_LOADING_STATES = new Set([ "reloading", "activating", "deactivating", "maintenance", ]); @@ -35,6 +45,15 @@ let _supportTimerInt = null; let _supportEnabledAt = null; let _cachedExternalIp = null; +// Feature Manager state +let _featuresData = null; +let _rebuildLog = ""; +let _rebuildLogOffset = 0; +let _rebuildPollTimer = null; +let _rebuildFinished = false; +let _rebuildServerDown = false; +let _pendingToggle = null; // {feature, extra} waiting for domain/confirm + // ── DOM refs ────────────────────────────────────────────────────── const $tilesArea = document.getElementById("tiles-area"); @@ -63,6 +82,35 @@ const $supportModal = document.getElementById("support-modal"); const $supportBody = document.getElementById("support-body"); const $supportCloseBtn = document.getElementById("support-close-btn"); +// Feature Manager — rebuild modal +const $rebuildModal = document.getElementById("rebuild-modal"); +const $rebuildSpinner = document.getElementById("rebuild-spinner"); +const $rebuildStatus = document.getElementById("rebuild-status"); +const $rebuildLog = document.getElementById("rebuild-log"); +const $rebuildReboot = document.getElementById("rebuild-reboot-btn"); +const $rebuildSave = document.getElementById("rebuild-save-report"); +const $rebuildClose = document.getElementById("rebuild-close-btn"); + +// Feature Manager — domain setup modal +const $domainSetupModal = document.getElementById("domain-setup-modal"); +const $domainSetupTitle = document.getElementById("domain-setup-title"); +const $domainSetupBody = document.getElementById("domain-setup-body"); +const $domainSetupClose = document.getElementById("domain-setup-close-btn"); + +// Feature Manager — SSL email modal +const $sslEmailModal = document.getElementById("ssl-email-modal"); +const $sslEmailInput = document.getElementById("ssl-email-input"); +const $sslEmailSave = document.getElementById("ssl-email-save-btn"); +const $sslEmailCancel = document.getElementById("ssl-email-cancel-btn"); +const $sslEmailClose = document.getElementById("ssl-email-close-btn"); + +// Feature Manager — confirm modal +const $featureConfirmModal = document.getElementById("feature-confirm-modal"); +const $featureConfirmMsg = document.getElementById("feature-confirm-message"); +const $featureConfirmOk = document.getElementById("feature-confirm-ok-btn"); +const $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn"); +const $featureConfirmClose = document.getElementById("feature-confirm-close-btn"); + // ── Helpers ─────────────────────────────────────────────────────── function tileId(svc) { return svc.unit + "::" + svc.name; } @@ -480,6 +528,417 @@ function waitForServerReboot() { .catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); }); } +// ── Rebuild modal ───────────────────────────────────────────────── + +function openRebuildModal() { + if (!$rebuildModal) return; + _rebuildLog = ""; + _rebuildLogOffset = 0; + _rebuildServerDown = false; + _rebuildFinished = false; + if ($rebuildLog) $rebuildLog.textContent = ""; + if ($rebuildStatus) $rebuildStatus.textContent = "Rebuilding…"; + if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning"); + if ($rebuildReboot) $rebuildReboot.style.display = "none"; + if ($rebuildSave) $rebuildSave.style.display = "none"; + if ($rebuildClose) $rebuildClose.disabled = true; + $rebuildModal.classList.add("open"); + startRebuildPoll(); +} + +function closeRebuildModal() { + if ($rebuildModal) $rebuildModal.classList.remove("open"); + stopRebuildPoll(); +} + +function appendRebuildLog(text) { + if (!text) return; + _rebuildLog += text; + if ($rebuildLog) { $rebuildLog.textContent += text; $rebuildLog.scrollTop = $rebuildLog.scrollHeight; } +} + +function startRebuildPoll() { + pollRebuildStatus(); + _rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL); +} + +function stopRebuildPoll() { + if (_rebuildPollTimer) { clearInterval(_rebuildPollTimer); _rebuildPollTimer = null; } +} + +async function pollRebuildStatus() { + if (_rebuildFinished) return; + try { + var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset); + if (_rebuildServerDown) { _rebuildServerDown = false; appendRebuildLog("[Server reconnected]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Rebuilding…"; } + if (data.log) appendRebuildLog(data.log); + _rebuildLogOffset = data.offset; + if (data.running) return; + _rebuildFinished = true; + stopRebuildPoll(); + onRebuildDone(data.result === "success"); + } catch (err) { + if (!_rebuildServerDown) { _rebuildServerDown = true; appendRebuildLog("\n[Server restarting — waiting for it to come back…]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Server restarting…"; } + } +} + +function onRebuildDone(success) { + if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning"); + if ($rebuildClose) $rebuildClose.disabled = false; + if (success) { + if ($rebuildStatus) $rebuildStatus.textContent = "āœ“ Rebuild complete"; + if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; + // Refresh feature states + loadFeatureManager(); + } else { + if ($rebuildStatus) $rebuildStatus.textContent = "āœ— Rebuild failed"; + if ($rebuildSave) $rebuildSave.style.display = "inline-flex"; + if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; + } +} + +function saveRebuildErrorReport() { + var blob = new Blob([_rebuildLog], { type: "text/plain" }); + var url = URL.createObjectURL(blob); + var a = document.createElement("a"); + a.href = url; + a.download = "sovran-rebuild-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// ── Feature confirm modal ───────────────────────────────────────── + +function openFeatureConfirm(message, onConfirm) { + if (!$featureConfirmModal) return; + if ($featureConfirmMsg) $featureConfirmMsg.textContent = message; + $featureConfirmModal.classList.add("open"); + // Replace ok handler + var newOk = $featureConfirmOk.cloneNode(true); + $featureConfirmOk.parentNode.replaceChild(newOk, $featureConfirmOk); + newOk.addEventListener("click", function() { + closeFeatureConfirm(); + onConfirm(); + }); +} + +function closeFeatureConfirm() { + if ($featureConfirmModal) $featureConfirmModal.classList.remove("open"); +} + +// ── SSL Email modal ─────────────────────────────────────────────── + +function openSslEmailModal(onSaved) { + if (!$sslEmailModal) return; + if ($sslEmailInput) $sslEmailInput.value = ""; + $sslEmailModal.classList.add("open"); + // Replace save handler + var newSave = $sslEmailSave.cloneNode(true); + $sslEmailSave.parentNode.replaceChild(newSave, $sslEmailSave); + newSave.addEventListener("click", async function() { + var email = $sslEmailInput ? $sslEmailInput.value.trim() : ""; + if (!email) { alert("Please enter an email address."); return; } + newSave.disabled = true; + newSave.textContent = "Saving…"; + try { + await apiFetch("/api/domains/set-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email }), + }); + closeSslEmailModal(); + onSaved(); + } catch (err) { + newSave.disabled = false; + newSave.textContent = "Save"; + alert("Failed to save email. Please try again."); + } + }); +} + +function closeSslEmailModal() { + if ($sslEmailModal) $sslEmailModal.classList.remove("open"); +} + +// ── Domain Setup modal ──────────────────────────────────────────── + +function openDomainSetupModal(feat, onSaved) { + if (!$domainSetupModal) return; + if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name; + + var npubField = ""; + if (feat.id === "haven") { + var currentNpub = ""; + if (feat.extra_fields && feat.extra_fields.length > 0) { + for (var i = 0; i < feat.extra_fields.length; i++) { + if (feat.extra_fields[i].id === "nostr_npub") { + currentNpub = feat.extra_fields[i].current_value || ""; + break; + } + } + } + npubField = '
      '; + } + + $domainSetupBody.innerHTML = + '

      Before continuing, you need:

      1. A subdomain purchased on njal.la
      2. A Dynamic DNS record for it
      ' + + '
      ' + + '

      ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record

      ' + + npubField + + '
      '; + + document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal); + + document.getElementById("domain-setup-save-btn").addEventListener("click", async function() { + var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || ""; + var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || ""; + var npub = document.getElementById("domain-npub-input") ? (document.getElementById("domain-npub-input").value || "") : ""; + subdomain = subdomain.trim(); + ddnsUrl = ddnsUrl.trim(); + npub = npub.trim(); + + if (!subdomain) { alert("Please enter a subdomain."); return; } + if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; } + + var saveBtn = document.getElementById("domain-setup-save-btn"); + saveBtn.disabled = true; + saveBtn.textContent = "Saving…"; + + try { + await apiFetch("/api/domains/set", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + domain_name: feat.domain_name, + domain: subdomain, + ddns_url: ddnsUrl, + }), + }); + closeDomainSetupModal(); + onSaved(npub); + } catch (err) { + saveBtn.disabled = false; + saveBtn.textContent = "Save & Enable"; + alert("Failed to save domain. Please try again."); + } + }); + + $domainSetupModal.classList.add("open"); +} + +function closeDomainSetupModal() { + if ($domainSetupModal) $domainSetupModal.classList.remove("open"); +} + +// ── Feature toggle logic ────────────────────────────────────────── + +async function performFeatureToggle(featId, enabled, extra) { + try { + var res = await fetch("/api/features/toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ feature: featId, enabled: enabled, extra: extra || {} }), + }); + var body = await res.json(); + if (!res.ok) { + if (body && body.error === "domain_required") { + alert("Domain not configured for this feature. Please configure it first."); + } else { + alert("Error: " + (body.detail || body.error || "Unknown error")); + } + loadFeatureManager(); + return; + } + openRebuildModal(); + } catch (err) { + alert("Failed to toggle feature: " + err); + loadFeatureManager(); + } +} + +function handleFeatureToggle(feat, newEnabled) { + if (!newEnabled) { + // Disable: ask confirmation + openFeatureConfirm( + "This will disable " + feat.name + ". The system will rebuild. Continue?", + function() { performFeatureToggle(feat.id, false, {}); } + ); + return; + } + + // Enabling + var conflictNames = []; + if (feat.conflicts_with && feat.conflicts_with.length > 0 && _featuresData) { + feat.conflicts_with.forEach(function(cid) { + var cf = _featuresData.features.find(function(f) { return f.id === cid; }); + if (cf && cf.enabled) conflictNames.push(cf.name); + }); + } + + function proceedAfterConflictCheck() { + // Check SSL email first + if (!_featuresData || !_featuresData.ssl_email_configured) { + if (feat.needs_domain) { + openSslEmailModal(function() { + // After ssl email saved, check domain + checkDomainAndEnable(feat, {}); + }); + return; + } + } + if (feat.needs_domain && !feat.domain_configured) { + checkDomainAndEnable(feat, {}); + return; + } + if (feat.id === "haven") { + var npub = ""; + if (feat.extra_fields) { + var ef = feat.extra_fields.find(function(e) { return e.id === "nostr_npub"; }); + if (ef) npub = ef.current_value || ""; + } + if (!npub) { + // Need to collect npub via domain modal + openDomainSetupModal(feat, function(collectedNpub) { + performFeatureToggle(feat.id, true, { nostr_npub: collectedNpub }); + }); + return; + } + } + performFeatureToggle(feat.id, true, {}); + } + + if (conflictNames.length > 0) { + openFeatureConfirm( + "This will disable " + conflictNames.join(", ") + ". Continue?", + proceedAfterConflictCheck + ); + } else { + proceedAfterConflictCheck(); + } +} + +function checkDomainAndEnable(feat, extra) { + openDomainSetupModal(feat, function(collectedNpub) { + var extraData = {}; + if (collectedNpub) extraData.nostr_npub = collectedNpub; + performFeatureToggle(feat.id, true, extraData); + }); +} + +// ── Feature Manager rendering ───────────────────────────────────── + +async function loadFeatureManager() { + try { + var data = await apiFetch("/api/features"); + _featuresData = data; + renderFeatureManager(data); + } catch (err) { + console.warn("Failed to load features:", err); + } +} + +function renderFeatureManager(data) { + // Remove old feature manager section if it exists + var old = $tilesArea.querySelector(".feature-manager-section"); + if (old) old.parentNode.removeChild(old); + + var section = document.createElement("div"); + section.className = "category-section feature-manager-section"; + section.dataset.category = "feature-manager"; + section.innerHTML = '
      Feature Manager

      '; + + // Group by sub-category + var grouped = {}; + for (var i = 0; i < data.features.length; i++) { + var f = data.features[i]; + var cat = f.category || "other"; + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push(f); + } + + var orderedCats = FEATURE_SUBCATEGORY_ORDER.filter(function(k) { return grouped[k]; }); + Object.keys(grouped).forEach(function(k) { + if (orderedCats.indexOf(k) === -1) orderedCats.push(k); + }); + + for (var j = 0; j < orderedCats.length; j++) { + var catKey = orderedCats[j]; + var feats = grouped[catKey]; + if (!feats || feats.length === 0) continue; + + var subcat = document.createElement("div"); + subcat.className = "feature-subcategory"; + var subcatLabel = FEATURE_SUBCATEGORY_LABELS[catKey] || catKey; + subcat.innerHTML = '
      ' + escHtml(subcatLabel) + '
      '; + + var cardsWrap = document.createElement("div"); + cardsWrap.className = "feature-cards-wrap"; + + for (var k = 0; k < feats.length; k++) { + cardsWrap.appendChild(buildFeatureCard(feats[k])); + } + subcat.appendChild(cardsWrap); + section.appendChild(subcat); + } + + $tilesArea.appendChild(section); +} + +function buildFeatureCard(feat) { + var card = document.createElement("div"); + card.className = "feature-card"; + + var conflictHtml = ""; + if (feat.conflicts_with && feat.conflicts_with.length > 0) { + var conflictNames = feat.conflicts_with.map(function(cid) { + if (!_featuresData) return cid; + var cf = _featuresData.features.find(function(f) { return f.id === cid; }); + return cf ? cf.name : cid; + }); + conflictHtml = '
      ⚠ Conflicts with: ' + escHtml(conflictNames.join(", ")) + '
      '; + } + + var domainHtml = ""; + if (feat.needs_domain) { + if (feat.domain_configured) { + domainHtml = '
      🌐 Domain: Configured
      '; + } else { + domainHtml = '
      🌐 Domain: Not configured
      '; + } + } + + var statusText = feat.enabled ? "Enabled" : "Disabled"; + + card.innerHTML = + '
      ' + + '
      ' + + '
      ' + escHtml(feat.name) + '
      ' + + '
      ' + escHtml(feat.description) + '
      ' + + '
      ' + + '' + + '
      ' + + domainHtml + + conflictHtml + + '
      Status: ' + escHtml(statusText) + '
      '; + + var toggle = card.querySelector(".feature-toggle-input"); + var toggleLabel = card.querySelector(".feature-toggle"); + toggle.addEventListener("change", function() { + var newEnabled = toggle.checked; + // Revert visually until confirmed + toggle.checked = feat.enabled; + if (newEnabled) { toggleLabel.classList.remove("active"); } else { toggleLabel.classList.add("active"); } + handleFeatureToggle(feat, newEnabled); + }); + + return card; +} + // ── Event listeners ─────────────────────────────────────────────── if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); @@ -490,6 +949,26 @@ if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal); if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal); +// Rebuild modal +if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal); +if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot); +if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport); +if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); }); + +// Domain setup modal +if ($domainSetupClose) $domainSetupClose.addEventListener("click", closeDomainSetupModal); +if ($domainSetupModal) $domainSetupModal.addEventListener("click", function(e) { if (e.target === $domainSetupModal) closeDomainSetupModal(); }); + +// SSL Email modal +if ($sslEmailClose) $sslEmailClose.addEventListener("click", closeSslEmailModal); +if ($sslEmailCancel) $sslEmailCancel.addEventListener("click", closeSslEmailModal); +if ($sslEmailModal) $sslEmailModal.addEventListener("click", function(e) { if (e.target === $sslEmailModal) closeSslEmailModal(); }); + +// Feature confirm modal +if ($featureConfirmClose) $featureConfirmClose.addEventListener("click", closeFeatureConfirm); +if ($featureConfirmCancel) $featureConfirmCancel.addEventListener("click", closeFeatureConfirm); +if ($featureConfirmModal) $featureConfirmModal.addEventListener("click", function(e) { if (e.target === $featureConfirmModal) closeFeatureConfirm(); }); + if ($modal) $modal.addEventListener("click", function(e) { if (e.target === $modal) closeUpdateModal(); }); if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); }); if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); }); @@ -506,14 +985,24 @@ async function init() { } var badge = document.getElementById("role-badge"); if (badge && cfg.role_label) badge.textContent = cfg.role_label; - } catch (_) {} - await refreshServices(); - loadNetwork(); - checkUpdates(); + await refreshServices(); + loadNetwork(); + checkUpdates(); - setInterval(refreshServices, POLL_INTERVAL_SERVICES); - setInterval(checkUpdates, POLL_INTERVAL_UPDATES); + setInterval(refreshServices, POLL_INTERVAL_SERVICES); + setInterval(checkUpdates, POLL_INTERVAL_UPDATES); + + if (cfg.feature_manager) { + loadFeatureManager(); + } + } catch (_) { + await refreshServices(); + loadNetwork(); + checkUpdates(); + setInterval(refreshServices, POLL_INTERVAL_SERVICES); + setInterval(checkUpdates, POLL_INTERVAL_UPDATES); + } } document.addEventListener("DOMContentLoaded", init); \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index cbfa0e1..068ec04 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -960,3 +960,220 @@ button.btn-reboot:hover:not(:disabled) { } } + +/* ── Feature Manager ─────────────────────────────────────────────── */ + +.feature-manager-section { + margin-bottom: 32px; +} + +.feature-subcategory { + margin-bottom: 24px; +} + +.feature-subcategory-header { + font-size: 0.88rem; + font-weight: 700; + color: var(--text-secondary); + margin-bottom: 10px; + padding-left: 2px; +} + +.feature-cards-wrap { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; +} + +.feature-card { + background-color: var(--card-color); + padding: 14px 18px; + border-bottom: 1px solid var(--border-color); +} + +.feature-card:last-child { + border-bottom: none; +} + +.feature-card-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.feature-card-info { + flex: 1; + min-width: 0; +} + +.feature-card-name { + font-size: 0.95rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 2px; +} + +.feature-card-desc { + font-size: 0.82rem; + color: var(--text-secondary); + line-height: 1.4; +} + +.feature-card-status { + font-size: 0.75rem; + color: var(--text-dim); + margin-top: 6px; +} + +/* ── Feature toggle switch ───────────────────────────────────────── */ + +.feature-toggle { + position: relative; + display: inline-flex; + align-items: center; + width: 44px; + height: 24px; + flex-shrink: 0; + cursor: pointer; + margin-top: 2px; +} + +.feature-toggle-input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.feature-toggle-slider { + position: absolute; + inset: 0; + background-color: var(--border-color); + border-radius: 24px; + transition: background-color 0.2s; +} + +.feature-toggle-slider::before { + content: ""; + position: absolute; + width: 18px; + height: 18px; + left: 3px; + top: 3px; + background-color: var(--text-secondary); + border-radius: 50%; + transition: transform 0.2s, background-color 0.2s; +} + +.feature-toggle.active .feature-toggle-slider { + background-color: var(--green); +} + +.feature-toggle.active .feature-toggle-slider::before { + transform: translateX(20px); + background-color: #fff; +} + +/* ── Feature domain badge ────────────────────────────────────────── */ + +.feature-domain-badge { + font-size: 0.75rem; + font-weight: 600; + margin-top: 6px; + padding: 2px 0; +} + +.feature-domain-badge.configured { + color: var(--green); +} + +.feature-domain-badge.not-configured { + color: var(--yellow); +} + +/* ── Feature conflict warning ────────────────────────────────────── */ + +.feature-conflict-warning { + font-size: 0.75rem; + color: var(--yellow); + margin-top: 4px; +} + +/* ── Domain setup modal inputs ───────────────────────────────────── */ + +.domain-narrow-dialog { + max-width: 520px; +} + +.domain-setup-intro { + font-size: 0.88rem; + color: var(--text-secondary); + margin-bottom: 18px; + line-height: 1.6; +} + +.domain-setup-intro ol { + padding-left: 20px; + margin-top: 6px; +} + +.domain-field-group { + margin-bottom: 14px; +} + +.domain-field-label { + display: block; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); + margin-bottom: 6px; +} + +.domain-field-input { + width: 100%; + background-color: #12121c; + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + font-family: inherit; + font-size: 0.92rem; + padding: 10px 14px; + outline: none; + transition: border-color 0.15s; +} + +.domain-field-input:focus { + border-color: var(--accent-color); +} + +.domain-field-hint { + font-size: 0.75rem; + color: var(--text-dim); + margin-top: 5px; + font-style: italic; +} + +.domain-field-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; +} + +@media (max-width: 600px) { + .feature-cards-wrap { + border-radius: 10px; + } + .feature-card { + padding: 12px 14px; + } + .domain-narrow-dialog { + margin: 0 12px; + } +} diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index 1b7455b..f0e9bd0 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -80,6 +80,72 @@
      + + + + + + + + + + + +
      diff --git a/flake.nix b/flake.nix index 89222a7..eb5b029 100755 --- a/flake.nix +++ b/flake.nix @@ -27,6 +27,7 @@ self.nixosModules.Sovran_SystemsOS /etc/nixos/role-state.nix /etc/nixos/custom.nix + /etc/nixos/hub-overrides.nix ]; }; diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index f8f7923..02faeaf 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -99,6 +99,7 @@ let command_method = "systemctl"; role = activeRole; services = monitoredServices; + feature_manager = true; }); # ── Update wrapper script ────────────────────────────────────── @@ -159,6 +160,39 @@ let exit "$RC" ''; + # ── Rebuild wrapper script ───────────────────────────────────── + rebuild-script = pkgs.writeShellScript "sovran-hub-rebuild.sh" '' + set -uo pipefail + export PATH="${lib.makeBinPath [ pkgs.nix pkgs.nixos-rebuild pkgs.coreutils ]}:$PATH" + + LOG="/var/log/sovran-hub-rebuild.log" + STATUS="/var/log/sovran-hub-rebuild.status" + + echo "RUNNING" > "$STATUS" + : > "$LOG" + exec > >(tee -a "$LOG") 2>&1 + + echo "══════════════════════════════════════════════════" + echo " Sovran_SystemsOS Rebuild — $(date)" + echo "══════════════════════════════════════════════════" + echo "" + echo "── Rebuilding system configuration ──────────────" + if nixos-rebuild switch --flake /etc/nixos --print-build-logs 2>&1; then + echo "" + echo "══════════════════════════════════════════════════" + echo " āœ“ Rebuild completed successfully" + echo "══════════════════════════════════════════════════" + echo "SUCCESS" > "$STATUS" + else + echo "" + echo "══════════════════════════════════════════════════" + echo " āœ— Rebuild failed — see errors above" + echo "══════════════════════════════════════════════════" + echo "FAILED" > "$STATUS" + exit 1 + fi + ''; + sovran-hub-web = pkgs.python3Packages.buildPythonApplication { pname = "sovran-systemsos-hub-web"; version = "1.0.0"; @@ -241,6 +275,32 @@ in }; }; + systemd.services.sovran-hub-rebuild = { + description = "Sovran_SystemsOS System Rebuild"; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${rebuild-script}"; + }; + }; + + systemd.services.hub-overrides-init = { + description = "Initialize hub-overrides.nix if missing"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + unitConfig.ConditionPathExists = "!/etc/nixos/hub-overrides.nix"; + script = '' + cat > /etc/nixos/hub-overrides.nix <<'EOF' +# Auto-generated by Sovran Hub — do not edit manually +{ ... }: +{ +} +EOF + ''; + }; + networking.firewall.allowedTCPPorts = [ 8937 ]; }; } \ No newline at end of file -- 2.53.0 From e43552373cd92a3125b9cf65d54f6ae4168d07b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:27:14 +0000 Subject: [PATCH 187/857] fix: validate domain_name to prevent path injection; fix toggle revert logic Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/9088415a-efc3-4dd1-9c22-877a543af47b Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 10 ++++++++++ app/sovran_systemsos_web/static/app.js | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index f4de31c..5fbd526 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -929,9 +929,19 @@ class DomainSetRequest(BaseModel): ddns_url: str = "" +_SAFE_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]+$') + + +def _validate_safe_name(name: str) -> bool: + """Return True if name contains only safe path characters (no separators).""" + return bool(name) and _SAFE_NAME_RE.match(name) is not None + + @app.post("/api/domains/set") async def api_domains_set(req: DomainSetRequest): """Save a domain and optionally register a DDNS URL.""" + if not _validate_safe_name(req.domain_name): + raise HTTPException(status_code=400, detail="Invalid domain_name") os.makedirs(DOMAINS_DIR, exist_ok=True) domain_path = os.path.join(DOMAINS_DIR, req.domain_name) with open(domain_path, "w") as f: diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 179c0ed..39f875f 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -930,9 +930,9 @@ function buildFeatureCard(feat) { var toggleLabel = card.querySelector(".feature-toggle"); toggle.addEventListener("change", function() { var newEnabled = toggle.checked; - // Revert visually until confirmed + // Revert visually to original state while confirmation/modal is pending toggle.checked = feat.enabled; - if (newEnabled) { toggleLabel.classList.remove("active"); } else { toggleLabel.classList.add("active"); } + if (feat.enabled) { toggleLabel.classList.add("active"); } else { toggleLabel.classList.remove("active"); } handleFeatureToggle(feat, newEnabled); }); -- 2.53.0 From a54beaffadae9017b0b122eaacb9f7bf83dadadc Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 18:42:00 -0500 Subject: [PATCH 188/857] updated Feature Manager --- app/sovran_systemsos_web/server.py | 45 +- app/sovran_systemsos_web/static/app.js | 794 +++---------------------- 2 files changed, 122 insertions(+), 717 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 5fbd526..ffedc15 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -22,7 +22,7 @@ from pydantic import BaseModel from .config import load_config from . import systemctl as sysctl -# ── Constants ──────────────────────────────��───────────────────── +# ── Constants ────────────────────────────────────────────────────── FLAKE_LOCK_PATH = "/etc/nixos/flake.lock" FLAKE_INPUT_NAME = "Sovran_Systems" @@ -146,6 +146,16 @@ FEATURE_REGISTRY = [ }, ] +# Map feature IDs to their systemd units in config.json +FEATURE_SERVICE_MAP = { + "rdp": "gnome-remote-desktop.service", + "haven": "haven-relay.service", + "element-calling": "livekit.service", + "mempool": "mempool-frontend.service", + "bip110": None, + "bitcoin-core": None, +} + ROLE_LABELS = { "server_plus_desktop": "Server + Desktop", "desktop": "Desktop Only", @@ -191,7 +201,7 @@ def _file_hash(filename: str) -> str: _APP_JS_HASH = _file_hash("app.js") _STYLE_CSS_HASH = _file_hash("style.css") -# ── Update check helpers ──────────────────��────────────────────── +# ── Update check helpers ────────────────────────────────────────── def _get_locked_info(): try: @@ -463,6 +473,21 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None) -> None: f.write(content) +# ── Feature status helpers ───────────────────────────────────────── + +def _is_feature_enabled_in_config(feature_id: str) -> bool | None: + """Check if a feature's service appears as enabled in the running config.json. + Returns True/False if found, None if the feature has no mapped service.""" + unit = FEATURE_SERVICE_MAP.get(feature_id) + if unit is None: + return None # bip110, bitcoin-core — can't determine from config + cfg = load_config() + for svc in cfg.get("services", []): + if svc.get("unit") == unit: + return svc.get("enabled", False) + return None + + # ── Tech Support helpers ────────────────────────────────────────── def _is_support_active() -> bool: @@ -582,7 +607,6 @@ async def api_config(): "role": role, "role_label": ROLE_LABELS.get(role, role), "category_order": CATEGORY_ORDER, - "feature_manager": cfg.get("feature_manager", False), } @@ -786,7 +810,18 @@ async def api_features(): features = [] for feat in FEATURE_REGISTRY: feat_id = feat["id"] - enabled = overrides.get(feat_id, False) + + # Determine enabled state: + # 1. Check hub-overrides.nix first (explicit hub toggle) + # 2. Fall back to config.json services (features enabled in custom.nix) + if feat_id in overrides: + enabled = overrides[feat_id] + else: + config_state = _is_feature_enabled_in_config(feat_id) + if config_state is not None: + enabled = config_state + else: + enabled = False domain_name = feat.get("domain_name") domain_configured = True @@ -1015,4 +1050,4 @@ async def _startup_save_ip(): """Write internal IP to file on server start so credentials work immediately.""" loop = asyncio.get_event_loop() ip = await loop.run_in_executor(None, _get_internal_ip) - _save_internal_ip(ip) + _save_internal_ip(ip) \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 39f875f..6db8a1e 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -1,14 +1,14 @@ /* Sovran_SystemsOS Hub — Vanilla JS Frontend - v7 — Status-only dashboard + Tech Support + Feature Manager */ + v8 — Status-only dashboard + Tech Support + Feature Manager */ "use strict"; -const POLL_INTERVAL_SERVICES = 5000; -const POLL_INTERVAL_UPDATES = 1800000; -const UPDATE_POLL_INTERVAL = 2000; -const REBOOT_CHECK_INTERVAL = 5000; -const SUPPORT_TIMER_INTERVAL = 1000; +var POLL_INTERVAL_SERVICES = 5000; +var POLL_INTERVAL_UPDATES = 1800000; +var UPDATE_POLL_INTERVAL = 2000; +var REBOOT_CHECK_INTERVAL = 5000; +var SUPPORT_TIMER_INTERVAL = 1000; -const CATEGORY_ORDER = [ +var CATEGORY_ORDER = [ "infrastructure", "bitcoin-base", "bitcoin-apps", @@ -19,97 +19,104 @@ const CATEGORY_ORDER = [ "feature-manager", ]; -const FEATURE_SUBCATEGORY_LABELS = { +var FEATURE_SUBCATEGORY_LABELS = { "infrastructure": "šŸ”§ Infrastructure", "bitcoin": "₿ Bitcoin", "communication": "šŸ’¬ Communication", "nostr": "šŸ“” Nostr", }; -const FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"]; +var FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"]; -const STATUS_LOADING_STATES = new Set([ +var FEATURE_UNIT_MAP = { + "rdp": "gnome-remote-desktop.service", + "haven": "haven-relay.service", + "element-calling": "livekit.service", + "mempool": "mempool-frontend.service", +}; + +var STATUS_LOADING_STATES = new Set([ "reloading", "activating", "deactivating", "maintenance", ]); // ── State ───────────────────────────────────────────────────────── -let _servicesCache = []; -let _categoryLabels = {}; -let _updateLog = ""; -let _updatePollTimer = null; -let _updateLogOffset = 0; -let _serverWasDown = false; -let _updateFinished = false; -let _supportTimerInt = null; -let _supportEnabledAt = null; -let _cachedExternalIp = null; +var _servicesCache = []; +var _categoryLabels = {}; +var _updateLog = ""; +var _updatePollTimer = null; +var _updateLogOffset = 0; +var _serverWasDown = false; +var _updateFinished = false; +var _supportTimerInt = null; +var _supportEnabledAt = null; +var _cachedExternalIp = null; // Feature Manager state -let _featuresData = null; -let _rebuildLog = ""; -let _rebuildLogOffset = 0; -let _rebuildPollTimer = null; -let _rebuildFinished = false; -let _rebuildServerDown = false; -let _pendingToggle = null; // {feature, extra} waiting for domain/confirm +var _featuresData = null; +var _rebuildLog = ""; +var _rebuildLogOffset = 0; +var _rebuildPollTimer = null; +var _rebuildFinished = false; +var _rebuildServerDown = false; +var _pendingToggle = null; // ── DOM refs ────────────────────────────────────────────────────── -const $tilesArea = document.getElementById("tiles-area"); -const $updateBtn = document.getElementById("btn-update"); -const $updateBadge = document.getElementById("update-badge"); -const $refreshBtn = document.getElementById("btn-refresh"); -const $internalIp = document.getElementById("ip-internal"); -const $externalIp = document.getElementById("ip-external"); +var $tilesArea = document.getElementById("tiles-area"); +var $updateBtn = document.getElementById("btn-update"); +var $updateBadge = document.getElementById("update-badge"); +var $refreshBtn = document.getElementById("btn-refresh"); +var $internalIp = document.getElementById("ip-internal"); +var $externalIp = document.getElementById("ip-external"); -const $modal = document.getElementById("update-modal"); -const $modalSpinner = document.getElementById("modal-spinner"); -const $modalStatus = document.getElementById("modal-status"); -const $modalLog = document.getElementById("modal-log"); -const $btnReboot = document.getElementById("btn-reboot"); -const $btnSave = document.getElementById("btn-save-report"); -const $btnCloseModal = document.getElementById("btn-close-modal"); +var $modal = document.getElementById("update-modal"); +var $modalSpinner = document.getElementById("modal-spinner"); +var $modalStatus = document.getElementById("modal-status"); +var $modalLog = document.getElementById("modal-log"); +var $btnReboot = document.getElementById("btn-reboot"); +var $btnSave = document.getElementById("btn-save-report"); +var $btnCloseModal = document.getElementById("btn-close-modal"); -const $rebootOverlay = document.getElementById("reboot-overlay"); +var $rebootOverlay = document.getElementById("reboot-overlay"); -const $credsModal = document.getElementById("creds-modal"); -const $credsTitle = document.getElementById("creds-modal-title"); -const $credsBody = document.getElementById("creds-body"); -const $credsCloseBtn = document.getElementById("creds-close-btn"); +var $credsModal = document.getElementById("creds-modal"); +var $credsTitle = document.getElementById("creds-modal-title"); +var $credsBody = document.getElementById("creds-body"); +var $credsCloseBtn = document.getElementById("creds-close-btn"); -const $supportModal = document.getElementById("support-modal"); -const $supportBody = document.getElementById("support-body"); -const $supportCloseBtn = document.getElementById("support-close-btn"); +var $supportModal = document.getElementById("support-modal"); +var $supportBody = document.getElementById("support-body"); +var $supportCloseBtn = document.getElementById("support-close-btn"); // Feature Manager — rebuild modal -const $rebuildModal = document.getElementById("rebuild-modal"); -const $rebuildSpinner = document.getElementById("rebuild-spinner"); -const $rebuildStatus = document.getElementById("rebuild-status"); -const $rebuildLog = document.getElementById("rebuild-log"); -const $rebuildReboot = document.getElementById("rebuild-reboot-btn"); -const $rebuildSave = document.getElementById("rebuild-save-report"); -const $rebuildClose = document.getElementById("rebuild-close-btn"); +var $rebuildModal = document.getElementById("rebuild-modal"); +var $rebuildSpinner = document.getElementById("rebuild-spinner"); +var $rebuildStatus = document.getElementById("rebuild-status"); +var $rebuildLogEl = document.getElementById("rebuild-log"); +var $rebuildReboot = document.getElementById("rebuild-reboot-btn"); +var $rebuildSave = document.getElementById("rebuild-save-report"); +var $rebuildClose = document.getElementById("rebuild-close-btn"); // Feature Manager — domain setup modal -const $domainSetupModal = document.getElementById("domain-setup-modal"); -const $domainSetupTitle = document.getElementById("domain-setup-title"); -const $domainSetupBody = document.getElementById("domain-setup-body"); -const $domainSetupClose = document.getElementById("domain-setup-close-btn"); +var $domainSetupModal = document.getElementById("domain-setup-modal"); +var $domainSetupTitle = document.getElementById("domain-setup-title"); +var $domainSetupBody = document.getElementById("domain-setup-body"); +var $domainSetupClose = document.getElementById("domain-setup-close-btn"); // Feature Manager — SSL email modal -const $sslEmailModal = document.getElementById("ssl-email-modal"); -const $sslEmailInput = document.getElementById("ssl-email-input"); -const $sslEmailSave = document.getElementById("ssl-email-save-btn"); -const $sslEmailCancel = document.getElementById("ssl-email-cancel-btn"); -const $sslEmailClose = document.getElementById("ssl-email-close-btn"); +var $sslEmailModal = document.getElementById("ssl-email-modal"); +var $sslEmailInput = document.getElementById("ssl-email-input"); +var $sslEmailSave = document.getElementById("ssl-email-save-btn"); +var $sslEmailCancel = document.getElementById("ssl-email-cancel-btn"); +var $sslEmailClose = document.getElementById("ssl-email-close-btn"); // Feature Manager — confirm modal -const $featureConfirmModal = document.getElementById("feature-confirm-modal"); -const $featureConfirmMsg = document.getElementById("feature-confirm-message"); -const $featureConfirmOk = document.getElementById("feature-confirm-ok-btn"); -const $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn"); -const $featureConfirmClose = document.getElementById("feature-confirm-close-btn"); +var $featureConfirmModal = document.getElementById("feature-confirm-modal"); +var $featureConfirmMsg = document.getElementById("feature-confirm-message"); +var $featureConfirmOk = document.getElementById("feature-confirm-ok-btn"); +var $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn"); +var $featureConfirmClose = document.getElementById("feature-confirm-close-btn"); // ── Helpers ─────────────────────────────────────────────────────── @@ -140,9 +147,9 @@ function linkify(str) { } function formatDuration(seconds) { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = Math.floor(seconds % 60); + var h = Math.floor(seconds / 3600); + var m = Math.floor((seconds % 3600) / 60); + var s = Math.floor(seconds % 60); if (h > 0) return h + "h " + m + "m " + s + "s"; if (m > 0) return m + "m " + s + "s"; return s + "s"; @@ -151,7 +158,7 @@ function formatDuration(seconds) { // ── Fetch wrappers ──────────────────────────────────────────────── async function apiFetch(path, options) { - const res = await fetch(path, options || {}); + var res = await fetch(path, options || {}); if (!res.ok) throw new Error(res.status + " " + res.statusText); return res.json(); } @@ -280,7 +287,7 @@ async function checkUpdates() { } catch (_) {} } -// ── Credentials info modal ──────────────────────────────────────── +// ── Credentials info modal ──────────��───────────────────────────── async function openCredsModal(unit, name) { if (!$credsModal) return; @@ -368,641 +375,4 @@ async function enableSupport() { if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; } try { await apiFetch("/api/support/enable", { method: "POST" }); - var status = await apiFetch("/api/support/status"); - _supportEnabledAt = status.enabled_at; - renderSupportActive(); - } catch (err) { - if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; } - alert("Failed to enable support access. Please try again."); - } -} - -async function disableSupport() { - var btn = document.getElementById("btn-support-disable"); - if (btn) { btn.disabled = true; btn.textContent = "Removing key…"; } - try { - var result = await apiFetch("/api/support/disable", { method: "POST" }); - renderSupportRemoved(result.verified); - } catch (err) { - if (btn) { btn.disabled = false; btn.textContent = "End Support Session"; } - alert("Failed to disable support access. Please try again."); - } -} - -function startSupportTimer() { - stopSupportTimer(); - updateSupportTimer(); - _supportTimerInt = setInterval(updateSupportTimer, SUPPORT_TIMER_INTERVAL); -} - -function stopSupportTimer() { - if (_supportTimerInt) { clearInterval(_supportTimerInt); _supportTimerInt = null; } -} - -function updateSupportTimer() { - var el = document.getElementById("support-timer"); - if (!el || !_supportEnabledAt) return; - var elapsed = (Date.now() / 1000) - _supportEnabledAt; - el.textContent = formatDuration(Math.max(0, elapsed)); -} - -function closeSupportModal() { - if ($supportModal) $supportModal.classList.remove("open"); - stopSupportTimer(); -} - -// ── Update modal ────────────────────────────────────────────────── - -function openUpdateModal() { - if (!$modal) return; - _updateLog = ""; - _updateLogOffset = 0; - _serverWasDown = false; - _updateFinished = false; - if ($modalLog) $modalLog.textContent = ""; - if ($modalStatus) $modalStatus.textContent = "Starting update…"; - if ($modalSpinner) $modalSpinner.classList.add("spinning"); - if ($btnReboot) $btnReboot.style.display = "none"; - if ($btnSave) $btnSave.style.display = "none"; - if ($btnCloseModal) $btnCloseModal.disabled = true; - $modal.classList.add("open"); - startUpdate(); -} - -function closeUpdateModal() { - if (!$modal) return; - $modal.classList.remove("open"); - stopUpdatePoll(); -} - -function appendLog(text) { - if (!text) return; - _updateLog += text; - if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; } -} - -function startUpdate() { - fetch("/api/updates/run", { method: "POST" }) - .then(function(response) { - if (!response.ok) return response.text().then(function(t) { throw new Error(t); }); - return response.json(); - }) - .then(function(data) { - if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n"); - if ($modalStatus) $modalStatus.textContent = "Updating…"; - startUpdatePoll(); - }) - .catch(function(err) { - appendLog("[Error: failed to start update — " + err + "]\n"); - onUpdateDone(false); - }); -} - -function startUpdatePoll() { - pollUpdateStatus(); - _updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL); -} - -function stopUpdatePoll() { - if (_updatePollTimer) { clearInterval(_updatePollTimer); _updatePollTimer = null; } -} - -async function pollUpdateStatus() { - if (_updateFinished) return; - try { - var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset); - if (_serverWasDown) { _serverWasDown = false; appendLog("[Server reconnected]\n"); if ($modalStatus) $modalStatus.textContent = "Updating…"; } - if (data.log) appendLog(data.log); - _updateLogOffset = data.offset; - if (data.running) return; - _updateFinished = true; - stopUpdatePoll(); - if (data.result === "success") onUpdateDone(true); - else onUpdateDone(false); - } catch (err) { - if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; } - } -} - -function onUpdateDone(success) { - if ($modalSpinner) $modalSpinner.classList.remove("spinning"); - if ($btnCloseModal) $btnCloseModal.disabled = false; - if (success) { - if ($modalStatus) $modalStatus.textContent = "āœ“ Update complete"; - if ($btnReboot) $btnReboot.style.display = "inline-flex"; - } else { - if ($modalStatus) $modalStatus.textContent = "āœ— Update failed"; - if ($btnSave) $btnSave.style.display = "inline-flex"; - if ($btnReboot) $btnReboot.style.display = "inline-flex"; - } -} - -function saveErrorReport() { - var blob = new Blob([_updateLog], { type: "text/plain" }); - var url = URL.createObjectURL(blob); - var a = document.createElement("a"); - a.href = url; - a.download = "sovran-update-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -// ── Reboot ──────────────────────────────────────────────────────── - -function doReboot() { - if ($modal) $modal.classList.remove("open"); - stopUpdatePoll(); - if ($rebootOverlay) $rebootOverlay.classList.add("visible"); - fetch("/api/reboot", { method: "POST" }).catch(function() {}); - setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); -} - -function waitForServerReboot() { - fetch("/api/config", { cache: "no-store" }) - .then(function(res) { - if (res.ok) window.location.reload(); - else setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); - }) - .catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); }); -} - -// ── Rebuild modal ───────────────────────────────────────────────── - -function openRebuildModal() { - if (!$rebuildModal) return; - _rebuildLog = ""; - _rebuildLogOffset = 0; - _rebuildServerDown = false; - _rebuildFinished = false; - if ($rebuildLog) $rebuildLog.textContent = ""; - if ($rebuildStatus) $rebuildStatus.textContent = "Rebuilding…"; - if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning"); - if ($rebuildReboot) $rebuildReboot.style.display = "none"; - if ($rebuildSave) $rebuildSave.style.display = "none"; - if ($rebuildClose) $rebuildClose.disabled = true; - $rebuildModal.classList.add("open"); - startRebuildPoll(); -} - -function closeRebuildModal() { - if ($rebuildModal) $rebuildModal.classList.remove("open"); - stopRebuildPoll(); -} - -function appendRebuildLog(text) { - if (!text) return; - _rebuildLog += text; - if ($rebuildLog) { $rebuildLog.textContent += text; $rebuildLog.scrollTop = $rebuildLog.scrollHeight; } -} - -function startRebuildPoll() { - pollRebuildStatus(); - _rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL); -} - -function stopRebuildPoll() { - if (_rebuildPollTimer) { clearInterval(_rebuildPollTimer); _rebuildPollTimer = null; } -} - -async function pollRebuildStatus() { - if (_rebuildFinished) return; - try { - var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset); - if (_rebuildServerDown) { _rebuildServerDown = false; appendRebuildLog("[Server reconnected]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Rebuilding…"; } - if (data.log) appendRebuildLog(data.log); - _rebuildLogOffset = data.offset; - if (data.running) return; - _rebuildFinished = true; - stopRebuildPoll(); - onRebuildDone(data.result === "success"); - } catch (err) { - if (!_rebuildServerDown) { _rebuildServerDown = true; appendRebuildLog("\n[Server restarting — waiting for it to come back…]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Server restarting…"; } - } -} - -function onRebuildDone(success) { - if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning"); - if ($rebuildClose) $rebuildClose.disabled = false; - if (success) { - if ($rebuildStatus) $rebuildStatus.textContent = "āœ“ Rebuild complete"; - if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; - // Refresh feature states - loadFeatureManager(); - } else { - if ($rebuildStatus) $rebuildStatus.textContent = "āœ— Rebuild failed"; - if ($rebuildSave) $rebuildSave.style.display = "inline-flex"; - if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; - } -} - -function saveRebuildErrorReport() { - var blob = new Blob([_rebuildLog], { type: "text/plain" }); - var url = URL.createObjectURL(blob); - var a = document.createElement("a"); - a.href = url; - a.download = "sovran-rebuild-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -// ── Feature confirm modal ───────────────────────────────────────── - -function openFeatureConfirm(message, onConfirm) { - if (!$featureConfirmModal) return; - if ($featureConfirmMsg) $featureConfirmMsg.textContent = message; - $featureConfirmModal.classList.add("open"); - // Replace ok handler - var newOk = $featureConfirmOk.cloneNode(true); - $featureConfirmOk.parentNode.replaceChild(newOk, $featureConfirmOk); - newOk.addEventListener("click", function() { - closeFeatureConfirm(); - onConfirm(); - }); -} - -function closeFeatureConfirm() { - if ($featureConfirmModal) $featureConfirmModal.classList.remove("open"); -} - -// ── SSL Email modal ─────────────────────────────────────────────── - -function openSslEmailModal(onSaved) { - if (!$sslEmailModal) return; - if ($sslEmailInput) $sslEmailInput.value = ""; - $sslEmailModal.classList.add("open"); - // Replace save handler - var newSave = $sslEmailSave.cloneNode(true); - $sslEmailSave.parentNode.replaceChild(newSave, $sslEmailSave); - newSave.addEventListener("click", async function() { - var email = $sslEmailInput ? $sslEmailInput.value.trim() : ""; - if (!email) { alert("Please enter an email address."); return; } - newSave.disabled = true; - newSave.textContent = "Saving…"; - try { - await apiFetch("/api/domains/set-email", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: email }), - }); - closeSslEmailModal(); - onSaved(); - } catch (err) { - newSave.disabled = false; - newSave.textContent = "Save"; - alert("Failed to save email. Please try again."); - } - }); -} - -function closeSslEmailModal() { - if ($sslEmailModal) $sslEmailModal.classList.remove("open"); -} - -// ── Domain Setup modal ──────────────────────────────────────────── - -function openDomainSetupModal(feat, onSaved) { - if (!$domainSetupModal) return; - if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name; - - var npubField = ""; - if (feat.id === "haven") { - var currentNpub = ""; - if (feat.extra_fields && feat.extra_fields.length > 0) { - for (var i = 0; i < feat.extra_fields.length; i++) { - if (feat.extra_fields[i].id === "nostr_npub") { - currentNpub = feat.extra_fields[i].current_value || ""; - break; - } - } - } - npubField = '
      '; - } - - $domainSetupBody.innerHTML = - '

      Before continuing, you need:

      1. A subdomain purchased on njal.la
      2. A Dynamic DNS record for it
      ' + - '
      ' + - '

      ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record

      ' + - npubField + - '
      '; - - document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal); - - document.getElementById("domain-setup-save-btn").addEventListener("click", async function() { - var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || ""; - var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || ""; - var npub = document.getElementById("domain-npub-input") ? (document.getElementById("domain-npub-input").value || "") : ""; - subdomain = subdomain.trim(); - ddnsUrl = ddnsUrl.trim(); - npub = npub.trim(); - - if (!subdomain) { alert("Please enter a subdomain."); return; } - if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; } - - var saveBtn = document.getElementById("domain-setup-save-btn"); - saveBtn.disabled = true; - saveBtn.textContent = "Saving…"; - - try { - await apiFetch("/api/domains/set", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - domain_name: feat.domain_name, - domain: subdomain, - ddns_url: ddnsUrl, - }), - }); - closeDomainSetupModal(); - onSaved(npub); - } catch (err) { - saveBtn.disabled = false; - saveBtn.textContent = "Save & Enable"; - alert("Failed to save domain. Please try again."); - } - }); - - $domainSetupModal.classList.add("open"); -} - -function closeDomainSetupModal() { - if ($domainSetupModal) $domainSetupModal.classList.remove("open"); -} - -// ── Feature toggle logic ────────────────────────────────────────── - -async function performFeatureToggle(featId, enabled, extra) { - try { - var res = await fetch("/api/features/toggle", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ feature: featId, enabled: enabled, extra: extra || {} }), - }); - var body = await res.json(); - if (!res.ok) { - if (body && body.error === "domain_required") { - alert("Domain not configured for this feature. Please configure it first."); - } else { - alert("Error: " + (body.detail || body.error || "Unknown error")); - } - loadFeatureManager(); - return; - } - openRebuildModal(); - } catch (err) { - alert("Failed to toggle feature: " + err); - loadFeatureManager(); - } -} - -function handleFeatureToggle(feat, newEnabled) { - if (!newEnabled) { - // Disable: ask confirmation - openFeatureConfirm( - "This will disable " + feat.name + ". The system will rebuild. Continue?", - function() { performFeatureToggle(feat.id, false, {}); } - ); - return; - } - - // Enabling - var conflictNames = []; - if (feat.conflicts_with && feat.conflicts_with.length > 0 && _featuresData) { - feat.conflicts_with.forEach(function(cid) { - var cf = _featuresData.features.find(function(f) { return f.id === cid; }); - if (cf && cf.enabled) conflictNames.push(cf.name); - }); - } - - function proceedAfterConflictCheck() { - // Check SSL email first - if (!_featuresData || !_featuresData.ssl_email_configured) { - if (feat.needs_domain) { - openSslEmailModal(function() { - // After ssl email saved, check domain - checkDomainAndEnable(feat, {}); - }); - return; - } - } - if (feat.needs_domain && !feat.domain_configured) { - checkDomainAndEnable(feat, {}); - return; - } - if (feat.id === "haven") { - var npub = ""; - if (feat.extra_fields) { - var ef = feat.extra_fields.find(function(e) { return e.id === "nostr_npub"; }); - if (ef) npub = ef.current_value || ""; - } - if (!npub) { - // Need to collect npub via domain modal - openDomainSetupModal(feat, function(collectedNpub) { - performFeatureToggle(feat.id, true, { nostr_npub: collectedNpub }); - }); - return; - } - } - performFeatureToggle(feat.id, true, {}); - } - - if (conflictNames.length > 0) { - openFeatureConfirm( - "This will disable " + conflictNames.join(", ") + ". Continue?", - proceedAfterConflictCheck - ); - } else { - proceedAfterConflictCheck(); - } -} - -function checkDomainAndEnable(feat, extra) { - openDomainSetupModal(feat, function(collectedNpub) { - var extraData = {}; - if (collectedNpub) extraData.nostr_npub = collectedNpub; - performFeatureToggle(feat.id, true, extraData); - }); -} - -// ── Feature Manager rendering ───────────────────────────────────── - -async function loadFeatureManager() { - try { - var data = await apiFetch("/api/features"); - _featuresData = data; - renderFeatureManager(data); - } catch (err) { - console.warn("Failed to load features:", err); - } -} - -function renderFeatureManager(data) { - // Remove old feature manager section if it exists - var old = $tilesArea.querySelector(".feature-manager-section"); - if (old) old.parentNode.removeChild(old); - - var section = document.createElement("div"); - section.className = "category-section feature-manager-section"; - section.dataset.category = "feature-manager"; - section.innerHTML = '
      Feature Manager

      '; - - // Group by sub-category - var grouped = {}; - for (var i = 0; i < data.features.length; i++) { - var f = data.features[i]; - var cat = f.category || "other"; - if (!grouped[cat]) grouped[cat] = []; - grouped[cat].push(f); - } - - var orderedCats = FEATURE_SUBCATEGORY_ORDER.filter(function(k) { return grouped[k]; }); - Object.keys(grouped).forEach(function(k) { - if (orderedCats.indexOf(k) === -1) orderedCats.push(k); - }); - - for (var j = 0; j < orderedCats.length; j++) { - var catKey = orderedCats[j]; - var feats = grouped[catKey]; - if (!feats || feats.length === 0) continue; - - var subcat = document.createElement("div"); - subcat.className = "feature-subcategory"; - var subcatLabel = FEATURE_SUBCATEGORY_LABELS[catKey] || catKey; - subcat.innerHTML = '
      ' + escHtml(subcatLabel) + '
      '; - - var cardsWrap = document.createElement("div"); - cardsWrap.className = "feature-cards-wrap"; - - for (var k = 0; k < feats.length; k++) { - cardsWrap.appendChild(buildFeatureCard(feats[k])); - } - subcat.appendChild(cardsWrap); - section.appendChild(subcat); - } - - $tilesArea.appendChild(section); -} - -function buildFeatureCard(feat) { - var card = document.createElement("div"); - card.className = "feature-card"; - - var conflictHtml = ""; - if (feat.conflicts_with && feat.conflicts_with.length > 0) { - var conflictNames = feat.conflicts_with.map(function(cid) { - if (!_featuresData) return cid; - var cf = _featuresData.features.find(function(f) { return f.id === cid; }); - return cf ? cf.name : cid; - }); - conflictHtml = '
      ⚠ Conflicts with: ' + escHtml(conflictNames.join(", ")) + '
      '; - } - - var domainHtml = ""; - if (feat.needs_domain) { - if (feat.domain_configured) { - domainHtml = '
      🌐 Domain: Configured
      '; - } else { - domainHtml = '
      🌐 Domain: Not configured
      '; - } - } - - var statusText = feat.enabled ? "Enabled" : "Disabled"; - - card.innerHTML = - '
      ' + - '
      ' + - '
      ' + escHtml(feat.name) + '
      ' + - '
      ' + escHtml(feat.description) + '
      ' + - '
      ' + - '' + - '
      ' + - domainHtml + - conflictHtml + - '
      Status: ' + escHtml(statusText) + '
      '; - - var toggle = card.querySelector(".feature-toggle-input"); - var toggleLabel = card.querySelector(".feature-toggle"); - toggle.addEventListener("change", function() { - var newEnabled = toggle.checked; - // Revert visually to original state while confirmation/modal is pending - toggle.checked = feat.enabled; - if (feat.enabled) { toggleLabel.classList.add("active"); } else { toggleLabel.classList.remove("active"); } - handleFeatureToggle(feat, newEnabled); - }); - - return card; -} - -// ── Event listeners ─────────────────────────────────────────────── - -if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); -if ($refreshBtn) $refreshBtn.addEventListener("click", function() { refreshServices(); }); -if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal); -if ($btnReboot) $btnReboot.addEventListener("click", doReboot); -if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); -if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal); -if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal); - -// Rebuild modal -if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal); -if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot); -if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport); -if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); }); - -// Domain setup modal -if ($domainSetupClose) $domainSetupClose.addEventListener("click", closeDomainSetupModal); -if ($domainSetupModal) $domainSetupModal.addEventListener("click", function(e) { if (e.target === $domainSetupModal) closeDomainSetupModal(); }); - -// SSL Email modal -if ($sslEmailClose) $sslEmailClose.addEventListener("click", closeSslEmailModal); -if ($sslEmailCancel) $sslEmailCancel.addEventListener("click", closeSslEmailModal); -if ($sslEmailModal) $sslEmailModal.addEventListener("click", function(e) { if (e.target === $sslEmailModal) closeSslEmailModal(); }); - -// Feature confirm modal -if ($featureConfirmClose) $featureConfirmClose.addEventListener("click", closeFeatureConfirm); -if ($featureConfirmCancel) $featureConfirmCancel.addEventListener("click", closeFeatureConfirm); -if ($featureConfirmModal) $featureConfirmModal.addEventListener("click", function(e) { if (e.target === $featureConfirmModal) closeFeatureConfirm(); }); - -if ($modal) $modal.addEventListener("click", function(e) { if (e.target === $modal) closeUpdateModal(); }); -if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); }); -if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); }); - -// ── Init ────────────────────────────────────────────────────────── - -async function init() { - try { - var cfg = await apiFetch("/api/config"); - if (cfg.category_order) { - for (var i = 0; i < cfg.category_order.length; i++) { - _categoryLabels[cfg.category_order[i][0]] = cfg.category_order[i][1]; - } - } - var badge = document.getElementById("role-badge"); - if (badge && cfg.role_label) badge.textContent = cfg.role_label; - - await refreshServices(); - loadNetwork(); - checkUpdates(); - - setInterval(refreshServices, POLL_INTERVAL_SERVICES); - setInterval(checkUpdates, POLL_INTERVAL_UPDATES); - - if (cfg.feature_manager) { - loadFeatureManager(); - } - } catch (_) { - await refreshServices(); - loadNetwork(); - checkUpdates(); - setInterval(refreshServices, POLL_INTERVAL_SERVICES); - setInterval(checkUpdates, POLL_INTERVAL_UPDATES); - } -} - -document.addEventListener("DOMContentLoaded", init); \ No newline at end of file + var status = await apiFetch("/api/support/status"); \ No newline at end of file -- 2.53.0 From 6a0a4e0489155bdd5a3c56e37d40f6211ddfabfc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:50:44 +0000 Subject: [PATCH 189/857] Initial plan -- 2.53.0 From 2378a278f227456dbaae57503b3fa2f0b5b7ccca Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 19:49:51 -0500 Subject: [PATCH 190/857] reverted to old file --- app/sovran_systemsos_web/static/app.js | 794 ++++++++++++++++++++++--- 1 file changed, 712 insertions(+), 82 deletions(-) diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 6db8a1e..ddcd132 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -1,14 +1,14 @@ /* Sovran_SystemsOS Hub — Vanilla JS Frontend - v8 — Status-only dashboard + Tech Support + Feature Manager */ + v7 — Status-only dashboard + Tech Support + Feature Manager */ "use strict"; -var POLL_INTERVAL_SERVICES = 5000; -var POLL_INTERVAL_UPDATES = 1800000; -var UPDATE_POLL_INTERVAL = 2000; -var REBOOT_CHECK_INTERVAL = 5000; -var SUPPORT_TIMER_INTERVAL = 1000; +const POLL_INTERVAL_SERVICES = 5000; +const POLL_INTERVAL_UPDATES = 1800000; +const UPDATE_POLL_INTERVAL = 2000; +const REBOOT_CHECK_INTERVAL = 5000; +const SUPPORT_TIMER_INTERVAL = 1000; -var CATEGORY_ORDER = [ +const CATEGORY_ORDER = [ "infrastructure", "bitcoin-base", "bitcoin-apps", @@ -19,104 +19,97 @@ var CATEGORY_ORDER = [ "feature-manager", ]; -var FEATURE_SUBCATEGORY_LABELS = { +const FEATURE_SUBCATEGORY_LABELS = { "infrastructure": "šŸ”§ Infrastructure", "bitcoin": "₿ Bitcoin", "communication": "šŸ’¬ Communication", "nostr": "šŸ“” Nostr", }; -var FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"]; +const FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"]; -var FEATURE_UNIT_MAP = { - "rdp": "gnome-remote-desktop.service", - "haven": "haven-relay.service", - "element-calling": "livekit.service", - "mempool": "mempool-frontend.service", -}; - -var STATUS_LOADING_STATES = new Set([ +const STATUS_LOADING_STATES = new Set([ "reloading", "activating", "deactivating", "maintenance", ]); // ── State ───────────────────────────────────────────────────────── -var _servicesCache = []; -var _categoryLabels = {}; -var _updateLog = ""; -var _updatePollTimer = null; -var _updateLogOffset = 0; -var _serverWasDown = false; -var _updateFinished = false; -var _supportTimerInt = null; -var _supportEnabledAt = null; -var _cachedExternalIp = null; +let _servicesCache = []; +let _categoryLabels = {}; +let _updateLog = ""; +let _updatePollTimer = null; +let _updateLogOffset = 0; +let _serverWasDown = false; +let _updateFinished = false; +let _supportTimerInt = null; +let _supportEnabledAt = null; +let _cachedExternalIp = null; // Feature Manager state -var _featuresData = null; -var _rebuildLog = ""; -var _rebuildLogOffset = 0; -var _rebuildPollTimer = null; -var _rebuildFinished = false; -var _rebuildServerDown = false; -var _pendingToggle = null; +let _featuresData = null; +let _rebuildLog = ""; +let _rebuildLogOffset = 0; +let _rebuildPollTimer = null; +let _rebuildFinished = false; +let _rebuildServerDown = false; +let _pendingToggle = null; // {feature, extra} waiting for domain/confirm // ── DOM refs ────────────────────────────────────────────────────── -var $tilesArea = document.getElementById("tiles-area"); -var $updateBtn = document.getElementById("btn-update"); -var $updateBadge = document.getElementById("update-badge"); -var $refreshBtn = document.getElementById("btn-refresh"); -var $internalIp = document.getElementById("ip-internal"); -var $externalIp = document.getElementById("ip-external"); +const $tilesArea = document.getElementById("tiles-area"); +const $updateBtn = document.getElementById("btn-update"); +const $updateBadge = document.getElementById("update-badge"); +const $refreshBtn = document.getElementById("btn-refresh"); +const $internalIp = document.getElementById("ip-internal"); +const $externalIp = document.getElementById("ip-external"); -var $modal = document.getElementById("update-modal"); -var $modalSpinner = document.getElementById("modal-spinner"); -var $modalStatus = document.getElementById("modal-status"); -var $modalLog = document.getElementById("modal-log"); -var $btnReboot = document.getElementById("btn-reboot"); -var $btnSave = document.getElementById("btn-save-report"); -var $btnCloseModal = document.getElementById("btn-close-modal"); +const $modal = document.getElementById("update-modal"); +const $modalSpinner = document.getElementById("modal-spinner"); +const $modalStatus = document.getElementById("modal-status"); +const $modalLog = document.getElementById("modal-log"); +const $btnReboot = document.getElementById("btn-reboot"); +const $btnSave = document.getElementById("btn-save-report"); +const $btnCloseModal = document.getElementById("btn-close-modal"); -var $rebootOverlay = document.getElementById("reboot-overlay"); +const $rebootOverlay = document.getElementById("reboot-overlay"); -var $credsModal = document.getElementById("creds-modal"); -var $credsTitle = document.getElementById("creds-modal-title"); -var $credsBody = document.getElementById("creds-body"); -var $credsCloseBtn = document.getElementById("creds-close-btn"); +const $credsModal = document.getElementById("creds-modal"); +const $credsTitle = document.getElementById("creds-modal-title"); +const $credsBody = document.getElementById("creds-body"); +const $credsCloseBtn = document.getElementById("creds-close-btn"); -var $supportModal = document.getElementById("support-modal"); -var $supportBody = document.getElementById("support-body"); -var $supportCloseBtn = document.getElementById("support-close-btn"); +const $supportModal = document.getElementById("support-modal"); +const $supportBody = document.getElementById("support-body"); +const $supportCloseBtn = document.getElementById("support-close-btn"); // Feature Manager — rebuild modal -var $rebuildModal = document.getElementById("rebuild-modal"); -var $rebuildSpinner = document.getElementById("rebuild-spinner"); -var $rebuildStatus = document.getElementById("rebuild-status"); -var $rebuildLogEl = document.getElementById("rebuild-log"); -var $rebuildReboot = document.getElementById("rebuild-reboot-btn"); -var $rebuildSave = document.getElementById("rebuild-save-report"); -var $rebuildClose = document.getElementById("rebuild-close-btn"); +const $rebuildModal = document.getElementById("rebuild-modal"); +const $rebuildSpinner = document.getElementById("rebuild-spinner"); +const $rebuildStatus = document.getElementById("rebuild-status"); +const $rebuildLog = document.getElementById("rebuild-log"); +const $rebuildReboot = document.getElementById("rebuild-reboot-btn"); +const $rebuildSave = document.getElementById("rebuild-save-report"); +const $rebuildClose = document.getElementById("rebuild-close-btn"); // Feature Manager — domain setup modal -var $domainSetupModal = document.getElementById("domain-setup-modal"); -var $domainSetupTitle = document.getElementById("domain-setup-title"); -var $domainSetupBody = document.getElementById("domain-setup-body"); -var $domainSetupClose = document.getElementById("domain-setup-close-btn"); +const $domainSetupModal = document.getElementById("domain-setup-modal"); +const $domainSetupTitle = document.getElementById("domain-setup-title"); +const $domainSetupBody = document.getElementById("domain-setup-body"); +const $domainSetupClose = document.getElementById("domain-setup-close-btn"); // Feature Manager — SSL email modal -var $sslEmailModal = document.getElementById("ssl-email-modal"); -var $sslEmailInput = document.getElementById("ssl-email-input"); -var $sslEmailSave = document.getElementById("ssl-email-save-btn"); -var $sslEmailCancel = document.getElementById("ssl-email-cancel-btn"); -var $sslEmailClose = document.getElementById("ssl-email-close-btn"); +const $sslEmailModal = document.getElementById("ssl-email-modal"); +const $sslEmailInput = document.getElementById("ssl-email-input"); +const $sslEmailSave = document.getElementById("ssl-email-save-btn"); +const $sslEmailCancel = document.getElementById("ssl-email-cancel-btn"); +const $sslEmailClose = document.getElementById("ssl-email-close-btn"); // Feature Manager — confirm modal -var $featureConfirmModal = document.getElementById("feature-confirm-modal"); -var $featureConfirmMsg = document.getElementById("feature-confirm-message"); -var $featureConfirmOk = document.getElementById("feature-confirm-ok-btn"); -var $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn"); -var $featureConfirmClose = document.getElementById("feature-confirm-close-btn"); +const $featureConfirmModal = document.getElementById("feature-confirm-modal"); +const $featureConfirmMsg = document.getElementById("feature-confirm-message"); +const $featureConfirmOk = document.getElementById("feature-confirm-ok-btn"); +const $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn"); +const $featureConfirmClose = document.getElementById("feature-confirm-close-btn"); // ── Helpers ─────────────────────────────────────────────────────── @@ -147,9 +140,9 @@ function linkify(str) { } function formatDuration(seconds) { - var h = Math.floor(seconds / 3600); - var m = Math.floor((seconds % 3600) / 60); - var s = Math.floor(seconds % 60); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); if (h > 0) return h + "h " + m + "m " + s + "s"; if (m > 0) return m + "m " + s + "s"; return s + "s"; @@ -158,7 +151,7 @@ function formatDuration(seconds) { // ── Fetch wrappers ──────────────────────────────────────────────── async function apiFetch(path, options) { - var res = await fetch(path, options || {}); + const res = await fetch(path, options || {}); if (!res.ok) throw new Error(res.status + " " + res.statusText); return res.json(); } @@ -287,7 +280,7 @@ async function checkUpdates() { } catch (_) {} } -// ── Credentials info modal ──────────��───────────────────────────── +// ── Credentials info modal ──────────────────────────────────────── async function openCredsModal(unit, name) { if (!$credsModal) return; @@ -375,4 +368,641 @@ async function enableSupport() { if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; } try { await apiFetch("/api/support/enable", { method: "POST" }); - var status = await apiFetch("/api/support/status"); \ No newline at end of file + var status = await apiFetch("/api/support/status"); + _supportEnabledAt = status.enabled_at; + renderSupportActive(); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; } + alert("Failed to enable support access. Please try again."); + } +} + +async function disableSupport() { + var btn = document.getElementById("btn-support-disable"); + if (btn) { btn.disabled = true; btn.textContent = "Removing key…"; } + try { + var result = await apiFetch("/api/support/disable", { method: "POST" }); + renderSupportRemoved(result.verified); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "End Support Session"; } + alert("Failed to disable support access. Please try again."); + } +} + +function startSupportTimer() { + stopSupportTimer(); + updateSupportTimer(); + _supportTimerInt = setInterval(updateSupportTimer, SUPPORT_TIMER_INTERVAL); +} + +function stopSupportTimer() { + if (_supportTimerInt) { clearInterval(_supportTimerInt); _supportTimerInt = null; } +} + +function updateSupportTimer() { + var el = document.getElementById("support-timer"); + if (!el || !_supportEnabledAt) return; + var elapsed = (Date.now() / 1000) - _supportEnabledAt; + el.textContent = formatDuration(Math.max(0, elapsed)); +} + +function closeSupportModal() { + if ($supportModal) $supportModal.classList.remove("open"); + stopSupportTimer(); +} + +// ── Update modal ────────────────────────────────────────────────── + +function openUpdateModal() { + if (!$modal) return; + _updateLog = ""; + _updateLogOffset = 0; + _serverWasDown = false; + _updateFinished = false; + if ($modalLog) $modalLog.textContent = ""; + if ($modalStatus) $modalStatus.textContent = "Starting update…"; + if ($modalSpinner) $modalSpinner.classList.add("spinning"); + if ($btnReboot) $btnReboot.style.display = "none"; + if ($btnSave) $btnSave.style.display = "none"; + if ($btnCloseModal) $btnCloseModal.disabled = true; + $modal.classList.add("open"); + startUpdate(); +} + +function closeUpdateModal() { + if (!$modal) return; + $modal.classList.remove("open"); + stopUpdatePoll(); +} + +function appendLog(text) { + if (!text) return; + _updateLog += text; + if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; } +} + +function startUpdate() { + fetch("/api/updates/run", { method: "POST" }) + .then(function(response) { + if (!response.ok) return response.text().then(function(t) { throw new Error(t); }); + return response.json(); + }) + .then(function(data) { + if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n"); + if ($modalStatus) $modalStatus.textContent = "Updating…"; + startUpdatePoll(); + }) + .catch(function(err) { + appendLog("[Error: failed to start update — " + err + "]\n"); + onUpdateDone(false); + }); +} + +function startUpdatePoll() { + pollUpdateStatus(); + _updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL); +} + +function stopUpdatePoll() { + if (_updatePollTimer) { clearInterval(_updatePollTimer); _updatePollTimer = null; } +} + +async function pollUpdateStatus() { + if (_updateFinished) return; + try { + var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset); + if (_serverWasDown) { _serverWasDown = false; appendLog("[Server reconnected]\n"); if ($modalStatus) $modalStatus.textContent = "Updating…"; } + if (data.log) appendLog(data.log); + _updateLogOffset = data.offset; + if (data.running) return; + _updateFinished = true; + stopUpdatePoll(); + if (data.result === "success") onUpdateDone(true); + else onUpdateDone(false); + } catch (err) { + if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; } + } +} + +function onUpdateDone(success) { + if ($modalSpinner) $modalSpinner.classList.remove("spinning"); + if ($btnCloseModal) $btnCloseModal.disabled = false; + if (success) { + if ($modalStatus) $modalStatus.textContent = "āœ“ Update complete"; + if ($btnReboot) $btnReboot.style.display = "inline-flex"; + } else { + if ($modalStatus) $modalStatus.textContent = "āœ— Update failed"; + if ($btnSave) $btnSave.style.display = "inline-flex"; + if ($btnReboot) $btnReboot.style.display = "inline-flex"; + } +} + +function saveErrorReport() { + var blob = new Blob([_updateLog], { type: "text/plain" }); + var url = URL.createObjectURL(blob); + var a = document.createElement("a"); + a.href = url; + a.download = "sovran-update-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// ── Reboot ──────────────────────────────────────────────────────── + +function doReboot() { + if ($modal) $modal.classList.remove("open"); + stopUpdatePoll(); + if ($rebootOverlay) $rebootOverlay.classList.add("visible"); + fetch("/api/reboot", { method: "POST" }).catch(function() {}); + setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); +} + +function waitForServerReboot() { + fetch("/api/config", { cache: "no-store" }) + .then(function(res) { + if (res.ok) window.location.reload(); + else setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); + }) + .catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); }); +} + +// ── Rebuild modal ───────────────────────────────────────────────── + +function openRebuildModal() { + if (!$rebuildModal) return; + _rebuildLog = ""; + _rebuildLogOffset = 0; + _rebuildServerDown = false; + _rebuildFinished = false; + if ($rebuildLog) $rebuildLog.textContent = ""; + if ($rebuildStatus) $rebuildStatus.textContent = "Rebuilding…"; + if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning"); + if ($rebuildReboot) $rebuildReboot.style.display = "none"; + if ($rebuildSave) $rebuildSave.style.display = "none"; + if ($rebuildClose) $rebuildClose.disabled = true; + $rebuildModal.classList.add("open"); + startRebuildPoll(); +} + +function closeRebuildModal() { + if ($rebuildModal) $rebuildModal.classList.remove("open"); + stopRebuildPoll(); +} + +function appendRebuildLog(text) { + if (!text) return; + _rebuildLog += text; + if ($rebuildLog) { $rebuildLog.textContent += text; $rebuildLog.scrollTop = $rebuildLog.scrollHeight; } +} + +function startRebuildPoll() { + pollRebuildStatus(); + _rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL); +} + +function stopRebuildPoll() { + if (_rebuildPollTimer) { clearInterval(_rebuildPollTimer); _rebuildPollTimer = null; } +} + +async function pollRebuildStatus() { + if (_rebuildFinished) return; + try { + var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset); + if (_rebuildServerDown) { _rebuildServerDown = false; appendRebuildLog("[Server reconnected]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Rebuilding…"; } + if (data.log) appendRebuildLog(data.log); + _rebuildLogOffset = data.offset; + if (data.running) return; + _rebuildFinished = true; + stopRebuildPoll(); + onRebuildDone(data.result === "success"); + } catch (err) { + if (!_rebuildServerDown) { _rebuildServerDown = true; appendRebuildLog("\n[Server restarting — waiting for it to come back…]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Server restarting…"; } + } +} + +function onRebuildDone(success) { + if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning"); + if ($rebuildClose) $rebuildClose.disabled = false; + if (success) { + if ($rebuildStatus) $rebuildStatus.textContent = "āœ“ Rebuild complete"; + if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; + // Refresh feature states + loadFeatureManager(); + } else { + if ($rebuildStatus) $rebuildStatus.textContent = "āœ— Rebuild failed"; + if ($rebuildSave) $rebuildSave.style.display = "inline-flex"; + if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; + } +} + +function saveRebuildErrorReport() { + var blob = new Blob([_rebuildLog], { type: "text/plain" }); + var url = URL.createObjectURL(blob); + var a = document.createElement("a"); + a.href = url; + a.download = "sovran-rebuild-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// ── Feature confirm modal ───────────────────────────────────────── + +function openFeatureConfirm(message, onConfirm) { + if (!$featureConfirmModal) return; + if ($featureConfirmMsg) $featureConfirmMsg.textContent = message; + $featureConfirmModal.classList.add("open"); + // Replace ok handler + var newOk = $featureConfirmOk.cloneNode(true); + $featureConfirmOk.parentNode.replaceChild(newOk, $featureConfirmOk); + newOk.addEventListener("click", function() { + closeFeatureConfirm(); + onConfirm(); + }); +} + +function closeFeatureConfirm() { + if ($featureConfirmModal) $featureConfirmModal.classList.remove("open"); +} + +// ── SSL Email modal ─────────────────────────────────────────────── + +function openSslEmailModal(onSaved) { + if (!$sslEmailModal) return; + if ($sslEmailInput) $sslEmailInput.value = ""; + $sslEmailModal.classList.add("open"); + // Replace save handler + var newSave = $sslEmailSave.cloneNode(true); + $sslEmailSave.parentNode.replaceChild(newSave, $sslEmailSave); + newSave.addEventListener("click", async function() { + var email = $sslEmailInput ? $sslEmailInput.value.trim() : ""; + if (!email) { alert("Please enter an email address."); return; } + newSave.disabled = true; + newSave.textContent = "Saving…"; + try { + await apiFetch("/api/domains/set-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email }), + }); + closeSslEmailModal(); + onSaved(); + } catch (err) { + newSave.disabled = false; + newSave.textContent = "Save"; + alert("Failed to save email. Please try again."); + } + }); +} + +function closeSslEmailModal() { + if ($sslEmailModal) $sslEmailModal.classList.remove("open"); +} + +// ── Domain Setup modal ──────────────────────────────────────────── + +function openDomainSetupModal(feat, onSaved) { + if (!$domainSetupModal) return; + if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name; + + var npubField = ""; + if (feat.id === "haven") { + var currentNpub = ""; + if (feat.extra_fields && feat.extra_fields.length > 0) { + for (var i = 0; i < feat.extra_fields.length; i++) { + if (feat.extra_fields[i].id === "nostr_npub") { + currentNpub = feat.extra_fields[i].current_value || ""; + break; + } + } + } + npubField = '
      '; + } + + $domainSetupBody.innerHTML = + '

      Before continuing, you need:

      1. A subdomain purchased on njal.la
      2. A Dynamic DNS record for it
      ' + + '
      ' + + '

      ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record

      ' + + npubField + + '
      '; + + document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal); + + document.getElementById("domain-setup-save-btn").addEventListener("click", async function() { + var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || ""; + var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || ""; + var npub = document.getElementById("domain-npub-input") ? (document.getElementById("domain-npub-input").value || "") : ""; + subdomain = subdomain.trim(); + ddnsUrl = ddnsUrl.trim(); + npub = npub.trim(); + + if (!subdomain) { alert("Please enter a subdomain."); return; } + if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; } + + var saveBtn = document.getElementById("domain-setup-save-btn"); + saveBtn.disabled = true; + saveBtn.textContent = "Saving…"; + + try { + await apiFetch("/api/domains/set", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + domain_name: feat.domain_name, + domain: subdomain, + ddns_url: ddnsUrl, + }), + }); + closeDomainSetupModal(); + onSaved(npub); + } catch (err) { + saveBtn.disabled = false; + saveBtn.textContent = "Save & Enable"; + alert("Failed to save domain. Please try again."); + } + }); + + $domainSetupModal.classList.add("open"); +} + +function closeDomainSetupModal() { + if ($domainSetupModal) $domainSetupModal.classList.remove("open"); +} + +// ── Feature toggle logic ────────────────────────────────────────── + +async function performFeatureToggle(featId, enabled, extra) { + try { + var res = await fetch("/api/features/toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ feature: featId, enabled: enabled, extra: extra || {} }), + }); + var body = await res.json(); + if (!res.ok) { + if (body && body.error === "domain_required") { + alert("Domain not configured for this feature. Please configure it first."); + } else { + alert("Error: " + (body.detail || body.error || "Unknown error")); + } + loadFeatureManager(); + return; + } + openRebuildModal(); + } catch (err) { + alert("Failed to toggle feature: " + err); + loadFeatureManager(); + } +} + +function handleFeatureToggle(feat, newEnabled) { + if (!newEnabled) { + // Disable: ask confirmation + openFeatureConfirm( + "This will disable " + feat.name + ". The system will rebuild. Continue?", + function() { performFeatureToggle(feat.id, false, {}); } + ); + return; + } + + // Enabling + var conflictNames = []; + if (feat.conflicts_with && feat.conflicts_with.length > 0 && _featuresData) { + feat.conflicts_with.forEach(function(cid) { + var cf = _featuresData.features.find(function(f) { return f.id === cid; }); + if (cf && cf.enabled) conflictNames.push(cf.name); + }); + } + + function proceedAfterConflictCheck() { + // Check SSL email first + if (!_featuresData || !_featuresData.ssl_email_configured) { + if (feat.needs_domain) { + openSslEmailModal(function() { + // After ssl email saved, check domain + checkDomainAndEnable(feat, {}); + }); + return; + } + } + if (feat.needs_domain && !feat.domain_configured) { + checkDomainAndEnable(feat, {}); + return; + } + if (feat.id === "haven") { + var npub = ""; + if (feat.extra_fields) { + var ef = feat.extra_fields.find(function(e) { return e.id === "nostr_npub"; }); + if (ef) npub = ef.current_value || ""; + } + if (!npub) { + // Need to collect npub via domain modal + openDomainSetupModal(feat, function(collectedNpub) { + performFeatureToggle(feat.id, true, { nostr_npub: collectedNpub }); + }); + return; + } + } + performFeatureToggle(feat.id, true, {}); + } + + if (conflictNames.length > 0) { + openFeatureConfirm( + "This will disable " + conflictNames.join(", ") + ". Continue?", + proceedAfterConflictCheck + ); + } else { + proceedAfterConflictCheck(); + } +} + +function checkDomainAndEnable(feat, extra) { + openDomainSetupModal(feat, function(collectedNpub) { + var extraData = {}; + if (collectedNpub) extraData.nostr_npub = collectedNpub; + performFeatureToggle(feat.id, true, extraData); + }); +} + +// ── Feature Manager rendering ───────────────────────────────────── + +async function loadFeatureManager() { + try { + var data = await apiFetch("/api/features"); + _featuresData = data; + renderFeatureManager(data); + } catch (err) { + console.warn("Failed to load features:", err); + } +} + +function renderFeatureManager(data) { + // Remove old feature manager section if it exists + var old = $tilesArea.querySelector(".feature-manager-section"); + if (old) old.parentNode.removeChild(old); + + var section = document.createElement("div"); + section.className = "category-section feature-manager-section"; + section.dataset.category = "feature-manager"; + section.innerHTML = '
      Feature Manager

      '; + + // Group by sub-category + var grouped = {}; + for (var i = 0; i < data.features.length; i++) { + var f = data.features[i]; + var cat = f.category || "other"; + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push(f); + } + + var orderedCats = FEATURE_SUBCATEGORY_ORDER.filter(function(k) { return grouped[k]; }); + Object.keys(grouped).forEach(function(k) { + if (orderedCats.indexOf(k) === -1) orderedCats.push(k); + }); + + for (var j = 0; j < orderedCats.length; j++) { + var catKey = orderedCats[j]; + var feats = grouped[catKey]; + if (!feats || feats.length === 0) continue; + + var subcat = document.createElement("div"); + subcat.className = "feature-subcategory"; + var subcatLabel = FEATURE_SUBCATEGORY_LABELS[catKey] || catKey; + subcat.innerHTML = '
      ' + escHtml(subcatLabel) + '
      '; + + var cardsWrap = document.createElement("div"); + cardsWrap.className = "feature-cards-wrap"; + + for (var k = 0; k < feats.length; k++) { + cardsWrap.appendChild(buildFeatureCard(feats[k])); + } + subcat.appendChild(cardsWrap); + section.appendChild(subcat); + } + + $tilesArea.appendChild(section); +} + +function buildFeatureCard(feat) { + var card = document.createElement("div"); + card.className = "feature-card"; + + var conflictHtml = ""; + if (feat.conflicts_with && feat.conflicts_with.length > 0) { + var conflictNames = feat.conflicts_with.map(function(cid) { + if (!_featuresData) return cid; + var cf = _featuresData.features.find(function(f) { return f.id === cid; }); + return cf ? cf.name : cid; + }); + conflictHtml = '
      ⚠ Conflicts with: ' + escHtml(conflictNames.join(", ")) + '
      '; + } + + var domainHtml = ""; + if (feat.needs_domain) { + if (feat.domain_configured) { + domainHtml = '
      🌐 Domain: Configured
      '; + } else { + domainHtml = '
      🌐 Domain: Not configured
      '; + } + } + + var statusText = feat.enabled ? "Enabled" : "Disabled"; + + card.innerHTML = + '
      ' + + '
      ' + + '
      ' + escHtml(feat.name) + '
      ' + + '
      ' + escHtml(feat.description) + '
      ' + + '
      ' + + '' + + '
      ' + + domainHtml + + conflictHtml + + '
      Status: ' + escHtml(statusText) + '
      '; + + var toggle = card.querySelector(".feature-toggle-input"); + var toggleLabel = card.querySelector(".feature-toggle"); + toggle.addEventListener("change", function() { + var newEnabled = toggle.checked; + // Revert visually until confirmed + toggle.checked = feat.enabled; + if (newEnabled) { toggleLabel.classList.remove("active"); } else { toggleLabel.classList.add("active"); } + handleFeatureToggle(feat, newEnabled); + }); + + return card; +} + +// ── Event listeners ─────────────────────────────────────────────── + +if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); +if ($refreshBtn) $refreshBtn.addEventListener("click", function() { refreshServices(); }); +if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal); +if ($btnReboot) $btnReboot.addEventListener("click", doReboot); +if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); +if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal); +if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal); + +// Rebuild modal +if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal); +if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot); +if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport); +if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); }); + +// Domain setup modal +if ($domainSetupClose) $domainSetupClose.addEventListener("click", closeDomainSetupModal); +if ($domainSetupModal) $domainSetupModal.addEventListener("click", function(e) { if (e.target === $domainSetupModal) closeDomainSetupModal(); }); + +// SSL Email modal +if ($sslEmailClose) $sslEmailClose.addEventListener("click", closeSslEmailModal); +if ($sslEmailCancel) $sslEmailCancel.addEventListener("click", closeSslEmailModal); +if ($sslEmailModal) $sslEmailModal.addEventListener("click", function(e) { if (e.target === $sslEmailModal) closeSslEmailModal(); }); + +// Feature confirm modal +if ($featureConfirmClose) $featureConfirmClose.addEventListener("click", closeFeatureConfirm); +if ($featureConfirmCancel) $featureConfirmCancel.addEventListener("click", closeFeatureConfirm); +if ($featureConfirmModal) $featureConfirmModal.addEventListener("click", function(e) { if (e.target === $featureConfirmModal) closeFeatureConfirm(); }); + +if ($modal) $modal.addEventListener("click", function(e) { if (e.target === $modal) closeUpdateModal(); }); +if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); }); +if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); }); + +// ── Init ────────────────────────────────────────────────────────── + +async function init() { + try { + var cfg = await apiFetch("/api/config"); + if (cfg.category_order) { + for (var i = 0; i < cfg.category_order.length; i++) { + _categoryLabels[cfg.category_order[i][0]] = cfg.category_order[i][1]; + } + } + var badge = document.getElementById("role-badge"); + if (badge && cfg.role_label) badge.textContent = cfg.role_label; + + await refreshServices(); + loadNetwork(); + checkUpdates(); + + setInterval(refreshServices, POLL_INTERVAL_SERVICES); + setInterval(checkUpdates, POLL_INTERVAL_UPDATES); + + if (cfg.feature_manager) { + loadFeatureManager(); + } + } catch (_) { + await refreshServices(); + loadNetwork(); + checkUpdates(); + setInterval(refreshServices, POLL_INTERVAL_SERVICES); + setInterval(checkUpdates, POLL_INTERVAL_UPDATES); + } +} + +document.addEventListener("DOMContentLoaded", init); -- 2.53.0 From 1090aa056b31f9d8041af7ecbc0a65d4f47df511 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 20:00:27 -0500 Subject: [PATCH 191/857] fix for feature manager --- app/sovran_systemsos_web/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index ffedc15..926f7b0 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1050,4 +1050,4 @@ async def _startup_save_ip(): """Write internal IP to file on server start so credentials work immediately.""" loop = asyncio.get_event_loop() ip = await loop.run_in_executor(None, _get_internal_ip) - _save_internal_ip(ip) \ No newline at end of file + _save_internal_ip(ip) -- 2.53.0 From 69c01d605f7c9f87cdf9a28ee25363564dd8c08a Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 20:03:13 -0500 Subject: [PATCH 192/857] fix for feature manager --- app/sovran_systemsos_web/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 926f7b0..4f7d1bf 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -607,6 +607,7 @@ async def api_config(): "role": role, "role_label": ROLE_LABELS.get(role, role), "category_order": CATEGORY_ORDER, + "feature_manager": True, } -- 2.53.0 From 0670f1248ab6c90eac05428b9fed1896a4132023 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Thu, 2 Apr 2026 20:15:57 -0500 Subject: [PATCH 193/857] fix for feature manager --- app/sovran_systemsos_web/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 4f7d1bf..3dd2eac 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -151,7 +151,7 @@ FEATURE_SERVICE_MAP = { "rdp": "gnome-remote-desktop.service", "haven": "haven-relay.service", "element-calling": "livekit.service", - "mempool": "mempool-frontend.service", + "mempool": "mempool.service", "bip110": None, "bitcoin-core": None, } -- 2.53.0 From 12eb68abdfdf424f96ff122e2df6af1b5284ed48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 01:26:13 +0000 Subject: [PATCH 194/857] Initial plan -- 2.53.0 From cba66e86df96113774ff05b22ed6b8bbcb6865d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 01:29:21 +0000 Subject: [PATCH 195/857] Fix service tiles showing stale enabled state by overlaying runtime hub-overrides Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e840f6c9-69a3-4ced-b6ef-128a0775321c Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 3dd2eac..58d88c2 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -156,6 +156,12 @@ FEATURE_SERVICE_MAP = { "bitcoin-core": None, } +# For features that share a unit, disambiguate by icon field +FEATURE_ICON_MAP = { + "bip110": "bip110", + "bitcoin-core": "bitcoin-core", +} + ROLE_LABELS = { "server_plus_desktop": "Server + Desktop", "desktop": "Desktop Only", @@ -616,12 +622,31 @@ async def api_services(): cfg = load_config() services = cfg.get("services", []) + # Build reverse map: unit → feature_id (for features with a unit) + unit_to_feature = { + unit: feat_id + for feat_id, unit in FEATURE_SERVICE_MAP.items() + if unit is not None + } + loop = asyncio.get_event_loop() + # Read runtime feature overrides from hub-overrides.nix + overrides, _ = await loop.run_in_executor(None, _read_hub_overrides) + async def get_status(entry): unit = entry.get("unit", "") scope = entry.get("type", "system") + icon = entry.get("icon", "") enabled = entry.get("enabled", True) + + # Overlay runtime feature state from hub-overrides.nix + feat_id = unit_to_feature.get(unit) + if feat_id is None: + feat_id = FEATURE_ICON_MAP.get(icon) + if feat_id is not None and feat_id in overrides: + enabled = overrides[feat_id] + if enabled: status = await loop.run_in_executor( None, lambda: sysctl.is_active(unit, scope) @@ -636,7 +661,7 @@ async def api_services(): "name": entry.get("name", ""), "unit": unit, "type": scope, - "icon": entry.get("icon", ""), + "icon": icon, "enabled": enabled, "category": entry.get("category", "other"), "status": status, -- 2.53.0 From e0447c551ad4260b92fdb31c7c6865957428cc4b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 01:38:04 +0000 Subject: [PATCH 196/857] Initial plan -- 2.53.0 From c139496af948bf89ed16f329fced4e6bdc7cc024 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 01:40:43 +0000 Subject: [PATCH 197/857] fix: clear stale rebuild log before new rebuild and delay first poll Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/eee95839-bfd2-4733-9799-a034178bcdd6 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 6 ++++++ app/sovran_systemsos_web/static/app.js | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 58d88c2..9695032 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -950,6 +950,12 @@ async def api_features_toggle(req: FeatureToggleRequest): await loop.run_in_executor(None, _write_hub_overrides, features, nostr_npub) + # Clear the old rebuild log so the frontend doesn't pick up stale results + try: + open(REBUILD_LOG, "w").close() + except OSError: + pass + # Start the rebuild service await asyncio.create_subprocess_exec( "systemctl", "reset-failed", REBUILD_UNIT, diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index ddcd132..d3d93ef 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -543,7 +543,8 @@ function openRebuildModal() { if ($rebuildSave) $rebuildSave.style.display = "none"; if ($rebuildClose) $rebuildClose.disabled = true; $rebuildModal.classList.add("open"); - startRebuildPoll(); + // Delay first poll slightly to let the rebuild service start and clear stale log + setTimeout(startRebuildPoll, 1500); } function closeRebuildModal() { -- 2.53.0 From 1f273d922902c46dd3fe4cadc82f5f8a98716be4 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 3 Apr 2026 07:08:09 -0500 Subject: [PATCH 198/857] fix for RDP regeneration --- modules/rdp.nix | 63 ++++++++++++++----------------------------------- 1 file changed, 18 insertions(+), 45 deletions(-) diff --git a/modules/rdp.nix b/modules/rdp.nix index e89e8c9..40cdbb2 100755 --- a/modules/rdp.nix +++ b/modules/rdp.nix @@ -1,39 +1,3 @@ -{ config, lib, pkgs, ... }: - -lib.mkIf config.sovran_systemsOS.features.rdp { - - users.users.gnome-remote-desktop = { - isSystemUser = true; - group = "gnome-remote-desktop"; - home = "/var/lib/gnome-remote-desktop"; - createHome = true; - }; - users.groups.gnome-remote-desktop = {}; - - systemd.tmpfiles.rules = [ - "d /var/lib/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -" - "d /var/lib/gnome-remote-desktop/.local 0750 gnome-remote-desktop gnome-remote-desktop -" - "d /var/lib/gnome-remote-desktop/.local/share 0750 gnome-remote-desktop gnome-remote-desktop -" - "d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -" - ]; - - systemd.services.gnome-remote-desktop-setup = { - description = "Configure GNOME Remote Desktop RDP"; - wantedBy = [ "multi-user.target" ]; - before = [ "gnome-remote-desktop.service" ]; - after = [ "systemd-tmpfiles-setup.service" "network-online.target" ]; - wants = [ "network-online.target" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - }; - path = [ - pkgs.gnome-remote-desktop - pkgs.polkit - pkgs.openssl - pkgs.hostname - pkgs.gawk - ]; script = '' # Ensure directory structure exists mkdir -p /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop @@ -42,20 +6,31 @@ lib.mkIf config.sovran_systemsOS.features.rdp { TLS_DIR="/var/lib/gnome-remote-desktop/tls" CRED_FILE="/var/lib/gnome-remote-desktop/rdp-credentials" - # Generate TLS certificate if it doesn't exist - if [ ! -f "$TLS_DIR/rdp-tls.crt" ]; then + # Regenerate TLS certificate if missing OR if ownership is wrong + # (disable/re-enable cycle can break ownership or grdctl state) + NEED_REGEN=0 + if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then + NEED_REGEN=1 + elif [ "$(stat -c '%U' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop" ]; then + NEED_REGEN=1 + fi + + if [ "$NEED_REGEN" = "1" ]; then mkdir -p "$TLS_DIR" + rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt" openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ -sha256 -nodes -days 3650 \ -keyout "$TLS_DIR/rdp-tls.key" \ -out "$TLS_DIR/rdp-tls.crt" \ -subj "/CN=gnome-remote-desktop" - chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR" - chmod 600 "$TLS_DIR/rdp-tls.key" - chmod 644 "$TLS_DIR/rdp-tls.crt" - echo "Generated RDP TLS certificate" + echo "Generated new RDP TLS certificate" fi + # Always fix ownership and permissions (handles re-enable after disable) + chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR" + chmod 600 "$TLS_DIR/rdp-tls.key" + chmod 644 "$TLS_DIR/rdp-tls.crt" + # Configure TLS certificate grdctl --system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt" grdctl --system rdp set-tls-key "$TLS_DIR/rdp-tls.key" @@ -99,6 +74,4 @@ lib.mkIf config.sovran_systemsOS.features.rdp { grdctl --system rdp set-credentials sovran "$PASSWORD" echo "GNOME Remote Desktop RDP configured successfully" - ''; - }; -} \ No newline at end of file + ''; \ No newline at end of file -- 2.53.0 From bc7a9d96daf2a0dd2ce59a50b274e18cbcc79e84 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 3 Apr 2026 07:10:40 -0500 Subject: [PATCH 199/857] deeper fix for RDP regeneration --- modules/rdp.nix | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/modules/rdp.nix b/modules/rdp.nix index 40cdbb2..50386e7 100755 --- a/modules/rdp.nix +++ b/modules/rdp.nix @@ -1,3 +1,39 @@ +{ config, lib, pkgs, ... }: + +lib.mkIf config.sovran_systemsOS.features.rdp { + + users.users.gnome-remote-desktop = { + isSystemUser = true; + group = "gnome-remote-desktop"; + home = "/var/lib/gnome-remote-desktop"; + createHome = true; + }; + users.groups.gnome-remote-desktop = {}; + + systemd.tmpfiles.rules = [ + "d /var/lib/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -" + "d /var/lib/gnome-remote-desktop/.local 0750 gnome-remote-desktop gnome-remote-desktop -" + "d /var/lib/gnome-remote-desktop/.local/share 0750 gnome-remote-desktop gnome-remote-desktop -" + "d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -" + ]; + + systemd.services.gnome-remote-desktop-setup = { + description = "Configure GNOME Remote Desktop RDP"; + wantedBy = [ "multi-user.target" ]; + before = [ "gnome-remote-desktop.service" ]; + after = [ "systemd-tmpfiles-setup.service" "network-online.target" ]; + wants = [ "network-online.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ + pkgs.gnome-remote-desktop + pkgs.polkit + pkgs.openssl + pkgs.hostname + pkgs.gawk + ]; script = '' # Ensure directory structure exists mkdir -p /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop @@ -74,4 +110,6 @@ grdctl --system rdp set-credentials sovran "$PASSWORD" echo "GNOME Remote Desktop RDP configured successfully" - ''; \ No newline at end of file + ''; + }; +} \ No newline at end of file -- 2.53.0 From 801b46b95f27c570e72a4c2eeb9f3f9bad2ee4d7 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 3 Apr 2026 07:16:01 -0500 Subject: [PATCH 200/857] deeper fix for RDP regeneration --- modules/rdp.nix | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/rdp.nix b/modules/rdp.nix index 50386e7..1813c9b 100755 --- a/modules/rdp.nix +++ b/modules/rdp.nix @@ -10,6 +10,12 @@ lib.mkIf config.sovran_systemsOS.features.rdp { }; users.groups.gnome-remote-desktop = {}; + # Enable the GNOME Remote Desktop service at the system level + services.gnome.gnome-remote-desktop.enable = true; + + # Open RDP port in the firewall + networking.firewall.allowedTCPPorts = [ 3389 ]; + systemd.tmpfiles.rules = [ "d /var/lib/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -" "d /var/lib/gnome-remote-desktop/.local 0750 gnome-remote-desktop gnome-remote-desktop -" -- 2.53.0 From 304df327e3c59c3359cbae340dc054aa988aad19 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 3 Apr 2026 07:31:17 -0500 Subject: [PATCH 201/857] UX update for feature manager --- app/sovran_systemsos_web/static/app.js | 62 +++++++++++++++----------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index d3d93ef..0f8274b 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -46,13 +46,15 @@ let _supportEnabledAt = null; let _cachedExternalIp = null; // Feature Manager state -let _featuresData = null; -let _rebuildLog = ""; -let _rebuildLogOffset = 0; -let _rebuildPollTimer = null; -let _rebuildFinished = false; -let _rebuildServerDown = false; -let _pendingToggle = null; // {feature, extra} waiting for domain/confirm +let _featuresData = null; +let _rebuildLog = ""; +let _rebuildLogOffset = 0; +let _rebuildPollTimer = null; +let _rebuildFinished = false; +let _rebuildServerDown = false; +let _pendingToggle = null; // {feature, extra} waiting for domain/confirm +let _rebuildFeatureName = ""; +let _rebuildIsEnabling = true; // ── DOM refs ────────────────────────────────────────────────────── @@ -205,14 +207,14 @@ function buildTile(svc) { if (dis) tile.title = svc.name + " is not enabled in custom.nix"; if (isSupport) { - tile.innerHTML = '' + escHtml(svc.name) + '
      ' + escHtml(svc.name) + '
      Click to manage
      '; + tile.innerHTML = '' + escHtml(svc.name) + '
      ' + escHtml(svc.name) + '
      Click for help
      '; tile.style.cursor = "pointer"; tile.addEventListener("click", function() { openSupportModal(); }); return tile; } var infoBtn = hasCreds ? '' : ""; - tile.innerHTML = infoBtn + '' + escHtml(svc.name) + '
      ' + escHtml(svc.name) + '
      ' + escHtml(st) + '
      '; + tile.innerHTML = infoBtn + '' + escHtml(svc.name) + '
      ' + escHtml(svc.name) + '
      ' + st + '
      '; var infoBtnEl = tile.querySelector(".tile-info-btn"); if (infoBtnEl) { @@ -342,13 +344,13 @@ async function openSupportModal() { function renderSupportInactive() { stopSupportTimer(); var ip = _cachedExternalIp || "loading…"; - $supportBody.innerHTML = '
      šŸ›Ÿ

      Need help from Sovran Systems?

      This will temporarily give Sovran Systems secure SSH access to your machine so we can diagnose and fix issues for you.

      Your External IP' + escHtml(ip) + '

      Give this IP to your Sovran Systems technician when asked.

      What happens when you click Enable:

      1. A Sovran Systems SSH key is added to this machine
      2. You give us your External IP shown above
      3. We connect and help you remotely
      4. When done, you click End Support Session to remove the key

      You can end the session at any time. The access key will be completely removed.

      '; + $supportBody.innerHTML = '
      šŸ›Ÿ

      Need help from Sovran Systems?

      This will temporarily grant our support team SSH access to your machine so we can help diagnose and fix issues.

      Your IP' + escHtml(ip) + '
      This IP will be shared with Sovran Systems support
      What happens:
      1. Our public SSH key is added to your machine
      2. We connect and help fix the issue
      3. You click "End Session" to remove our access

      You can revoke access at any time

      '; document.getElementById("btn-support-enable").addEventListener("click", enableSupport); } function renderSupportActive() { var ip = _cachedExternalIp || "loading…"; - $supportBody.innerHTML = '
      šŸ”“

      Support Access is Active

      Sovran Systems can currently connect to your machine via SSH.

      Your External IP' + escHtml(ip) + '
      Session Duration—

      When your support session is complete, click the button below to immediately remove the access key.

      '; + $supportBody.innerHTML = '
      šŸ”“

      Support Access is Active

      Sovran Systems can currently connect to your machine via SSH.

      Your IP' + escHtml(ip) + '
      Duration…

      This will remove the SSH key immediately

      '; document.getElementById("btn-support-disable").addEventListener("click", disableSupport); startSupportTimer(); } @@ -356,7 +358,7 @@ function renderSupportActive() { function renderSupportRemoved(verified) { stopSupportTimer(); var icon = verified ? "āœ…" : "āš ļø"; - var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot your machine to be sure."; + var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot to ensure it is gone."; var vclass = verified ? "verified-gone" : "verify-warning"; var vlabel = verified ? "āœ“ Removed — No access" : "⚠ Verify by rebooting"; $supportBody.innerHTML = '
      ' + icon + '

      Support Session Ended

      ' + escHtml(msg) + '

      SSH Key Status:' + vlabel + '
      '; @@ -513,7 +515,9 @@ function saveErrorReport() { function doReboot() { if ($modal) $modal.classList.remove("open"); + if ($rebuildModal) $rebuildModal.classList.remove("open"); stopUpdatePoll(); + stopRebuildPoll(); if ($rebootOverlay) $rebootOverlay.classList.add("visible"); fetch("/api/reboot", { method: "POST" }).catch(function() {}); setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); @@ -536,8 +540,10 @@ function openRebuildModal() { _rebuildLogOffset = 0; _rebuildServerDown = false; _rebuildFinished = false; - if ($rebuildLog) $rebuildLog.textContent = ""; - if ($rebuildStatus) $rebuildStatus.textContent = "Rebuilding…"; + if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; } + var action = _rebuildIsEnabling ? "Enabling" : "Disabling"; + var label = _rebuildFeatureName || "feature"; + if ($rebuildStatus) $rebuildStatus.textContent = action + " " + label + "…"; if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning"); if ($rebuildReboot) $rebuildReboot.style.display = "none"; if ($rebuildSave) $rebuildSave.style.display = "none"; @@ -555,7 +561,7 @@ function closeRebuildModal() { function appendRebuildLog(text) { if (!text) return; _rebuildLog += text; - if ($rebuildLog) { $rebuildLog.textContent += text; $rebuildLog.scrollTop = $rebuildLog.scrollHeight; } + // Log is collected silently for error reports — not displayed to user } function startRebuildPoll() { @@ -571,7 +577,7 @@ async function pollRebuildStatus() { if (_rebuildFinished) return; try { var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset); - if (_rebuildServerDown) { _rebuildServerDown = false; appendRebuildLog("[Server reconnected]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Rebuilding…"; } + if (_rebuildServerDown) { _rebuildServerDown = false; } if (data.log) appendRebuildLog(data.log); _rebuildLogOffset = data.offset; if (data.running) return; @@ -579,7 +585,7 @@ async function pollRebuildStatus() { stopRebuildPoll(); onRebuildDone(data.result === "success"); } catch (err) { - if (!_rebuildServerDown) { _rebuildServerDown = true; appendRebuildLog("\n[Server restarting — waiting for it to come back…]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Server restarting…"; } + if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; } } } @@ -587,12 +593,11 @@ function onRebuildDone(success) { if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning"); if ($rebuildClose) $rebuildClose.disabled = false; if (success) { - if ($rebuildStatus) $rebuildStatus.textContent = "āœ“ Rebuild complete"; - if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; - // Refresh feature states - loadFeatureManager(); + if ($rebuildStatus) $rebuildStatus.textContent = "āœ“ Done"; + // Auto-reload the page after a short delay so tiles and toggles reflect the new state + setTimeout(function() { window.location.reload(); }, 1200); } else { - if ($rebuildStatus) $rebuildStatus.textContent = "āœ— Rebuild failed"; + if ($rebuildStatus) $rebuildStatus.textContent = "āœ— Something went wrong"; if ($rebuildSave) $rebuildSave.style.display = "inline-flex"; if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; } @@ -680,12 +685,12 @@ function openDomainSetupModal(feat, onSaved) { } } } - npubField = '
      '; + npubField = '
      '; } $domainSetupBody.innerHTML = '

      Before continuing, you need:

      1. A subdomain purchased on njal.la
      2. A Dynamic DNS record for it
      ' + - '
      ' + + '
      ' + '

      ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record

      ' + npubField + '
      '; @@ -736,6 +741,13 @@ function closeDomainSetupModal() { // ── Feature toggle logic ────────────────────────────────────────── async function performFeatureToggle(featId, enabled, extra) { + // Look up feature name for the rebuild modal + _rebuildIsEnabling = enabled; + _rebuildFeatureName = featId; + if (_featuresData) { + var found = _featuresData.features.find(function(f) { return f.id === featId; }); + if (found) _rebuildFeatureName = found.name; + } try { var res = await fetch("/api/features/toggle", { method: "POST", @@ -1006,4 +1018,4 @@ async function init() { } } -document.addEventListener("DOMContentLoaded", init); +document.addEventListener("DOMContentLoaded", init); \ No newline at end of file -- 2.53.0 From f3d75b9ba5c201bfe976932c6578206c0cc6016e Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 3 Apr 2026 08:37:21 -0500 Subject: [PATCH 202/857] updated wiring for hub feature enable --- app/sovran_systemsos_web/server.py | 10 +++++----- modules/core/sovran-hub.nix | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 9695032..2499733 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -444,11 +444,11 @@ def _read_hub_overrides() -> tuple[dict, str | None]: with open(HUB_OVERRIDES_NIX, "r") as f: content = f.read() for m in re.finditer( - r'sovran_systemsOS\.features\.([a-zA-Z0-9_-]+)\s*=\s*(true|false)\s*;', + r'sovran_systemsOS\.features\.([a-zA-Z0-9_-]+)\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;', content, ): features[m.group(1)] = m.group(2) == "true" - m2 = re.search(r'sovran_systemsOS\.nostr_npub\s*=\s*"([^"]*)"', content) + m2 = re.search(r'sovran_systemsOS\.nostr_npub\s*=\s*(?:lib\.mkForce\s+)?"([^"]*)"', content) if m2: nostr_npub = m2.group(1) except FileNotFoundError: @@ -461,13 +461,13 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None) -> None: lines = [] for feat_id, enabled in features.items(): val = "true" if enabled else "false" - lines.append(f" sovran_systemsOS.features.{feat_id} = {val};") + lines.append(f" sovran_systemsOS.features.{feat_id} = lib.mkForce {val};") if nostr_npub: - lines.append(f' sovran_systemsOS.nostr_npub = "{nostr_npub}";') + lines.append(f' sovran_systemsOS.nostr_npub = lib.mkForce "{nostr_npub}";') body = "\n".join(lines) + "\n" if lines else "" content = ( "# Auto-generated by Sovran Hub — do not edit manually\n" - "{ ... }:\n" + "{ lib, ... }:\n" "{\n" + body + "}\n" diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 02faeaf..0d3b56d 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -294,7 +294,7 @@ in script = '' cat > /etc/nixos/hub-overrides.nix <<'EOF' # Auto-generated by Sovran Hub — do not edit manually -{ ... }: +{ lib, ... }: { } EOF @@ -303,4 +303,4 @@ EOF networking.firewall.allowedTCPPorts = [ 8937 ]; }; -} \ No newline at end of file +} -- 2.53.0 From f5180767b1ca4aee5ca3da38d5cb454802e9b45e Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 3 Apr 2026 09:07:07 -0500 Subject: [PATCH 203/857] updated wiring for hub feature enable --- configuration.nix | 4 +++- flake.nix | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/configuration.nix b/configuration.nix index d738c70..aa51f70 100644 --- a/configuration.nix +++ b/configuration.nix @@ -4,7 +4,9 @@ imports = [ ./modules/modules.nix ./iso/branding.nix - ]; + ] ++ (if builtins.pathExists /etc/nixos/hub-overrides.nix + then [ /etc/nixos/hub-overrides.nix ] + else []); # ── Boot ──────────────────────────────────────────────────── boot.loader.systemd-boot.enable = true; diff --git a/flake.nix b/flake.nix index eb5b029..89222a7 100755 --- a/flake.nix +++ b/flake.nix @@ -27,7 +27,6 @@ self.nixosModules.Sovran_SystemsOS /etc/nixos/role-state.nix /etc/nixos/custom.nix - /etc/nixos/hub-overrides.nix ]; }; -- 2.53.0 From 8d05f43594d656011aef37640296dbcb14173e52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:24:14 +0000 Subject: [PATCH 204/857] Initial plan -- 2.53.0 From 3c6106d06a7d4d35ae4a4d3aa3d367603d687689 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:28:24 +0000 Subject: [PATCH 205/857] Eliminate hub-overrides.nix: write feature toggles into custom.nix instead Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/db82f216-af3e-4d7f-a972-86c03f23e069 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 74 +++++++++++++++++++++--------- configuration.nix | 4 +- modules/core/sovran-hub.nix | 18 -------- 3 files changed, 53 insertions(+), 43 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 2499733..c337981 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -36,8 +36,10 @@ REBUILD_LOG = "/var/log/sovran-hub-rebuild.log" REBUILD_STATUS = "/var/log/sovran-hub-rebuild.status" REBUILD_UNIT = "sovran-hub-rebuild.service" -HUB_OVERRIDES_NIX = "/etc/nixos/hub-overrides.nix" -DOMAINS_DIR = "/var/lib/domains" +CUSTOM_NIX = "/etc/nixos/custom.nix" +HUB_BEGIN = " # ── Hub Managed (do not edit) ──────────────" +HUB_END = " # ── End Hub Managed ────────────────────────" +DOMAINS_DIR = "/var/lib/domains" NOSTR_NPUB_FILE = "/var/lib/secrets/nostr_npub" NJALLA_SCRIPT = "/var/lib/njalla/njalla.sh" @@ -434,21 +436,30 @@ def _read_rebuild_log(offset: int = 0) -> tuple[str, int]: return "", 0 -# ── hub-overrides.nix helpers ───────────────────────────────────── +# ── custom.nix Hub Managed section helpers ──────────────────────── def _read_hub_overrides() -> tuple[dict, str | None]: - """Parse hub-overrides.nix. Returns (features_dict, nostr_npub_or_none).""" + """Parse the Hub Managed section inside custom.nix. + Returns (features_dict, nostr_npub_or_none).""" features: dict[str, bool] = {} nostr_npub = None try: - with open(HUB_OVERRIDES_NIX, "r") as f: + with open(CUSTOM_NIX, "r") as f: content = f.read() + begin = content.find(HUB_BEGIN) + end = content.find(HUB_END) + if begin == -1 or end == -1: + return features, nostr_npub + section = content[begin:end] for m in re.finditer( r'sovran_systemsOS\.features\.([a-zA-Z0-9_-]+)\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;', - content, + section, ): features[m.group(1)] = m.group(2) == "true" - m2 = re.search(r'sovran_systemsOS\.nostr_npub\s*=\s*(?:lib\.mkForce\s+)?"([^"]*)"', content) + m2 = re.search( + r'sovran_systemsOS\.nostr_npub\s*=\s*(?:lib\.mkForce\s+)?"([^"]*)"', + section, + ) if m2: nostr_npub = m2.group(1) except FileNotFoundError: @@ -457,25 +468,44 @@ def _read_hub_overrides() -> tuple[dict, str | None]: def _write_hub_overrides(features: dict, nostr_npub: str | None) -> None: - """Write a complete hub-overrides.nix from the given state.""" + """Write the Hub Managed section inside custom.nix.""" lines = [] for feat_id, enabled in features.items(): val = "true" if enabled else "false" lines.append(f" sovran_systemsOS.features.{feat_id} = lib.mkForce {val};") if nostr_npub: lines.append(f' sovran_systemsOS.nostr_npub = lib.mkForce "{nostr_npub}";') - body = "\n".join(lines) + "\n" if lines else "" - content = ( - "# Auto-generated by Sovran Hub — do not edit manually\n" - "{ lib, ... }:\n" - "{\n" - + body - + "}\n" + hub_block = ( + HUB_BEGIN + "\n" + + "\n".join(lines) + ("\n" if lines else "") + + HUB_END + "\n" ) - nix_dir = os.path.dirname(HUB_OVERRIDES_NIX) - if nix_dir: - os.makedirs(nix_dir, exist_ok=True) - with open(HUB_OVERRIDES_NIX, "w") as f: + + try: + with open(CUSTOM_NIX, "r") as f: + content = f.read() + except FileNotFoundError: + return + + begin = content.find(HUB_BEGIN) + end = content.find(HUB_END) + + if begin != -1 and end != -1: + # Replace existing hub section (include the HUB_END line itself) + newline_after_end = content.find("\n", end) + if newline_after_end == -1: + end_of_marker = len(content) + else: + end_of_marker = newline_after_end + 1 + content = content[:begin] + hub_block + content[end_of_marker:] + else: + # Insert hub section just before the final closing } + last_brace = content.rfind("}") + if last_brace == -1: + return + content = content[:last_brace] + "\n" + hub_block + content[last_brace:] + + with open(CUSTOM_NIX, "w") as f: f.write(content) @@ -631,7 +661,7 @@ async def api_services(): loop = asyncio.get_event_loop() - # Read runtime feature overrides from hub-overrides.nix + # Read runtime feature overrides from custom.nix Hub Managed section overrides, _ = await loop.run_in_executor(None, _read_hub_overrides) async def get_status(entry): @@ -640,7 +670,7 @@ async def api_services(): icon = entry.get("icon", "") enabled = entry.get("enabled", True) - # Overlay runtime feature state from hub-overrides.nix + # Overlay runtime feature state from custom.nix Hub Managed section feat_id = unit_to_feature.get(unit) if feat_id is None: feat_id = FEATURE_ICON_MAP.get(icon) @@ -838,7 +868,7 @@ async def api_features(): feat_id = feat["id"] # Determine enabled state: - # 1. Check hub-overrides.nix first (explicit hub toggle) + # 1. Check custom.nix Hub Managed section first (explicit hub toggle) # 2. Fall back to config.json services (features enabled in custom.nix) if feat_id in overrides: enabled = overrides[feat_id] diff --git a/configuration.nix b/configuration.nix index aa51f70..d738c70 100644 --- a/configuration.nix +++ b/configuration.nix @@ -4,9 +4,7 @@ imports = [ ./modules/modules.nix ./iso/branding.nix - ] ++ (if builtins.pathExists /etc/nixos/hub-overrides.nix - then [ /etc/nixos/hub-overrides.nix ] - else []); + ]; # ── Boot ──────────────────────────────────────────────────── boot.loader.systemd-boot.enable = true; diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 0d3b56d..862a9b1 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -283,24 +283,6 @@ in }; }; - systemd.services.hub-overrides-init = { - description = "Initialize hub-overrides.nix if missing"; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - }; - unitConfig.ConditionPathExists = "!/etc/nixos/hub-overrides.nix"; - script = '' - cat > /etc/nixos/hub-overrides.nix <<'EOF' -# Auto-generated by Sovran Hub — do not edit manually -{ lib, ... }: -{ -} -EOF - ''; - }; - networking.firewall.allowedTCPPorts = [ 8937 ]; }; } -- 2.53.0 From ab60d2b5042b8f3897715e41a057dc7808bb25ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:50:04 +0000 Subject: [PATCH 206/857] Initial plan -- 2.53.0 From 9cc237fb5ba7a0ba3ddca124d64d7af396bfdfd1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:52:14 +0000 Subject: [PATCH 207/857] Fix all 4 Feature Manager bugs in server.py Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/77921fb1-4d4b-4d10-b982-b3768b858b86 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index c337981..f165e78 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -882,7 +882,12 @@ async def api_features(): domain_name = feat.get("domain_name") domain_configured = True if domain_name: - domain_configured = os.path.exists(os.path.join(DOMAINS_DIR, domain_name)) + domain_path = os.path.join(DOMAINS_DIR, domain_name) + try: + with open(domain_path, "r") as f: + domain_configured = bool(f.read(256).strip()) + except OSError: + domain_configured = False extra_fields = [] for ef in feat.get("extra_fields", []): -- 2.53.0 From dfe45bdbb214d725b193f9c7607dc98f04d54e81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:19:46 +0000 Subject: [PATCH 208/857] Initial plan -- 2.53.0 From e6cdb3b84000c579cc9cedaa0b2c2e3ef10d1199 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:21:01 +0000 Subject: [PATCH 209/857] Add RTL and Mempool LAN reverse proxies, open firewall ports Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d29c1b82-a70e-4092-88c7-b521a1b3cac3 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- modules/core/caddy.nix | 18 ++++++++++++++++++ modules/core/sovran-hub.nix | 4 ++-- modules/mempool.nix | 11 ----------- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/modules/core/caddy.nix b/modules/core/caddy.nix index b7d885c..8293f12 100755 --- a/modules/core/caddy.nix +++ b/modules/core/caddy.nix @@ -144,6 +144,24 @@ $HAVEN { } EOF fi + + # ── RTL (LAN access) ──────────────────────────── + cat >> /run/caddy/Caddyfile <> /run/caddy/Caddyfile < Date: Fri, 3 Apr 2026 15:26:58 +0000 Subject: [PATCH 210/857] Initial plan -- 2.53.0 From 8f6d29499538b7934e2157f0c6578f785978c21e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:27:40 +0000 Subject: [PATCH 211/857] Fix copy buttons failing on non-HTTPS browsers with clipboard fallback Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5f3c4b7f-716c-46ef-9a2a-b97b7c1f9501 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/static/app.js | 33 +++++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 0f8274b..2bb4ec1 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -310,12 +310,33 @@ async function openCredsModal(unit, name) { $credsBody.querySelectorAll(".creds-copy-btn").forEach(function(btn) { btn.addEventListener("click", function() { var target = document.getElementById(btn.dataset.target); - if (target) { - navigator.clipboard.writeText(target.textContent).then(function() { - btn.textContent = "Copied!"; - btn.classList.add("copied"); - setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1500); - }).catch(function() {}); + if (!target) return; + var text = target.textContent; + + function onSuccess() { + btn.textContent = "Copied!"; + btn.classList.add("copied"); + setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1500); + } + + function fallbackCopy() { + var ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand("copy"); + onSuccess(); + } catch (e) {} + document.body.removeChild(ta); + } + + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(onSuccess).catch(fallbackCopy); + } else { + fallbackCopy(); } }); }); -- 2.53.0 From 90ddd5812eebd8718df0775301795628eea4c035 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:42:53 +0000 Subject: [PATCH 212/857] Initial plan -- 2.53.0 From 570a76763608d2a26f179fb78c54fb45921f747e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:44:00 +0000 Subject: [PATCH 213/857] fix(synapse): tolerate existing users in matrix-synapse-create-users script Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/f76f46da-0836-4295-8e26-c656acc38e3f Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- modules/synapse.nix | 51 +++++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/modules/synapse.nix b/modules/synapse.nix index 46bd4e7..580e835 100755 --- a/modules/synapse.nix +++ b/modules/synapse.nix @@ -153,8 +153,8 @@ EOF }; path = [ pkgs.pwgen pkgs.matrix-synapse pkgs.curl pkgs.coreutils pkgs.jq ]; script = '' - set -euo pipefail - + set -uo pipefail + # Wait for Synapse to be fully responsive for i in {1..30}; do if curl -s http://localhost:8008/_matrix/client/versions > /dev/null; then @@ -170,23 +170,33 @@ EOF # Only run if we haven't already generated the file if [ ! -f "$CREDS_FILE" ]; then mkdir -p /var/lib/secrets - + ADMIN_USER="admin" ADMIN_PASS=$(pwgen -s 24 1) - + TEST_USER="test" TEST_PASS=$(pwgen -s 24 1) - # Create Admin user - register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \ - -u "$ADMIN_USER" -p "$ADMIN_PASS" -a http://localhost:8008 + ADMIN_CREATED=true + TEST_CREATED=true - # Create Test user (non-admin) - register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \ - -u "$TEST_USER" -p "$TEST_PASS" --no-admin http://localhost:8008 + # Create Admin user (tolerate "already exists") + if ! register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \ + -u "$ADMIN_USER" -p "$ADMIN_PASS" -a http://localhost:8008 2>&1; then + echo "Admin user already exists, skipping." + ADMIN_CREATED=false + fi - # Save the credentials - cat > "$CREDS_FILE" << CREDS + # Create Test user (tolerate "already exists") + if ! register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \ + -u "$TEST_USER" -p "$TEST_PASS" --no-admin http://localhost:8008 2>&1; then + echo "Test user already exists, skipping." + TEST_CREATED=false + fi + + # Write credentials file + if [ "$ADMIN_CREATED" = true ] && [ "$TEST_CREATED" = true ]; then + cat > "$CREDS_FILE" << CREDS Matrix (Element) Credentials ════════════════════════════ Homeserver URL: https://$DOMAIN @@ -199,9 +209,24 @@ Password: $ADMIN_PASS Username: @$TEST_USER:$DOMAIN Password: $TEST_PASS CREDS + else + cat > "$CREDS_FILE" << CREDS +Matrix (Element) Credentials +════════════════════════════ +Homeserver URL: https://$DOMAIN + +[ Admin Account ] +Username: @$ADMIN_USER:$DOMAIN +Password: $(if [ "$ADMIN_CREATED" = true ]; then echo "$ADMIN_PASS"; else echo "(pre-existing — password set during original setup)"; fi) + +[ Test Account ] +Username: @$TEST_USER:$DOMAIN +Password: $(if [ "$TEST_CREATED" = true ]; then echo "$TEST_PASS"; else echo "(pre-existing — password set during original setup)"; fi) +CREDS + fi chmod 600 "$CREDS_FILE" - echo "Matrix users created successfully." + echo "Matrix users setup completed." fi ''; }; -- 2.53.0 From e90fbccde05048ef2a79f5421c8c0ae0b846f443 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:53:39 +0000 Subject: [PATCH 214/857] Initial plan -- 2.53.0 From fc2c7e7928c820019f6c1fe0aa4d7a835f428984 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:58:33 +0000 Subject: [PATCH 215/857] Fix CSS media query, add Matrix user management UI and API endpoints Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/84f10dbb-7db4-4f3f-b9b4-0f20455ac3e0 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 181 ++++++++++++++++++++++ app/sovran_systemsos_web/static/app.js | 126 ++++++++++++++- app/sovran_systemsos_web/static/style.css | 142 ++++++++++++++++- 3 files changed, 444 insertions(+), 5 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index f165e78..6844370 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -10,6 +10,8 @@ import os import re import socket import subprocess +import urllib.error +import urllib.parse import urllib.request from fastapi import FastAPI, HTTPException @@ -1110,6 +1112,185 @@ async def api_domains_status(): return {"domains": domains} +# ── Matrix user management ──────────────────────────────────────── + +MATRIX_USERS_FILE = "/var/lib/secrets/matrix-users" +MATRIX_DOMAINS_FILE = "/var/lib/domains/matrix" + +_SAFE_USERNAME_RE = re.compile(r'^[a-z0-9._\-]+$') + + +def _validate_matrix_username(username: str) -> bool: + """Return True if username is a valid Matrix localpart.""" + return bool(username) and len(username) <= 255 and bool(_SAFE_USERNAME_RE.match(username)) + + +def _parse_matrix_admin_creds() -> tuple[str, str]: + """Parse admin username and password from the matrix-users credentials file. + + Returns (localpart, password) for the admin account. + Raises FileNotFoundError if the file does not exist. + Raises ValueError if the file cannot be parsed. + """ + with open(MATRIX_USERS_FILE, "r") as f: + content = f.read() + + admin_user: str | None = None + admin_pass: str | None = None + in_admin_section = False + + for line in content.splitlines(): + stripped = line.strip() + if stripped == "[ Admin Account ]": + in_admin_section = True + continue + if stripped.startswith("[ ") and in_admin_section: + break + if in_admin_section: + if stripped.startswith("Username:"): + raw = stripped[len("Username:"):].strip() + # Format is @localpart:domain — extract localpart + if raw.startswith("@") and ":" in raw: + admin_user = raw[1:raw.index(":")] + else: + admin_user = raw + elif stripped.startswith("Password:"): + admin_pass = stripped[len("Password:"):].strip() + + if not admin_user or not admin_pass: + raise ValueError("Could not parse admin credentials from matrix-users file") + if "(pre-existing" in admin_pass: + raise ValueError( + "Admin password is not stored (user was pre-existing). " + "Please reset the admin password manually before using this feature." + ) + return admin_user, admin_pass + + +def _matrix_get_admin_token(domain: str, admin_user: str, admin_pass: str) -> str: + """Log in to the local Synapse instance and return an access token.""" + url = "http://[::1]:8008/_matrix/client/v3/login" + payload = json.dumps({ + "type": "m.login.password", + "identifier": {"type": "m.id.user", "user": admin_user}, + "password": admin_pass, + }).encode() + req = urllib.request.Request( + url, data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=15) as resp: + body = json.loads(resp.read()) + token: str = body.get("access_token", "") + if not token: + raise ValueError("No access_token in Synapse login response") + return token + + +class MatrixCreateUserRequest(BaseModel): + username: str + password: str + admin: bool = False + + +@app.post("/api/matrix/create-user") +async def api_matrix_create_user(req: MatrixCreateUserRequest): + """Create a new Matrix user via register_new_matrix_user.""" + if not _validate_matrix_username(req.username): + raise HTTPException(status_code=400, detail="Invalid username. Use only lowercase letters, digits, '.', '_', '-'.") + if not req.password: + raise HTTPException(status_code=400, detail="Password must not be empty.") + + admin_flag = ["-a"] if req.admin else ["--no-admin"] + cmd = [ + "register_new_matrix_user", + "-c", "/run/matrix-synapse/runtime-config.yaml", + "-u", req.username, + "-p", req.password, + *admin_flag, + "http://localhost:8008", + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + except FileNotFoundError: + raise HTTPException(status_code=500, detail="register_new_matrix_user not found on this system.") + except subprocess.TimeoutExpired: + raise HTTPException(status_code=500, detail="Command timed out.") + + output = (result.stdout + result.stderr).strip() + if result.returncode != 0: + # Surface the actual error from the tool (e.g. "User ID already taken") + raise HTTPException(status_code=400, detail=output or "Failed to create user.") + + return {"ok": True, "username": req.username} + + +class MatrixChangePasswordRequest(BaseModel): + username: str + new_password: str + + +@app.post("/api/matrix/change-password") +async def api_matrix_change_password(req: MatrixChangePasswordRequest): + """Change a Matrix user's password via the Synapse Admin API.""" + if not _validate_matrix_username(req.username): + raise HTTPException(status_code=400, detail="Invalid username. Use only lowercase letters, digits, '.', '_', '-'.") + if not req.new_password: + raise HTTPException(status_code=400, detail="New password must not be empty.") + + # Read domain + try: + with open(MATRIX_DOMAINS_FILE, "r") as f: + domain = f.read().strip() + except FileNotFoundError: + raise HTTPException(status_code=500, detail="Matrix domain not configured.") + + # Parse admin credentials + try: + admin_user, admin_pass = _parse_matrix_admin_creds() + except FileNotFoundError: + raise HTTPException(status_code=500, detail="Matrix credentials file not found.") + except ValueError as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + # Obtain admin access token + loop = asyncio.get_event_loop() + try: + token = await loop.run_in_executor( + None, _matrix_get_admin_token, domain, admin_user, admin_pass + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Could not authenticate as admin: {exc}") + + # Call Synapse Admin API to reset the password + target_user_id = f"@{req.username}:{domain}" + url = f"http://[::1]:8008/_synapse/admin/v2/users/{urllib.parse.quote(target_user_id, safe='@:')}" + payload = json.dumps({"password": req.new_password}).encode() + api_req = urllib.request.Request( + url, data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + }, + method="PUT", + ) + try: + with urllib.request.urlopen(api_req, timeout=15) as resp: + resp.read() + except urllib.error.HTTPError as exc: + body = exc.read().decode(errors="replace") + try: + detail = json.loads(body).get("error", body) + except Exception: + detail = body + raise HTTPException(status_code=400, detail=detail) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Admin API call failed: {exc}") + + return {"ok": True, "username": req.username} + + # ── Startup: seed the internal IP file immediately ─────────────── @app.on_event("startup") diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 2bb4ec1..09e6f89 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -154,7 +154,11 @@ function formatDuration(seconds) { async function apiFetch(path, options) { const res = await fetch(path, options || {}); - if (!res.ok) throw new Error(res.status + " " + res.statusText); + if (!res.ok) { + let detail = res.status + " " + res.statusText; + try { const body = await res.json(); if (body && body.detail) detail = body.detail; } catch (e) {} + throw new Error(detail); + } return res.json(); } @@ -306,6 +310,12 @@ async function openCredsModal(unit, name) { } html += '
      ' + escHtml(cred.label) + '
      ' + qrBlock + '
      ' + displayValue + '
      '; } + if (unit === "matrix-synapse.service") { + html += '
      ' + + '' + + '' + + '
      '; + } $credsBody.innerHTML = html; $credsBody.querySelectorAll(".creds-copy-btn").forEach(function(btn) { btn.addEventListener("click", function() { @@ -340,11 +350,125 @@ async function openCredsModal(unit, name) { } }); }); + if (unit === "matrix-synapse.service") { + var addBtn = document.getElementById("matrix-add-user-btn"); + var changePwBtn = document.getElementById("matrix-change-pw-btn"); + if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name); }); + if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name); }); + } } catch (err) { $credsBody.innerHTML = '

      Could not load credentials.

      '; } } +function openMatrixCreateUserModal(unit, name) { + if (!$credsBody) return; + $credsBody.innerHTML = + '
      ' + + '
      ' + + '
      ' + + '
      ' + + '
      ' + + '
      ' + + '' + + '' + + '
      ' + + '
      '; + + document.getElementById("matrix-create-back-btn").addEventListener("click", function() { + openCredsModal(unit, name); + }); + + document.getElementById("matrix-create-submit-btn").addEventListener("click", async function() { + var submitBtn = document.getElementById("matrix-create-submit-btn"); + var resultEl = document.getElementById("matrix-create-result"); + var username = (document.getElementById("matrix-new-username").value || "").trim(); + var password = document.getElementById("matrix-new-password").value || ""; + var isAdmin = document.getElementById("matrix-new-admin").checked; + + if (!username || !password) { + resultEl.className = "matrix-form-result error"; + resultEl.textContent = "Username and password are required."; + return; + } + + submitBtn.disabled = true; + submitBtn.textContent = "Creating…"; + resultEl.className = "matrix-form-result"; + resultEl.textContent = ""; + + try { + var resp = await apiFetch("/api/matrix/create-user", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: username, password: password, admin: isAdmin }) + }); + resultEl.className = "matrix-form-result success"; + resultEl.textContent = "āœ… User @" + escHtml(resp.username) + " created successfully."; + submitBtn.textContent = "Create User"; + submitBtn.disabled = false; + } catch (err) { + resultEl.className = "matrix-form-result error"; + resultEl.textContent = "āŒ " + (err.message || "Failed to create user."); + submitBtn.textContent = "Create User"; + submitBtn.disabled = false; + } + }); +} + +function openMatrixChangePasswordModal(unit, name) { + if (!$credsBody) return; + $credsBody.innerHTML = + '
      ' + + '
      ' + + '
      ' + + '
      ' + + '
      ' + + '' + + '' + + '
      ' + + '
      '; + + document.getElementById("matrix-chpw-back-btn").addEventListener("click", function() { + openCredsModal(unit, name); + }); + + document.getElementById("matrix-chpw-submit-btn").addEventListener("click", async function() { + var submitBtn = document.getElementById("matrix-chpw-submit-btn"); + var resultEl = document.getElementById("matrix-chpw-result"); + var username = (document.getElementById("matrix-chpw-username").value || "").trim(); + var newPassword = document.getElementById("matrix-chpw-password").value || ""; + + if (!username || !newPassword) { + resultEl.className = "matrix-form-result error"; + resultEl.textContent = "Username and new password are required."; + return; + } + + submitBtn.disabled = true; + submitBtn.textContent = "Changing…"; + resultEl.className = "matrix-form-result"; + resultEl.textContent = ""; + + try { + var resp = await apiFetch("/api/matrix/change-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: username, new_password: newPassword }) + }); + resultEl.className = "matrix-form-result success"; + resultEl.textContent = "āœ… Password for @" + escHtml(resp.username) + " changed successfully."; + submitBtn.textContent = "Change Password"; + submitBtn.disabled = false; + } catch (err) { + resultEl.className = "matrix-form-result error"; + resultEl.textContent = "āŒ " + (err.message || "Failed to change password."); + submitBtn.textContent = "Change Password"; + submitBtn.disabled = false; + } + }); +} + function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); } // ── Tech Support modal ──────────────────────────────────────────── diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 068ec04..a65ffa4 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -592,6 +592,143 @@ button.btn-reboot:hover:not(:disabled) { color: #defce6; } +/* ── Matrix action buttons ───────────────────────────────────────── */ + +.matrix-actions-divider { + border: none; + border-top: 1px solid var(--border-color); + margin: 18px 0 14px; +} + +.matrix-actions-row { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.matrix-action-btn { + background-color: var(--accent-color); + color: #0f0f19; + font-size: 0.88rem; + font-weight: 700; + padding: 10px 18px; + border-radius: 8px; + border: none; + cursor: pointer; + flex: 1; + min-width: 140px; +} + +.matrix-action-btn:hover { + background-color: #a8c8ff; +} + +.matrix-form-group { + margin-bottom: 14px; +} + +.matrix-form-label { + display: block; + font-size: 0.82rem; + color: var(--text-secondary); + margin-bottom: 6px; + font-weight: 600; +} + +.matrix-form-input { + width: 100%; + background-color: #12121c; + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 12px; + font-size: 0.9rem; + box-sizing: border-box; +} + +.matrix-form-input:focus { + outline: none; + border-color: var(--accent-color); +} + +.matrix-form-checkbox-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.matrix-form-checkbox-row input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent-color); +} + +.matrix-form-actions { + display: flex; + gap: 10px; + margin-top: 18px; +} + +.matrix-form-submit { + background-color: var(--accent-color); + color: #0f0f19; + font-size: 0.88rem; + font-weight: 700; + padding: 10px 20px; + border-radius: 8px; + border: none; + cursor: pointer; + flex: 1; +} + +.matrix-form-submit:hover:not(:disabled) { + background-color: #a8c8ff; +} + +.matrix-form-submit:disabled { + opacity: 0.6; + cursor: default; +} + +.matrix-form-back { + background-color: var(--border-color); + color: var(--text-primary); + font-size: 0.88rem; + font-weight: 600; + padding: 10px 20px; + border-radius: 8px; + border: none; + cursor: pointer; +} + +.matrix-form-back:hover { + background-color: #5a5c72; +} + +.matrix-form-result { + margin-top: 14px; + padding: 12px 16px; + border-radius: 8px; + font-size: 0.88rem; + line-height: 1.5; + display: none; +} + +.matrix-form-result.success { + background-color: rgba(74, 222, 128, 0.12); + border: 1px solid var(--green); + color: var(--green); + display: block; +} + +.matrix-form-result.error { + background-color: rgba(239, 68, 68, 0.12); + border: 1px solid #ef4444; + color: #f87171; + display: block; +} + /* ── QR code in credentials modal ────────────────────────────────── */ .creds-qr-wrap { @@ -756,6 +893,7 @@ button.btn-reboot:hover:not(:disabled) { width: 200px; height: 200px; } +} /* ── Tech Support tile ───────────────────────────────────────────── */ @@ -955,10 +1093,6 @@ button.btn-reboot:hover:not(:disabled) { .support-btn-done:hover:not(:disabled) { background-color: #5a5c72; - - -} - } /* ── Feature Manager ─────────────────────────────────────────────── */ -- 2.53.0 From 9dd08dc2aec491d2d3405ae0537767cf3e9b9a7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:09:01 +0000 Subject: [PATCH 216/857] Initial plan -- 2.53.0 From b1386ba701440527ca42ef3f133c5048a3121841 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:12:00 +0000 Subject: [PATCH 217/857] Fix Matrix credentials modal: write individual credential files and update hub config Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/f4c4df17-1ef8-4b72-be8a-82472a5f4476 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- modules/core/sovran-hub.nix | 6 +++++- modules/synapse.nix | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 51f3526..a981ed1 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -61,7 +61,11 @@ let # ── Communication ────────────────────────────────────────── ++ [ { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; category = "communication"; credentials = [ - { label = "Users"; file = "/var/lib/secrets/matrix-users"; multiline = true; } + { label = "Homeserver URL"; file = "/var/lib/secrets/matrix-homeserver-url"; } + { label = "Admin Username"; file = "/var/lib/secrets/matrix-admin-username"; } + { label = "Admin Password"; file = "/var/lib/secrets/matrix-admin-password"; } + { label = "Test Username"; file = "/var/lib/secrets/matrix-test-username"; } + { label = "Test Password"; file = "/var/lib/secrets/matrix-test-password"; } ]; } { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; enabled = cfg.features.element-calling; category = "communication"; credentials = []; } ] diff --git a/modules/synapse.nix b/modules/synapse.nix index 580e835..9bf64bb 100755 --- a/modules/synapse.nix +++ b/modules/synapse.nix @@ -226,6 +226,23 @@ CREDS fi chmod 600 "$CREDS_FILE" + + # Write individual credential files for the hub UI (umask 077 ensures 600 from creation) + PREEXISTING_NOTE="Password set during original setup" + (umask 077; echo "https://$DOMAIN" > /var/lib/secrets/matrix-homeserver-url) + (umask 077; echo "@$ADMIN_USER:$DOMAIN" > /var/lib/secrets/matrix-admin-username) + if [ "$ADMIN_CREATED" = true ]; then + (umask 077; echo "$ADMIN_PASS" > /var/lib/secrets/matrix-admin-password) + else + (umask 077; echo "$PREEXISTING_NOTE" > /var/lib/secrets/matrix-admin-password) + fi + (umask 077; echo "@$TEST_USER:$DOMAIN" > /var/lib/secrets/matrix-test-username) + if [ "$TEST_CREATED" = true ]; then + (umask 077; echo "$TEST_PASS" > /var/lib/secrets/matrix-test-password) + else + (umask 077; echo "$PREEXISTING_NOTE" > /var/lib/secrets/matrix-test-password) + fi + echo "Matrix users setup completed." fi ''; -- 2.53.0 From 13b34ca5b9d1dbec4de6339b12858b0bae988e79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:28:25 +0000 Subject: [PATCH 218/857] Initial plan -- 2.53.0 From 0f4f53b9e5d286e44d7635682fd1367525e5c83a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:32:25 +0000 Subject: [PATCH 219/857] fix: matrix-synapse-create-users always writes individual Hub credential files Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4259c835-2875-4a48-86c9-1efccbeb6887 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- modules/synapse.nix | 51 ++++++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/modules/synapse.nix b/modules/synapse.nix index 9bf64bb..759ba7e 100755 --- a/modules/synapse.nix +++ b/modules/synapse.nix @@ -167,14 +167,16 @@ EOF CREDS_FILE="/var/lib/secrets/matrix-users" SECRET=$(cat /var/lib/matrix-synapse/registration-secret) - # Only run if we haven't already generated the file + mkdir -p /var/lib/secrets + + ADMIN_USER="admin" + TEST_USER="test" + ADMIN_PASS="" + TEST_PASS="" + + # Only run user registration if we haven't already generated the credentials file if [ ! -f "$CREDS_FILE" ]; then - mkdir -p /var/lib/secrets - - ADMIN_USER="admin" ADMIN_PASS=$(pwgen -s 24 1) - - TEST_USER="test" TEST_PASS=$(pwgen -s 24 1) ADMIN_CREATED=true @@ -226,25 +228,26 @@ CREDS fi chmod 600 "$CREDS_FILE" - - # Write individual credential files for the hub UI (umask 077 ensures 600 from creation) - PREEXISTING_NOTE="Password set during original setup" - (umask 077; echo "https://$DOMAIN" > /var/lib/secrets/matrix-homeserver-url) - (umask 077; echo "@$ADMIN_USER:$DOMAIN" > /var/lib/secrets/matrix-admin-username) - if [ "$ADMIN_CREATED" = true ]; then - (umask 077; echo "$ADMIN_PASS" > /var/lib/secrets/matrix-admin-password) - else - (umask 077; echo "$PREEXISTING_NOTE" > /var/lib/secrets/matrix-admin-password) - fi - (umask 077; echo "@$TEST_USER:$DOMAIN" > /var/lib/secrets/matrix-test-username) - if [ "$TEST_CREATED" = true ]; then - (umask 077; echo "$TEST_PASS" > /var/lib/secrets/matrix-test-password) - else - (umask 077; echo "$PREEXISTING_NOTE" > /var/lib/secrets/matrix-test-password) - fi - - echo "Matrix users setup completed." fi + + # Always write individual credential files for the hub UI, even if the bulk + # credentials file already existed from a prior run (umask 077 ensures mode 600). + # If passwords were not freshly generated above, parse them from the bulk file. + if [ -z "$ADMIN_PASS" ]; then + ADMIN_PASS=$(awk '/\[ Admin Account \]/{f=1} f && /^Password:/{sub(/^Password: /,""); print; exit}' "$CREDS_FILE") + [ -z "$ADMIN_PASS" ] && ADMIN_PASS="Password not available — check $CREDS_FILE" + fi + if [ -z "$TEST_PASS" ]; then + TEST_PASS=$(awk '/\[ Test Account \]/{f=1} f && /^Password:/{sub(/^Password: /,""); print; exit}' "$CREDS_FILE") + [ -z "$TEST_PASS" ] && TEST_PASS="Password not available — check $CREDS_FILE" + fi + (umask 077; echo "https://$DOMAIN" > /var/lib/secrets/matrix-homeserver-url) + (umask 077; echo "@$ADMIN_USER:$DOMAIN" > /var/lib/secrets/matrix-admin-username) + (umask 077; echo "$ADMIN_PASS" > /var/lib/secrets/matrix-admin-password) + (umask 077; echo "@$TEST_USER:$DOMAIN" > /var/lib/secrets/matrix-test-username) + (umask 077; echo "$TEST_PASS" > /var/lib/secrets/matrix-test-password) + + echo "Matrix users setup completed." ''; }; -- 2.53.0 From d4f81339ef4c6b69aa6cb179de4df57ad75283c1 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Fri, 3 Apr 2026 11:36:03 -0500 Subject: [PATCH 220/857] added awk command --- modules/synapse.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/synapse.nix b/modules/synapse.nix index 759ba7e..2950d78 100755 --- a/modules/synapse.nix +++ b/modules/synapse.nix @@ -151,7 +151,7 @@ EOF Type = "oneshot"; RemainAfterExit = true; }; - path = [ pkgs.pwgen pkgs.matrix-synapse pkgs.curl pkgs.coreutils pkgs.jq ]; + path = [ pkgs.pwgen pkgs.matrix-synapse pkgs.curl pkgs.gawk pkgs.coreutils pkgs.jq ]; script = '' set -uo pipefail -- 2.53.0 From ede46facf1ad0e3645e9382f69b8c44a1a519d74 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:51:31 +0000 Subject: [PATCH 221/857] Initial plan -- 2.53.0 From b2fb7035e06f4458940dde91c805cd1db9edb8e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:03:42 +0000 Subject: [PATCH 222/857] Add network port requirements UI, install notification, and tile port info Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/54981eb1-b1c5-4e1a-b587-730f41c59e01 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 50 +++++++++ app/sovran_systemsos_web/static/app.js | 90 ++++++++++++++- app/sovran_systemsos_web/static/style.css | 79 +++++++++++++ app/sovran_systemsos_web/templates/index.html | 11 ++ iso/installer.py | 105 +++++++++++++++++- 5 files changed, 332 insertions(+), 3 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 6844370..da23ac3 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -83,6 +83,7 @@ FEATURE_REGISTRY = [ "needs_ddns": False, "extra_fields": [], "conflicts_with": [], + "port_requirements": [], }, { "id": "haven", @@ -102,6 +103,8 @@ FEATURE_REGISTRY = [ }, ], "conflicts_with": [], + # Haven uses only 80/443, already covered by the main install alert + "port_requirements": [], }, { "id": "element-calling", @@ -114,6 +117,15 @@ FEATURE_REGISTRY = [ "extra_fields": [], "conflicts_with": [], "requires": ["matrix_domain"], + "port_requirements": [ + {"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"}, + {"port": "443", "protocol": "TCP", "description": "HTTPS (domain)"}, + {"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"}, + {"port": "7882-7894", "protocol": "UDP", "description": "LiveKit media streams"}, + {"port": "5349", "protocol": "TCP", "description": "TURN over TLS"}, + {"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"}, + {"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"}, + ], }, { "id": "mempool", @@ -125,6 +137,7 @@ FEATURE_REGISTRY = [ "needs_ddns": False, "extra_fields": [], "conflicts_with": [], + "port_requirements": [], }, { "id": "bip110", @@ -136,6 +149,7 @@ FEATURE_REGISTRY = [ "needs_ddns": False, "extra_fields": [], "conflicts_with": ["bitcoin-core"], + "port_requirements": [], }, { "id": "bitcoin-core", @@ -147,6 +161,7 @@ FEATURE_REGISTRY = [ "needs_ddns": False, "extra_fields": [], "conflicts_with": ["bip110"], + "port_requirements": [], }, ] @@ -160,6 +175,37 @@ FEATURE_SERVICE_MAP = { "bitcoin-core": None, } +# Port requirements for service tiles (keyed by unit name or icon) +# Services using only 80/443 for domain access share the same base list. +_PORTS_WEB = [ + {"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"}, + {"port": "443", "protocol": "TCP", "description": "HTTPS"}, +] +_PORTS_MATRIX_FEDERATION = _PORTS_WEB + [ + {"port": "8448", "protocol": "TCP", "description": "Matrix server-to-server federation"}, +] +_PORTS_ELEMENT_CALLING = _PORTS_WEB + [ + {"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"}, + {"port": "7882-7894", "protocol": "UDP", "description": "LiveKit media streams"}, + {"port": "5349", "protocol": "TCP", "description": "TURN over TLS"}, + {"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"}, + {"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"}, +] + +SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = { + # Infrastructure + "caddy.service": _PORTS_WEB, + # Communication + "matrix-synapse.service": _PORTS_MATRIX_FEDERATION, + "livekit.service": _PORTS_ELEMENT_CALLING, + # Domain-based apps (80/443) + "btcpayserver.service": _PORTS_WEB, + "vaultwarden.service": _PORTS_WEB, + "phpfpm-nextcloud.service": _PORTS_WEB, + "phpfpm-wordpress.service": _PORTS_WEB, + "haven-relay.service": _PORTS_WEB, +} + # For features that share a unit, disambiguate by icon field FEATURE_ICON_MAP = { "bip110": "bip110", @@ -689,6 +735,8 @@ async def api_services(): creds = entry.get("credentials", []) has_credentials = len(creds) > 0 + port_requirements = SERVICE_PORT_REQUIREMENTS.get(unit, []) + return { "name": entry.get("name", ""), "unit": unit, @@ -698,6 +746,7 @@ async def api_services(): "category": entry.get("category", "other"), "status": status, "has_credentials": has_credentials, + "port_requirements": port_requirements, } results = await asyncio.gather(*[get_status(s) for s in services]) @@ -910,6 +959,7 @@ async def api_features(): "needs_ddns": feat.get("needs_ddns", False), "extra_fields": extra_fields, "conflicts_with": feat.get("conflicts_with", []), + "port_requirements": feat.get("port_requirements", []), } if "requires" in feat: entry["requires"] = feat["requires"] diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 09e6f89..7308f57 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -113,6 +113,11 @@ const $featureConfirmOk = document.getElementById("feature-confirm-ok-btn") const $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn"); const $featureConfirmClose = document.getElementById("feature-confirm-close-btn"); +// Port Requirements modal +const $portReqModal = document.getElementById("port-requirements-modal"); +const $portReqBody = document.getElementById("port-req-body"); +const $portReqClose = document.getElementById("port-req-close-btn"); + // ── Helpers ─────────────────────────────────────────────────────── function tileId(svc) { return svc.unit + "::" + svc.name; } @@ -218,7 +223,16 @@ function buildTile(svc) { } var infoBtn = hasCreds ? '' : ""; - tile.innerHTML = infoBtn + '' + escHtml(svc.name) + '
      ' + escHtml(svc.name) + '
      ' + st + '
      '; + + // Port requirements badge + var ports = svc.port_requirements || []; + var portsHtml = ""; + if (ports.length > 0) { + var portLabels = ports.map(function(p) { return escHtml(p.port) + ' (' + escHtml(p.protocol) + ')'; }); + portsHtml = '
      šŸ”ŒPorts: ' + portLabels.join(', ') + '
      '; + } + + tile.innerHTML = infoBtn + '' + escHtml(svc.name) + '
      ' + escHtml(svc.name) + '
      ' + st + '
      ' + portsHtml; var infoBtnEl = tile.querySelector(".tile-info-btn"); if (infoBtnEl) { @@ -227,6 +241,16 @@ function buildTile(svc) { openCredsModal(svc.unit, svc.name); }); } + + var portsEl = tile.querySelector(".tile-ports"); + if (portsEl) { + portsEl.style.cursor = "pointer"; + portsEl.addEventListener("click", function(e) { + e.stopPropagation(); + openPortRequirementsModal(svc.name, ports, null); + }); + } + return tile; } @@ -883,6 +907,58 @@ function closeDomainSetupModal() { if ($domainSetupModal) $domainSetupModal.classList.remove("open"); } +// ── Port Requirements modal ─────────────────────────────────────── + +function openPortRequirementsModal(featureName, ports, onContinue) { + if (!$portReqModal || !$portReqBody) return; + + var rows = ports.map(function(p) { + return '' + escHtml(p.port) + '' + + '' + escHtml(p.protocol) + '' + + '' + escHtml(p.description) + ''; + }).join(""); + + var continueBtn = onContinue + ? '' + : ''; + + $portReqBody.innerHTML = + '

      You have enabled ' + escHtml(featureName) + '. ' + + 'For it to work with clients outside your local network you must open the following ports ' + + 'on your home router / WAN firewall:

      ' + + '' + + '' + + '' + rows + '' + + '
      Port(s)ProtocolPurpose
      ' + + '

      ℹ Consult your router manual or search "how to open ports on [router model]" ' + + 'for instructions. Features like Element Video Calling will not work for remote users until these ports are open.

      ' + + '
      ' + + '' + + continueBtn + + '
      '; + + document.getElementById("port-req-dismiss-btn").addEventListener("click", function() { + closePortRequirementsModal(); + }); + + if (onContinue) { + document.getElementById("port-req-continue-btn").addEventListener("click", function() { + closePortRequirementsModal(); + onContinue(); + }); + } + + $portReqModal.classList.add("open"); +} + +function closePortRequirementsModal() { + if ($portReqModal) $portReqModal.classList.remove("open"); +} + +if ($portReqClose) { + $portReqClose.addEventListener("click", closePortRequirementsModal); +} + // ── Feature toggle logic ────────────────────────────────────────── async function performFeatureToggle(featId, enabled, extra) { @@ -935,7 +1011,7 @@ function handleFeatureToggle(feat, newEnabled) { }); } - function proceedAfterConflictCheck() { + function proceedAfterPortCheck() { // Check SSL email first if (!_featuresData || !_featuresData.ssl_email_configured) { if (feat.needs_domain) { @@ -967,6 +1043,16 @@ function handleFeatureToggle(feat, newEnabled) { performFeatureToggle(feat.id, true, {}); } + function proceedAfterConflictCheck() { + // Show port requirements notification if the feature has extra port needs + var ports = feat.port_requirements || []; + if (ports.length > 0) { + openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck); + } else { + proceedAfterPortCheck(); + } + } + if (conflictNames.length > 0) { openFeatureConfirm( "This will disable " + conflictNames.join(", ") + ". Continue?", diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index a65ffa4..05f4c23 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -1311,3 +1311,82 @@ button.btn-reboot:hover:not(:disabled) { margin: 0 12px; } } + +/* ── Tile: Port Requirements badge ──────────────────────────────── */ + +.tile-ports { + margin-top: 6px; + font-size: 0.7rem; + color: var(--text-secondary); + display: flex; + align-items: flex-start; + gap: 4px; + line-height: 1.4; + flex-wrap: wrap; +} + +.tile-ports:hover { + color: var(--accent-color); +} + +.tile-ports-icon { + flex-shrink: 0; +} + +.tile-ports-label { + word-break: break-word; +} + +/* ── Port Requirements Modal ────────────────────────────────────── */ + +.port-req-intro { + font-size: 0.9rem; + color: var(--text-primary); + margin-bottom: 14px; + line-height: 1.5; +} + +.port-req-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + margin-bottom: 14px; +} + +.port-req-table thead th { + text-align: left; + padding: 6px 10px; + border-bottom: 1px solid var(--border-color); + color: var(--text-secondary); + font-weight: 600; +} + +.port-req-table tbody tr:nth-child(even) { + background-color: rgba(255,255,255,0.03); +} + +.port-req-port { + padding: 5px 10px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.82rem; + color: var(--accent-color); + white-space: nowrap; +} + +.port-req-proto { + padding: 5px 10px; + color: var(--text-secondary); + white-space: nowrap; +} + +.port-req-desc { + padding: 5px 10px; + color: var(--text-primary); +} + +.port-req-hint { + font-size: 0.78rem; + color: var(--text-dim); + line-height: 1.5; + margin-bottom: 14px; +} diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index f0e9bd0..618528e 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -129,6 +129,17 @@
      + + + + + + + + + + + \ No newline at end of file -- 2.53.0 From cfb6c3409f0a300ef6ce710242f5a70d7add29ca Mon Sep 17 00:00:00 2001 From: Sovran_Systems <99053422+naturallaw777@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:44:41 -0500 Subject: [PATCH 244/857] Update onboarding Step 2 description to clarify Njal.la account/domain/Dynamic record flow --- app/sovran_systemsos_web/templates/onboarding.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/sovran_systemsos_web/templates/onboarding.html b/app/sovran_systemsos_web/templates/onboarding.html index 09b9dbf..8b7a24a 100644 --- a/app/sovran_systemsos_web/templates/onboarding.html +++ b/app/sovran_systemsos_web/templates/onboarding.html @@ -72,7 +72,8 @@

      Domain Configuration

      Sovran_SystemsOS uses Njal.la for domains and Dynamic DNS. - For each service, enter the subdomain you purchased on Njal.la and paste the DDNS curl command from your Njal.la dashboard. + First, create an account at Njal.la and purchase your domain. Then, create a Dynamic DNS record in the Njal.la web interface pointing to this machine's external IP address (shown below). + Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.

      @@ -198,4 +199,4 @@ - + \ No newline at end of file -- 2.53.0 From 21fc552f4086123d84954fdbc6f400cb7e4b3c20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:51:06 +0000 Subject: [PATCH 245/857] Initial plan -- 2.53.0 From 15e6cfb866e559e846ae39c81ed7d246101b06b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:54:01 +0000 Subject: [PATCH 246/857] Update onboarding Step 2: clarify Njal.la sequence and display external IP Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4e4b917b-6246-4db3-9e2d-536cce11a19a Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/static/onboarding.js | 14 ++++++++++---- app/sovran_systemsos_web/templates/onboarding.html | 3 ++- onboarding.html | 3 ++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/sovran_systemsos_web/static/onboarding.js b/app/sovran_systemsos_web/static/onboarding.js index d66ecd7..580a001 100644 --- a/app/sovran_systemsos_web/static/onboarding.js +++ b/app/sovran_systemsos_web/static/onboarding.js @@ -109,18 +109,22 @@ async function loadStep2() { if (!body) return; try { - // Fetch services + domains in parallel + // Fetch services, domains, and network info in parallel var results = await Promise.all([ apiFetch("/api/services"), apiFetch("/api/domains/status"), + apiFetch("/api/network"), ]); _servicesData = results[0]; _domainsData = results[1]; + var networkData = results[2]; } catch (err) { body.innerHTML = '

      ⚠ Could not load service data: ' + escHtml(err.message) + '

      '; return; } + var externalIp = (networkData && networkData.external_ip) || "Unknown (could not retrieve)"; + // Build set of enabled service units var enabledUnits = new Set(); (_servicesData || []).forEach(function(svc) { @@ -140,11 +144,13 @@ async function loadStep2() { html += '
      ' + 'Before you continue:' + '
        ' - + '
      1. Purchase your subdomains on https://njal.la
      2. ' - + '
      3. For each subdomain, add a Dynamic record in your Njal.la dashboard
      4. ' + + '
      5. Create an account at https://njal.la
      6. ' + + '
      7. Purchase your domain on Njal.la
      8. ' + + '
      9. In the Njal.la web interface, create a Dynamic record pointing to this machine\'s external IP address:
        ' + + '' + escHtml(externalIp) + '
      10. ' + '
      11. Njal.la will give you a curl command like:
        ' + 'curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"
      12. ' - + '
      13. Enter the subdomain and paste that curl command below
      14. ' + + '
      15. Enter the subdomain and paste that curl command below for each service
      16. ' + '
      ' + '
      '; html += '

      Enter each fully-qualified subdomain (e.g. matrix.yourdomain.com) and its Njal.la DDNS curl command.

      '; diff --git a/app/sovran_systemsos_web/templates/onboarding.html b/app/sovran_systemsos_web/templates/onboarding.html index 8b7a24a..e31edd3 100644 --- a/app/sovran_systemsos_web/templates/onboarding.html +++ b/app/sovran_systemsos_web/templates/onboarding.html @@ -72,7 +72,8 @@

      Domain Configuration

      Sovran_SystemsOS uses Njal.la for domains and Dynamic DNS. - First, create an account at Njal.la and purchase your domain. Then, create a Dynamic DNS record in the Njal.la web interface pointing to this machine's external IP address (shown below). + First, create an account at Njal.la and purchase your domain. + Then, in the Njal.la web interface, create a Dynamic record pointing to this machine's external IP address (shown below). Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.

      diff --git a/onboarding.html b/onboarding.html index 8b7a24a..e31edd3 100644 --- a/onboarding.html +++ b/onboarding.html @@ -72,7 +72,8 @@

      Domain Configuration

      Sovran_SystemsOS uses Njal.la for domains and Dynamic DNS. - First, create an account at Njal.la and purchase your domain. Then, create a Dynamic DNS record in the Njal.la web interface pointing to this machine's external IP address (shown below). + First, create an account at Njal.la and purchase your domain. + Then, in the Njal.la web interface, create a Dynamic record pointing to this machine's external IP address (shown below). Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.

      -- 2.53.0 From 2e9bb9e92032ff3ab6eb20c96c237744140e31e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:06:27 +0000 Subject: [PATCH 247/857] Initial plan -- 2.53.0 From ab5494f4ad55c99a381954462bb2d3a735d8657b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:06:42 +0000 Subject: [PATCH 248/857] Initial plan -- 2.53.0 From 08452e06cc4421c4700d842b9eecb05a3d821e7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:08:21 +0000 Subject: [PATCH 249/857] feat: enable mDNS (Avahi) and local reverse proxy for sovransystemsos.local Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4159c571-2bfb-48fc-a6bc-e0765ef88ef6 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- configuration.nix | 14 ++++++++++++-- modules/core/caddy.nix | 8 ++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/configuration.nix b/configuration.nix index d738c70..f9f7bb6 100644 --- a/configuration.nix +++ b/configuration.nix @@ -28,15 +28,25 @@ }; # ── Networking ────────────────────────────────────────────── - networking.hostName = "nixos"; + networking.hostName = "sovransystemsos"; networking.networkmanager.enable = true; networking.firewall.enable = true; networking.firewall.allowedTCPPorts = [ 80 443 8448 3051 ]; - networking.firewall.allowedUDPPorts = [ 80 443 8448 3051 ]; + networking.firewall.allowedUDPPorts = [ 80 443 8448 3051 5353 ]; networking.firewall.allowedUDPPortRanges = [ { from = 49152; to = 65535; } ]; + # ── mDNS / Avahi (sovransystemsos.local) ────────────────── + services.avahi = { + enable = true; + nssmdns4 = true; + publish = { + enable = true; + addresses = true; + }; + }; + # ── Locale / Time ────────────────────────────────────────── time.timeZone = "America/Los_Angeles"; i18n.defaultLocale = "en_US.UTF-8"; diff --git a/modules/core/caddy.nix b/modules/core/caddy.nix index 8293f12..f7d0e76 100755 --- a/modules/core/caddy.nix +++ b/modules/core/caddy.nix @@ -145,6 +145,14 @@ $HAVEN { EOF fi + # ── Sovran Hub (LAN mDNS access) ──────────────── + cat >> /run/caddy/Caddyfile <> /run/caddy/Caddyfile < Date: Fri, 3 Apr 2026 21:08:58 +0000 Subject: [PATCH 250/857] Improve port forwarding panel UI: larger table font, no scroll cap on Step 3 Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/7fc0a8b1-1f5b-489c-8e6a-8cf9ed628ccf Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/static/style.css | 12 ++++++++---- app/sovran_systemsos_web/templates/onboarding.html | 2 +- onboarding.html | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 05dba53..abe17c9 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -1862,6 +1862,10 @@ button.btn-reboot:hover:not(:disabled) { overflow-y: auto; } +.onboarding-card--ports { + overflow-y: visible; +} + .onboarding-body-text { font-size: 0.92rem; color: var(--text-secondary); @@ -2121,13 +2125,13 @@ button.btn-reboot:hover:not(:disabled) { width: 100%; border-collapse: collapse; margin-top: 10px; - font-size: 0.82rem; + font-size: 0.92rem; } .onboarding-port-table th { text-align: left; - padding: 4px 8px; - font-size: 0.72rem; + padding: 6px 10px; + font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); @@ -2135,7 +2139,7 @@ button.btn-reboot:hover:not(:disabled) { } .onboarding-port-table td { - padding: 4px 8px; + padding: 8px 10px; vertical-align: top; } diff --git a/app/sovran_systemsos_web/templates/onboarding.html b/app/sovran_systemsos_web/templates/onboarding.html index e31edd3..807568c 100644 --- a/app/sovran_systemsos_web/templates/onboarding.html +++ b/app/sovran_systemsos_web/templates/onboarding.html @@ -99,7 +99,7 @@ Ports 80 and 443 must be open for SSL certificates to work.

      -
      +

      Checking ports…

      -
      +

      Checking ports…

      '; // Totals - html += '
      '; + html += '
      '; html += 'Total port openings: 4 (without Element Calling)
      '; html += 'Total port openings: 9 (with Element Calling — 4 required + 5 optional)'; html += '
      '; diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index abe17c9..a4438ec 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -2066,13 +2066,24 @@ button.btn-reboot:hover:not(:disabled) { margin-bottom: 12px; } +.onboarding-port-totals { + background: var(--card-color); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 10px 14px; + font-size: 0.93em; + color: var(--text-primary); + margin-bottom: 18px; + line-height: 1.6; +} + .onboarding-port-warn { - background: rgba(229, 165, 10, 0.08); - border: 1px solid rgba(229, 165, 10, 0.3); + background: rgba(229, 165, 10, 0.15); + border: 1px solid rgba(229, 165, 10, 0.5); border-radius: 8px; padding: 12px 16px; font-size: 0.88rem; - color: var(--text-secondary); + color: var(--text-primary); margin-bottom: 14px; line-height: 1.5; } -- 2.53.0 From 74853431e1600f60ea59c782fad2c258d18490a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 00:22:41 +0000 Subject: [PATCH 253/857] Initial plan -- 2.53.0 From 0a323d7b3c48ec1b225d6d962aeb7f89837019a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 00:24:10 +0000 Subject: [PATCH 254/857] Revert hostName/mDNS/Caddy .local block changes from PR #34 Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8d17fed2-7329-442e-bfa5-a96a38fb31e4 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- configuration.nix | 17 +++++------------ modules/core/caddy.nix | 8 -------- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/configuration.nix b/configuration.nix index f9f7bb6..aca7b45 100644 --- a/configuration.nix +++ b/configuration.nix @@ -28,25 +28,18 @@ }; # ── Networking ────────────────────────────────────────────── - networking.hostName = "sovransystemsos"; + # NOTE: hostName must remain "nixos" to match the nixosConfigurations key in + # flake.nix. Changing it breaks flake-based remote upgrades with: + # error: does not provide attribute 'nixosConfigurations.""' + networking.hostName = "nixos"; networking.networkmanager.enable = true; networking.firewall.enable = true; networking.firewall.allowedTCPPorts = [ 80 443 8448 3051 ]; - networking.firewall.allowedUDPPorts = [ 80 443 8448 3051 5353 ]; + networking.firewall.allowedUDPPorts = [ 80 443 8448 3051 ]; networking.firewall.allowedUDPPortRanges = [ { from = 49152; to = 65535; } ]; - # ── mDNS / Avahi (sovransystemsos.local) ────────────────── - services.avahi = { - enable = true; - nssmdns4 = true; - publish = { - enable = true; - addresses = true; - }; - }; - # ── Locale / Time ────────────────────────────────────────── time.timeZone = "America/Los_Angeles"; i18n.defaultLocale = "en_US.UTF-8"; diff --git a/modules/core/caddy.nix b/modules/core/caddy.nix index f7d0e76..8293f12 100755 --- a/modules/core/caddy.nix +++ b/modules/core/caddy.nix @@ -145,14 +145,6 @@ $HAVEN { EOF fi - # ── Sovran Hub (LAN mDNS access) ──────────────── - cat >> /run/caddy/Caddyfile <> /run/caddy/Caddyfile < Date: Fri, 3 Apr 2026 19:31:57 -0500 Subject: [PATCH 255/857] bump --- result | 1 + 1 file changed, 1 insertion(+) create mode 120000 result diff --git a/result b/result new file mode 120000 index 0000000..aee59aa --- /dev/null +++ b/result @@ -0,0 +1 @@ +/nix/store/b084r2ravrdcyw3lwh7p5jpawfgamn20-Sovran_SystemsOS.iso \ No newline at end of file -- 2.53.0 From beada8f174a5cd46880ca92158e4649987089f05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 00:39:05 +0000 Subject: [PATCH 256/857] Initial plan -- 2.53.0 From ed1548ea814b8bd83a6b617b85e98e0be2655b7b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 00:40:24 +0000 Subject: [PATCH 257/857] Add Avahi mDNS hostName override and Caddy .local block for sovransystemsos.local LAN access Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ca3945d7-a2cb-4121-bd89-a5e3fe31fc47 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- configuration.nix | 12 +++++++++++- modules/core/caddy.nix | 8 ++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/configuration.nix b/configuration.nix index aca7b45..23b668d 100644 --- a/configuration.nix +++ b/configuration.nix @@ -35,11 +35,21 @@ networking.networkmanager.enable = true; networking.firewall.enable = true; networking.firewall.allowedTCPPorts = [ 80 443 8448 3051 ]; - networking.firewall.allowedUDPPorts = [ 80 443 8448 3051 ]; + networking.firewall.allowedUDPPorts = [ 80 443 8448 3051 5353 ]; networking.firewall.allowedUDPPortRanges = [ { from = 49152; to = 65535; } ]; + # ── Avahi (mDNS) ─────────────────────────────────────────── + # Advertise as sovransystemsos.local on the LAN without changing the system + # hostname (which must remain "nixos" for flake compatibility — see above). + services.avahi = { + enable = true; + hostName = "sovransystemsos"; + nssmdns4 = true; + publish = { enable = true; addresses = true; }; + }; + # ── Locale / Time ────────────────────────────────────────── time.timeZone = "America/Los_Angeles"; i18n.defaultLocale = "en_US.UTF-8"; diff --git a/modules/core/caddy.nix b/modules/core/caddy.nix index 8293f12..7f4d811 100755 --- a/modules/core/caddy.nix +++ b/modules/core/caddy.nix @@ -145,6 +145,14 @@ $HAVEN { EOF fi + # ── Sovran Hub (LAN access via mDNS) ──────────── + cat >> /run/caddy/Caddyfile <> /run/caddy/Caddyfile < Date: Sat, 4 Apr 2026 00:52:59 +0000 Subject: [PATCH 258/857] Initial plan -- 2.53.0 From dd3a20ed009b6070655a1979fa2fd72eca59b4e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 01:02:58 +0000 Subject: [PATCH 259/857] feat: wallet privacy control and audit logging for tech support sessions - Add dedicated `sovran-support` restricted user (non-root) for SSH sessions - Apply POSIX ACLs via setfacl to block support user from wallet directories (LND, Sparrow, Bisq, nix-bitcoin-secrets) by default - Graceful fallback to root authorized_keys if user creation fails (with UI warning) - Add time-limited wallet unlock consent: POST /api/support/wallet-unlock - Add wallet re-lock: POST /api/support/wallet-lock - Add audit log: GET /api/support/audit-log (append-only, all events logged) - Expand /api/support/status with wallet_protected, wallet_unlocked, wallet_unlocked_until, protected_paths, acl_applied fields - Update frontend to show wallet protection status box with protected path list - Show wallet unlock/re-lock controls with duration selector (30min/1h/2h) - Show audit log viewer in support modal (toggleable) - Add wallet unlock expiry auto-refresh timer in JS - Add CSS styles for wallet protection box, unlock/lock buttons, audit log Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/70330ce3-1ed7-46b1-ac66-4cdc50de6017 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 345 ++++++++++++++++++++-- app/sovran_systemsos_web/static/app.js | 189 +++++++++++- app/sovran_systemsos_web/static/style.css | 163 +++++++++- 3 files changed, 668 insertions(+), 29 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 4ceea91..0dc3eed 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -7,9 +7,11 @@ import base64 import hashlib import json import os +import pwd import re import socket import subprocess +import time import urllib.error import urllib.parse import urllib.request @@ -63,6 +65,30 @@ SOVRAN_SUPPORT_PUBKEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFLY8hjksaWzQmIQVut SUPPORT_KEY_COMMENT = "sovransystemsos-support" +# Dedicated restricted support user (non-root) for wallet privacy +SUPPORT_USER = "sovran-support" +SUPPORT_USER_HOME = "/var/lib/sovran-support" +SUPPORT_USER_SSH_DIR = "/var/lib/sovran-support/.ssh" +SUPPORT_USER_AUTH_KEYS = "/var/lib/sovran-support/.ssh/authorized_keys" + +# Audit log for all support session events +SUPPORT_AUDIT_LOG = "/var/log/sovran-support-audit.log" + +# Time-limited wallet unlock state +WALLET_UNLOCK_FILE = "/var/lib/secrets/support-wallet-unlock" +WALLET_UNLOCK_DURATION_DEFAULT = 3600 # seconds (1 hour) + +# Wallet paths protected by default from the support user +PROTECTED_WALLET_PATHS: list[str] = [ + "/var/lib/lnd", + "/root/.lnd", + "/var/lib/sparrow", + "/root/.sparrow", + "/root/.bisq", + "/etc/nix-bitcoin-secrets", + "/var/lib/bitcoind", +] + CATEGORY_ORDER = [ ("infrastructure", "Infrastructure"), ("bitcoin-base", "Bitcoin Base"), @@ -714,7 +740,15 @@ def _is_feature_enabled_in_config(feature_id: str) -> bool | None: # ── Tech Support helpers ────────────────────────────────────────── def _is_support_active() -> bool: - """Check if the support key is currently in authorized_keys.""" + """Check if the support key is currently in authorized_keys or support user's authorized_keys.""" + # Check support user's authorized_keys first + try: + with open(SUPPORT_USER_AUTH_KEYS, "r") as f: + if SUPPORT_KEY_COMMENT in f.read(): + return True + except FileNotFoundError: + pass + # Fall back to root authorized_keys try: with open(AUTHORIZED_KEYS, "r") as f: content = f.read() @@ -732,48 +766,222 @@ def _get_support_session_info() -> dict: return {} -def _enable_support() -> bool: - """Add the Sovran support public key to root's authorized_keys.""" +def _log_support_audit(event: str, details: str = "") -> None: + """Append a timestamped event to the support audit log.""" + timestamp = time.strftime("%Y-%m-%d %H:%M:%S %Z") + line = f"[{timestamp}] {event}" + if details: + line += f": {details}" + line += "\n" try: - os.makedirs("/root/.ssh", mode=0o700, exist_ok=True) + os.makedirs(os.path.dirname(SUPPORT_AUDIT_LOG), exist_ok=True) + with open(SUPPORT_AUDIT_LOG, "a") as f: + f.write(line) + except Exception: + pass - # Write the key to the dedicated support key file - with open(SUPPORT_KEY_FILE, "w") as f: - f.write(SOVRAN_SUPPORT_PUBKEY + "\n") - os.chmod(SUPPORT_KEY_FILE, 0o600) - # Append to authorized_keys if not already present - existing = "" +def _get_support_audit_log(max_lines: int = 100) -> list: + """Return the last N lines from the audit log.""" + try: + with open(SUPPORT_AUDIT_LOG, "r") as f: + lines = f.readlines() + return [l.rstrip("\n") for l in lines[-max_lines:]] + except FileNotFoundError: + return [] + + +def _get_existing_wallet_paths() -> list: + """Return the subset of PROTECTED_WALLET_PATHS that actually exist on disk.""" + return [p for p in PROTECTED_WALLET_PATHS if os.path.exists(p)] + + +def _ensure_support_user() -> bool: + """Ensure the sovran-support restricted user exists. Returns True on success.""" + try: + result = subprocess.run( + ["id", SUPPORT_USER], capture_output=True, timeout=5, + ) + if result.returncode == 0: + return True + except Exception: + return False + + try: + subprocess.run( + [ + "useradd", + "--system", + "--no-create-home", + "--home-dir", SUPPORT_USER_HOME, + "--shell", "/bin/bash", + "--comment", "Sovran Systems Support (restricted)", + SUPPORT_USER, + ], + check=True, capture_output=True, timeout=15, + ) + os.makedirs(SUPPORT_USER_HOME, mode=0o700, exist_ok=True) + os.makedirs(SUPPORT_USER_SSH_DIR, mode=0o700, exist_ok=True) + pw = pwd.getpwnam(SUPPORT_USER) + os.chown(SUPPORT_USER_HOME, pw.pw_uid, pw.pw_gid) + os.chown(SUPPORT_USER_SSH_DIR, pw.pw_uid, pw.pw_gid) + return True + except Exception: + return False + + +def _apply_wallet_acls() -> bool: + """Apply POSIX ACLs to deny the support user access to wallet directories. + + Sets a deny-all ACL entry (u:sovran-support:---) on each existing protected + path. Returns True if all existing paths were handled without error. + setfacl is tried; if it is not available the function returns False without + raising so callers can warn the user appropriately. + """ + existing = _get_existing_wallet_paths() + if not existing: + return True + success = True + for path in existing: try: - with open(AUTHORIZED_KEYS, "r") as f: - existing = f.read() + result = subprocess.run( + ["setfacl", "-R", "-m", f"u:{SUPPORT_USER}:---", path], + capture_output=True, timeout=15, + ) + if result.returncode != 0: + success = False except FileNotFoundError: - pass + # setfacl not installed + return False + except Exception: + success = False + return success - if SUPPORT_KEY_COMMENT not in existing: - with open(AUTHORIZED_KEYS, "a") as f: + +def _revoke_wallet_acls() -> bool: + """Remove the support user's deny ACL from wallet directories.""" + existing = _get_existing_wallet_paths() + if not existing: + return True + success = True + for path in existing: + try: + result = subprocess.run( + ["setfacl", "-R", "-x", f"u:{SUPPORT_USER}", path], + capture_output=True, timeout=15, + ) + if result.returncode != 0: + success = False + except FileNotFoundError: + return False + except Exception: + success = False + return success + + +def _is_wallet_unlocked() -> bool: + """Return True if the user has granted time-limited wallet access and it has not expired.""" + try: + with open(WALLET_UNLOCK_FILE, "r") as f: + data = json.load(f) + return time.time() < data.get("expires_at", 0) + except (FileNotFoundError, json.JSONDecodeError, KeyError): + return False + + +def _get_wallet_unlock_info() -> dict: + """Read wallet unlock state. Re-locks and returns {} if the grant has expired.""" + try: + with open(WALLET_UNLOCK_FILE, "r") as f: + data = json.load(f) + if time.time() >= data.get("expires_at", 0): + try: + os.remove(WALLET_UNLOCK_FILE) + except FileNotFoundError: + pass + _apply_wallet_acls() + _log_support_audit("WALLET_RELOCKED", "auto-expired") + return {} + return data + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _enable_support() -> bool: + """Add the Sovran support public key to the restricted support user's authorized_keys. + + Falls back to root's authorized_keys if the support user cannot be created. + Applies POSIX ACLs to wallet directories to prevent access by the support + user without explicit user consent. + """ + try: + use_restricted_user = _ensure_support_user() + + if use_restricted_user: + os.makedirs(SUPPORT_USER_SSH_DIR, mode=0o700, exist_ok=True) + with open(SUPPORT_USER_AUTH_KEYS, "w") as f: f.write(SOVRAN_SUPPORT_PUBKEY + "\n") - os.chmod(AUTHORIZED_KEYS, 0o600) + os.chmod(SUPPORT_USER_AUTH_KEYS, 0o600) + try: + pw = pwd.getpwnam(SUPPORT_USER) + os.chown(SUPPORT_USER_AUTH_KEYS, pw.pw_uid, pw.pw_gid) + os.chown(SUPPORT_USER_SSH_DIR, pw.pw_uid, pw.pw_gid) + except Exception: + pass + else: + # Fallback: add key to root's authorized_keys + os.makedirs("/root/.ssh", mode=0o700, exist_ok=True) + with open(SUPPORT_KEY_FILE, "w") as f: + f.write(SOVRAN_SUPPORT_PUBKEY + "\n") + os.chmod(SUPPORT_KEY_FILE, 0o600) + + existing = "" + try: + with open(AUTHORIZED_KEYS, "r") as f: + existing = f.read() + except FileNotFoundError: + pass + + if SUPPORT_KEY_COMMENT not in existing: + with open(AUTHORIZED_KEYS, "a") as f: + f.write(SOVRAN_SUPPORT_PUBKEY + "\n") + os.chmod(AUTHORIZED_KEYS, 0o600) + + acl_applied = _apply_wallet_acls() if use_restricted_user else False + wallet_paths = _get_existing_wallet_paths() - # Write session metadata - import time session_info = { "enabled_at": time.time(), "enabled_at_human": time.strftime("%Y-%m-%d %H:%M:%S %Z"), + "use_restricted_user": use_restricted_user, + "wallet_protected": use_restricted_user, + "acl_applied": acl_applied, + "protected_paths": wallet_paths, } os.makedirs(os.path.dirname(SUPPORT_STATUS_FILE), exist_ok=True) with open(SUPPORT_STATUS_FILE, "w") as f: json.dump(session_info, f) + _log_support_audit( + "SUPPORT_ENABLED", + f"restricted_user={use_restricted_user} acl_applied={acl_applied} " + f"protected_paths={len(wallet_paths)}", + ) return True except Exception: return False def _disable_support() -> bool: - """Remove the Sovran support public key from authorized_keys.""" + """Remove the Sovran support public key and revoke all wallet access.""" try: - # Remove from authorized_keys + # Remove from support user's authorized_keys + try: + os.remove(SUPPORT_USER_AUTH_KEYS) + except FileNotFoundError: + pass + + # Remove from root's authorized_keys (fallback / legacy) try: with open(AUTHORIZED_KEYS, "r") as f: lines = f.readlines() @@ -790,19 +998,35 @@ def _disable_support() -> bool: except FileNotFoundError: pass + # Revoke any outstanding wallet unlock + try: + os.remove(WALLET_UNLOCK_FILE) + except FileNotFoundError: + pass + + # Re-apply ACLs to ensure wallet access is revoked + _revoke_wallet_acls() + # Remove session metadata try: os.remove(SUPPORT_STATUS_FILE) except FileNotFoundError: pass + _log_support_audit("SUPPORT_DISABLED") return True except Exception: return False def _verify_support_removed() -> bool: - """Verify the support key is truly gone from authorized_keys.""" + """Verify the support key is truly gone from all authorized_keys files.""" + try: + with open(SUPPORT_USER_AUTH_KEYS, "r") as f: + if SUPPORT_KEY_COMMENT in f.read(): + return False + except FileNotFoundError: + pass try: with open(AUTHORIZED_KEYS, "r") as f: content = f.read() @@ -1164,10 +1388,18 @@ async def api_support_status(): loop = asyncio.get_event_loop() active = await loop.run_in_executor(None, _is_support_active) session = await loop.run_in_executor(None, _get_support_session_info) + unlock_info = await loop.run_in_executor(None, _get_wallet_unlock_info) + wallet_unlocked = bool(unlock_info) return { "active": active, "enabled_at": session.get("enabled_at"), "enabled_at_human": session.get("enabled_at_human"), + "wallet_protected": session.get("wallet_protected", False), + "acl_applied": session.get("acl_applied", False), + "protected_paths": session.get("protected_paths", []), + "wallet_unlocked": wallet_unlocked, + "wallet_unlocked_until": unlock_info.get("expires_at") if wallet_unlocked else None, + "wallet_unlocked_until_human": unlock_info.get("expires_at_human") if wallet_unlocked else None, } @@ -1194,6 +1426,77 @@ async def api_support_disable(): return {"ok": True, "verified": verified, "message": "Support access removed and verified"} +class WalletUnlockRequest(BaseModel): + duration: int = WALLET_UNLOCK_DURATION_DEFAULT # seconds + + +@app.post("/api/support/wallet-unlock") +async def api_support_wallet_unlock(req: WalletUnlockRequest): + """Grant the support user time-limited access to wallet directories. + + Removes the deny ACL for the support user on all protected wallet paths. + Access is automatically revoked when the timer expires (checked lazily on + next status call) or when the support session is ended. + """ + + loop = asyncio.get_event_loop() + active = await loop.run_in_executor(None, _is_support_active) + if not active: + raise HTTPException(status_code=400, detail="No active support session") + + duration = max(300, min(req.duration, 14400)) # clamp: 5 min – 4 hours + expires_at = time.time() + duration + expires_human = time.strftime("%Y-%m-%d %H:%M:%S %Z", time.localtime(expires_at)) + + # Remove ACL restrictions + await loop.run_in_executor(None, _revoke_wallet_acls) + + unlock_info = { + "unlocked_at": time.time(), + "expires_at": expires_at, + "expires_at_human": expires_human, + "duration": duration, + } + os.makedirs(os.path.dirname(WALLET_UNLOCK_FILE), exist_ok=True) + with open(WALLET_UNLOCK_FILE, "w") as f: + json.dump(unlock_info, f) + + _log_support_audit( + "WALLET_UNLOCKED", + f"duration={duration}s expires={expires_human}", + ) + return { + "ok": True, + "expires_at": expires_at, + "expires_at_human": expires_human, + "message": f"Wallet access granted for {duration // 60} minutes", + } + + +@app.post("/api/support/wallet-lock") +async def api_support_wallet_lock(): + """Revoke wallet access and re-apply ACL protections.""" + loop = asyncio.get_event_loop() + + try: + os.remove(WALLET_UNLOCK_FILE) + except FileNotFoundError: + pass + + await loop.run_in_executor(None, _apply_wallet_acls) + _log_support_audit("WALLET_LOCKED", "user-initiated") + return {"ok": True, "message": "Wallet access revoked"} + + +@app.get("/api/support/audit-log") +async def api_support_audit_log(limit: int = 100): + """Return the last N lines of the support audit log.""" + limit = max(1, min(limit, 500)) + loop = asyncio.get_event_loop() + lines = await loop.run_in_executor(None, _get_support_audit_log, limit) + return {"entries": lines} + + # ── Feature Manager endpoints ───────────────────────────────────── @app.get("/api/features") diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index be55d95..bf402c5 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -42,8 +42,10 @@ let _updatePollTimer = null; let _updateLogOffset = 0; let _serverWasDown = false; let _updateFinished = false; -let _supportTimerInt = null; -let _supportEnabledAt = null; +let _supportTimerInt = null; +let _supportEnabledAt = null; +let _supportStatus = null; // last fetched /api/support/status payload +let _walletUnlockTimerInt = null; let _cachedExternalIp = null; // Feature Manager state @@ -572,7 +574,8 @@ async function openSupportModal() { $supportBody.innerHTML = '

      Checking support status…

      '; try { var status = await apiFetch("/api/support/status"); - if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(); } + _supportStatus = status; + if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(status); } else { renderSupportInactive(); } } catch (err) { $supportBody.innerHTML = '

      Could not check support status.

      '; @@ -582,19 +585,114 @@ async function openSupportModal() { function renderSupportInactive() { stopSupportTimer(); var ip = _cachedExternalIp || "loading…"; - $supportBody.innerHTML = '
      šŸ›Ÿ

      Need help from Sovran Systems?

      This will temporarily grant our support team SSH access to your machine so we can help diagnose and fix issues.

      Your IP' + escHtml(ip) + '
      This IP will be shared with Sovran Systems support
      What happens:
      1. Our public SSH key is added to your machine
      2. We connect and help fix the issue
      3. You click "End Session" to remove our access

      You can revoke access at any time

      '; + $supportBody.innerHTML = [ + '
      ', + '
      šŸ›Ÿ
      ', + '

      Need help from Sovran Systems?

      ', + '

      This will temporarily grant our support team SSH access to your machine so we can help diagnose and fix issues.

      ', + '
      ', + '
      Your IP' + escHtml(ip) + '
      ', + '
      This IP will be shared with Sovran Systems support
      ', + '
      ', + '
      ', + '
      šŸ”’Wallet Protection
      ', + '

      Wallet files (LND, Sparrow, Bisq) are protected by default. Support staff cannot access your private keys unless you explicitly grant access.

      ', + '
      ', + '
      What happens:
        ', + '
      1. A restricted sovran-support user is created with limited access
      2. ', + '
      3. Our SSH key is added only to that restricted account
      4. ', + '
      5. Wallet files are locked via access controls — not visible to support
      6. ', + '
      7. You control if and when wallet access is granted (time-limited)
      8. ', + '
      9. All session events are logged for your audit
      10. ', + '
      ', + '', + '

      You can revoke access at any time. Wallet files are protected unless you unlock them.

      ', + '
      ', + ].join(""); document.getElementById("btn-support-enable").addEventListener("click", enableSupport); } -function renderSupportActive() { +function renderSupportActive(status) { var ip = _cachedExternalIp || "loading…"; - $supportBody.innerHTML = '
      šŸ”“

      Support Access is Active

      Sovran Systems can currently connect to your machine via SSH.

      Your IP' + escHtml(ip) + '
      Duration…

      This will remove the SSH key immediately

      '; + var walletProtected = status && status.wallet_protected; + var walletUnlocked = status && status.wallet_unlocked; + var unlockUntil = status && status.wallet_unlocked_until_human ? status.wallet_unlocked_until_human : ""; + var protectedPaths = (status && status.protected_paths && status.protected_paths.length) + ? status.protected_paths : []; + + var walletSection; + if (walletProtected) { + if (walletUnlocked) { + walletSection = [ + '
      ', + '
      šŸ”“Wallet Access: UNLOCKED
      ', + '

      You have granted support temporary access to wallet files' + (unlockUntil ? ' until ' + escHtml(unlockUntil) + '' : '') + '.

      ', + '', + '
      ', + ].join(""); + } else { + var pathList = protectedPaths.length + ? '
        ' + protectedPaths.map(function(p){ return '
      • ' + escHtml(p) + '
      • '; }).join("") + '
      ' + : ''; + walletSection = [ + '
      ', + '
      šŸ”’Wallet Files: Protected
      ', + '

      Support cannot access your wallet files. Grant temporary access only if needed for wallet troubleshooting.

      ', + pathList, + '
      ', + '', + '', + '
      ', + '
      ', + ].join(""); + } + } else { + walletSection = [ + '
      ', + '
      āš ļøWallet Protection Unavailable
      ', + '

      The restricted support user could not be created. Support is running with root access — wallet files may be accessible. End the session if you are concerned.

      ', + '
      ', + ].join(""); + } + + $supportBody.innerHTML = [ + '
      ', + '
      šŸ”“
      ', + '

      Support Access is Active

      ', + '

      Sovran Systems can currently connect to your machine via SSH.

      ', + '
      ', + '
      Your IP' + escHtml(ip) + '
      ', + '
      Duration…
      ', + '
      ', + walletSection, + '', + '

      This will remove the SSH key and revoke all wallet access immediately.

      ', + '', + '
      ', + '', + ].join(""); + document.getElementById("btn-support-disable").addEventListener("click", disableSupport); + document.getElementById("btn-support-audit").addEventListener("click", toggleAuditLog); + if (walletProtected && !walletUnlocked) { + document.getElementById("btn-wallet-unlock").addEventListener("click", walletUnlock); + } + if (walletProtected && walletUnlocked) { + document.getElementById("btn-wallet-lock").addEventListener("click", walletLock); + } startSupportTimer(); + if (walletUnlocked && status.wallet_unlocked_until) { + startWalletUnlockTimer(status.wallet_unlocked_until); + } } function renderSupportRemoved(verified) { stopSupportTimer(); + stopWalletUnlockTimer(); var icon = verified ? "āœ…" : "āš ļø"; var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot to ensure it is gone."; var vclass = verified ? "verified-gone" : "verify-warning"; @@ -609,8 +707,9 @@ async function enableSupport() { try { await apiFetch("/api/support/enable", { method: "POST" }); var status = await apiFetch("/api/support/status"); + _supportStatus = status; _supportEnabledAt = status.enabled_at; - renderSupportActive(); + renderSupportActive(status); } catch (err) { if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; } alert("Failed to enable support access. Please try again."); @@ -629,6 +728,63 @@ async function disableSupport() { } } +async function walletUnlock() { + var btn = document.getElementById("btn-wallet-unlock"); + var sel = document.getElementById("wallet-unlock-duration"); + var duration = sel ? parseInt(sel.value, 10) : 3600; + if (btn) { btn.disabled = true; btn.textContent = "Unlocking…"; } + try { + var result = await apiFetch("/api/support/wallet-unlock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ duration: duration }), + }); + var status = await apiFetch("/api/support/status"); + _supportStatus = status; + renderSupportActive(status); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "Grant Wallet Access"; } + alert("Failed to unlock wallet access: " + (err.message || "Unknown error")); + } +} + +async function walletLock() { + var btn = document.getElementById("btn-wallet-lock"); + if (btn) { btn.disabled = true; btn.textContent = "Locking…"; } + try { + await apiFetch("/api/support/wallet-lock", { method: "POST" }); + var status = await apiFetch("/api/support/status"); + _supportStatus = status; + renderSupportActive(status); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "Re-lock Wallet Now"; } + alert("Failed to re-lock wallet: " + (err.message || "Unknown error")); + } +} + +async function toggleAuditLog() { + var container = document.getElementById("support-audit-container"); + if (!container) return; + if (container.style.display !== "none") { + container.style.display = "none"; + return; + } + container.style.display = "block"; + container.innerHTML = '

      Loading audit log…

      '; + try { + var data = await apiFetch("/api/support/audit-log"); + if (!data.entries || data.entries.length === 0) { + container.innerHTML = '

      No audit events recorded yet.

      '; + } else { + container.innerHTML = '
      ' + + data.entries.map(function(e) { return '
      ' + escHtml(e) + '
      '; }).join("") + + '
      '; + } + } catch (err) { + container.innerHTML = '

      Could not load audit log.

      '; + } +} + function startSupportTimer() { stopSupportTimer(); updateSupportTimer(); @@ -646,9 +802,28 @@ function updateSupportTimer() { el.textContent = formatDuration(Math.max(0, elapsed)); } +function startWalletUnlockTimer(expiresAt) { + stopWalletUnlockTimer(); + _walletUnlockTimerInt = setInterval(function() { + if (Date.now() / 1000 >= expiresAt) { + stopWalletUnlockTimer(); + // Refresh the support modal to show re-locked state + apiFetch("/api/support/status").then(function(status) { + _supportStatus = status; + renderSupportActive(status); + }).catch(function() {}); + } + }, 10000); +} + +function stopWalletUnlockTimer() { + if (_walletUnlockTimerInt) { clearInterval(_walletUnlockTimerInt); _walletUnlockTimerInt = null; } +} + function closeSupportModal() { if ($supportModal) $supportModal.classList.remove("open"); stopSupportTimer(); + stopWalletUnlockTimer(); } // ── Update modal ────────────────────────────────────────────────── diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index a4438ec..94fe13c 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -1316,7 +1316,168 @@ button.btn-reboot:hover:not(:disabled) { background-color: #5a5c72; } -/* ── Feature Manager ─────────────────────────────────────────────── */ +/* ── Tech Support — wallet protection ────────────────────────────── */ + +.support-wallet-box { + border-radius: 10px; + border: 1px solid var(--border-color); + padding: 14px 18px; + margin: 0 auto 20px; + max-width: 460px; + text-align: left; +} + +.support-wallet-protected { + border-color: var(--green); + background-color: rgba(30, 150, 96, 0.08); +} + +.support-wallet-unlocked { + border-color: var(--yellow); + background-color: rgba(230, 180, 0, 0.08); +} + +.support-wallet-warning { + border-color: var(--red); + background-color: rgba(220, 38, 38, 0.08); +} + +.support-wallet-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.support-wallet-icon { + font-size: 1.2rem; +} + +.support-wallet-title { + font-size: 0.88rem; + font-weight: 700; + color: var(--text-primary); +} + +.support-wallet-desc { + font-size: 0.82rem; + color: var(--text-secondary); + line-height: 1.55; + margin-bottom: 10px; +} + +.support-wallet-paths { + list-style: none; + padding: 0; + margin: 0 0 12px; +} + +.support-wallet-paths li { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.78rem; + color: var(--text-dim); + padding: 2px 0; +} + +.support-wallet-paths li::before { + content: "šŸ—‚ "; +} + +.support-wallet-unlock-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.support-unlock-select { + background-color: #1c1c2e; + border: 1px solid var(--border-color); + color: var(--text-primary); + border-radius: 6px; + padding: 6px 10px; + font-size: 0.85rem; +} + +.support-btn-wallet-unlock { + background-color: var(--yellow); + color: #111; + padding: 7px 18px; + font-size: 0.85rem; + font-weight: 700; + border-radius: 8px; +} + +.support-btn-wallet-unlock:hover:not(:disabled) { + background-color: #c9a200; +} + +.support-btn-wallet-lock { + background-color: var(--green); + color: #fff; + padding: 7px 18px; + font-size: 0.85rem; + font-weight: 700; + border-radius: 8px; +} + +.support-btn-wallet-lock:hover:not(:disabled) { + background-color: #1a8557; +} + +.support-btn-auditlog { + background-color: transparent; + color: var(--accent-color); + border: 1px solid var(--accent-color); + padding: 6px 18px; + font-size: 0.82rem; + font-weight: 600; + border-radius: 8px; + margin-top: 10px; +} + +.support-btn-auditlog:hover:not(:disabled) { + background-color: rgba(100, 130, 220, 0.12); +} + +/* ── Tech Support — audit log ────────────────────────────────────── */ + +.support-audit-container { + margin: 0 auto; + max-width: 520px; + padding: 0 4px 12px; +} + +.support-audit-log { + background-color: #0d0d1a; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 14px; + max-height: 220px; + overflow-y: auto; +} + +.support-audit-entry { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.76rem; + color: var(--text-secondary); + line-height: 1.7; + border-bottom: 1px solid #1e1e30; + padding: 2px 0; +} + +.support-audit-entry:last-child { + border-bottom: none; +} + +.support-audit-empty { + font-size: 0.82rem; + color: var(--text-dim); + text-align: center; + padding: 12px 0; +} + + .feature-manager-section { margin-bottom: 32px; -- 2.53.0 From 3407612ea92685e0e3ed0d4cb97019ad0abed569 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 01:25:16 +0000 Subject: [PATCH 260/857] Initial plan -- 2.53.0 From 85396e804d580c343fc99f17918d66357db5bad2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 01:31:56 +0000 Subject: [PATCH 261/857] Add NixOS tech-support module and security documentation Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/7e7a94ca-202b-4eb5-aa3a-a36a1365574b Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- docs/tech-support-security.md | 263 ++++++++++++++++++++++++++++++++++ modules/core/tech-support.nix | 42 ++++++ modules/modules.nix | 1 + 3 files changed, 306 insertions(+) create mode 100644 docs/tech-support-security.md create mode 100644 modules/core/tech-support.nix diff --git a/docs/tech-support-security.md b/docs/tech-support-security.md new file mode 100644 index 0000000..fc81309 --- /dev/null +++ b/docs/tech-support-security.md @@ -0,0 +1,263 @@ +# Tech Support: Security Design, User Flow, and Incident Response + +## Overview + +The Sovran Hub includes a **Tech Support** feature that lets Sovran Systems +staff remotely diagnose and fix issues on a user's machine via SSH — without +ever having access to private keys or wallet funds. + +Wallet protection is the default. The user must make an active, time-limited +choice to grant support staff access to wallet files, and can revoke that +access at any time. + +--- + +## Implementation Details + +### Restricted User Instead of Root + +When a user enables support access the Hub: + +1. Ensures the `sovran-support` system user exists (declared declaratively in + `modules/core/tech-support.nix`; the Hub also provisions it on demand as a + fallback on non-NixOS systems). +2. Writes the Sovran Systems public SSH key **only** to + `/var/lib/sovran-support/.ssh/authorized_keys`, not to root's + `authorized_keys`. +3. Applies POSIX ACLs (`setfacl -R -m u:sovran-support:---`) to every wallet + directory that exists on disk, denying all access by the support user. +4. Records a timestamped `SUPPORT_ENABLED` event in the audit log at + `/var/log/sovran-support-audit.log`. + +When the session ends (or if the Hub cannot create the restricted user), the +key is removed and all ACLs are revoked immediately. + +### Protected Wallet Paths + +The following directories are locked by default when a support session starts: + +| Path | Contents | +|------|----------| +| `/var/lib/lnd` | LND wallet and channel database | +| `/root/.lnd` | LND wallet (alternate location) | +| `/var/lib/sparrow` | Sparrow wallet data | +| `/root/.sparrow` | Sparrow wallet (alternate location) | +| `/root/.bisq` | Bisq wallet and keys | +| `/etc/nix-bitcoin-secrets` | nix-bitcoin generated secrets | +| `/var/lib/bitcoind` | Bitcoin Core chainstate and wallet | + +Paths are only locked if they exist on disk at the time the session starts. + +### POSIX ACL Mechanics + +POSIX ACLs on Linux handle access checks in this order: + +1. If the process UID matches the file owner UID → use owner permissions +2. **If there is a matching named-user ACL entry → use that entry's + permissions** (clamped by the mask entry) +3. If any group matches → use group permissions +4. Otherwise → use "other" permissions + +Setting `u:sovran-support:---` creates a named-user ACL entry with no +permissions. Because the named-user entry is checked before the group/other +entries, the support user cannot access those directories regardless of the +"other" permission bits. + +`setfacl` and `getfacl` are provided by the `acl` package, which is added to +`environment.systemPackages` by `modules/core/tech-support.nix`. + +### Fallback to Root (When Restricted User Cannot Be Created) + +If the `sovran-support` user does not exist and cannot be created (e.g., +`users.mutableUsers = false` and the declarative module has not been deployed +yet), the Hub falls back to adding the support key to root's +`authorized_keys`. The modal prominently warns the user when this has happened +so they can decide whether to end the session. + +### Audit Log + +Every session event is appended to `/var/log/sovran-support-audit.log`: + +``` +[2025-01-15 14:32:01 UTC] SUPPORT_ENABLED: restricted_user=True acl_applied=True protected_paths=4 +[2025-01-15 14:45:00 UTC] WALLET_UNLOCKED: duration=3600s expires=2025-01-15 15:45:00 UTC +[2025-01-15 15:45:00 UTC] WALLET_RELOCKED: auto-expired +[2025-01-15 16:01:22 UTC] SUPPORT_DISABLED +``` + +The last 100 lines of this log are accessible from the Hub UI while a session +is active (or after it ends, until the page is refreshed). + +--- + +## Security Tradeoffs + +### What This Protects Against + +- **Accidental wallet exposure** — support staff cannot read wallet files + during a normal session; they must ask the user to explicitly grant access. +- **Credential theft** — private keys in the wallet directories are not + visible to the `sovran-support` user by default. +- **Scope creep** — the restricted user account limits the blast radius of an + SSH session compared to direct root access. + +### Known Limitations + +| Limitation | Mitigation | +|------------|------------| +| Support user still has system-wide bash access | Restrict with `ForceCommand` or AppArmor in the NixOS config if a narrower scope is required | +| ACLs apply only to directories that exist at session start | If new wallet directories are created during a session, they are not auto-protected. Re-lock and re-enable support to pick up new paths | +| Root fallback grants full access | The Hub UI warns the user prominently; users should end the session if they are uncomfortable | +| `setfacl` / ACL filesystem support required | The `acl` package is declared in `tech-support.nix`; most Linux filesystems (ext4, btrfs, xfs) support ACLs by default | +| Wallet access grant is time-limited but lazy-expired | Expiry is checked on the next `/api/support/status` poll (every 10 seconds in the UI); there is a small window after expiry | + +### Defense-in-Depth Recommendations + +For environments that require stronger isolation, consider layering one or +more additional controls: + +- **`ForceCommand`** in `sshd_config` (or `~/.ssh/authorized_keys` command + prefix) to restrict the support user to a specific diagnostic script. +- **`ChrootDirectory`** in the `sshd_config` `Match User sovran-support` block + to confine the session to a prepared directory tree. +- **AppArmor or SELinux** profiles that deny the support process read access + to wallet paths at the kernel level. +- **Namespace/bind-mount overlays** (e.g., via a wrapper systemd unit) to + present a sanitized filesystem view. + +--- + +## User Flow + +``` +User opens Hub → Clicks "Tech Support" in sidebar + │ + ā–¼ +Modal: "Need help from Sovran Systems?" + • Explains what will happen + • Shows Wallet Protection notice + • User clicks "Enable Support Access" + │ + ā–¼ +Hub: 1. Creates / verifies sovran-support user + 2. Writes SSH key to that user's authorized_keys + 3. Applies POSIX ACL deny on all existing wallet paths + 4. Saves session metadata + writes SUPPORT_ENABLED to audit log + │ + ā–¼ +Modal: "Support Access is Active" + • Live session duration timer + • Wallet Files: Protected panel + – Optional: "Grant Wallet Access" (time-limited, user-chosen) + • "End Support Session" button + • "View Audit Log" button + │ + (User grants wallet access) + │ + ā–¼ +Hub: • Removes ACL deny entries + • Records WALLET_UNLOCKED event with expiry time + • Starts countdown timer in UI + │ + (Timer expires or user clicks "Re-lock Wallet Now") + │ + ā–¼ +Hub: • Re-applies ACL deny entries + • Removes WALLET_UNLOCK_FILE + • Records WALLET_RELOCKED event + │ + (User clicks "End Support Session") + │ + ā–¼ +Hub: 1. Removes SSH key from sovran-support authorized_keys + 2. Removes SSH key from root authorized_keys (legacy cleanup) + 3. Revokes any wallet unlock, re-applies ACL deny + 4. Verifies key is gone + 5. Records SUPPORT_DISABLED event + │ + ā–¼ +Modal: "Support Session Ended — SSH key removed" + • Shows verified removal status +``` + +--- + +## Incident Response + +### Scenario 1 — You accidentally granted wallet access and are unsure what was copied + +**Immediate steps:** + +1. Click **"Re-lock Wallet Now"** in the Hub modal, or click + **"End Support Session"** to simultaneously revoke SSH access and wallet + access. +2. Open the **Audit Log** from the Hub modal and note the timestamps of + `WALLET_UNLOCKED` and `WALLET_RELOCKED` events. +3. Check `/var/log/auth.log` (or `journalctl -u sshd`) for SSH login events + by `sovran-support` during the unlocked window. + +**Assessment:** + +- If no SSH login occurred during the wallet-unlocked window, your keys are + safe. +- If an SSH login did occur, treat private keys as potentially compromised. + +**Recovery if keys may be compromised:** + +| Wallet | Recovery action | +|--------|----------------| +| LND | Move all funds out using `lncli sendcoins` to a freshly generated on-chain address; close channels; recreate wallet | +| Sparrow | Sweep funds to a new wallet generated on an air-gapped device | +| Bisq | Withdraw all BSQ and BTC to external wallets; delete the Bisq data directory and recreate | +| nix-bitcoin secrets | Rotate all secrets with `nix-bitcoin-secrets generate` and redeploy | + +**Report the incident:** + +Contact Sovran Systems immediately at support@sovransystems.com with: +- The audit log output (`/var/log/sovran-support-audit.log`) +- The SSH auth log for the affected time window +- A description of what you were troubleshooting + +--- + +### Scenario 2 — Support session cannot be ended (button fails or server is unresponsive) + +**Manual key removal (run as root on the device):** + +```bash +# Remove from support user's authorized_keys +rm -f /var/lib/sovran-support/.ssh/authorized_keys + +# Remove from root's authorized_keys (fallback / legacy) +sed -i '/sovransystemsos-support/d' /root/.ssh/authorized_keys + +# Remove wallet unlock state +rm -f /var/lib/secrets/support-wallet-unlock + +# Re-apply wallet ACL protections +setfacl -R -m u:sovran-support:--- /var/lib/lnd /root/.lnd \ + /var/lib/sparrow /root/.sparrow /root/.bisq \ + /etc/nix-bitcoin-secrets /var/lib/bitcoind 2>/dev/null || true + +# Restart sshd to drop any active connections +systemctl restart sshd +``` + +--- + +### Scenario 3 — You see an unexpected SUPPORT_ENABLED in the audit log + +This should never happen without physical or remote access to the Hub web +interface. If you see an unexpected entry: + +1. Immediately run the manual key removal commands above. +2. Change the Sovran Hub web interface password. +3. Check `/var/log/nginx/access.log` (or Caddy access logs) for unexpected + requests to `/api/support/enable`. +4. Consider rebooting the device to clear any in-memory state. +5. Report the incident to Sovran Systems. + +--- + +*This document is part of the Sovran_SystemsOS repository. For the +authoritative and up-to-date version, see the repository.* diff --git a/modules/core/tech-support.nix b/modules/core/tech-support.nix new file mode 100644 index 0000000..d276e6c --- /dev/null +++ b/modules/core/tech-support.nix @@ -0,0 +1,42 @@ +{ config, lib, pkgs, ... }: + +# ── Tech Support — restricted support user & tooling ───────────────────────── +# +# This module declaratively provisions the `sovran-support` system account that +# the Sovran Hub uses when a user enables remote tech support access. +# +# Security design: +# • Support staff log in as `sovran-support`, not as root. +# • Wallet directories (LND, Sparrow, Bisq, …) are locked with POSIX ACLs +# (u:sovran-support:---) by the Hub API as soon as a session is started. +# • The Hub web UI lets the user grant time-limited access to wallet files +# and view a full audit log of every session event. +# +# The `acl` package provides the `setfacl` / `getfacl` utilities required by +# the Hub's _apply_wallet_acls() and _revoke_wallet_acls() helpers. +{ + # ── System packages ──────────────────────────────────────────────────────── + environment.systemPackages = [ pkgs.acl ]; + + # ── Restricted support user and group ───────────────────────────────────── + users.groups.sovran-support = {}; + + users.users.sovran-support = { + isSystemUser = true; + group = "sovran-support"; + description = "Sovran Systems restricted tech support account"; + home = "/var/lib/sovran-support"; + createHome = false; + # Use a real interactive shell so support staff can run diagnostic commands; + # the Hub API limits *when* they can connect (key present only while active). + shell = pkgs.bashInteractive; + }; + + # ── Home and SSH directories ─────────────────────────────────────────────── + # tmpfiles ensures the directories exist at boot with the correct ownership + # even before the first support session is started. + systemd.tmpfiles.rules = [ + "d /var/lib/sovran-support 0700 sovran-support sovran-support -" + "d /var/lib/sovran-support/.ssh 0700 sovran-support sovran-support -" + ]; +} diff --git a/modules/modules.nix b/modules/modules.nix index a9690dc..16dc0a9 100755 --- a/modules/modules.nix +++ b/modules/modules.nix @@ -8,6 +8,7 @@ ./core/caddy.nix ./core/njalla.nix ./core/ssh-bootstrap.nix + ./core/tech-support.nix ./core/sovran-manage-domains.nix ./core/sovran_systemsos-desktop.nix ./core/sovran-hub.nix -- 2.53.0 From 159238f4f80d963d00b11286b4693ee4765279c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 13:15:17 +0000 Subject: [PATCH 262/857] Initial plan -- 2.53.0 From 2a105edf044bc14b342e906ecfe12bc8a8b8074b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 13:16:22 +0000 Subject: [PATCH 263/857] Update tech support protected paths: remove root/.lnd, sparrow, bisq; add /home Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c1303e8b-ff51-4951-b64c-2162d9e9a805 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 7 ++----- docs/tech-support-security.md | 12 ++++-------- modules/core/tech-support.nix | 2 +- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 0dc3eed..f5b278f 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -80,13 +80,10 @@ WALLET_UNLOCK_DURATION_DEFAULT = 3600 # seconds (1 hour) # Wallet paths protected by default from the support user PROTECTED_WALLET_PATHS: list[str] = [ - "/var/lib/lnd", - "/root/.lnd", - "/var/lib/sparrow", - "/root/.sparrow", - "/root/.bisq", "/etc/nix-bitcoin-secrets", "/var/lib/bitcoind", + "/var/lib/lnd", + "/home", ] CATEGORY_ORDER = [ diff --git a/docs/tech-support-security.md b/docs/tech-support-security.md index fc81309..e0610a5 100644 --- a/docs/tech-support-security.md +++ b/docs/tech-support-security.md @@ -38,13 +38,10 @@ The following directories are locked by default when a support session starts: | Path | Contents | |------|----------| -| `/var/lib/lnd` | LND wallet and channel database | -| `/root/.lnd` | LND wallet (alternate location) | -| `/var/lib/sparrow` | Sparrow wallet data | -| `/root/.sparrow` | Sparrow wallet (alternate location) | -| `/root/.bisq` | Bisq wallet and keys | | `/etc/nix-bitcoin-secrets` | nix-bitcoin generated secrets | | `/var/lib/bitcoind` | Bitcoin Core chainstate and wallet | +| `/var/lib/lnd` | LND wallet and channel database | +| `/home` | User home directories | Paths are only locked if they exist on disk at the time the session starts. @@ -235,9 +232,8 @@ sed -i '/sovransystemsos-support/d' /root/.ssh/authorized_keys rm -f /var/lib/secrets/support-wallet-unlock # Re-apply wallet ACL protections -setfacl -R -m u:sovran-support:--- /var/lib/lnd /root/.lnd \ - /var/lib/sparrow /root/.sparrow /root/.bisq \ - /etc/nix-bitcoin-secrets /var/lib/bitcoind 2>/dev/null || true +setfacl -R -m u:sovran-support:--- /etc/nix-bitcoin-secrets \ + /var/lib/bitcoind /var/lib/lnd /home 2>/dev/null || true # Restart sshd to drop any active connections systemctl restart sshd diff --git a/modules/core/tech-support.nix b/modules/core/tech-support.nix index d276e6c..2a0fd5d 100644 --- a/modules/core/tech-support.nix +++ b/modules/core/tech-support.nix @@ -7,7 +7,7 @@ # # Security design: # • Support staff log in as `sovran-support`, not as root. -# • Wallet directories (LND, Sparrow, Bisq, …) are locked with POSIX ACLs +# • Protected directories (LND, bitcoind, nix-bitcoin-secrets, /home) are locked with POSIX ACLs # (u:sovran-support:---) by the Hub API as soon as a session is started. # • The Hub web UI lets the user grant time-limited access to wallet files # and view a full audit log of every session event. -- 2.53.0 From f49a542ddf5142d040b5c91431bef9a79b7f068e Mon Sep 17 00:00:00 2001 From: Sovran_Systems <99053422+naturallaw777@users.noreply.github.com> Date: Sat, 4 Apr 2026 09:27:06 -0500 Subject: [PATCH 264/857] Update service data model to include requiresDomain and domain status fields. --- path/to/service_model.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 path/to/service_model.js diff --git a/path/to/service_model.js b/path/to/service_model.js new file mode 100644 index 0000000..a1c8c78 --- /dev/null +++ b/path/to/service_model.js @@ -0,0 +1,15 @@ +// Update the service data model to include requiresDomain and domain status fields. + +class Service { + String name; + boolean requiresDomain; + DomainStatus domainStatus; +} + +// Enum to represent domain status health checks for services +enum DomainStatus { + MISSING, + MISCONFIGURED, + CONNECTED, + UNKNOWN +} \ No newline at end of file -- 2.53.0 From cf46424f50dfd0929c151ce2e2e26c36a552ac5a Mon Sep 17 00:00:00 2001 From: Sovran_Systems <99053422+naturallaw777@users.noreply.github.com> Date: Sat, 4 Apr 2026 09:30:27 -0500 Subject: [PATCH 265/857] Delete path/to directory --- path/to/service_model.js | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 path/to/service_model.js diff --git a/path/to/service_model.js b/path/to/service_model.js deleted file mode 100644 index a1c8c78..0000000 --- a/path/to/service_model.js +++ /dev/null @@ -1,15 +0,0 @@ -// Update the service data model to include requiresDomain and domain status fields. - -class Service { - String name; - boolean requiresDomain; - DomainStatus domainStatus; -} - -// Enum to represent domain status health checks for services -enum DomainStatus { - MISSING, - MISCONFIGURED, - CONNECTED, - UNKNOWN -} \ No newline at end of file -- 2.53.0 From 6ee3d0080207acc4c0f655c58bbf7a9b9a7b136f Mon Sep 17 00:00:00 2001 From: Sovran_Systems <99053422+naturallaw777@users.noreply.github.com> Date: Sat, 4 Apr 2026 09:31:19 -0500 Subject: [PATCH 266/857] Update .gitignore --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d702d14..b7757c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ +custom.nix +role-state.nix +*.iso +*.zip +*.pma __pycache__/ *.pyc *.pyo - -- 2.53.0 From 1998fc065239840076e008bd112f996c26cb5f92 Mon Sep 17 00:00:00 2001 From: Sovran_Systems <99053422+naturallaw777@users.noreply.github.com> Date: Sat, 4 Apr 2026 09:31:46 -0500 Subject: [PATCH 267/857] Delete .gitignore.txt --- .gitignore.txt | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 .gitignore.txt diff --git a/.gitignore.txt b/.gitignore.txt deleted file mode 100644 index b7757c0..0000000 --- a/.gitignore.txt +++ /dev/null @@ -1,8 +0,0 @@ -custom.nix -role-state.nix -*.iso -*.zip -*.pma -__pycache__/ -*.pyc -*.pyo -- 2.53.0 From a3c75462c9d1b015d5c66b0ad60e10daa7347575 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:42:09 +0000 Subject: [PATCH 268/857] Initial plan -- 2.53.0 From 8002b180b18687c0b776868d41dd66a529897f26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:49:30 +0000 Subject: [PATCH 269/857] Add domain health status to hub tiles and Feature Manager Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/52147672-b757-4524-971a-9e0dab981354 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 77 +++++++++++ app/sovran_systemsos_web/static/app.js | 149 +++++++++++++++++++++- app/sovran_systemsos_web/static/style.css | 74 ++++++++++- 3 files changed, 294 insertions(+), 6 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index f5b278f..631e3ef 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -231,6 +231,19 @@ SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = { "haven-relay.service": _PORTS_WEB, } +# Maps service unit names to their domain file name in DOMAINS_DIR. +# Only services that require a domain are listed here. +SERVICE_DOMAIN_MAP: dict[str, str] = { + "matrix-synapse.service": "matrix", + "btcpayserver.service": "btcpayserver", + "vaultwarden.service": "vaultwarden", + "phpfpm-nextcloud.service": "nextcloud", + "phpfpm-wordpress.service": "wordpress", + "haven-relay.service": "haven", + "livekit.service": "element-calling", + "caddy.service": "matrix", # Caddy serves the main domain +} + # For features that share a unit, disambiguate by icon field FEATURE_ICON_MAP = { "bip110": "bip110", @@ -1123,6 +1136,18 @@ async def api_services(): port_requirements = SERVICE_PORT_REQUIREMENTS.get(unit, []) + domain_key = SERVICE_DOMAIN_MAP.get(unit) + needs_domain = domain_key is not None + domain: str | None = None + if domain_key: + domain_path = os.path.join(DOMAINS_DIR, domain_key) + try: + with open(domain_path, "r") as f: + val = f.read(512).strip() + domain = val if val else None + except OSError: + domain = None + return { "name": entry.get("name", ""), "unit": unit, @@ -1133,6 +1158,8 @@ async def api_services(): "status": status, "has_credentials": has_credentials, "port_requirements": port_requirements, + "needs_domain": needs_domain, + "domain": domain, } results = await asyncio.gather(*[get_status(s) for s in services]) @@ -1753,6 +1780,56 @@ async def api_domains_status(): return {"domains": domains} +class DomainCheckRequest(BaseModel): + domains: list[str] + + +@app.post("/api/domains/check") +async def api_domains_check(req: DomainCheckRequest): + """Check DNS resolution for each domain and verify it points to this server.""" + loop = asyncio.get_event_loop() + external_ip = await loop.run_in_executor(None, _get_external_ip) + + def check_domain(domain: str) -> dict: + try: + results = socket.getaddrinfo(domain, None) + if not results: + return { + "domain": domain, "status": "unresolvable", + "resolved_ip": None, "expected_ip": external_ip, + } + resolved_ip = results[0][4][0] + if external_ip == "unavailable": + return { + "domain": domain, "status": "error", + "resolved_ip": resolved_ip, "expected_ip": external_ip, + } + if resolved_ip == external_ip: + return { + "domain": domain, "status": "connected", + "resolved_ip": resolved_ip, "expected_ip": external_ip, + } + return { + "domain": domain, "status": "dns_mismatch", + "resolved_ip": resolved_ip, "expected_ip": external_ip, + } + except socket.gaierror: + return { + "domain": domain, "status": "unresolvable", + "resolved_ip": None, "expected_ip": external_ip, + } + except Exception: + return { + "domain": domain, "status": "error", + "resolved_ip": None, "expected_ip": external_ip, + } + + check_results = await asyncio.gather(*[ + loop.run_in_executor(None, check_domain, d) for d in req.domains + ]) + return {"domains": list(check_results)} + + # ── Matrix user management ──────────────────────────────────────── MATRIX_USERS_FILE = "/var/lib/secrets/matrix-users" diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index bf402c5..1ab67e0 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -269,7 +269,16 @@ function buildTile(svc) { portsHtml = '
      šŸ”ŒPorts: ' + ports.length + ' required
      '; } - tile.innerHTML = infoBtn + '' + escHtml(svc.name) + '
      ' + escHtml(svc.name) + '
      ' + st + '
      ' + portsHtml; + // Domain badge — ONLY for services that require a domain + var domainHtml = ""; + if (svc.needs_domain) { + domainHtml = '
      ' + + '🌐' + + '' + (svc.domain ? escHtml(svc.domain) : 'Not set') + '' + + '
      '; + } + + tile.innerHTML = infoBtn + '' + escHtml(svc.name) + '
      ' + escHtml(svc.name) + '
      ' + st + '
      ' + portsHtml + domainHtml; var infoBtnEl = tile.querySelector(".tile-info-btn"); if (infoBtnEl) { @@ -322,6 +331,51 @@ function buildTile(svc) { } } + // Domain badge async check + var domainEl = tile.querySelector(".tile-domain"); + if (domainEl && svc.needs_domain) { + domainEl.style.cursor = "pointer"; + domainEl.addEventListener("click", function(e) { + e.stopPropagation(); + }); + + if (svc.domain) { + fetch("/api/domains/check", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ domains: [svc.domain] }), + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + var d = (data.domains || [])[0]; + var lbl = domainEl.querySelector(".tile-domain-label"); + if (!lbl || !d) return; + lbl.classList.remove("tile-domain-label--checking"); + if (d.status === "connected") { + lbl.className = "tile-domain-label tile-domain-label--ok"; + lbl.textContent = svc.domain + " āœ“"; + } else if (d.status === "dns_mismatch") { + lbl.className = "tile-domain-label tile-domain-label--warn"; + lbl.textContent = svc.domain + " (IP mismatch)"; + } else if (d.status === "unresolvable") { + lbl.className = "tile-domain-label tile-domain-label--error"; + lbl.textContent = svc.domain + " (DNS error)"; + } else { + lbl.className = "tile-domain-label tile-domain-label--warn"; + lbl.textContent = svc.domain + " (unknown)"; + } + }) + .catch(function() {}); + } else { + var lbl = domainEl.querySelector(".tile-domain-label"); + if (lbl) { + lbl.classList.remove("tile-domain-label--checking"); + lbl.className = "tile-domain-label tile-domain-label--warn"; + lbl.textContent = "Domain: Not set"; + } + } + } + return tile; } @@ -1398,11 +1452,94 @@ async function loadFeatureManager() { var data = await apiFetch("/api/features"); _featuresData = data; renderFeatureManager(data); + // After rendering, do a batch domain check for all features that have a configured domain + _checkFeatureManagerDomains(data); } catch (err) { console.warn("Failed to load features:", err); } } +function _checkFeatureManagerDomains(data) { + // Collect all features with a configured domain + var featsWithDomain = (data.features || []).filter(function(f) { + return f.needs_domain && f.domain_configured; + }); + if (!featsWithDomain.length) return; + + // Get the actual domain values from /api/domains/status, then check them + fetch("/api/domains/status") + .then(function(r) { return r.json(); }) + .then(function(statusData) { + var domainFileMap = statusData.domains || {}; + // Build list of domains to check and a map from domain value → feature id + var domainsToCheck = []; + var domainToFeatIds = {}; + featsWithDomain.forEach(function(feat) { + var domainName = feat.domain_name; + var domainVal = domainName ? domainFileMap[domainName] : null; + if (domainVal) { + domainsToCheck.push(domainVal); + if (!domainToFeatIds[domainVal]) domainToFeatIds[domainVal] = []; + domainToFeatIds[domainVal].push(feat.id); + } else { + // Domain file missing — update badge to warn + _updateFeatureDomainBadge(feat.id, null, "unresolvable"); + } + }); + + if (!domainsToCheck.length) return; + + return fetch("/api/domains/check", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ domains: domainsToCheck }), + }) + .then(function(r) { return r.json(); }) + .then(function(checkData) { + (checkData.domains || []).forEach(function(d) { + var featIds = domainToFeatIds[d.domain] || []; + featIds.forEach(function(featId) { + _updateFeatureDomainBadge(featId, d.domain, d.status); + }); + }); + }); + }) + .catch(function() {}); +} + +function _updateFeatureDomainBadge(featId, domainVal, status) { + var section = $sidebarFeatures.querySelector(".feature-manager-section"); + if (!section) return; + // Find the card — cards don't have a data-feat-id, so find via name match + var badges = section.querySelectorAll(".feature-domain-badge.configured"); + badges.forEach(function(badge) { + var domainNameAttr = badge.getAttribute("data-domain-name"); + // Match by domain_name attribute — we need to look up the feat's domain_name + var feat = _featuresData && _featuresData.features + ? _featuresData.features.find(function(f) { return f.id === featId; }) + : null; + if (!feat) return; + if (domainNameAttr !== (feat.domain_name || "")) return; + + var lbl = badge.querySelector(".feature-domain-label"); + if (!lbl) return; + lbl.classList.remove("feature-domain-label--checking"); + if (status === "connected") { + lbl.className = "feature-domain-label feature-domain-label--ok"; + lbl.textContent = (domainVal || "Domain") + " āœ“"; + } else if (status === "dns_mismatch") { + lbl.className = "feature-domain-label feature-domain-label--warn"; + lbl.textContent = (domainVal || "Domain") + " (IP mismatch)"; + } else if (status === "unresolvable") { + lbl.className = "feature-domain-label feature-domain-label--error"; + lbl.textContent = (domainVal || "Domain") + " (DNS error)"; + } else { + lbl.className = "feature-domain-label feature-domain-label--warn"; + lbl.textContent = (domainVal || "Domain") + " (unknown)"; + } + }); +} + function renderFeatureManager(data) { // Remove old feature manager section if it exists var old = $sidebarFeatures.querySelector(".feature-manager-section"); @@ -1467,9 +1604,15 @@ function buildFeatureCard(feat) { var domainHtml = ""; if (feat.needs_domain) { if (feat.domain_configured) { - domainHtml = '
      🌐 Domain: Configured
      '; + domainHtml = '
      ' + + '🌐' + + 'Domain: Checking\u2026' + + '
      '; } else { - domainHtml = '
      🌐 Domain: Not configured
      '; + domainHtml = '
      ' + + '🌐' + + 'Domain: Not configured' + + '
      '; } } diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 94fe13c..e7dee5a 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -1594,21 +1594,48 @@ button.btn-reboot:hover:not(:disabled) { background-color: #fff; } -/* ── Feature domain badge ────────────────────────────────────────── */ +/* ── Feature domain badge (consistent with tile domain badge) ────── */ .feature-domain-badge { font-size: 0.75rem; font-weight: 600; margin-top: 6px; padding: 2px 0; + display: flex; + align-items: center; + gap: 4px; +} + +.feature-domain-icon { + flex-shrink: 0; +} + +.feature-domain-label { + word-break: break-word; } .feature-domain-badge.configured { - color: var(--green); + color: #a6e3a1; } .feature-domain-badge.not-configured { - color: var(--yellow); + color: #f9e2af; +} + +.feature-domain-label--checking { + color: var(--text-dim); +} + +.feature-domain-label--ok { + color: #a6e3a1; +} + +.feature-domain-label--warn { + color: #f9e2af; +} + +.feature-domain-label--error { + color: #f38ba8; } /* ── Feature conflict warning ────────────────────────────────────── */ @@ -1827,6 +1854,47 @@ button.btn-reboot:hover:not(:disabled) { color: var(--text-dim); } +/* ── Tile: Domain Status badge ──────────────────────────────────── */ + +.tile-domain { + margin-top: 6px; + font-size: 0.7rem; + color: var(--text-secondary); + display: flex; + align-items: flex-start; + gap: 4px; + line-height: 1.4; + flex-wrap: wrap; +} + +.tile-domain:hover { + color: var(--accent-color); +} + +.tile-domain-icon { + flex-shrink: 0; +} + +.tile-domain-label { + word-break: break-word; +} + +.tile-domain-label--checking { + color: var(--text-dim); +} + +.tile-domain-label--ok { + color: #a6e3a1; +} + +.tile-domain-label--warn { + color: #f9e2af; +} + +.tile-domain-label--error { + color: #f38ba8; +} + /* ── Sidebar: compact feature card overrides ─────────────────────── */ .sidebar .feature-manager-section { -- 2.53.0 From 13af3fb071755f26a146dd786c72aee3e92cd50c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:20:54 +0000 Subject: [PATCH 270/857] Initial plan -- 2.53.0 From 03dd3eefb5706e4c9fa353cb02ae0506844e9a22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:28:07 +0000 Subject: [PATCH 271/857] Redesign dashboard: simplify tiles to icon/name/status, add service detail modal, new /api/service-detail endpoint, SERVICE_DESCRIPTIONS dict, and updated CSS styles Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4f00183a-525f-4c71-91f8-c96c95ca1025 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 246 ++++++++++++++ app/sovran_systemsos_web/static/app.js | 392 +++++++++++++--------- app/sovran_systemsos_web/static/style.css | 216 ++++++------ 3 files changed, 597 insertions(+), 257 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 631e3ef..dec9bf5 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -256,6 +256,93 @@ ROLE_LABELS = { "node": "Bitcoin Node", } +SERVICE_DESCRIPTIONS: dict[str, str] = { + "bitcoind.service": ( + "The foundation of your financial sovereignty. Your node independently verifies " + "every transaction and block — no banks, no intermediaries, no trust required. " + "Powered by Sovran_SystemsOS, your node is always on and fully synced." + ), + "electrs.service": ( + "Your own Electrum indexing server. Connect any Electrum-compatible wallet " + "directly to your node for maximum privacy — your transactions never touch " + "a third-party server. Sovran_SystemsOS keeps it running and indexed automatically." + ), + "lnd.service": ( + "Your Lightning Network node for instant, low-fee Bitcoin payments. " + "LND powers your Zeus wallet, Ride The Lightning dashboard, and BTCPayServer's " + "Lightning capabilities. With Sovran_SystemsOS, it's always connected and ready." + ), + "rtl.service": ( + "Your personal Lightning Network command center. Open channels, manage liquidity, " + "send payments, and monitor your node — all from a clean browser interface. " + "Sovran_SystemsOS gives you full visibility into your Lightning operations." + ), + "btcpayserver.service": ( + "Your own payment processor — accept Bitcoin and Lightning payments directly, " + "with zero fees to any third party. No Stripe, no Square, no middleman. " + "Sovran_SystemsOS makes running a production-grade payment gateway as simple as flipping a switch." + ), + "zeus-connect-setup.service": ( + "Connect the Zeus mobile wallet to your Lightning node. Send and receive " + "Lightning payments from your phone, backed by your own infrastructure. " + "Scan the QR code and your phone becomes a sovereign wallet." + ), + "mempool.service": ( + "Your own blockchain explorer and mempool visualizer. Monitor transactions, " + "fee estimates, and blocks in real time — verified by your node, not someone else's. " + "Sovran_SystemsOS runs it locally so your queries stay private." + ), + "matrix-synapse.service": ( + "Your own encrypted messaging server. Chat, call, and collaborate using Element " + "or any Matrix client — every message is end-to-end encrypted and stored on hardware you control. " + "No corporate surveillance, no data harvesting. Sovran_SystemsOS makes private communication effortless." + ), + "livekit.service": ( + "Encrypted voice and video calling, integrated directly with your Matrix server. " + "Private video conferences without Zoom, Google Meet, or any third-party cloud. " + "Sovran_SystemsOS handles the infrastructure — you just make the call." + ), + "vaultwarden.service": ( + "Your own password manager, compatible with all Bitwarden apps. Store passwords, " + "credit cards, and secure notes across every device — synced through your server, " + "never a third-party cloud. Sovran_SystemsOS keeps your vault always accessible and always private." + ), + "phpfpm-nextcloud.service": ( + "Your private cloud — file storage, calendar, contacts, and collaboration tools " + "all running on your own hardware. Think Google Drive and Google Docs, but without Google. " + "Sovran_SystemsOS delivers a full productivity suite that you actually own." + ), + "phpfpm-wordpress.service": ( + "Your own publishing platform, powered by the world's most popular CMS. " + "Build websites, blogs, or online stores with full creative control and zero monthly hosting fees. " + "Sovran_SystemsOS hosts it on your infrastructure — your content, your rules." + ), + "haven-relay.service": ( + "Your own Nostr relay for censorship-resistant social networking. Publish and receive notes " + "on the Nostr protocol from infrastructure you control — no platform can silence you. " + "Sovran_SystemsOS keeps your relay online and connected to the network." + ), + "caddy.service": ( + "The automatic HTTPS web server and reverse proxy powering all your services. " + "Caddy handles SSL certificates, domain routing, and secure connections behind the scenes. " + "Sovran_SystemsOS configures it automatically — you never have to touch a config file." + ), + "tor.service": ( + "The onion router, providing .onion addresses for your services. Access your node, " + "wallet, and apps from anywhere in the world — privately and without port forwarding. " + "Sovran_SystemsOS integrates Tor natively across your entire stack." + ), + "gnome-remote-desktop.service": ( + "Access your server's full desktop environment from anywhere using any RDP client. " + "Manage your system visually without being physically present. " + "Sovran_SystemsOS sets up secure remote access with generated credentials — connect and go." + ), + "root-password-setup.service": ( + "Your system account credentials. These are the keys to your Sovran_SystemsOS machine — " + "root access, user accounts, and SSH passphrases. Keep them safe." + ), +} + # ── App setup ──────────────────────────────────────────────────── _BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -1197,6 +1284,165 @@ async def api_credentials(unit: str): } +@app.get("/api/service-detail/{unit}") +async def api_service_detail(unit: str): + """Return comprehensive details for a single service — status, credentials, + port health, domain health, description, and IPs — in one API call.""" + cfg = load_config() + services = cfg.get("services", []) + + # Build reverse map: unit → feature_id + unit_to_feature = { + u: feat_id + for feat_id, u in FEATURE_SERVICE_MAP.items() + if u is not None + } + + loop = asyncio.get_event_loop() + overrides, _ = await loop.run_in_executor(None, _read_hub_overrides) + + # Find the service config entry + entry = next((s for s in services if s.get("unit") == unit), None) + if entry is None: + raise HTTPException(status_code=404, detail="Service not found") + + icon = entry.get("icon", "") + enabled = entry.get("enabled", True) + + feat_id = unit_to_feature.get(unit) + if feat_id is None: + feat_id = FEATURE_ICON_MAP.get(icon) + if feat_id is not None and feat_id in overrides: + enabled = overrides[feat_id] + + # Service status + if enabled: + status = await loop.run_in_executor( + None, lambda: sysctl.is_active(unit, entry.get("type", "system")) + ) + else: + status = "disabled" + + # Credentials + creds_list = entry.get("credentials", []) + has_credentials = len(creds_list) > 0 + resolved_creds: list[dict] = [] + if has_credentials: + for cred in creds_list: + result = await loop.run_in_executor(None, _resolve_credential, cred) + if result: + resolved_creds.append(result) + + # Domain + domain_key = SERVICE_DOMAIN_MAP.get(unit) + needs_domain = domain_key is not None + domain: str | None = None + if domain_key: + domain_path = os.path.join(DOMAINS_DIR, domain_key) + try: + with open(domain_path, "r") as f: + val = f.read(512).strip() + domain = val if val else None + except OSError: + domain = None + + # IPs + internal_ip, external_ip = await asyncio.gather( + loop.run_in_executor(None, _get_internal_ip), + loop.run_in_executor(None, _get_external_ip), + ) + _save_internal_ip(internal_ip) + + # Domain status check + domain_status: dict | None = None + if needs_domain: + if domain: + def _check_one_domain(d: str) -> dict: + try: + results = socket.getaddrinfo(d, None) + if not results: + return { + "status": "unresolvable", + "resolved_ip": None, + "expected_ip": external_ip, + } + resolved_ip = results[0][4][0] + if external_ip == "unavailable": + return { + "status": "error", + "resolved_ip": resolved_ip, + "expected_ip": external_ip, + } + if resolved_ip == external_ip: + return { + "status": "connected", + "resolved_ip": resolved_ip, + "expected_ip": external_ip, + } + return { + "status": "dns_mismatch", + "resolved_ip": resolved_ip, + "expected_ip": external_ip, + } + except socket.gaierror: + return { + "status": "unresolvable", + "resolved_ip": None, + "expected_ip": external_ip, + } + except Exception: + return { + "status": "error", + "resolved_ip": None, + "expected_ip": external_ip, + } + + domain_status = await loop.run_in_executor(None, _check_one_domain, domain) + else: + domain_status = { + "status": "not_set", + "resolved_ip": None, + "expected_ip": external_ip, + } + + # Port requirements and statuses + port_requirements = SERVICE_PORT_REQUIREMENTS.get(unit, []) + port_statuses: list[dict] = [] + if port_requirements: + listening, allowed = await asyncio.gather( + loop.run_in_executor(None, _get_listening_ports), + loop.run_in_executor(None, _get_firewall_allowed_ports), + ) + for p in port_requirements: + port_str = str(p.get("port", "")) + protocol = str(p.get("protocol", "TCP")) + ps = _check_port_status(port_str, protocol, listening, allowed) + port_statuses.append({ + "port": port_str, + "protocol": protocol, + "status": ps, + "description": p.get("description", ""), + }) + + return { + "name": entry.get("name", ""), + "unit": unit, + "icon": icon, + "status": status, + "enabled": enabled, + "description": SERVICE_DESCRIPTIONS.get(unit, ""), + "has_credentials": has_credentials and bool(resolved_creds), + "credentials": resolved_creds, + "needs_domain": needs_domain, + "domain": domain, + "domain_status": domain_status, + "port_requirements": port_requirements, + "port_statuses": port_statuses, + "external_ip": external_ip, + "internal_ip": internal_ip, + } + + @app.get("/api/network") async def api_network(): loop = asyncio.get_event_loop() diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 1ab67e0..5533b7d 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -245,7 +245,6 @@ function buildTile(svc) { var sc = statusClass(svc.status); var st = statusText(svc.status, svc.enabled); var dis = !svc.enabled; - var hasCreds = svc.has_credentials && svc.enabled; var tile = document.createElement("div"); tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : ""); @@ -260,121 +259,12 @@ function buildTile(svc) { return tile; } - var infoBtn = hasCreds ? '' : ""; + tile.innerHTML = '' + escHtml(svc.name) + '
      ' + escHtml(svc.name) + '
      ' + st + '
      '; - // Port requirements badge - var ports = svc.port_requirements || []; - var portsHtml = ""; - if (ports.length > 0) { - portsHtml = '
      šŸ”ŒPorts: ' + ports.length + ' required
      '; - } - - // Domain badge — ONLY for services that require a domain - var domainHtml = ""; - if (svc.needs_domain) { - domainHtml = '
      ' - + '🌐' - + '' + (svc.domain ? escHtml(svc.domain) : 'Not set') + '' - + '
      '; - } - - tile.innerHTML = infoBtn + '' + escHtml(svc.name) + '
      ' + escHtml(svc.name) + '
      ' + st + '
      ' + portsHtml + domainHtml; - - var infoBtnEl = tile.querySelector(".tile-info-btn"); - if (infoBtnEl) { - infoBtnEl.addEventListener("click", function(e) { - e.stopPropagation(); - openCredsModal(svc.unit, svc.name); - }); - } - - var portsEl = tile.querySelector(".tile-ports"); - if (portsEl) { - portsEl.style.cursor = "pointer"; - portsEl.addEventListener("click", function(e) { - e.stopPropagation(); - openPortRequirementsModal(svc.name, ports, null); - }); - - // Async: fetch port status and update badge summary - if (ports.length > 0) { - fetch("/api/ports/status", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ports: ports }), - }) - .then(function(r) { return r.json(); }) - .then(function(data) { - var listeningCount = 0; - (data.ports || []).forEach(function(p) { - if (p.status === "listening") listeningCount++; - }); - var total = ports.length; - var labelEl = portsEl.querySelector(".tile-ports-label"); - if (labelEl) { - labelEl.classList.remove("tile-ports-label--loading"); - if (listeningCount === total) { - labelEl.className = "tile-ports-label tile-ports-all-ready"; - labelEl.textContent = "Ports: " + total + "/" + total + " ready āœ“"; - } else if (listeningCount > 0) { - labelEl.className = "tile-ports-label tile-ports-partial"; - labelEl.textContent = "Ports: " + listeningCount + "/" + total + " ready"; - } else { - labelEl.className = "tile-ports-label tile-ports-none-ready"; - labelEl.textContent = "Ports: " + total + " required"; - } - } - }) - .catch(function() { - // Leave badge as-is on error - }); - } - } - - // Domain badge async check - var domainEl = tile.querySelector(".tile-domain"); - if (domainEl && svc.needs_domain) { - domainEl.style.cursor = "pointer"; - domainEl.addEventListener("click", function(e) { - e.stopPropagation(); - }); - - if (svc.domain) { - fetch("/api/domains/check", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ domains: [svc.domain] }), - }) - .then(function(r) { return r.json(); }) - .then(function(data) { - var d = (data.domains || [])[0]; - var lbl = domainEl.querySelector(".tile-domain-label"); - if (!lbl || !d) return; - lbl.classList.remove("tile-domain-label--checking"); - if (d.status === "connected") { - lbl.className = "tile-domain-label tile-domain-label--ok"; - lbl.textContent = svc.domain + " āœ“"; - } else if (d.status === "dns_mismatch") { - lbl.className = "tile-domain-label tile-domain-label--warn"; - lbl.textContent = svc.domain + " (IP mismatch)"; - } else if (d.status === "unresolvable") { - lbl.className = "tile-domain-label tile-domain-label--error"; - lbl.textContent = svc.domain + " (DNS error)"; - } else { - lbl.className = "tile-domain-label tile-domain-label--warn"; - lbl.textContent = svc.domain + " (unknown)"; - } - }) - .catch(function() {}); - } else { - var lbl = domainEl.querySelector(".tile-domain-label"); - if (lbl) { - lbl.classList.remove("tile-domain-label--checking"); - lbl.className = "tile-domain-label tile-domain-label--warn"; - lbl.textContent = "Domain: Not set"; - } - } - } + tile.style.cursor = "pointer"; + tile.addEventListener("click", function() { + openServiceDetailModal(svc.unit, svc.name); + }); return tile; } @@ -435,6 +325,228 @@ async function checkUpdates() { } catch (_) {} } +// ── Service detail modal ────────────────────────────────────────── + +function _renderCredsHtml(credentials, unit) { + var html = ""; + for (var i = 0; i < credentials.length; i++) { + var cred = credentials[i]; + var id = "cred-" + Math.random().toString(36).substring(2, 8); + var displayValue = linkify(cred.value); + var qrBlock = ""; + if (cred.qrcode) { + qrBlock = '
      QR Code for ' + escHtml(cred.label) + '
      Scan with Zeus app on your phone
      '; + } + html += '
      ' + escHtml(cred.label) + '
      ' + qrBlock + '
      ' + displayValue + '
      '; + } + return html; +} + +function _attachCopyHandlers(container) { + container.querySelectorAll(".creds-copy-btn").forEach(function(btn) { + btn.addEventListener("click", function() { + var target = document.getElementById(btn.dataset.target); + if (!target) return; + var text = target.textContent; + + function onSuccess() { + btn.textContent = "Copied!"; + btn.classList.add("copied"); + setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1500); + } + + function fallbackCopy() { + var ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand("copy"); + onSuccess(); + } catch (e) {} + document.body.removeChild(ta); + } + + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(onSuccess).catch(fallbackCopy); + } else { + fallbackCopy(); + } + }); + }); +} + +async function openServiceDetailModal(unit, name) { + if (!$credsModal) return; + if ($credsTitle) $credsTitle.textContent = name; + if ($credsBody) $credsBody.innerHTML = '

      Loading…

      '; + $credsModal.classList.add("open"); + + try { + var data = await apiFetch("/api/service-detail/" + encodeURIComponent(unit)); + var html = ""; + + // Section A: Description + if (data.description) { + html += '
      ' + + '

      ' + escHtml(data.description) + '

      ' + + '
      '; + } + + // Section B: Status + var sc = statusClass(data.status); + var st = statusText(data.status, data.enabled); + html += '
      ' + + '
      Status
      ' + + '
      ' + + '' + + '' + escHtml(st) + '' + + '
      ' + + '
      '; + + // Section C: Ports (only if service has port_requirements) + if (data.port_statuses && data.port_statuses.length > 0) { + var anyPortClosed = data.port_statuses.some(function(p) { return p.status === "closed"; }); + var portTableRows = ""; + data.port_statuses.forEach(function(p) { + var statusIcon, statusClass2; + if (p.status === "listening") { + statusIcon = "āœ… Open"; + statusClass2 = "port-status-listening"; + } else if (p.status === "firewall_open") { + statusIcon = "🟔 Firewall open"; + statusClass2 = "port-status-open"; + } else if (p.status === "closed") { + statusIcon = "šŸ”“ Closed"; + statusClass2 = "port-status-closed"; + } else { + statusIcon = "— Unknown"; + statusClass2 = "port-status-unknown"; + } + portTableRows += '' + + '' + escHtml(p.port) + '' + + '' + escHtml(p.protocol) + '' + + '' + escHtml(p.description) + '' + + '' + statusIcon + '' + + ''; + }); + + var troubleshootHtml = ""; + if (anyPortClosed) { + troubleshootHtml = '
      ' + + 'āš ļø Some ports are not open yet. Here\'s how to fix it:' + + '
        ' + + '
      1. Log into your router\'s admin panel (usually http://192.168.1.1)
      2. ' + + '
      3. Find the Port Forwarding section
      4. ' + + '
      5. Forward each closed port below to this machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + '
      6. ' + + '
      7. Save your router settings
      8. ' + + '
      ' + + '

      šŸ’” Search "how to set up port forwarding on [your router model]" for step-by-step instructions.

      ' + + '
      '; + } + + html += '
      ' + + '
      Port Status
      ' + + '' + + '' + + '' + + '' + + '' + portTableRows + '' + + '
      PortProtocolDescriptionStatus
      ' + + troubleshootHtml + + '
      '; + } + + // Section D: Domain (only if service needs_domain) + if (data.needs_domain) { + var domainStatusHtml = ""; + var ds = data.domain_status || {}; + var domainBadge = ""; + + if (data.domain) { + if (ds.status === "connected") { + domainBadge = 'āœ“ ' + escHtml(data.domain) + ''; + } else if (ds.status === "dns_mismatch") { + domainBadge = '⚠ ' + escHtml(data.domain) + ' (IP mismatch)'; + domainStatusHtml = '
      ' + + 'āš ļø Your domain resolves to ' + escHtml(ds.resolved_ip || "unknown") + ' but your external IP is ' + escHtml(ds.expected_ip || "unknown") + '.' + + '

      This usually means the DNS record needs to be updated:

      ' + + '
        ' + + '
      1. Go to njal.la and log into your account
      2. ' + + '
      3. Find your domain and check the Dynamic DNS record
      4. ' + + '
      5. Make sure it points to your current external IP: ' + escHtml(ds.expected_ip || "—") + '
      6. ' + + '
      7. If you set up a DDNS curl command during onboarding, verify it\'s running correctly
      8. ' + + '
      ' + + '
      '; + } else if (ds.status === "unresolvable") { + domainBadge = 'āœ— ' + escHtml(data.domain) + ' (DNS error)'; + domainStatusHtml = '
      ' + + 'āš ļø This domain cannot be resolved. DNS is not configured yet.' + + '

      Let\'s get it set up:

      ' + + '
        ' + + '
      1. Go to njal.la and log into your account
      2. ' + + '
      3. Find the domain you purchased for this service
      4. ' + + '
      5. Create a Dynamic DNS record pointing to your external IP: ' + escHtml(ds.expected_ip || "—") + '
      6. ' + + '
      7. Copy the DDNS curl command from Njal.la\'s dashboard
      8. ' + + '
      9. You can re-enter it in the Feature Manager to update your configuration
      10. ' + + '
      ' + + '
      '; + } else { + domainBadge = '' + escHtml(data.domain) + ''; + } + } else { + domainBadge = 'Not configured'; + domainStatusHtml = '
      ' + + 'āš ļø No domain has been configured for this service yet.' + + '

      To get this service working:

      ' + + '
        ' + + '
      1. Purchase a subdomain at njal.la (if you haven\'t already)
      2. ' + + '
      3. Go to the Feature Manager in the sidebar
      4. ' + + '
      5. Find this service and configure your domain through the setup wizard
      6. ' + + '
      ' + + '
      '; + } + + html += '
      ' + + '
      Domain
      ' + + domainBadge + + domainStatusHtml + + '
      '; + } + + // Section E: Credentials & Links + if (data.has_credentials && data.credentials && data.credentials.length > 0) { + html += '
      ' + + '
      Credentials & Access
      ' + + _renderCredsHtml(data.credentials, unit) + + (unit === "matrix-synapse.service" ? + '
      ' + + '' + + '' + + '
      ' : "") + + '
      '; + } else if (!data.enabled) { + html += '
      ' + + '

      This service is not enabled in your configuration. You can enable it from the Feature Manager in the sidebar.

      ' + + '
      '; + } + + $credsBody.innerHTML = html; + _attachCopyHandlers($credsBody); + + if (unit === "matrix-synapse.service") { + var addBtn = document.getElementById("matrix-add-user-btn"); + var changePwBtn = document.getElementById("matrix-change-pw-btn"); + if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name); }); + if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name); }); + } + } catch (err) { + if ($credsBody) $credsBody.innerHTML = '

      Could not load service details.

      '; + } +} + // ── Credentials info modal ──────────────────────────────────────── async function openCredsModal(unit, name) { @@ -448,17 +560,7 @@ async function openCredsModal(unit, name) { $credsBody.innerHTML = '

      No connection info available yet.

      '; return; } - var html = ""; - for (var i = 0; i < data.credentials.length; i++) { - var cred = data.credentials[i]; - var id = "cred-" + Math.random().toString(36).substring(2, 8); - var displayValue = linkify(cred.value); - var qrBlock = ""; - if (cred.qrcode) { - qrBlock = '
      QR Code for ' + escHtml(cred.label) + '
      Scan with Zeus app on your phone
      '; - } - html += '
      ' + escHtml(cred.label) + '
      ' + qrBlock + '
      ' + displayValue + '
      '; - } + var html = _renderCredsHtml(data.credentials, unit); if (unit === "matrix-synapse.service") { html += '
      ' + '' + @@ -466,39 +568,7 @@ async function openCredsModal(unit, name) { '
      '; } $credsBody.innerHTML = html; - $credsBody.querySelectorAll(".creds-copy-btn").forEach(function(btn) { - btn.addEventListener("click", function() { - var target = document.getElementById(btn.dataset.target); - if (!target) return; - var text = target.textContent; - - function onSuccess() { - btn.textContent = "Copied!"; - btn.classList.add("copied"); - setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1500); - } - - function fallbackCopy() { - var ta = document.createElement("textarea"); - ta.value = text; - ta.style.position = "fixed"; - ta.style.left = "-9999px"; - document.body.appendChild(ta); - ta.select(); - try { - document.execCommand("copy"); - onSuccess(); - } catch (e) {} - document.body.removeChild(ta); - } - - if (navigator.clipboard && window.isSecureContext) { - navigator.clipboard.writeText(text).then(onSuccess).catch(fallbackCopy); - } else { - fallbackCopy(); - } - }); - }); + _attachCopyHandlers($credsBody); if (unit === "matrix-synapse.service") { var addBtn = document.getElementById("matrix-add-user-btn"); var changePwBtn = document.getElementById("matrix-change-pw-btn"); @@ -525,7 +595,7 @@ function openMatrixCreateUserModal(unit, name) { '
      '; document.getElementById("matrix-create-back-btn").addEventListener("click", function() { - openCredsModal(unit, name); + openServiceDetailModal(unit, name); }); document.getElementById("matrix-create-submit-btn").addEventListener("click", async function() { @@ -579,7 +649,7 @@ function openMatrixChangePasswordModal(unit, name) { '
      '; document.getElementById("matrix-chpw-back-btn").addEventListener("click", function() { - openCredsModal(unit, name); + openServiceDetailModal(unit, name); }); document.getElementById("matrix-chpw-submit-btn").addEventListener("click", async function() { diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index e7dee5a..f795d0e 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -428,7 +428,7 @@ button:disabled { .service-tile { width: 160px; - min-height: 150px; + min-height: 130px; background-color: var(--card-color); border: 1px solid var(--border-color); border-radius: var(--radius-card); @@ -441,6 +441,7 @@ button:disabled { gap: 0; transition: box-shadow 0.2s, border-color 0.2s; position: relative; + cursor: pointer; } .service-tile:hover { @@ -452,32 +453,6 @@ button:disabled { opacity: 0.45; } -/* Info badge on tiles with credentials */ -.tile-info-btn { - position: absolute; - top: 8px; - right: 8px; - width: 24px; - height: 24px; - border-radius: 50%; - background-color: var(--accent-color); - color: #1e1e2e; - font-size: 0.75rem; - font-weight: 800; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - border: none; - transition: transform 0.15s, background-color 0.15s; - line-height: 1; -} - -.tile-info-btn:hover { - transform: scale(1.15); - background-color: #a8c8ff; -} - .tile-icon { width: 48px; height: 48px; @@ -1723,29 +1698,6 @@ button.btn-reboot:hover:not(:disabled) { /* ── Tile: Port Requirements badge ──────────────────────────────── */ -.tile-ports { - margin-top: 6px; - font-size: 0.7rem; - color: var(--text-secondary); - display: flex; - align-items: flex-start; - gap: 4px; - line-height: 1.4; - flex-wrap: wrap; -} - -.tile-ports:hover { - color: var(--accent-color); -} - -.tile-ports-icon { - flex-shrink: 0; -} - -.tile-ports-label { - word-break: break-word; -} - /* ── Port Requirements Modal ────────────────────────────────────── */ .port-req-intro { @@ -1837,52 +1789,7 @@ button.btn-reboot:hover:not(:disabled) { font-size: 0.95em; } -/* Tile port badge status colours */ -.tile-ports-all-ready { - color: #a6e3a1; -} - -.tile-ports-partial { - color: #f9e2af; -} - -.tile-ports-none-ready { - color: var(--text-secondary); -} - -.tile-ports-label--loading { - color: var(--text-dim); -} - -/* ── Tile: Domain Status badge ──────────────────────────────────── */ - -.tile-domain { - margin-top: 6px; - font-size: 0.7rem; - color: var(--text-secondary); - display: flex; - align-items: flex-start; - gap: 4px; - line-height: 1.4; - flex-wrap: wrap; -} - -.tile-domain:hover { - color: var(--accent-color); -} - -.tile-domain-icon { - flex-shrink: 0; -} - -.tile-domain-label { - word-break: break-word; -} - -.tile-domain-label--checking { - color: var(--text-dim); -} - +/* Domain status colour helpers (used in detail modal) */ .tile-domain-label--ok { color: #a6e3a1; } @@ -1895,6 +1802,123 @@ button.btn-reboot:hover:not(:disabled) { color: #f38ba8; } +/* ── Service detail modal sections ──────────────────────────────── */ + +.svc-detail-section { + margin-bottom: 24px; + padding-bottom: 24px; + border-bottom: 1px solid var(--border-color); +} + +.svc-detail-section:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.svc-detail-desc { + font-size: 0.92rem; + line-height: 1.7; + color: var(--text-secondary); +} + +.svc-detail-section-title { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + margin-bottom: 12px; +} + +.svc-detail-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.92rem; + font-weight: 600; +} + +.svc-detail-troubleshoot { + background-color: rgba(229, 165, 10, 0.08); + border: 1px solid rgba(229, 165, 10, 0.25); + border-radius: 10px; + padding: 16px 20px; + margin-top: 14px; + font-size: 0.85rem; + line-height: 1.7; + color: var(--text-secondary); +} + +.svc-detail-troubleshoot ol { + margin: 10px 0 0 20px; + padding: 0; +} + +.svc-detail-troubleshoot li { + margin-bottom: 4px; +} + +.svc-detail-troubleshoot code { + background-color: #12121c; + padding: 2px 8px; + border-radius: 4px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.85rem; +} + +/* Port status table inside detail modal */ +.svc-detail-port-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.svc-detail-port-table th { + text-align: left; + padding: 6px 10px; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); + border-bottom: 1px solid var(--border-color); +} + +.svc-detail-port-table td { + padding: 8px 10px; + border-bottom: 1px solid rgba(69, 71, 90, 0.3); +} + +.svc-detail-port-table-port { + font-family: 'JetBrains Mono', monospace; + color: var(--accent-color); + white-space: nowrap; +} + +.svc-detail-port-table-proto { + color: var(--text-secondary); + white-space: nowrap; +} + +.svc-detail-port-table-desc { + color: var(--text-primary); +} + +.svc-detail-port-table-status { + white-space: nowrap; + font-weight: 600; +} + +/* Domain status badge in detail modal */ +.svc-detail-domain-value { + font-family: 'JetBrains Mono', monospace; + font-size: 0.9rem; + display: flex; + align-items: center; + gap: 8px; +} + /* ── Sidebar: compact feature card overrides ─────────────────────── */ .sidebar .feature-manager-section { -- 2.53.0 From cf176ea2db144d4c467234d0a17343ea8e2b5075 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:46:48 +0000 Subject: [PATCH 272/857] Initial plan -- 2.53.0 From 7361047b48eeb02f663bc336f95c3d57ddcceca7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:52:44 +0000 Subject: [PATCH 273/857] Add composite health status, smart port language, remove banner, center layout, bigger logo Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/fbd178f9-a25d-4065-b3c1-79eecd3caade Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 55 +++++ app/sovran_systemsos_web/static/app.js | 226 +++++++----------- app/sovran_systemsos_web/static/style.css | 133 +---------- app/sovran_systemsos_web/templates/index.html | 3 - 4 files changed, 147 insertions(+), 270 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index dec9bf5..099781c 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1198,6 +1198,12 @@ async def api_services(): # Read runtime feature overrides from custom.nix Hub Managed section overrides, _ = await loop.run_in_executor(None, _read_hub_overrides) + # Cache port/firewall data once for the entire /api/services request + listening_ports, firewall_ports = await asyncio.gather( + loop.run_in_executor(None, _get_listening_ports), + loop.run_in_executor(None, _get_firewall_allowed_ports), + ) + async def get_status(entry): unit = entry.get("unit", "") scope = entry.get("type", "system") @@ -1235,6 +1241,34 @@ async def api_services(): except OSError: domain = None + # Compute composite health + if not enabled: + health = "disabled" + elif status == "active": + has_port_issues = False + if port_requirements: + for p in port_requirements: + ps = _check_port_status( + str(p.get("port", "")), + str(p.get("protocol", "TCP")), + listening_ports, + firewall_ports, + ) + if ps == "closed": + has_port_issues = True + break + has_domain_issues = False + if needs_domain: + if not domain: + has_domain_issues = True + health = "needs_attention" if (has_port_issues or has_domain_issues) else "healthy" + elif status == "inactive": + health = "inactive" + elif status == "failed": + health = "failed" + else: + health = status # loading states, etc. + return { "name": entry.get("name", ""), "unit": unit, @@ -1243,6 +1277,7 @@ async def api_services(): "enabled": enabled, "category": entry.get("category", "other"), "status": status, + "health": health, "has_credentials": has_credentials, "port_requirements": port_requirements, "needs_domain": needs_domain, @@ -1424,11 +1459,31 @@ async def api_service_detail(unit: str): "description": p.get("description", ""), }) + # Compute composite health + if not enabled: + health = "disabled" + elif status == "active": + has_port_issues = any(p["status"] == "closed" for p in port_statuses) + has_domain_issues = False + if needs_domain: + if not domain: + has_domain_issues = True + elif domain_status and domain_status.get("status") not in ("connected", None): + has_domain_issues = True + health = "needs_attention" if (has_port_issues or has_domain_issues) else "healthy" + elif status == "inactive": + health = "inactive" + elif status == "failed": + health = "failed" + else: + health = status # loading states, etc. + return { "name": entry.get("name", ""), "unit": unit, "icon": icon, "status": status, + "health": health, "enabled": enabled, "description": SERVICE_DESCRIPTIONS.get(unit, ""), "has_credentials": has_credentials and bool(resolved_creds), diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 5533b7d..bef1aa3 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -4,12 +4,9 @@ const POLL_INTERVAL_SERVICES = 5000; const POLL_INTERVAL_UPDATES = 1800000; -const POLL_INTERVAL_PORT_HEALTH = 15000; const UPDATE_POLL_INTERVAL = 2000; const REBOOT_CHECK_INTERVAL = 5000; const SUPPORT_TIMER_INTERVAL = 1000; -const BANNER_AUTO_FADE_DELAY = 5000; -const BANNER_FADE_TRANSITION_MS = 550; const CATEGORY_ORDER = [ "infrastructure", @@ -124,26 +121,34 @@ const $portReqBody = document.getElementById("port-req-body"); const $portReqClose = document.getElementById("port-req-close-btn"); // System status banner -const $statusBanner = document.getElementById("system-status-banner"); +// (removed — health is now shown per-tile via the composite health field) // ── Helpers ─────────────────────────────────────────────────────── function tileId(svc) { return svc.unit + "::" + svc.name; } -function statusClass(status) { - if (!status) return "unknown"; - if (status === "active") return "active"; - if (status === "inactive") return "inactive"; - if (status === "failed") return "failed"; - if (status === "disabled") return "disabled"; - if (STATUS_LOADING_STATES.has(status)) return "loading"; +function statusClass(health) { + if (!health) return "unknown"; + if (health === "healthy") return "active"; + if (health === "needs_attention") return "needs-attention"; + if (health === "active") return "active"; // backwards compat + if (health === "inactive") return "inactive"; + if (health === "failed") return "failed"; + if (health === "disabled") return "disabled"; + if (STATUS_LOADING_STATES.has(health)) return "loading"; return "unknown"; } -function statusText(status, enabled) { - if (!enabled) return "disabled"; - if (!status || status === "unknown") return "unknown"; - return status; +function statusText(health, enabled) { + if (!enabled) return "Disabled"; + if (health === "healthy") return "Active"; + if (health === "needs_attention") return "Needs Attention"; + if (health === "active") return "Active"; + if (health === "inactive") return "Inactive"; + if (health === "failed") return "Failed"; + if (!health || health === "unknown") return "Unknown"; + if (STATUS_LOADING_STATES.has(health)) return health; + return health; } function escHtml(str) { @@ -242,8 +247,8 @@ function renderSidebarSupport(supportServices) { function buildTile(svc) { var isSupport = svc.type === "support"; - var sc = statusClass(svc.status); - var st = statusText(svc.status, svc.enabled); + var sc = statusClass(svc.health || svc.status); + var st = statusText(svc.health || svc.status, svc.enabled); var dis = !svc.enabled; var tile = document.createElement("div"); @@ -279,8 +284,8 @@ function updateTiles(services) { var id = CSS.escape(tileId(svc)); var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]'); if (!tile) continue; - var sc = statusClass(svc.status); - var st = statusText(svc.status, svc.enabled); + var sc = statusClass(svc.health || svc.status); + var st = statusText(svc.health || svc.status, svc.enabled); var dot = tile.querySelector(".status-dot"); var text = tile.querySelector(".status-text"); if (dot) dot.className = "status-dot " + sc; @@ -396,8 +401,8 @@ async function openServiceDetailModal(unit, name) { } // Section B: Status - var sc = statusClass(data.status); - var st = statusText(data.status, data.enabled); + var sc = statusClass(data.health || data.status); + var st = statusText(data.health || data.status, data.enabled); html += '
      ' + '
      Status
      ' + '
      ' + @@ -425,26 +430,69 @@ async function openServiceDetailModal(unit, name) { statusIcon = "— Unknown"; statusClass2 = "port-status-unknown"; } + var desc = p.description; + var portNum = parseInt(p.port, 10); + if (portNum === 80 || portNum === 443) { + desc += " (shared — all services)"; + } portTableRows += '' + '' + escHtml(p.port) + '' + '' + escHtml(p.protocol) + '' + - '' + escHtml(p.description) + '' + + '' + escHtml(desc) + '' + '' + statusIcon + '' + ''; }); var troubleshootHtml = ""; if (anyPortClosed) { - troubleshootHtml = '
      ' + - 'āš ļø Some ports are not open yet. Here\'s how to fix it:' + - '
        ' + - '
      1. Log into your router\'s admin panel (usually http://192.168.1.1)
      2. ' + - '
      3. Find the Port Forwarding section
      4. ' + - '
      5. Forward each closed port below to this machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + '
      6. ' + - '
      7. Save your router settings
      8. ' + - '
      ' + - '

      šŸ’” Search "how to set up port forwarding on [your router model]" for step-by-step instructions.

      ' + - '
      '; + var sharedPorts = []; + var specificPorts = []; + data.port_statuses.forEach(function(p) { + if (p.status === "closed") { + var portNum = parseInt(p.port, 10); + if (portNum === 80 || portNum === 443) { + sharedPorts.push(p); + } else { + specificPorts.push(p); + } + } + }); + + var troubleParts = []; + + if (sharedPorts.length > 0) { + troubleParts.push( + 'āš ļø Ports 80 and 443 need to be forwarded on your router.' + + '

      These are shared system ports — you only need to set them up once and they cover all your domain-based services ' + + '(BTCPayServer, Nextcloud, Matrix, WordPress, etc.).

      ' + + '

      If you already forwarded these ports during onboarding, you don\'t need to do it again. Otherwise:

      ' + + '
        ' + + '
      1. Log into your router\'s admin panel (usually http://192.168.1.1)
      2. ' + + '
      3. Find the Port Forwarding section
      4. ' + + '
      5. Forward port 80 (TCP) and port 443 (TCP) to your machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + '
      6. ' + + '
      7. Save your router settings
      8. ' + + '
      ' + + '

      šŸ’” Once these two ports are forwarded, you won\'t see this warning on any service again.

      ' + ); + } + + if (specificPorts.length > 0) { + var portList = specificPorts.map(function(p) { + return '' + escHtml(p.port) + ' (' + escHtml(p.protocol) + ') — ' + escHtml(p.description); + }).join('
      '); + + troubleParts.push( + 'āš ļø This service requires additional ports to be forwarded:' + + '

      ' + portList + '

      ' + + '
        ' + + '
      1. Log into your router\'s admin panel
      2. ' + + '
      3. Forward each port listed above to your machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + '
      4. ' + + '
      5. Save your router settings
      6. ' + + '
      ' + ); + } + + troubleshootHtml = '
      ' + troubleParts.join('
      ') + '
      '; } html += '
      ' + @@ -1750,116 +1798,6 @@ if ($modal) $modal.addEventListener("click", function(e) { if (e.target === $mod if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); }); if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); }); -// ── Port health banner ──────────────────────────────────────────── - -var _bannerFadeTimer = null; -var _bannerDetailsOpen = false; - -async function loadPortHealth() { - if (!$statusBanner) return; - try { - var data = await apiFetch("/api/ports/health"); - _renderPortHealthBanner(data); - } catch (_) { - // Silently ignore — banner stays hidden on error - } -} - -function _renderPortHealthBanner(data) { - if (!$statusBanner) return; - - // Clear any pending fade-out timer - if (_bannerFadeTimer) { - clearTimeout(_bannerFadeTimer); - _bannerFadeTimer = null; - } - - var status = data.status || "ok"; - var totalPorts = data.total_ports || 0; - var closedPorts = data.closed_ports || 0; - var affectedSvcs = data.affected_services || []; - - // No port requirements — hide banner - if (totalPorts === 0) { - $statusBanner.style.display = "none"; - $statusBanner.className = "status-banner"; - return; - } - - // Build expandable details for warn/critical states - function buildDetailsHtml(svcs) { - if (!svcs.length) return ""; - var rows = svcs.map(function(svc) { - var portList = (svc.closed_ports || []).map(function(p) { - return 'šŸ”“ ' + escHtml(p.port) + '/' + escHtml(p.protocol) + '' - + (p.description ? ' — ' + escHtml(p.description) + '' : ''); - }).join(", "); - return '' + escHtml(svc.name) + '' + portList + ''; - }).join(""); - return '' - + '' - + '' + rows + '' - + '
      ServiceClosed Ports
      '; - } - - var html = ""; - $statusBanner.className = "status-banner"; - - if (status === "ok") { - // Switching from warn/critical to ok: reset details-open state - _bannerDetailsOpen = false; - $statusBanner.classList.add("status-banner--ok"); - html = "āœ… All Systems Operational — All ports open for all enabled services"; - $statusBanner.style.display = "block"; - $statusBanner.style.opacity = "1"; - $statusBanner.innerHTML = html; - // Auto-fade after BANNER_AUTO_FADE_DELAY - _bannerFadeTimer = setTimeout(function() { - $statusBanner.classList.add("status-banner--fade-out"); - _bannerFadeTimer = setTimeout(function() { - $statusBanner.style.display = "none"; - }, BANNER_FADE_TRANSITION_MS); - }, BANNER_AUTO_FADE_DELAY); - return; - } - - if (status === "partial") { - $statusBanner.classList.add("status-banner--warn"); - html = "āš ļø Some Services May Be Affected — " + closedPorts + " of " + totalPorts + " ports closed"; - } else { - // critical - $statusBanner.classList.add("status-banner--critical"); - html = "⚠ Some ports are closed — certain services may be affected"; - } - - var detailsId = "status-banner-detail-body"; - var toggleId = "status-banner-toggle"; - var detailsHtml = buildDetailsHtml(affectedSvcs); - - html += ' ' - + '
      ' - + detailsHtml - + '
      '; - - $statusBanner.style.display = "block"; - $statusBanner.style.opacity = "1"; - $statusBanner.innerHTML = html; - - var toggleBtn = document.getElementById(toggleId); - var detailsBody = document.getElementById(detailsId); - - if (toggleBtn && detailsBody) { - toggleBtn.addEventListener("click", function() { - _bannerDetailsOpen = !_bannerDetailsOpen; - detailsBody.style.display = _bannerDetailsOpen ? "block" : "none"; - toggleBtn.textContent = _bannerDetailsOpen ? "Hide Details ā–²" : "View Details ā–¼"; - }); - } -} - // ── Init ────────────────────────────────────────────────────────── async function init() { @@ -1887,11 +1825,9 @@ async function init() { await refreshServices(); loadNetwork(); checkUpdates(); - loadPortHealth(); setInterval(refreshServices, POLL_INTERVAL_SERVICES); setInterval(checkUpdates, POLL_INTERVAL_UPDATES); - setInterval(loadPortHealth, POLL_INTERVAL_PORT_HEALTH); if (cfg.feature_manager) { loadFeatureManager(); @@ -1900,10 +1836,8 @@ async function init() { await refreshServices(); loadNetwork(); checkUpdates(); - loadPortHealth(); setInterval(refreshServices, POLL_INTERVAL_SERVICES); setInterval(checkUpdates, POLL_INTERVAL_UPDATES); - setInterval(loadPortHealth, POLL_INTERVAL_PORT_HEALTH); } } diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index f795d0e..b7c0ee4 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -64,10 +64,10 @@ body { } .header-logo { - height: 30px; + height: 46px; width: auto; vertical-align: middle; - margin-right: 8px; + margin-right: 10px; } .role-badge { @@ -196,120 +196,6 @@ button:disabled { color: var(--border-color); } -/* ── System status banner ────────────────────────────────────────── */ - -.status-banner { - padding: 10px 24px; - font-size: 0.85rem; - font-weight: 600; - text-align: center; - transition: opacity 0.5s ease, max-height 0.3s ease; - overflow: hidden; -} - -.status-banner--ok { - background-color: rgba(46, 194, 126, 0.15); - border-bottom: 1px solid var(--green); - color: var(--green); -} - -.status-banner--warn { - background-color: rgba(229, 165, 10, 0.08); - border-bottom: 1px solid rgba(229, 165, 10, 0.4); - color: var(--text-secondary); -} - -.status-banner--critical { - background-color: rgba(224, 27, 36, 0.08); - border-bottom: 1px solid rgba(224, 27, 36, 0.4); - color: var(--text-secondary); -} - -.status-banner--fade-out { - opacity: 0; - pointer-events: none; -} - -.status-banner-details { - margin-top: 8px; - text-align: left; - max-width: 720px; - margin-left: auto; - margin-right: auto; -} - -.status-banner-toggle { - background: none; - border: none; - font: inherit; - color: inherit; - font-size: 0.82rem; - font-weight: 600; - cursor: pointer; - text-decoration: underline; - padding: 0; - opacity: 0.8; -} - -.status-banner-toggle:hover { - opacity: 1; -} - -.status-banner-table { - width: 100%; - border-collapse: collapse; - margin-top: 8px; - font-size: 0.8rem; - color: var(--text-secondary); -} - -.status-banner-table th { - text-align: left; - padding: 4px 8px; - opacity: 0.7; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - font-size: 0.72rem; - border-bottom: 1px solid var(--border-color); -} - -.status-banner-table td { - padding: 4px 8px; - vertical-align: top; -} - -.status-banner-table td:first-child { - font-weight: 600; - white-space: nowrap; -} - -.status-banner-port { - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.78rem; - font-weight: 600; -} - -@media (max-width: 768px) { - .status-banner { - padding: 10px 16px; - } - .status-banner-details { - max-width: 100%; - } -} - -@media (max-width: 600px) { - .status-banner { - padding: 10px 14px; - font-size: 0.82rem; - } - .status-banner-table th, - .status-banner-table td { - padding: 4px 4px; - } -} - /* ── Main content ───────────────────────────────────────────────── */ .main-content { @@ -317,6 +203,10 @@ button:disabled { align-items: flex-start; flex: 1; overflow: hidden; + max-width: 1400px; + width: 100%; + margin-left: auto; + margin-right: auto; } /* ── Sidebar ────────────────────────────────────────────────────── */ @@ -505,11 +395,12 @@ button:disabled { background-color: var(--grey); } -.status-dot.active { background-color: var(--green); } -.status-dot.inactive { background-color: var(--red); } -.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; } -.status-dot.failed { background-color: var(--red); } -.status-dot.disabled { background-color: var(--grey); } +.status-dot.active { background-color: var(--green); } +.status-dot.inactive { background-color: var(--red); } +.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; } +.status-dot.failed { background-color: var(--red); } +.status-dot.disabled { background-color: var(--grey); } +.status-dot.needs-attention { background-color: var(--yellow); } /* ── Update modal ─────────────────────────────────���─────────────── */ diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index c3fca03..69fded9 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -35,9 +35,6 @@
      - - -
      ' : "") + '
      '; - } else if (!data.enabled) { + } else if (!data.enabled && !data.feature) { html += '
      ' + - '

      This service is not enabled in your configuration. You can enable it from the Feature Manager in the sidebar.

      ' + + '

      This service is not enabled in your configuration.

      ' + + '
      '; + } + + // Section F: Addon Feature toggle + if (data.feature) { + var feat = data.feature; + // Sync this feature into _featuresData so handleFeatureToggle can look up conflicts / ssl state + if (!_featuresData) { + _featuresData = { features: [feat], ssl_email_configured: false }; + } else { + var fidx = _featuresData.features.findIndex(function(f) { return f.id === feat.id; }); + if (fidx >= 0) { _featuresData.features[fidx] = feat; } + else { _featuresData.features.push(feat); } + } + var addonStatusLabel = feat.enabled ? "Enabled \u2713" : "Disabled"; + var addonStatusCls = feat.enabled ? "addon-status--on" : "addon-status--off"; + var addonBtnLabel = feat.enabled ? "Disable Feature" : "Enable Feature"; + var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary"; + html += '
      ' + + '
      \uD83D\uDD27 Addon Feature
      ' + + '

      This is an optional addon feature. You can enable or disable it at any time.

      ' + + '
      ' + + '' + addonStatusLabel + '' + + '' + + '
      ' + '
      '; } @@ -590,6 +615,17 @@ async function openServiceDetailModal(unit, name) { if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name); }); if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name); }); } + + if (data.feature) { + var addonBtn = document.getElementById("svc-detail-addon-btn"); + if (addonBtn) { + var addonFeat = data.feature; + addonBtn.addEventListener("click", function() { + closeCredsModal(); + handleFeatureToggle(addonFeat, !addonFeat.enabled); + }); + } + } } catch (err) { if ($credsBody) $credsBody.innerHTML = '

      Could not load service details.

      '; } @@ -1257,7 +1293,7 @@ function closeSslEmailModal() { function openDomainSetupModal(feat, onSaved) { if (!$domainSetupModal) return; - if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name; + if ($domainSetupTitle) $domainSetupTitle.textContent = "\uD83C\uDF10 Domain Setup \u2014 " + feat.name; var npubField = ""; if (feat.id === "haven") { @@ -1273,10 +1309,23 @@ function openDomainSetupModal(feat, onSaved) { npubField = '
      '; } + var externalIp = _cachedExternalIp || "your external IP"; + $domainSetupBody.innerHTML = - '

      Before continuing, you need:

      1. A subdomain purchased on njal.la
      2. A Dynamic DNS record for it
      ' + - '
      ' + - '

      ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record

      ' + + '
      ' + + '

      Before continuing:

      ' + + '
        ' + + '
      1. Create an account at https://njal.la
      2. ' + + '
      3. Purchase your domain on Njal.la
      4. ' + + '
      5. In the Njal.la web interface, create a Dynamic record pointing to this machine\'s external IP address:
        ' + + '' + escHtml(externalIp) + '
      6. ' + + '
      7. Njal.la will give you a curl command like:
        ' + + 'curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"
      8. ' + + '
      9. Enter the subdomain and paste that curl command below
      10. ' + + '
      ' + + '
      ' + + '
      ' + + '

      \u2139 Paste the curl URL from your Njal.la dashboard\'s Dynamic record

      ' + npubField + '
      '; @@ -1569,9 +1618,7 @@ async function loadFeatureManager() { try { var data = await apiFetch("/api/features"); _featuresData = data; - renderFeatureManager(data); - // After rendering, do a batch domain check for all features that have a configured domain - _checkFeatureManagerDomains(data); + // Feature Manager is now integrated into tile modals; sidebar rendering removed. } catch (err) { console.warn("Failed to load features:", err); } diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index b7c0ee4..3ebe747 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -54,13 +54,19 @@ body { position: sticky; top: 0; z-index: 100; + justify-content: flex-end; } .header-bar .title { font-size: 1.15rem; font-weight: 700; color: var(--text-primary); - flex: 1; + position: absolute; + left: 0; + right: 0; + text-align: center; + pointer-events: none; + white-space: nowrap; } .header-logo { @@ -1530,6 +1536,19 @@ button.btn-reboot:hover:not(:disabled) { margin-top: 6px; } +.domain-setup-intro li { + margin-bottom: 6px; +} + +.domain-setup-intro code { + background-color: #12121c; + padding: 2px 8px; + border-radius: 4px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.82em; + word-break: break-all; +} + .domain-field-group { margin-bottom: 14px; } @@ -1810,6 +1829,28 @@ button.btn-reboot:hover:not(:disabled) { gap: 8px; } +/* Addon feature toggle row in service detail modal */ +.svc-detail-addon-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 10px; +} + +.svc-detail-addon-status { + font-size: 0.88rem; + font-weight: 600; +} + +.addon-status--on { + color: var(--green); +} + +.addon-status--off { + color: var(--text-dim); +} + /* ── Sidebar: compact feature card overrides ─────────────────────── */ .sidebar .feature-manager-section { diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index a981ed1..ea00a34 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -51,7 +51,7 @@ let ]; } { name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ { label = "Connection URL"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; } - { label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus �� Scan Node Config\n3. Scan the QR code above or paste the Connection URL"; } + { label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus → Scan Node Config\n3. Scan the QR code above or paste the Connection URL"; } ]; } { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [ { label = "Tor Access"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; } -- 2.53.0 From 3a87297b4173f6bb9110aabe36c168fffa1ab544 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:28:23 +0000 Subject: [PATCH 275/857] Polish: clean up Unicode escapes and fix DDNS label wording Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/faca798f-6820-4db6-adc9-d5a5c9ac1ba1 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/static/app.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 77d70f7..8e10f57 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -597,7 +597,7 @@ async function openServiceDetailModal(unit, name) { var addonBtnLabel = feat.enabled ? "Disable Feature" : "Enable Feature"; var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary"; html += '
      ' + - '
      \uD83D\uDD27 Addon Feature
      ' + + '
      šŸ”§ Addon Feature
      ' + '

      This is an optional addon feature. You can enable or disable it at any time.

      ' + '
      ' + '' + addonStatusLabel + '' + @@ -1293,7 +1293,7 @@ function closeSslEmailModal() { function openDomainSetupModal(feat, onSaved) { if (!$domainSetupModal) return; - if ($domainSetupTitle) $domainSetupTitle.textContent = "\uD83C\uDF10 Domain Setup \u2014 " + feat.name; + if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name; var npubField = ""; if (feat.id === "haven") { @@ -1325,7 +1325,7 @@ function openDomainSetupModal(feat, onSaved) { '

      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 diff --git a/staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.rev b/staging_alpha.git/objects/pack/pack-c9ab175d7121e5aa8c885ea4a95f502e6a8f14e3.rev deleted file mode 100644 index 0ac575462fa74bef4f986d4d92da42876ec98ad2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1368 zcmWIYbctYKU|@t|cLoNAMGOoKuNW8@Oc@v$)fpHVSs54@b}%q7+A}aP@-Q$kurM$% zN-!`mY-eC#uxDUk_`$%ysK&s+=*+;tsLH^=P|U!pj!3`ZCk7$O-M7(n6jl!1X!nSp`fH3I_!F9QQZ2Q+NgF)%QI{2ju;z;KCy zfkB9Yfx(x7fgy^4f#EO%1EU)Q0|Ore14A1F0|OTW10yK>LE+HJz`!8Jz`(GLfq|iw zfq{XOfq`Ka0|P?^0|Ub~1_nka1_lO@-v0~?j3ECdGcYi4GcYiUGB7a6GB7acFfcH5 zF)%QK>^sE3z{thGz);D+z^KQ-!0?rUfnf~;1H(ZE28L-23=9Vt7#JoqFffAjZDC+w zc*wxOaFv09;VlCL!!ZU1Mn?t)h5`l#Mo{>dFfcH(GcYiK><8KPgn@x!0s{lXY6b>| zXABGsjSLJ7vlti{E-)}KoMK>L2xDMikYiwASi!)+aEpO~!G(c=VLAf?Lklqjr@);Ny{1_M*5*Zj6J~1#b7%?y~g7gM3 zFfe2@FfcSQFfhb0Ffhb3Ffe#CFfi zpzw-gU|{&bz`&5sz`)?oz`*Fiz`!WLz`)SMz`!t-fq`KI0|Nsn|L8I>F#KjFfgbyFfjNqFfi<7U|>{XU|^WSz`)SWz`$^afq|i(fq_Aqfq?-O zpMMw_82K0&7_}G}7|IzK7y=m>7(^Ht7$q4P7(r>)hJk_6gn@yfih+Sqih+Tl1(Nj{ zni&`vVi_11tQi;>xfvK3JQx@lKzSp9fq|ivfq`KU0|TQx0|O%m0|O%y0|SFD0|O(- zPLMsf85kIf7#J9h85kHr@v)eJff1DE9xyO4^fNFp*fKCMfc$%sfq`K+0|SEt0|O(d z3}I$qV7S7-z~ITizyK;2E;BGN^f53nTxVcl_{YG&2+GqSw=Q5{VA#aKz;J_sfdLfe zXBik6oEaDxKzZy10|Uc31_lODxs$}ezz9kg6B!s7MHmJq%kls+MZl39$TpRbX8AB+>({?0eV^eB9AAseO6@Dm|pb5y7ahRc*o_C2@H|| DZoZur diff --git a/staging_alpha.git/packed-refs b/staging_alpha.git/packed-refs deleted file mode 100644 index c9bed10..0000000 --- a/staging_alpha.git/packed-refs +++ /dev/null @@ -1,7 +0,0 @@ -# pack-refs with: peeled fully-peeled sorted -3dda4b8f186aed482ec3a358512d59dae627dcdc refs/heads/main -0acad658541753f618fba793698481c3724fa8d3 refs/pull/1/head -181ecfc9214ba43e3365f88416ca7468cd7838de refs/pull/2/head -12d3128b8970de89352947da4ed951584832c534 refs/pull/3/head -fe8d30b1edc3008bbcd3e4278c24c06d32654c29 refs/pull/4/head -8b028fae510b5ec3d276a439416e08674f37f444 refs/pull/5/head -- 2.53.0 From af1ad09e2edf2897d3b490ab0a28666751fac7d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:50:57 +0000 Subject: [PATCH 144/857] Initial plan -- 2.53.0 From 42900608f6e923b45c26bc51ce219eda8b7d5f90 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:01:42 +0000 Subject: [PATCH 145/857] Replace GTK4 desktop app with FastAPI web app (Sovran_SystemsOS Hub) Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5c173acb-776f-4cd2-bc89-bb7675e38677 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_hub/__init__.py | 40 - app/sovran_systemsos_hub/application.py | 727 ------------------ app/sovran_systemsos_hub/service_row.py | 104 --- app/sovran_systemsos_hub/service_tile.py | 172 ----- app/sovran_systemsos_web/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 179 bytes .../__pycache__/config.cpython-312.pyc | Bin 0 -> 1399 bytes .../__pycache__/server.cpython-312.pyc | Bin 0 -> 15868 bytes .../__pycache__/systemctl.cpython-312.pyc | Bin 0 -> 1976 bytes .../config.py | 2 +- app/sovran_systemsos_web/server.py | 329 ++++++++ app/sovran_systemsos_web/static/app.js | 408 ++++++++++ app/sovran_systemsos_web/static/style.css | 530 +++++++++++++ .../systemctl.py | 6 +- app/sovran_systemsos_web/templates/index.html | 59 ++ app/style.css | 69 -- modules/core/sovran-hub.nix | 105 +-- 17 files changed, 1368 insertions(+), 1183 deletions(-) delete mode 100644 app/sovran_systemsos_hub/__init__.py delete mode 100644 app/sovran_systemsos_hub/application.py delete mode 100644 app/sovran_systemsos_hub/service_row.py delete mode 100644 app/sovran_systemsos_hub/service_tile.py create mode 100644 app/sovran_systemsos_web/__init__.py create mode 100644 app/sovran_systemsos_web/__pycache__/__init__.cpython-312.pyc create mode 100644 app/sovran_systemsos_web/__pycache__/config.cpython-312.pyc create mode 100644 app/sovran_systemsos_web/__pycache__/server.cpython-312.pyc create mode 100644 app/sovran_systemsos_web/__pycache__/systemctl.cpython-312.pyc rename app/{sovran_systemsos_hub => sovran_systemsos_web}/config.py (96%) create mode 100644 app/sovran_systemsos_web/server.py create mode 100644 app/sovran_systemsos_web/static/app.js create mode 100644 app/sovran_systemsos_web/static/style.css rename app/{sovran_systemsos_hub => sovran_systemsos_web}/systemctl.py (88%) create mode 100644 app/sovran_systemsos_web/templates/index.html delete mode 100644 app/style.css diff --git a/app/sovran_systemsos_hub/__init__.py b/app/sovran_systemsos_hub/__init__.py deleted file mode 100644 index 651da84..0000000 --- a/app/sovran_systemsos_hub/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Thin wrapper around the systemctl CLI for Sovran_SystemsOS_Hub.""" - -from __future__ import annotations - -import subprocess -from typing import Literal - - -def _run(cmd: list[str]) -> str: - try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) - return result.stdout.strip() - except Exception: - return "" - - -def is_active(unit: str, scope: Literal["system", "user"] = "system") -> str: - return _run(["systemctl", f"--{scope}", "is-active", unit]) or "unknown" - - -def is_enabled(unit: str, scope: Literal["system", "user"] = "system") -> str: - return _run(["systemctl", f"--{scope}", "is-enabled", unit]) or "unknown" - - -def run_action( - action: str, - unit: str, - scope: Literal["system", "user"] = "system", - method: str = "systemctl", -) -> bool: - base_cmd = ["systemctl", f"--{scope}", action, unit] - if scope == "system" and method == "pkexec": - cmd = ["pkexec", "--user", "root"] + base_cmd - else: - cmd = base_cmd - try: - subprocess.Popen(cmd) - return True - except Exception: - return False \ No newline at end of file diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py deleted file mode 100644 index e257b0b..0000000 --- a/app/sovran_systemsos_hub/application.py +++ /dev/null @@ -1,727 +0,0 @@ -"""Sovran_SystemsOS_Hub — Main GTK4 Application.""" - -from __future__ import annotations - -import json -import os -import socket -import subprocess -import threading -import urllib.request -from datetime import datetime - -import gi - -gi.require_version("Gtk", "4.0") -gi.require_version("Adw", "1") - -from gi.repository import Adw, Gdk, Gio, GLib, Gtk - -from .config import load_config -from .service_tile import ServiceTile - -APP_ID = "com.sovransystems.hub" - -Adw.init() - -CATEGORY_ORDER = [ - ("infrastructure", "Infrastructure"), - ("bitcoin-base", "Bitcoin Base"), - ("bitcoin-apps", "Bitcoin Apps"), - ("communication", "Communication"), - ("apps", "Self-Hosted Apps"), - ("nostr", "Nostr"), -] - -ROLE_LABELS = { - "server_plus_desktop": "Server + Desktop", - "desktop": "Desktop Only", - "node": "Bitcoin Node", -} - -AUTOSTART_DIR = os.path.join( - os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), - "autostart", -) -USER_AUTOSTART_FILE = os.path.join(AUTOSTART_DIR, "sovran-hub.desktop") -SYSTEM_AUTOSTART_FILE = "/etc/xdg/autostart/sovran-hub.desktop" - -DOWNLOADS_DIR = os.path.join(os.path.expanduser("~"), "Downloads") - -FLAKE_LOCK_PATH = "/etc/nixos/flake.lock" -FLAKE_INPUT_NAME = "Sovran_Systems" - -GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovran_SystemsOS/commits" - -UPDATE_COMMAND = [ - "ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes", - "root@localhost", - "cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y", -] - -REBOOT_COMMAND = [ - "ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes", - "root@localhost", - "reboot", -] - -UPDATE_CHECK_INTERVAL = 1800 -TILE_GRID_WIDTH = 820 - - -# ── Autostart helpers ──────────────────────────────────────────── - -def get_autostart_enabled() -> bool: - if os.path.isfile(USER_AUTOSTART_FILE): - try: - with open(USER_AUTOSTART_FILE, "r") as f: - for line in f: - if line.strip().lower() == "x-gnome-autostart-enabled=false": - return False - if line.strip().lower() == "hidden=true": - return False - return True - except Exception: - return True - return os.path.isfile(SYSTEM_AUTOSTART_FILE) - - -def set_autostart_enabled(enabled: bool): - os.makedirs(AUTOSTART_DIR, exist_ok=True) - if enabled: - if os.path.isfile(USER_AUTOSTART_FILE): - os.remove(USER_AUTOSTART_FILE) - else: - with open(USER_AUTOSTART_FILE, "w") as f: - f.write("[Desktop Entry]\n") - f.write("Type=Application\n") - f.write("Name=Sovran_SystemsOS Hub\n") - f.write("Exec=sovran-hub\n") - f.write("X-GNOME-Autostart-enabled=false\n") - f.write("Hidden=true\n") - - -# ── Update check helpers ──────────────────────────────────────── - -def _get_locked_info(): - try: - with open(FLAKE_LOCK_PATH, "r") as f: - lock = json.load(f) - nodes = lock.get("nodes", {}) - node = nodes.get(FLAKE_INPUT_NAME, {}) - locked = node.get("locked", {}) - rev = locked.get("rev") - branch = locked.get("ref") - if not branch: - branch = node.get("original", {}).get("ref") - return rev, branch - except Exception: - pass - return None, None - - -def _get_remote_rev(branch=None): - try: - url = GITEA_API_BASE + "?limit=1" - if branch: - url += f"&sha={branch}" - req = urllib.request.Request(url, method="GET") - req.add_header("Accept", "application/json") - with urllib.request.urlopen(req, timeout=15) as resp: - data = json.loads(resp.read().decode()) - if isinstance(data, list) and len(data) > 0: - return data[0].get("sha") - except Exception: - pass - return None - - -def check_for_updates() -> bool: - locked_rev, branch = _get_locked_info() - remote_rev = _get_remote_rev(branch) - if locked_rev and remote_rev: - return locked_rev != remote_rev - return False - - -# ── IP address helpers ─────────────────────────────────────────── - -def _get_internal_ip(): - try: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.settimeout(2) - s.connect(("1.1.1.1", 80)) - ip = s.getsockname()[0] - s.close() - return ip - except Exception: - pass - try: - result = subprocess.run( - ["hostname", "-I"], - capture_output=True, text=True, timeout=5, - ) - if result.returncode == 0: - parts = result.stdout.strip().split() - if parts: - return parts[0] - except Exception: - pass - return "unavailable" - - -def _get_external_ip(): - for url in [ - "https://api.ipify.org", - "https://ifconfig.me/ip", - "https://icanhazip.com", - ]: - try: - req = urllib.request.Request(url, method="GET") - with urllib.request.urlopen(req, timeout=8) as resp: - ip = resp.read().decode().strip() - if ip and len(ip) < 46: - return ip - except Exception: - continue - return "unavailable" - - -# ── UpdateDialog ───────────────────────────────────────────────── - -class UpdateDialog(Adw.Window): - - def __init__(self, parent): - super().__init__( - title="Sovran_SystemsOS Update", - default_width=900, - default_height=700, - modal=True, - transient_for=parent, - ) - - self._process = None - self._full_log = "" - - header = Adw.HeaderBar() - - self._close_btn = Gtk.Button(label="Close", sensitive=False) - self._close_btn.connect("clicked", lambda _b: self.close()) - header.pack_end(self._close_btn) - - self._reboot_btn = Gtk.Button( - label="Reboot", - css_classes=["destructive-action"], - tooltip_text="Reboot the system now", - visible=False, - ) - self._reboot_btn.connect("clicked", self._on_reboot_clicked) - header.pack_end(self._reboot_btn) - - self._save_btn = Gtk.Button( - label="Save Error Report", - css_classes=["warning"], - tooltip_text="Save full log to ~/Downloads", - visible=False, - ) - self._save_btn.connect("clicked", self._on_save_report) - header.pack_start(self._save_btn) - - self._spinner = Gtk.Spinner(spinning=True) - header.pack_start(self._spinner) - - self._status_label = Gtk.Label( - label="Updating…", - css_classes=["title-4"], - halign=Gtk.Align.CENTER, - margin_top=12, - margin_bottom=8, - ) - - self._textview = Gtk.TextView( - editable=False, - cursor_visible=False, - monospace=True, - wrap_mode=Gtk.WrapMode.WORD_CHAR, - top_margin=8, - bottom_margin=8, - left_margin=12, - right_margin=12, - ) - self._buffer = self._textview.get_buffer() - - scrolled = Gtk.ScrolledWindow( - hscrollbar_policy=Gtk.PolicyType.AUTOMATIC, - vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, - vexpand=True, - child=self._textview, - ) - - content = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, - spacing=0, - ) - content.append(self._status_label) - content.append(scrolled) - - toolbar_view = Adw.ToolbarView() - toolbar_view.add_top_bar(header) - toolbar_view.set_content(content) - self.set_content(toolbar_view) - - self._start_update() - - def _append_text(self, text): - self._full_log += text - end_iter = self._buffer.get_end_iter() - self._buffer.insert(end_iter, text) - mark = self._buffer.create_mark(None, self._buffer.get_end_iter(), False) - self._textview.scroll_mark_onscreen(mark) - self._buffer.delete_mark(mark) - - def _start_update(self): - self._append_text( - "$ ssh root@localhost 'cd /etc/nixos && nix flake update " - "&& nixos-rebuild switch && flatpak update -y'\n\n" - ) - thread = threading.Thread(target=self._run_update, daemon=True) - thread.start() - - def _run_update(self): - try: - self._process = subprocess.Popen( - UPDATE_COMMAND, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - ) - - for line in self._process.stdout: - GLib.idle_add(self._append_text, line) - - self._process.wait() - rc = self._process.returncode - - if rc == 0: - GLib.idle_add(self._on_finished, True, "Update complete!") - else: - GLib.idle_add(self._on_finished, False, f"Update failed (exit code {rc})") - - except Exception as e: - GLib.idle_add(self._on_finished, False, f"Error: {e}") - - def _on_finished(self, success, message): - self._spinner.set_spinning(False) - self._close_btn.set_sensitive(True) - - if success: - self._status_label.set_label("āœ“ " + message) - self._status_label.set_css_classes(["title-4", "success"]) - self._reboot_btn.set_visible(True) - else: - self._status_label.set_label("āœ— " + message) - self._status_label.set_css_classes(["title-4", "error"]) - self._save_btn.set_visible(True) - - self._append_text(f"\n{'─' * 60}\n{message}\n") - - def _on_save_report(self, _btn): - os.makedirs(DOWNLOADS_DIR, exist_ok=True) - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - filename = f"sovran-update-error-{timestamp}.log" - filepath = os.path.join(DOWNLOADS_DIR, filename) - try: - with open(filepath, "w") as f: - f.write(f"Sovran_SystemsOS Update Error Report\n") - f.write(f"Date: {datetime.now().isoformat()}\n") - f.write(f"{'═' * 60}\n\n") - f.write(self._full_log) - self._save_btn.set_label(f"Saved: {filename}") - self._save_btn.set_sensitive(False) - self._append_text(f"\nāœ“ Error report saved to ~/Downloads/{filename}\n") - except Exception as e: - self._append_text(f"\nāœ— Failed to save report: {e}\n") - - def _on_reboot_clicked(self, _btn): - dialog = Adw.MessageDialog( - transient_for=self, - heading="Reboot Now?", - body="The system will restart immediately. Save any open work first.", - ) - dialog.add_response("cancel", "Cancel") - dialog.add_response("reboot", "Reboot") - dialog.set_response_appearance("reboot", Adw.ResponseAppearance.DESTRUCTIVE) - dialog.set_default_response("cancel") - dialog.set_close_response("cancel") - dialog.connect("response", self._on_reboot_confirmed) - dialog.present() - - def _on_reboot_confirmed(self, dialog, response): - if response == "reboot": - try: - subprocess.Popen(REBOOT_COMMAND) - except Exception as e: - self._append_text(f"\nāœ— Reboot failed: {e}\n") - - -# ── Main Window ────────────────────────────────────────────────── - -class SovranHubWindow(Adw.ApplicationWindow): - - def __init__(self, app, config): - super().__init__( - application=app, - title="Sovran_SystemsOS Hub", - default_width=860, - default_height=800, - ) - self._config = config - self._tiles = [] - self._update_available = False - - css_path = os.environ.get("SOVRAN_HUB_CSS", "") - if css_path and os.path.isfile(css_path): - provider = Gtk.CssProvider() - provider.load_from_path(css_path) - Gtk.StyleContext.add_provider_for_display( - Gdk.Display.get_default(), provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, - ) - - header = Adw.HeaderBar() - header.set_title_widget(self._build_title_box()) - - self._update_btn = Gtk.Button( - label="Update System", - css_classes=["suggested-action"], - tooltip_text="System is up to date", - ) - self._update_btn.connect("clicked", self._on_update_clicked) - header.pack_start(self._update_btn) - - self._badge = Gtk.Label( - label=" ā—", - css_classes=["update-badge"], - visible=False, - ) - header.pack_start(self._badge) - - refresh_btn = Gtk.Button( - icon_name="view-refresh-symbolic", - tooltip_text="Refresh now", - ) - refresh_btn.connect("clicked", lambda _b: self._refresh_all()) - header.pack_end(refresh_btn) - - menu_btn = Gtk.MenuButton( - icon_name="open-menu-symbolic", - tooltip_text="Settings", - ) - popover = Gtk.Popover() - menu_box = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, - spacing=8, - margin_top=12, - margin_bottom=12, - margin_start=12, - margin_end=12, - ) - - autostart_row = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, - spacing=12, - ) - autostart_label = Gtk.Label( - label="Start at login", - hexpand=True, - halign=Gtk.Align.START, - ) - self._autostart_switch = Gtk.Switch( - valign=Gtk.Align.CENTER, - active=get_autostart_enabled(), - ) - self._autostart_switch.connect("state-set", self._on_autostart_toggled) - autostart_row.append(autostart_label) - autostart_row.append(self._autostart_switch) - menu_box.append(autostart_row) - - popover.set_child(menu_box) - menu_btn.set_popover(popover) - header.pack_end(menu_btn) - - # ── Main content area ──────────────────────────────────── - self._main_box = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, - spacing=0, - ) - - # ── IP Address Banner ──────────────────────────────────── - self._ip_bar = self._build_ip_bar() - self._main_box.append(self._ip_bar) - - scrolled = Gtk.ScrolledWindow( - hscrollbar_policy=Gtk.PolicyType.NEVER, - vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, - vexpand=True, - ) - - self._tiles_box = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, - spacing=0, - ) - scrolled.set_child(self._tiles_box) - self._main_box.append(scrolled) - - toolbar_view = Adw.ToolbarView() - toolbar_view.add_top_bar(header) - toolbar_view.set_content(self._main_box) - self.set_content(toolbar_view) - - self._build_tiles() - - interval = config.get("refresh_interval", 5) - if interval and interval > 0: - GLib.timeout_add_seconds(interval, self._auto_refresh) - - GLib.timeout_add_seconds(5, self._check_for_updates_once) - GLib.timeout_add_seconds(UPDATE_CHECK_INTERVAL, self._periodic_update_check) - GLib.timeout_add_seconds(1, self._fetch_ips_once) - - # ── IP Address Bar ─────────────────────────────────────────── - - def _build_ip_bar(self): - bar = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, - spacing=28, - halign=Gtk.Align.CENTER, - margin_top=14, - margin_bottom=6, - margin_start=24, - margin_end=24, - css_classes=["ip-bar"], - ) - - internal_box = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, - spacing=8, - ) - internal_icon = Gtk.Image( - icon_name="network-wired-symbolic", - pixel_size=18, - css_classes=["dim-label"], - ) - internal_label = Gtk.Label( - label="Internal:", - css_classes=["dim-label"], - ) - self._internal_ip_label = Gtk.Label( - label="…", - css_classes=["ip-value"], - selectable=True, - ) - internal_box.append(internal_icon) - internal_box.append(internal_label) - internal_box.append(self._internal_ip_label) - - sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) - - external_box = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, - spacing=8, - ) - external_icon = Gtk.Image( - icon_name="network-server-symbolic", - pixel_size=18, - css_classes=["dim-label"], - ) - external_label = Gtk.Label( - label="External:", - css_classes=["dim-label"], - ) - self._external_ip_label = Gtk.Label( - label="…", - css_classes=["ip-value"], - selectable=True, - ) - external_box.append(external_icon) - external_box.append(external_label) - external_box.append(self._external_ip_label) - - bar.append(internal_box) - bar.append(sep) - bar.append(external_box) - - return bar - - def _fetch_ips_once(self): - thread = threading.Thread(target=self._do_fetch_ips, daemon=True) - thread.start() - return False - - def _do_fetch_ips(self): - internal = _get_internal_ip() - GLib.idle_add(self._internal_ip_label.set_label, internal) - external = _get_external_ip() - GLib.idle_add(self._external_ip_label.set_label, external) - - # ── Title box ──────────────────────────────────────────────── - - def _build_title_box(self): - role = self._config.get("role", "server_plus_desktop") - role_label = ROLE_LABELS.get(role, role) - box = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, - halign=Gtk.Align.CENTER, - ) - box.append(Gtk.Label( - label="Sovran_SystemsOS Hub", - css_classes=["hub-title"], - )) - box.append(Gtk.Label( - label=role_label, - css_classes=["role-badge", "dim-label"], - )) - return box - - # ── Service tiles ──────────────────────────────────────────── - - def _build_tiles(self): - method = self._config.get("command_method", "systemctl") - services = self._config.get("services", []) - - grouped = {} - for entry in services: - cat = entry.get("category", "other") - grouped.setdefault(cat, []).append(entry) - - for cat_key, cat_label in CATEGORY_ORDER: - entries = grouped.get(cat_key, []) - if not entries: - continue - - container = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, - halign=Gtk.Align.CENTER, - css_classes=["tiles-container"], - ) - container.set_size_request(TILE_GRID_WIDTH, -1) - - section_label = Gtk.Label( - label=cat_label, - css_classes=["section-header"], - halign=Gtk.Align.START, - margin_top=24, - margin_bottom=6, - margin_start=16, - ) - container.append(section_label) - - sep = Gtk.Separator( - orientation=Gtk.Orientation.HORIZONTAL, - margin_start=16, - margin_end=16, - margin_bottom=10, - ) - container.append(sep) - - flowbox = Gtk.FlowBox( - max_children_per_line=4, - min_children_per_line=2, - selection_mode=Gtk.SelectionMode.NONE, - homogeneous=False, - row_spacing=14, - column_spacing=14, - margin_top=4, - margin_bottom=10, - margin_start=16, - margin_end=16, - halign=Gtk.Align.START, - valign=Gtk.Align.START, - ) - - for entry in entries: - tile = ServiceTile( - name=entry.get("name", entry["unit"]), - unit=entry["unit"], - scope=entry.get("type", "system"), - method=method, - icon_name=entry.get("icon", ""), - enabled=entry.get("enabled", True), - ) - flowbox.append(tile) - self._tiles.append(tile) - - container.append(flowbox) - self._tiles_box.append(container) - - GLib.idle_add(self._refresh_all) - - # ── Update check ───────────────────────────────────────────── - - def _check_for_updates_once(self): - thread = threading.Thread(target=self._do_update_check, daemon=True) - thread.start() - return False - - def _periodic_update_check(self): - thread = threading.Thread(target=self._do_update_check, daemon=True) - thread.start() - return True - - def _do_update_check(self): - available = check_for_updates() - GLib.idle_add(self._set_update_indicator, available) - - def _set_update_indicator(self, available): - self._update_available = available - if available: - self._update_btn.set_label("Update Available") - self._update_btn.set_css_classes(["update-available"]) - self._update_btn.set_tooltip_text("A new version of Sovran_SystemsOS is available!") - self._badge.set_visible(True) - else: - self._update_btn.set_label("Update System") - self._update_btn.set_css_classes(["suggested-action"]) - self._update_btn.set_tooltip_text("System is up to date") - self._badge.set_visible(False) - - # ── Callbacks ──────────────────────────────────────────────── - - def _on_update_clicked(self, _btn): - dialog = UpdateDialog(self) - dialog.connect("close-request", lambda _w: self._after_update()) - dialog.present() - - def _after_update(self): - GLib.timeout_add_seconds(3, self._check_for_updates_once) - return False - - def _on_autostart_toggled(self, switch, state): - set_autostart_enabled(state) - return False - - def _refresh_all(self): - for t in self._tiles: - t.refresh() - return False - - def _auto_refresh(self): - self._refresh_all() - return True - - -class SovranHubApp(Adw.Application): - - def __init__(self): - super().__init__( - application_id=APP_ID, - flags=Gio.ApplicationFlags.DEFAULT_FLAGS, - ) - self._config = load_config() - - def do_activate(self): - win = self.get_active_window() - if not win: - win = SovranHubWindow(self, self._config) - win.present() \ No newline at end of file diff --git a/app/sovran_systemsos_hub/service_row.py b/app/sovran_systemsos_hub/service_row.py deleted file mode 100644 index cb3b017..0000000 --- a/app/sovran_systemsos_hub/service_row.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Adw.ActionRow subclass representing a single systemd unit.""" - -from __future__ import annotations - -import gi - -gi.require_version("Gtk", "4.0") -gi.require_version("Adw", "1") - -from gi.repository import Adw, GLib, Gtk # noqa: E402 - -from . import systemctl # noqa: E402 - -LOADING_STATES = {"reloading", "activating", "deactivating", "maintenance"} - - -class ServiceRow(Adw.ActionRow): - """A row showing one systemd unit with a toggle switch and action buttons.""" - - def __init__( - self, - name: str, - unit: str, - scope: str = "system", - method: str = "systemctl", - **kwargs, - ): - super().__init__(title=name, subtitle=unit, **kwargs) - self._unit = unit - self._scope = scope - self._method = method - - # ── Active / Inactive switch ── - self._switch = Gtk.Switch(valign=Gtk.Align.CENTER) - self._switch.connect("state-set", self._on_toggled) - self.add_suffix(self._switch) - self.set_activatable_widget(self._switch) - - # ── Restart button ── - restart_btn = Gtk.Button( - icon_name="view-refresh-symbolic", - valign=Gtk.Align.CENTER, - tooltip_text="Restart", - css_classes=["flat"], - ) - restart_btn.connect("clicked", self._on_restart) - self.add_suffix(restart_btn) - - # ── Status pill ── - self._status_label = Gtk.Label( - css_classes=["caption", "dim-label"], - valign=Gtk.Align.CENTER, - margin_end=4, - ) - self.add_suffix(self._status_label) - - # Initial state - self.refresh() - - # ── public API ── - - def refresh(self): - """Poll systemctl and update the row.""" - active_state = systemctl.is_active(self._unit, self._scope) - enabled_state = systemctl.is_enabled(self._unit, self._scope) - - is_active = active_state == "active" - is_loading = active_state in LOADING_STATES - is_failed = active_state == "failed" - - # Block the handler so we don't trigger a start/stop when we - # programmatically flip the switch. - self._switch.handler_block_by_func(self._on_toggled) - self._switch.set_active(is_active) - self._switch.handler_unblock_by_func(self._on_toggled) - - self._switch.set_sensitive(not is_loading) - - # Status text - label = enabled_state - if is_failed: - label = "failed" - elif is_loading: - label = active_state - self._status_label.set_label(label) - - # Visual cue for failures - if is_failed: - self.add_css_class("error") - else: - self.remove_css_class("error") - - # ── signal handlers ── - - def _on_toggled(self, switch: Gtk.Switch, state: bool) -> bool: - action = "start" if state else "stop" - systemctl.run_action(action, self._unit, self._scope, self._method) - # Delay refresh so systemd has a moment to change state - GLib.timeout_add(1500, self.refresh) - return False # let GTK update the switch position - - def _on_restart(self, _btn: Gtk.Button): - systemctl.run_action("restart", self._unit, self._scope, self._method) - GLib.timeout_add(1500, self.refresh) \ No newline at end of file diff --git a/app/sovran_systemsos_hub/service_tile.py b/app/sovran_systemsos_hub/service_tile.py deleted file mode 100644 index 6692970..0000000 --- a/app/sovran_systemsos_hub/service_tile.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Square tile widget representing a single systemd unit.""" - -from __future__ import annotations - -import os - -import gi - -gi.require_version("Gtk", "4.0") -gi.require_version("Adw", "1") -gi.require_version("Gdk", "4.0") - -from gi.repository import Gdk, GdkPixbuf, GLib, Gtk, Pango - -from . import systemctl - -LOADING_STATES = {"reloading", "activating", "deactivating", "maintenance"} - -ICON_DIR = os.environ.get("SOVRAN_HUB_ICONS", "") -ICON_EXTENSIONS = [".svg", ".png"] - -# ── Locked tile dimensions ─────────────────────────────────────── -TILE_W = 180 -TILE_H = 210 -ICON_PX = 48 -LABEL_W = TILE_W - 24 # 12px padding each side - - -class ServiceTile(Gtk.Box): - - def __init__(self, name, unit, scope="system", method="systemctl", - icon_name="", enabled=True, **kw): - super().__init__( - orientation=Gtk.Orientation.VERTICAL, - spacing=2, - halign=Gtk.Align.CENTER, - valign=Gtk.Align.START, - css_classes=["card", "sovran-tile"], - **kw, - ) - self.set_size_request(TILE_W, TILE_H) - self.set_hexpand(False) - self.set_vexpand(False) - - self._unit = unit - self._scope = scope - self._method = method - self._enabled = enabled - - # ── Icon ───────────────────────────────────────────────── - self._logo = Gtk.Image( - pixel_size=ICON_PX, - margin_top=18, - halign=Gtk.Align.CENTER, - ) - self._set_logo(icon_name) - self.append(self._logo) - - # ── Name label ─────────────────────────────────────────── - self._name_label = Gtk.Label( - label=name, - css_classes=["tile-name"], - halign=Gtk.Align.CENTER, - justify=Gtk.Justification.CENTER, - wrap=True, - wrap_mode=Pango.WrapMode.WORD_CHAR, - lines=2, - ellipsize=Pango.EllipsizeMode.END, - margin_start=12, - margin_end=12, - margin_top=6, - ) - self._name_label.set_size_request(LABEL_W, -1) - self._name_label.set_max_width_chars(1) - self.append(self._name_label) - - # ── Status label ───────────────────────────────────────── - self._status_label = Gtk.Label( - label="ā— …", - css_classes=["caption", "tile-status", "dim-label"], - halign=Gtk.Align.CENTER, - margin_top=2, - ) - self.append(self._status_label) - - # ── Spacer ─────────────────────────────────────────────── - spacer = Gtk.Box(vexpand=True) - self.append(spacer) - - # ── Controls ───────────────���───────────────────────────── - controls = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, - spacing=10, - halign=Gtk.Align.CENTER, - margin_bottom=14, - ) - self._switch = Gtk.Switch(valign=Gtk.Align.CENTER) - self._switch.connect("state-set", self._on_toggled) - controls.append(self._switch) - - restart_btn = Gtk.Button( - icon_name="view-refresh-symbolic", - valign=Gtk.Align.CENTER, - tooltip_text="Restart", - css_classes=["flat", "circular"], - ) - restart_btn.connect("clicked", self._on_restart) - controls.append(restart_btn) - self.append(controls) - - if not self._enabled: - self._switch.set_active(False) - self._switch.set_sensitive(False) - self._status_label.set_label("ā—‹ disabled") - self._status_label.set_css_classes(["caption", "tile-status", "disabled-label"]) - self._logo.set_opacity(0.35) - self.set_tooltip_text(f"{name} is not enabled in custom.nix") - - def _set_logo(self, icon_name): - if icon_name and ICON_DIR: - for ext in ICON_EXTENSIONS: - path = os.path.join(ICON_DIR, f"{icon_name}{ext}") - if os.path.isfile(path): - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - path, ICON_PX, ICON_PX, True) - texture = Gdk.Texture.new_for_pixbuf(pixbuf) - self._logo.set_from_paintable(texture) - return - except Exception: - break - self._logo.set_from_icon_name("system-run-symbolic") - - def refresh(self): - if not self._enabled: - return - - active = systemctl.is_active(self._unit, self._scope) - is_on = active == "active" - is_loading = active in LOADING_STATES - is_failed = active == "failed" - - self._switch.handler_block_by_func(self._on_toggled) - self._switch.set_active(is_on) - self._switch.handler_unblock_by_func(self._on_toggled) - self._switch.set_sensitive(not is_loading) - - if is_failed: - self._status_label.set_label("ā— failed") - self._status_label.set_css_classes(["caption", "tile-status", "error"]) - elif is_on: - self._status_label.set_label("ā— running") - self._status_label.set_css_classes(["caption", "tile-status", "success"]) - elif is_loading: - self._status_label.set_label(f"ā— {active}") - self._status_label.set_css_classes(["caption", "tile-status", "warning"]) - else: - self._status_label.set_label(f"ā— {active}") - self._status_label.set_css_classes(["caption", "tile-status", "dim-label"]) - - def _on_toggled(self, switch, state): - if not self._enabled: - return True - systemctl.run_action("start" if state else "stop", self._unit, self._scope, self._method) - GLib.timeout_add(1500, self.refresh) - return False - - def _on_restart(self, _btn): - if not self._enabled: - return - systemctl.run_action("restart", self._unit, self._scope, self._method) - GLib.timeout_add(1500, self.refresh) \ No newline at end of file diff --git a/app/sovran_systemsos_web/__init__.py b/app/sovran_systemsos_web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sovran_systemsos_web/__pycache__/__init__.cpython-312.pyc b/app/sovran_systemsos_web/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..497e0a0d5cd4bae4aeff8e3ae78edf1a19d94adc GIT binary patch literal 179 zcmX@j%ge>Uz`(F~-nmQ={TM``Uz`(F~-nqi?AiMl-u@$A3losVBgLp8^!@$76%)r3#`5hz386_}r zhAfx^;UWwSS*%DLC^Ll-Ne>%T5Q)m-MB+f11axpil_617*TIWqIFwn!2W2p(nnX-p~%MVyQbDXb}M z=?p3CbC{EvA{l}i${8RgX>wFq2BjtFS9}+ zCABECEU_drKTq$LV6cB!kfUF`N2pW0v%jC4r+XDQBpvEy73b&OVl47|2}&-vIE%q) zF}Wm1llhiFQEFOIYH>z)-r@$iCLY2sl4f9FU}s=pC|<|Fz|g?(gqyeDs?)09 zuG8)Yhx8o|u737T_I{pDo;y5z{qCLa{r;W)bDZY8&vc*fKhuAW(|Y%n?(6+m`tR|$ ztmArF#_ckX`wbq+&#cUxT%TFlc-R_TKJc-yhB4mZ6`Y_lgSk8Y0|O(c;sT~S!s62{ zCtA)3nr!=>nUPcRJ4pBwi1_`4M|Muhb$R29^2QgqO+GO&vW79eIY31B7f*5 zj?fRxAhBP?1`G@ghopI&g%}R0i#oG0AF|UyVB=*BW1JB3g#knt$ulr8007SlJ97X4 literal 0 HcmV?d00001 diff --git a/app/sovran_systemsos_web/__pycache__/server.cpython-312.pyc b/app/sovran_systemsos_web/__pycache__/server.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ac7409534f3a54cacad1f4b8b53dbf46916652d GIT binary patch literal 15868 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 dict: with open(path, "r") as fh: return json.load(fh) except (FileNotFoundError, json.JSONDecodeError): - return {"refresh_interval": 5, "command_method": "systemctl", "services": []} \ No newline at end of file + return {"refresh_interval": 5, "command_method": "systemctl", "services": []} diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py new file mode 100644 index 0000000..8453630 --- /dev/null +++ b/app/sovran_systemsos_web/server.py @@ -0,0 +1,329 @@ +"""Sovran_SystemsOS Hub — FastAPI web server.""" + +from __future__ import annotations + +import asyncio +import json +import os +import socket +import subprocess +import threading +import urllib.request +from typing import AsyncIterator + +from fastapi import FastAPI, HTTPException, Response +from fastapi.responses import HTMLResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from fastapi.requests import Request + +from .config import load_config +from . import systemctl as sysctl + +# ── Constants ──────────────────────────────────────────────────── + +FLAKE_LOCK_PATH = "/etc/nixos/flake.lock" +FLAKE_INPUT_NAME = "Sovran_Systems" +GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovran_SystemsOS/commits" + +REBOOT_COMMAND = [ + "ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes", + "root@localhost", + "reboot", +] + +UPDATE_COMMAND = [ + "ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes", + "root@localhost", + "cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y", +] + +CATEGORY_ORDER = [ + ("infrastructure", "Infrastructure"), + ("bitcoin-base", "Bitcoin Base"), + ("bitcoin-apps", "Bitcoin Apps"), + ("communication", "Communication"), + ("apps", "Self-Hosted Apps"), + ("nostr", "Nostr"), +] + +ROLE_LABELS = { + "server_plus_desktop": "Server + Desktop", + "desktop": "Desktop Only", + "node": "Bitcoin Node", +} + +# ── App setup ──────────────────────────────────────────────────── + +_BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +app = FastAPI(title="Sovran_SystemsOS Hub") + +app.mount( + "/static", + StaticFiles(directory=os.path.join(_BASE_DIR, "static")), + name="static", +) + +# Also serve icons from the app/icons directory (set via env or adjacent folder) +_ICONS_DIR = os.environ.get( + "SOVRAN_HUB_ICONS", + os.path.join(os.path.dirname(_BASE_DIR), "icons"), +) +if os.path.isdir(_ICONS_DIR): + app.mount( + "/static/icons", + StaticFiles(directory=_ICONS_DIR), + name="icons", + ) + +templates = Jinja2Templates(directory=os.path.join(_BASE_DIR, "templates")) + +# ── Update check helpers ───────────────────────────────────────── + +def _get_locked_info(): + try: + with open(FLAKE_LOCK_PATH, "r") as f: + lock = json.load(f) + nodes = lock.get("nodes", {}) + node = nodes.get(FLAKE_INPUT_NAME, {}) + locked = node.get("locked", {}) + rev = locked.get("rev") + branch = locked.get("ref") + if not branch: + branch = node.get("original", {}).get("ref") + return rev, branch + except Exception: + pass + return None, None + + +def _get_remote_rev(branch=None): + try: + url = GITEA_API_BASE + "?limit=1" + if branch: + url += f"&sha={branch}" + req = urllib.request.Request(url, method="GET") + req.add_header("Accept", "application/json") + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read().decode()) + if isinstance(data, list) and len(data) > 0: + return data[0].get("sha") + except Exception: + pass + return None + + +def check_for_updates() -> bool: + locked_rev, branch = _get_locked_info() + remote_rev = _get_remote_rev(branch) + if locked_rev and remote_rev: + return locked_rev != remote_rev + return False + + +# ── IP helpers ─────────────────────────────────────────────────── + +def _get_internal_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(2) + s.connect(("1.1.1.1", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + pass + try: + result = subprocess.run( + ["hostname", "-I"], capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + parts = result.stdout.strip().split() + if parts: + return parts[0] + except Exception: + pass + return "unavailable" + + +def _get_external_ip() -> str: + # Max length 46 covers the longest valid IPv6 address (45 chars) plus a newline + MAX_IP_LENGTH = 46 + for url in [ + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://icanhazip.com", + ]: + try: + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req, timeout=8) as resp: + ip = resp.read().decode().strip() + if ip and len(ip) < MAX_IP_LENGTH: + return ip + except Exception: + continue + return "unavailable" + + +# ── Routes ─────────────────────────────────────────────────────── + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + return templates.TemplateResponse("index.html", {"request": request}) + + +@app.get("/api/config") +async def api_config(): + cfg = load_config() + role = cfg.get("role", "server_plus_desktop") + return { + "role": role, + "role_label": ROLE_LABELS.get(role, role), + "category_order": CATEGORY_ORDER, + } + + +@app.get("/api/services") +async def api_services(): + cfg = load_config() + method = cfg.get("command_method", "systemctl") + services = cfg.get("services", []) + + loop = asyncio.get_event_loop() + + async def get_status(entry): + unit = entry.get("unit", "") + scope = entry.get("type", "system") + enabled = entry.get("enabled", True) + if enabled: + status = await loop.run_in_executor( + None, lambda: sysctl.is_active(unit, scope) + ) + else: + status = "disabled" + return { + "name": entry.get("name", ""), + "unit": unit, + "type": scope, + "icon": entry.get("icon", ""), + "enabled": enabled, + "category": entry.get("category", "other"), + "status": status, + } + + results = await asyncio.gather(*[get_status(s) for s in services]) + return list(results) + + +def _get_allowed_units() -> set[str]: + """Return the set of unit names from the current config (whitelist).""" + cfg = load_config() + return {s.get("unit", "") for s in cfg.get("services", []) if s.get("unit")} + + +@app.post("/api/services/{unit}/start") +async def service_start(unit: str): + if unit not in _get_allowed_units(): + raise HTTPException(status_code=403, detail=f"Unit {unit!r} is not in the allowed service list") + cfg = load_config() + method = cfg.get("command_method", "systemctl") + loop = asyncio.get_event_loop() + ok = await loop.run_in_executor( + None, lambda: sysctl.run_action("start", unit, "system", method) + ) + if not ok: + raise HTTPException(status_code=500, detail=f"Failed to start {unit}") + return {"ok": True} + + +@app.post("/api/services/{unit}/stop") +async def service_stop(unit: str): + if unit not in _get_allowed_units(): + raise HTTPException(status_code=403, detail=f"Unit {unit!r} is not in the allowed service list") + cfg = load_config() + method = cfg.get("command_method", "systemctl") + loop = asyncio.get_event_loop() + ok = await loop.run_in_executor( + None, lambda: sysctl.run_action("stop", unit, "system", method) + ) + if not ok: + raise HTTPException(status_code=500, detail=f"Failed to stop {unit}") + return {"ok": True} + + +@app.post("/api/services/{unit}/restart") +async def service_restart(unit: str): + if unit not in _get_allowed_units(): + raise HTTPException(status_code=403, detail=f"Unit {unit!r} is not in the allowed service list") + cfg = load_config() + method = cfg.get("command_method", "systemctl") + loop = asyncio.get_event_loop() + ok = await loop.run_in_executor( + None, lambda: sysctl.run_action("restart", unit, "system", method) + ) + if not ok: + raise HTTPException(status_code=500, detail=f"Failed to restart {unit}") + return {"ok": True} + + +@app.get("/api/network") +async def api_network(): + loop = asyncio.get_event_loop() + internal, external = await asyncio.gather( + loop.run_in_executor(None, _get_internal_ip), + loop.run_in_executor(None, _get_external_ip), + ) + return {"internal_ip": internal, "external_ip": external} + + +@app.get("/api/updates/check") +async def api_updates_check(): + loop = asyncio.get_event_loop() + available = await loop.run_in_executor(None, check_for_updates) + return {"available": available} + + +@app.post("/api/reboot") +async def api_reboot(): + try: + await asyncio.create_subprocess_exec(*REBOOT_COMMAND) + except Exception: + raise HTTPException(status_code=500, detail="Failed to initiate reboot") + return {"ok": True} + + +async def api_updates_run(): + async def event_stream() -> AsyncIterator[str]: + yield "data: $ ssh root@localhost 'cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y'\n\n" + yield "data: \n\n" + + process = await asyncio.create_subprocess_exec( + *UPDATE_COMMAND, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + + assert process.stdout is not None + try: + async for raw_line in process.stdout: + line = raw_line.decode(errors="replace").rstrip("\n") + # SSE requires data: prefix; escape newlines within a line + yield f"data: {line}\n\n" + except Exception: + yield "data: [stream error: output read interrupted]\n\n" + + await process.wait() + if process.returncode == 0: + yield "event: done\ndata: success\n\n" + else: + yield f"event: error\ndata: exit code {process.returncode}\n\n" + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js new file mode 100644 index 0000000..79e88bd --- /dev/null +++ b/app/sovran_systemsos_web/static/app.js @@ -0,0 +1,408 @@ +/* Sovran_SystemsOS Hub — Vanilla JS Frontend */ +"use strict"; + +const POLL_INTERVAL_SERVICES = 5000; // 5 s +const POLL_INTERVAL_UPDATES = 1800000; // 30 min +const ACTION_REFRESH_DELAY = 1500; // 1.5 s after start/stop/restart + +const CATEGORY_ORDER = [ + "infrastructure", + "bitcoin-base", + "bitcoin-apps", + "communication", + "apps", + "nostr", +]; + +const STATUS_LOADING_STATES = new Set([ + "reloading", "activating", "deactivating", "maintenance", +]); + +// ── State ───────────────────────────────────────────────────────── + +let _servicesCache = []; +let _categoryLabels = {}; +let _updateSource = null; +let _updateLog = ""; + +// ── DOM refs ────────────────────────────────────────────────────── + +const $tilesArea = document.getElementById("tiles-area"); +const $updateBtn = document.getElementById("btn-update"); +const $updateBadge = document.getElementById("update-badge"); +const $refreshBtn = document.getElementById("btn-refresh"); +const $internalIp = document.getElementById("ip-internal"); +const $externalIp = document.getElementById("ip-external"); + +const $modal = document.getElementById("update-modal"); +const $modalSpinner = document.getElementById("modal-spinner"); +const $modalStatus = document.getElementById("modal-status"); +const $modalLog = document.getElementById("modal-log"); +const $btnReboot = document.getElementById("btn-reboot"); +const $btnSave = document.getElementById("btn-save-report"); +const $btnCloseModal = document.getElementById("btn-close-modal"); + +// ── Helpers ─────────────────────────────────────────────────────── + +function statusClass(status) { + if (!status) return "unknown"; + if (status === "active") return "active"; + if (status === "inactive") return "inactive"; + if (status === "failed") return "failed"; + if (status === "disabled") return "disabled"; + if (STATUS_LOADING_STATES.has(status)) return "loading"; + return "unknown"; +} + +function statusText(status, enabled) { + if (!enabled) return "disabled"; + if (!status || status === "unknown") return "unknown"; + return status; +} + +// ── Fetch wrappers ──────────────────────────────────────────────── + +async function apiFetch(path, options = {}) { + const res = await fetch(path, options); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return res.json(); +} + +// ── Render: initial build ───────────────────────────────────────── + +function buildTiles(services, categoryLabels) { + _servicesCache = services; + + // Group by category + const grouped = {}; + for (const svc of services) { + const cat = svc.category || "other"; + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push(svc); + } + + $tilesArea.innerHTML = ""; + + const orderedKeys = [ + ...CATEGORY_ORDER.filter(k => grouped[k]), + ...Object.keys(grouped).filter(k => !CATEGORY_ORDER.includes(k)), + ]; + + for (const catKey of orderedKeys) { + const entries = grouped[catKey]; + if (!entries || entries.length === 0) continue; + + const label = categoryLabels[catKey] || catKey; + + const section = document.createElement("div"); + section.className = "category-section"; + section.dataset.category = catKey; + + section.innerHTML = ` +

    ' + '' + '
    ' + - '

    \u2139 Paste the curl URL from your Njal.la dashboard\'s Dynamic record

    ' + + '

    ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record

    ' + npubField + '
    '; -- 2.53.0 From b67e34127a77e231a80ec7caa0b2f4ae11a6c522 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:43:32 +0000 Subject: [PATCH 276/857] Initial plan -- 2.53.0 From f5bff0b1399ae8e9a2f2163f50bb013fd987c6ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:48:15 +0000 Subject: [PATCH 277/857] Make logo bigger, clarify Bitcoin node mutual exclusivity, and improve domain setup instructions Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/45129e42-f838-47b6-a33d-61c50a2ba927 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 6 +++--- app/sovran_systemsos_web/static/app.js | 15 ++++++++++----- app/sovran_systemsos_web/static/onboarding.js | 16 +++++++++++++++- app/sovran_systemsos_web/static/style.css | 2 +- .../templates/onboarding.html | 2 +- 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index b1abb4e..684ad0a 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -166,8 +166,8 @@ FEATURE_REGISTRY = [ }, { "id": "bip110", - "name": "BIP-110 (Bitcoin Better Money)", - "description": "Bitcoin Knots with BIP-110 consensus changes", + "name": "Bitcoin Knots + BIP110", + "description": "Only one Bitcoin node implementation can be active at a time: Bitcoin Knots (default), Bitcoin Knots + BIP110, or Bitcoin Core. Enabling this option replaces the default Bitcoin Knots with Bitcoin Knots + BIP110 consensus changes. It will disable the currently active alternative.", "category": "bitcoin", "needs_domain": False, "domain_name": None, @@ -179,7 +179,7 @@ FEATURE_REGISTRY = [ { "id": "bitcoin-core", "name": "Bitcoin Core", - "description": "Use Bitcoin Core instead of Bitcoin Knots", + "description": "Only one Bitcoin node implementation can be active at a time: Bitcoin Knots (default), Bitcoin Knots + BIP110, or Bitcoin Core. Enabling this option replaces the default Bitcoin Knots with Bitcoin Core. It will disable the currently active alternative.", "category": "bitcoin", "needs_domain": False, "domain_name": None, diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 8e10f57..36a6943 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -1316,7 +1316,7 @@ function openDomainSetupModal(feat, onSaved) { '

    Before continuing:

    ' + '
      ' + '
    1. Create an account at https://njal.la
    2. ' + - '
    3. Purchase your domain on Njal.la
    4. ' + + '
    5. Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.
    6. ' + '
    7. In the Njal.la web interface, create a Dynamic record pointing to this machine\'s external IP address:
      ' + '' + escHtml(externalIp) + '
    8. ' + '
    9. Njal.la will give you a curl command like:
      ' + @@ -1595,10 +1595,15 @@ function handleFeatureToggle(feat, newEnabled) { } if (conflictNames.length > 0) { - openFeatureConfirm( - "This will disable " + conflictNames.join(", ") + ". Continue?", - proceedAfterConflictCheck - ); + var confirmMsg; + if (feat.id === "bip110") { + confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Knots + BIP110 will disable Bitcoin Core (if active). Continue?"; + } else if (feat.id === "bitcoin-core") { + confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will disable Bitcoin Knots + BIP110 (if active). Continue?"; + } else { + confirmMsg = "This will disable " + conflictNames.join(", ") + ". Continue?"; + } + openFeatureConfirm(confirmMsg, proceedAfterConflictCheck); } else { proceedAfterConflictCheck(); } diff --git a/app/sovran_systemsos_web/static/onboarding.js b/app/sovran_systemsos_web/static/onboarding.js index 293ef1f..195e51e 100644 --- a/app/sovran_systemsos_web/static/onboarding.js +++ b/app/sovran_systemsos_web/static/onboarding.js @@ -145,7 +145,7 @@ async function loadStep2() { + 'Before you continue:' + '
        ' + '
      1. Create an account at https://njal.la
      2. ' - + '
      3. Purchase your domain on Njal.la
      4. ' + + '
      5. Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.
      6. ' + '
      7. In the Njal.la web interface, create a Dynamic record pointing to this machine\'s external IP address:
        ' + '' + escHtml(externalIp) + '
      8. ' + '
      9. Njal.la will give you a curl command like:
        ' @@ -553,6 +553,20 @@ function renderFeaturesStep(data) { } async function handleFeatureToggleStep5(feat, newEnabled, inputEl, labelEl) { + // For Bitcoin features being enabled, show a clear mutual-exclusivity confirmation + if (newEnabled && (feat.id === "bip110" || feat.id === "bitcoin-core")) { + var confirmMsg; + if (feat.id === "bip110") { + confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Knots + BIP110 will disable Bitcoin Core (if active). Continue?"; + } else { + confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will disable Bitcoin Knots + BIP110 (if active). Continue?"; + } + if (!confirm(confirmMsg)) { + if (inputEl) inputEl.checked = feat.enabled; + return; + } + } + setStatus("step-5-rebuild-status", "Saving…", "info"); // Collect nostr_npub if needed diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 3ebe747..90a87ea 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -70,7 +70,7 @@ body { } .header-logo { - height: 46px; + height: 64px; width: auto; vertical-align: middle; margin-right: 10px; diff --git a/app/sovran_systemsos_web/templates/onboarding.html b/app/sovran_systemsos_web/templates/onboarding.html index 807568c..756c67b 100644 --- a/app/sovran_systemsos_web/templates/onboarding.html +++ b/app/sovran_systemsos_web/templates/onboarding.html @@ -72,7 +72,7 @@

        Domain Configuration

        Sovran_SystemsOS uses Njal.la for domains and Dynamic DNS. - First, create an account at Njal.la and purchase your domain. + First, create an account at Njal.la and purchase a new domain, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost. Then, in the Njal.la web interface, create a Dynamic record pointing to this machine's external IP address (shown below). Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.

        -- 2.53.0 From f86df9c1737c92421d1baef184351ec5ddd3fe45 Mon Sep 17 00:00:00 2001 From: Sovran_Systems <99053422+naturallaw777@users.noreply.github.com> Date: Sat, 4 Apr 2026 13:12:58 -0500 Subject: [PATCH 278/857] fix: reduce header logo height from 64px to 36px to fit header bar --- app/sovran_systemsos_web/static/style.css | 2493 +-------------------- 1 file changed, 2 insertions(+), 2491 deletions(-) diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 90a87ea..3f89d3f 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -1,2497 +1,8 @@ -/* Sovran_SystemsOS Hub — Web UI Stylesheet - Dark theme matching the Adwaita dark aesthetic - v6 — Status-only tiles (no controls) */ - -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -:root { - --bg-color: #1e1e2e; - --surface-color: #2a2a3c; - --card-color: #313244; - --border-color: #45475a; - --text-primary: #cdd6f4; - --text-secondary: #a6adc8; - --text-dim: #6c7086; - --accent-color: #89b4fa; - --green: #2ec27e; - --yellow: #e5a50a; - --red: #e01b24; - --grey: #888888; - --radius-card: 18px; - --radius-btn: 8px; - --shadow-card: 0 2px 8px rgba(0,0,0,0.4); - --shadow-hover: 0 6px 20px rgba(0,0,0,0.6); -} - -html, body { - height: 100%; -} - -body { - font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif; - background-color: var(--bg-color); - color: var(--text-primary); - line-height: 1.5; - min-height: 100vh; - display: flex; - flex-direction: column; - overflow: hidden; -} - -/* ── Header bar ─────────────────────────────────────────────────── */ - -.header-bar { - background-color: var(--surface-color); - border-bottom: 1px solid var(--border-color); - padding: 10px 24px; - display: flex; - align-items: center; - gap: 16px; - position: sticky; - top: 0; - z-index: 100; - justify-content: flex-end; -} - -.header-bar .title { - font-size: 1.15rem; - font-weight: 700; - color: var(--text-primary); - position: absolute; - left: 0; - right: 0; - text-align: center; - pointer-events: none; - white-space: nowrap; -} - .header-logo { - height: 64px; + height: 36px; width: auto; vertical-align: middle; margin-right: 10px; } -.role-badge { - background-color: var(--accent-color); - color: #1e1e2e; - font-size: 0.72rem; - font-weight: 700; - padding: 3px 10px; - border-radius: 20px; - letter-spacing: 0.03em; -} - -/* ── Buttons ────────────────────────────────────────────────────── */ - -button { - font-family: inherit; - cursor: pointer; - border: none; - outline: none; - transition: opacity 0.15s, box-shadow 0.15s, background-color 0.15s; -} - -button:disabled { - opacity: 0.45; - cursor: default; -} - -.btn { - padding: 7px 16px; - border-radius: var(--radius-btn); - font-size: 0.88rem; - font-weight: 600; -} - -.btn-primary { - background-color: var(--accent-color); - color: #1e1e2e; -} - -.btn-primary:hover:not(:disabled) { - opacity: 0.88; -} - -/* Update System button: BLUE by default */ -.btn-update { - background-color: #89b4fa; - color: #1e1e2e; - position: relative; - display: flex; - align-items: center; - gap: 8px; -} - -.btn-update:hover:not(:disabled) { - opacity: 0.88; -} - -/* Update System button: GREEN when updates are available */ -.btn-update.has-updates { - background-color: #2ec27e; - color: #fff; -} - -.btn-update.has-updates:hover:not(:disabled) { - background-color: #27ae6e; -} - -.update-badge { - display: none; - width: 10px; - height: 10px; - background-color: var(--yellow); - border-radius: 50%; - animation: pulse-badge 1.4s ease-in-out infinite; -} - -.update-badge.visible { - display: inline-block; -} - -@keyframes pulse-badge { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.5; transform: scale(1.35); } -} - -.btn-icon { - background: none; - color: var(--text-secondary); - padding: 6px; - border-radius: 50%; - font-size: 1.1rem; - line-height: 1; -} - -.btn-icon:hover:not(:disabled) { - background-color: var(--border-color); - color: var(--text-primary); -} - -/* ── IP bar ─────────────────────────────────────────────────────── */ - -.ip-bar { - background-color: var(--surface-color); - border-bottom: 1px solid var(--border-color); - padding: 8px 24px; - display: flex; - align-items: center; - justify-content: center; - gap: 32px; - font-size: 0.82rem; - color: var(--text-secondary); -} - -.ip-bar .ip-label { - color: var(--text-dim); - margin-right: 6px; -} - -.ip-bar .ip-value { - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - color: var(--accent-color); - font-weight: 600; -} - -.ip-separator { - color: var(--border-color); -} - -/* ── Main content ───────────────────────────────────────────────── */ - -.main-content { - display: flex; - align-items: flex-start; - flex: 1; - overflow: hidden; - max-width: 1400px; - width: 100%; - margin-left: auto; - margin-right: auto; -} - -/* ── Sidebar ────────────────────────────────────────────────────── */ - -.sidebar { - width: 270px; - flex-shrink: 0; - height: 100%; - overflow-y: auto; - border-right: 1px solid var(--border-color); - background-color: var(--surface-color); - padding: 20px 14px; - display: flex; - flex-direction: column; - gap: 0; -} - -/* ── Sidebar: Tech Support button ───────────────────────────────── */ - -.sidebar-support-btn { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - background-color: var(--card-color); - border: 2px dashed var(--accent-color); - border-radius: 12px; - padding: 12px 14px; - color: var(--text-primary); - cursor: pointer; - transition: border-style 0.15s, border-color 0.15s, background-color 0.15s; - text-align: left; -} - -.sidebar-support-btn:hover { - border-style: solid; - border-color: #a8c8ff; - background-color: #35354a; -} - -.sidebar-support-icon { - font-size: 1.5rem; - flex-shrink: 0; -} - -.sidebar-support-text { - display: flex; - flex-direction: column; - gap: 2px; -} - -.sidebar-support-title { - font-size: 0.88rem; - font-weight: 700; - color: var(--text-primary); -} - -.sidebar-support-hint { - font-size: 0.72rem; - color: var(--accent-color); - font-weight: 600; -} - -.sidebar-divider { - border: none; - border-top: 1px solid var(--border-color); - margin: 16px 0; -} - -/* ── Tiles area ─────────────────────────────────────────────────── */ - -#tiles-area { - flex: 1; - height: 100%; - overflow-y: auto; - padding: 24px 20px 48px; - min-width: 0; -} - -/* ── Category sections ──────────────────────────────────────────── */ - -.category-section { - margin-bottom: 32px; -} - -.section-header { - font-size: 0.82rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-secondary); - margin-bottom: 4px; - padding-left: 4px; -} - -.section-divider { - border: none; - border-top: 1px solid var(--border-color); - margin-bottom: 16px; -} - -.tiles-grid { - display: flex; - flex-wrap: wrap; - gap: 14px; -} - -/* ── Service tile card (status-only) ─────────────────────────────── */ - -.service-tile { - width: 160px; - min-height: 130px; - background-color: var(--card-color); - border: 1px solid var(--border-color); - border-radius: var(--radius-card); - box-shadow: var(--shadow-card); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 20px 12px 18px; - gap: 0; - transition: box-shadow 0.2s, border-color 0.2s; - position: relative; - cursor: pointer; -} - -.service-tile:hover { - box-shadow: var(--shadow-hover); - border-color: #6c7086; -} - -.service-tile.disabled { - opacity: 0.45; -} - -.tile-icon { - width: 48px; - height: 48px; - object-fit: contain; - margin-bottom: 10px; -} - -.tile-icon-fallback { - width: 48px; - height: 48px; - display: flex; - align-items: center; - justify-content: center; - background-color: var(--border-color); - border-radius: 12px; - color: var(--text-dim); - font-size: 1.5rem; - margin-bottom: 10px; -} - -.tile-name { - font-size: 0.88rem; - font-weight: 600; - text-align: center; - color: var(--text-primary); - line-height: 1.3; - max-width: 140px; - word-break: break-word; - hyphens: auto; - min-height: 1.3em; - display: flex; - align-items: center; - justify-content: center; -} - -.tile-status { - font-size: 0.75rem; - margin-top: 8px; - display: flex; - align-items: center; - gap: 5px; - color: var(--text-secondary); -} - -.status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; - background-color: var(--grey); -} - -.status-dot.active { background-color: var(--green); } -.status-dot.inactive { background-color: var(--red); } -.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; } -.status-dot.failed { background-color: var(--red); } -.status-dot.disabled { background-color: var(--grey); } -.status-dot.needs-attention { background-color: var(--yellow); } - -/* ── Update modal ─────────────────────────────────���─────────────── */ - -.modal-overlay { - display: none; - position: fixed; - inset: 0; - background-color: rgba(0,0,0,0.65); - z-index: 200; - align-items: center; - justify-content: center; -} - -.modal-overlay.open { - display: flex; -} - -.modal-dialog { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: 16px; - width: 90vw; - max-width: 900px; - max-height: 80vh; - display: flex; - flex-direction: column; - box-shadow: 0 16px 48px rgba(0,0,0,0.7); -} - -.modal-header { - display: flex; - align-items: center; - padding: 16px 20px; - border-bottom: 1px solid var(--border-color); - gap: 12px; -} - -.modal-title { - font-size: 1rem; - font-weight: 700; - flex: 1; -} - -.modal-status { - font-size: 0.85rem; - color: var(--text-secondary); -} - -.modal-spinner { - width: 18px; - height: 18px; - border: 2.5px solid var(--border-color); - border-top-color: var(--accent-color); - border-radius: 50%; - animation: spin 0.75s linear infinite; - display: none; -} - -.modal-spinner.spinning { - display: block; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -.modal-log { - flex: 1; - overflow-y: auto; - padding: 12px 16px; - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.78rem; - line-height: 1.6; - color: var(--text-primary); - background-color: #12121c; - white-space: pre-wrap; - word-break: break-all; - min-height: 200px; -} - -.modal-footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - padding: 12px 20px; - border-top: 1px solid var(--border-color); -} - -/* Reboot = GREEN */ -.modal-footer .btn-reboot, -button.btn-reboot { - background-color: #2ec27e; - color: #fff; -} - -.modal-footer .btn-reboot:hover:not(:disabled), -button.btn-reboot:hover:not(:disabled) { - background-color: #27ae6e; -} - -.btn-save { - background-color: var(--yellow); - color: #1e1e2e; -} - -.btn-save:hover:not(:disabled) { - background-color: #c98d08; -} - -.btn-close-modal { - background-color: var(--border-color); - color: var(--text-primary); -} - -.btn-close-modal:hover:not(:disabled) { - background-color: #5a5c72; -} - -/* ── Credentials info modal ──────────────────────────────────────── */ - -.creds-dialog { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: 16px; - width: 90vw; - max-width: 700px; - max-height: 85vh; - display: flex; - flex-direction: column; - box-shadow: 0 16px 48px rgba(0,0,0,0.7); - animation: creds-fade-in 0.2s ease-out; -} - -@keyframes creds-fade-in { - from { opacity: 0; transform: scale(0.95) translateY(8px); } - to { opacity: 1; transform: scale(1) translateY(0); } -} - -.creds-header { - display: flex; - align-items: center; - padding: 20px 28px; - border-bottom: 1px solid var(--border-color); -} - -.creds-title { - font-size: 1.15rem; - font-weight: 700; - flex: 1; -} - -.creds-close-btn { - background: none; - color: var(--text-secondary); - font-size: 1.3rem; - padding: 4px 8px; - border-radius: 6px; - cursor: pointer; - border: none; -} - -.creds-close-btn:hover { - background-color: var(--border-color); - color: var(--text-primary); -} - -.creds-body { - padding: 24px 28px; - overflow-y: auto; -} - -.creds-loading { - color: var(--text-dim); - text-align: center; - padding: 24px 0; -} - -.creds-row { - margin-bottom: 20px; -} - -.creds-row:last-child { - margin-bottom: 0; -} - -.creds-label { - font-size: 0.78rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-dim); - margin-bottom: 6px; -} - -.creds-value-wrap { - display: flex; - align-items: flex-start; - gap: 10px; -} - -.creds-value { - flex: 1; - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.92rem; - color: var(--text-primary); - background-color: #12121c; - padding: 12px 16px; - border-radius: 8px; - word-break: break-all; - white-space: pre-wrap; - line-height: 1.6; - border: 1px solid var(--border-color); -} - -.creds-copy-btn { - background-color: var(--border-color); - color: var(--text-primary); - font-size: 0.78rem; - font-weight: 600; - padding: 8px 14px; - border-radius: 6px; - cursor: pointer; - border: none; - white-space: nowrap; - flex-shrink: 0; - align-self: flex-start; - margin-top: 10px; -} - -.creds-copy-btn:hover { - background-color: #5a5c72; -} - -.creds-copy-btn.copied { - background-color: var(--green); - color: #fff; -} - -.creds-empty { - color: var(--text-dim); - text-align: center; - padding: 24px 0; - font-size: 0.88rem; -} - -/* ── Credential links ────────────────────────────────────────────── */ - -.creds-link { - color: #b8f0c0; - text-decoration: none; - word-break: break-all; -} - -.creds-link:hover { - text-decoration: underline; - color: #defce6; -} - -/* ── Matrix action buttons ───────────────────────────────────────── */ - -.matrix-actions-divider { - border: none; - border-top: 1px solid var(--border-color); - margin: 18px 0 14px; -} - -.matrix-actions-row { - display: flex; - gap: 12px; - flex-wrap: wrap; -} - -.matrix-action-btn { - background-color: var(--accent-color); - color: #0f0f19; - font-size: 0.88rem; - font-weight: 700; - padding: 10px 18px; - border-radius: 8px; - border: none; - cursor: pointer; - flex: 1; - min-width: 140px; -} - -.matrix-action-btn:hover { - background-color: #a8c8ff; -} - -.matrix-form-group { - margin-bottom: 14px; -} - -.matrix-form-label { - display: block; - font-size: 0.82rem; - color: var(--text-secondary); - margin-bottom: 6px; - font-weight: 600; -} - -.matrix-form-input { - width: 100%; - background-color: #12121c; - color: var(--text-primary); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 10px 12px; - font-size: 0.9rem; - box-sizing: border-box; -} - -.matrix-form-input:focus { - outline: none; - border-color: var(--accent-color); -} - -.matrix-form-checkbox-row { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 14px; -} - -.matrix-form-checkbox-row input[type="checkbox"] { - width: 16px; - height: 16px; - accent-color: var(--accent-color); -} - -.matrix-form-actions { - display: flex; - gap: 10px; - margin-top: 18px; -} - -.matrix-form-submit { - background-color: var(--accent-color); - color: #0f0f19; - font-size: 0.88rem; - font-weight: 700; - padding: 10px 20px; - border-radius: 8px; - border: none; - cursor: pointer; - flex: 1; -} - -.matrix-form-submit:hover:not(:disabled) { - background-color: #a8c8ff; -} - -.matrix-form-submit:disabled { - opacity: 0.6; - cursor: default; -} - -.matrix-form-back { - background-color: var(--border-color); - color: var(--text-primary); - font-size: 0.88rem; - font-weight: 600; - padding: 10px 20px; - border-radius: 8px; - border: none; - cursor: pointer; -} - -.matrix-form-back:hover { - background-color: #5a5c72; -} - -.matrix-form-result { - margin-top: 14px; - padding: 12px 16px; - border-radius: 8px; - font-size: 0.88rem; - line-height: 1.5; - display: none; -} - -.matrix-form-result.success { - background-color: rgba(74, 222, 128, 0.12); - border: 1px solid var(--green); - color: var(--green); - display: block; -} - -.matrix-form-result.error { - background-color: rgba(239, 68, 68, 0.12); - border: 1px solid #ef4444; - color: #f87171; - display: block; -} - -/* ── QR code in credentials modal ────────────────────────────────── */ - -.creds-qr-wrap { - display: flex; - flex-direction: column; - align-items: center; - padding: 20px 0; - margin-bottom: 10px; -} - -.creds-qr-img { - width: 240px; - height: 240px; - border-radius: 12px; - border: 4px solid #fff; - background-color: #fff; - image-rendering: pixelated; - box-shadow: 0 4px 16px rgba(0,0,0,0.4); -} - -.creds-qr-hint { - margin-top: 10px; - font-size: 0.82rem; - color: var(--text-secondary); - font-style: italic; -} - -/* ── Reboot overlay ─────────────────────────────────────────────── */ - -.reboot-overlay { - display: none; - position: fixed; - inset: 0; - background-color: rgba(15, 15, 25, 0.92); - z-index: 999; - align-items: center; - justify-content: center; -} - -.reboot-overlay.visible { - display: flex; -} - -.reboot-card { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: 20px; - padding: 48px 56px; - text-align: center; - max-width: 480px; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8); - animation: reboot-fade-in 0.4s ease-out; -} - -@keyframes reboot-fade-in { - from { opacity: 0; transform: scale(0.92) translateY(12px); } - to { opacity: 1; transform: scale(1) translateY(0); } -} - -.reboot-icon { - font-size: 3rem; - color: var(--accent-color); - margin-bottom: 16px; - animation: reboot-spin 2s linear infinite; - display: inline-block; -} - -@keyframes reboot-spin { - to { transform: rotate(360deg); } -} - -.reboot-title { - font-size: 1.35rem; - font-weight: 700; - color: var(--text-primary); - margin-bottom: 12px; -} - -.reboot-message { - font-size: 0.92rem; - color: var(--text-secondary); - line-height: 1.6; - margin-bottom: 24px; -} - -.reboot-dots { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - margin-bottom: 16px; -} - -.reboot-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background-color: var(--accent-color); - animation: reboot-bounce 1.4s ease-in-out infinite; -} - -.reboot-dot:nth-child(2) { animation-delay: 0.2s; } -.reboot-dot:nth-child(3) { animation-delay: 0.4s; } - -@keyframes reboot-bounce { - 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } - 40% { opacity: 1; transform: scale(1.2); } -} - -.reboot-submessage { - font-size: 0.82rem; - color: var(--text-dim); - font-style: italic; -} - -/* ── Empty state ────────────────────────────────────────────────── */ - -.empty-state { - text-align: center; - padding: 64px 24px; - color: var(--text-dim); -} - -.empty-state p { - font-size: 1rem; - margin-bottom: 8px; -} - -/* ── Responsive ─────────────────────────────────────────────────── */ - -@media (max-width: 768px) { - body { - overflow: auto; - } - .main-content { - flex-direction: column; - overflow: visible; - } - .sidebar { - width: 100%; - height: auto; - border-right: none; - border-bottom: 1px solid var(--border-color); - padding: 14px 12px; - } - #tiles-area { - height: auto; - overflow-y: visible; - padding: 16px 12px 40px; - } -} - -@media (max-width: 600px) { - .header-bar { - padding: 10px 14px; - gap: 10px; - } - .header-bar .title { - font-size: 0.95rem; - } - .ip-bar { - gap: 16px; - flex-wrap: wrap; - padding: 8px 14px; - } - .tiles-grid { - justify-content: center; - } - .service-tile { - width: 140px; - min-height: 130px; - } - .reboot-card { - padding: 36px 28px; - margin: 0 16px; - } - .creds-dialog { - margin: 0 12px; - } - .creds-qr-img { - width: 200px; - height: 200px; - } -} - -/* ── Tech Support tile ───────────────────────────────────────────── */ - -.support-tile { - border-color: var(--accent-color); - border-width: 2px; - border-style: dashed; -} - -.support-tile:hover { - border-color: #a8c8ff; - border-style: solid; -} - -.support-status-label { - font-size: 0.75rem; - color: var(--accent-color); - font-weight: 600; -} - -/* ── Tech Support modal content ──────────────────────────────────── */ - -.support-section { - text-align: center; - padding: 8px 0; -} - -.support-icon-big { - font-size: 3rem; - margin-bottom: 12px; -} - -.support-heading { - font-size: 1.15rem; - font-weight: 700; - color: var(--text-primary); - margin-bottom: 8px; -} - -.support-active-heading { - color: var(--yellow); -} - -.support-desc { - font-size: 0.88rem; - color: var(--text-secondary); - line-height: 1.6; - margin-bottom: 20px; - max-width: 480px; - margin-left: auto; - margin-right: auto; -} - -.support-info-box { - background-color: #12121c; - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 16px 20px; - margin: 0 auto 20px; - max-width: 400px; -} - -.support-active-box { - border-color: var(--yellow); -} - -.support-info-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 6px 0; -} - -.support-info-label { - font-size: 0.78rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-dim); -} - -.support-info-value { - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.92rem; - color: var(--accent-color); - font-weight: 600; -} - -.support-info-hint { - font-size: 0.78rem; - color: var(--text-dim); - margin-top: 8px; - font-style: italic; -} - -.support-steps { - text-align: left; - max-width: 420px; - margin: 0 auto 24px; -} - -.support-steps-title { - font-size: 0.82rem; - font-weight: 700; - color: var(--text-secondary); - margin-bottom: 8px; -} - -.support-steps ol { - padding-left: 20px; - font-size: 0.85rem; - color: var(--text-secondary); - line-height: 1.8; -} - -.support-btn-enable { - background-color: var(--green); - color: #fff; - padding: 12px 32px; - font-size: 1rem; - font-weight: 700; - border-radius: 10px; -} - -.support-btn-enable:hover:not(:disabled) { - background-color: #27ae6e; -} - -.support-btn-disable { - background-color: var(--red); - color: #fff; - padding: 12px 32px; - font-size: 1rem; - font-weight: 700; - border-radius: 10px; -} - -.support-btn-disable:hover:not(:disabled) { - background-color: #c41520; -} - -.support-active-note { - font-size: 0.85rem; - color: var(--text-secondary); - margin-bottom: 20px; - max-width: 420px; - margin-left: auto; - margin-right: auto; -} - -.support-fine-print { - font-size: 0.75rem; - color: var(--text-dim); - margin-top: 12px; - font-style: italic; -} - -.support-verify-box { - background-color: #12121c; - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 16px 20px; - margin: 20px auto; - max-width: 400px; - display: flex; - justify-content: space-between; - align-items: center; -} - -.support-verify-label { - font-size: 0.82rem; - font-weight: 700; - color: var(--text-dim); -} - -.support-verify-value { - font-size: 0.88rem; - font-weight: 700; -} - -.support-verify-value.verified-gone { - color: var(--green); -} - -.support-verify-value.verify-warning { - color: var(--yellow); -} - -.support-btn-done { - background-color: var(--border-color); - color: var(--text-primary); - padding: 10px 28px; - font-size: 0.92rem; - font-weight: 600; - border-radius: 10px; -} - -.support-btn-done:hover:not(:disabled) { - background-color: #5a5c72; -} - -/* ── Tech Support — wallet protection ────────────────────────────── */ - -.support-wallet-box { - border-radius: 10px; - border: 1px solid var(--border-color); - padding: 14px 18px; - margin: 0 auto 20px; - max-width: 460px; - text-align: left; -} - -.support-wallet-protected { - border-color: var(--green); - background-color: rgba(30, 150, 96, 0.08); -} - -.support-wallet-unlocked { - border-color: var(--yellow); - background-color: rgba(230, 180, 0, 0.08); -} - -.support-wallet-warning { - border-color: var(--red); - background-color: rgba(220, 38, 38, 0.08); -} - -.support-wallet-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; -} - -.support-wallet-icon { - font-size: 1.2rem; -} - -.support-wallet-title { - font-size: 0.88rem; - font-weight: 700; - color: var(--text-primary); -} - -.support-wallet-desc { - font-size: 0.82rem; - color: var(--text-secondary); - line-height: 1.55; - margin-bottom: 10px; -} - -.support-wallet-paths { - list-style: none; - padding: 0; - margin: 0 0 12px; -} - -.support-wallet-paths li { - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.78rem; - color: var(--text-dim); - padding: 2px 0; -} - -.support-wallet-paths li::before { - content: "šŸ—‚ "; -} - -.support-wallet-unlock-row { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.support-unlock-select { - background-color: #1c1c2e; - border: 1px solid var(--border-color); - color: var(--text-primary); - border-radius: 6px; - padding: 6px 10px; - font-size: 0.85rem; -} - -.support-btn-wallet-unlock { - background-color: var(--yellow); - color: #111; - padding: 7px 18px; - font-size: 0.85rem; - font-weight: 700; - border-radius: 8px; -} - -.support-btn-wallet-unlock:hover:not(:disabled) { - background-color: #c9a200; -} - -.support-btn-wallet-lock { - background-color: var(--green); - color: #fff; - padding: 7px 18px; - font-size: 0.85rem; - font-weight: 700; - border-radius: 8px; -} - -.support-btn-wallet-lock:hover:not(:disabled) { - background-color: #1a8557; -} - -.support-btn-auditlog { - background-color: transparent; - color: var(--accent-color); - border: 1px solid var(--accent-color); - padding: 6px 18px; - font-size: 0.82rem; - font-weight: 600; - border-radius: 8px; - margin-top: 10px; -} - -.support-btn-auditlog:hover:not(:disabled) { - background-color: rgba(100, 130, 220, 0.12); -} - -/* ── Tech Support — audit log ────────────────────────────────────── */ - -.support-audit-container { - margin: 0 auto; - max-width: 520px; - padding: 0 4px 12px; -} - -.support-audit-log { - background-color: #0d0d1a; - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 10px 14px; - max-height: 220px; - overflow-y: auto; -} - -.support-audit-entry { - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.76rem; - color: var(--text-secondary); - line-height: 1.7; - border-bottom: 1px solid #1e1e30; - padding: 2px 0; -} - -.support-audit-entry:last-child { - border-bottom: none; -} - -.support-audit-empty { - font-size: 0.82rem; - color: var(--text-dim); - text-align: center; - padding: 12px 0; -} - - - -.feature-manager-section { - margin-bottom: 32px; -} - -.feature-subcategory { - margin-bottom: 24px; -} - -.feature-subcategory-header { - font-size: 0.88rem; - font-weight: 700; - color: var(--text-secondary); - margin-bottom: 10px; - padding-left: 2px; -} - -.feature-cards-wrap { - display: flex; - flex-direction: column; - gap: 0; - border: 1px solid var(--border-color); - border-radius: 12px; - overflow: hidden; -} - -.feature-card { - background-color: var(--card-color); - padding: 14px 18px; - border-bottom: 1px solid var(--border-color); -} - -.feature-card:last-child { - border-bottom: none; -} - -.feature-card-top { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.feature-card-info { - flex: 1; - min-width: 0; -} - -.feature-card-name { - font-size: 0.95rem; - font-weight: 600; - color: var(--text-primary); - margin-bottom: 2px; -} - -.feature-card-desc { - font-size: 0.82rem; - color: var(--text-secondary); - line-height: 1.4; -} - -.feature-card-status { - font-size: 0.75rem; - color: var(--text-dim); - margin-top: 6px; -} - -/* ── Feature toggle switch ───────────────────────────────────────── */ - -.feature-toggle { - position: relative; - display: inline-flex; - align-items: center; - width: 44px; - height: 24px; - flex-shrink: 0; - cursor: pointer; - margin-top: 2px; -} - -.feature-toggle-input { - opacity: 0; - width: 0; - height: 0; - position: absolute; -} - -.feature-toggle-slider { - position: absolute; - inset: 0; - background-color: var(--border-color); - border-radius: 24px; - transition: background-color 0.2s; -} - -.feature-toggle-slider::before { - content: ""; - position: absolute; - width: 18px; - height: 18px; - left: 3px; - top: 3px; - background-color: var(--text-secondary); - border-radius: 50%; - transition: transform 0.2s, background-color 0.2s; -} - -.feature-toggle.active .feature-toggle-slider { - background-color: var(--green); -} - -.feature-toggle.active .feature-toggle-slider::before { - transform: translateX(20px); - background-color: #fff; -} - -/* ── Feature domain badge (consistent with tile domain badge) ────── */ - -.feature-domain-badge { - font-size: 0.75rem; - font-weight: 600; - margin-top: 6px; - padding: 2px 0; - display: flex; - align-items: center; - gap: 4px; -} - -.feature-domain-icon { - flex-shrink: 0; -} - -.feature-domain-label { - word-break: break-word; -} - -.feature-domain-badge.configured { - color: #a6e3a1; -} - -.feature-domain-badge.not-configured { - color: #f9e2af; -} - -.feature-domain-label--checking { - color: var(--text-dim); -} - -.feature-domain-label--ok { - color: #a6e3a1; -} - -.feature-domain-label--warn { - color: #f9e2af; -} - -.feature-domain-label--error { - color: #f38ba8; -} - -/* ── Feature conflict warning ────────────────────────────────────── */ - -.feature-conflict-warning { - font-size: 0.75rem; - color: var(--yellow); - margin-top: 4px; -} - -/* ── Domain setup modal inputs ───────────────────────────────────── */ - -.domain-narrow-dialog { - max-width: 520px; -} - -.domain-setup-intro { - font-size: 0.88rem; - color: var(--text-secondary); - margin-bottom: 18px; - line-height: 1.6; -} - -.domain-setup-intro ol { - padding-left: 20px; - margin-top: 6px; -} - -.domain-setup-intro li { - margin-bottom: 6px; -} - -.domain-setup-intro code { - background-color: #12121c; - padding: 2px 8px; - border-radius: 4px; - font-family: 'JetBrains Mono', monospace; - font-size: 0.82em; - word-break: break-all; -} - -.domain-field-group { - margin-bottom: 14px; -} - -.domain-field-label { - display: block; - font-size: 0.78rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-dim); - margin-bottom: 6px; -} - -.domain-field-input { - width: 100%; - background-color: #12121c; - border: 1px solid var(--border-color); - border-radius: 8px; - color: var(--text-primary); - font-family: inherit; - font-size: 0.92rem; - padding: 10px 14px; - outline: none; - transition: border-color 0.15s; -} - -.domain-field-input:focus { - border-color: var(--accent-color); -} - -.domain-field-hint { - font-size: 0.75rem; - color: var(--text-dim); - margin-top: 5px; - font-style: italic; -} - -.domain-field-actions { - display: flex; - justify-content: flex-end; - gap: 10px; - margin-top: 20px; -} - -@media (max-width: 600px) { - .feature-cards-wrap { - border-radius: 10px; - } - .feature-card { - padding: 12px 14px; - } - .domain-narrow-dialog { - margin: 0 12px; - } -} - -/* ── Tile: Port Requirements badge ──────────────────────────────── */ - -/* ── Port Requirements Modal ────────────────────────────────────── */ - -.port-req-intro { - font-size: 0.9rem; - color: var(--text-primary); - margin-bottom: 14px; - line-height: 1.5; -} - -.port-req-table { - width: 100%; - border-collapse: collapse; - font-size: 0.85rem; - margin-bottom: 14px; -} - -.port-req-table thead th { - text-align: left; - padding: 6px 10px; - border-bottom: 1px solid var(--border-color); - color: var(--text-secondary); - font-weight: 600; -} - -.port-req-table tbody tr:nth-child(even) { - background-color: rgba(255,255,255,0.03); -} - -.port-req-port { - padding: 5px 10px; - font-family: 'JetBrains Mono', monospace; - font-size: 0.82rem; - color: var(--accent-color); - white-space: nowrap; -} - -.port-req-proto { - padding: 5px 10px; - color: var(--text-secondary); - white-space: nowrap; -} - -.port-req-desc { - padding: 5px 10px; - color: var(--text-primary); -} - -.port-req-hint { - font-size: 0.78rem; - color: var(--text-dim); - line-height: 1.5; - margin-bottom: 14px; -} - -/* ── Port status indicators ─────────────────────────────────────── */ - -.port-req-status { - padding: 5px 10px; - white-space: nowrap; - font-size: 0.82rem; -} - -.port-status-listening { - color: #a6e3a1; - font-weight: 600; -} - -.port-status-open { - color: #f9e2af; - font-weight: 600; -} - -.port-status-closed { - color: #f38ba8; - font-weight: 600; -} - -.port-status-unknown { - color: var(--text-dim); -} - -/* Internal IP highlight in port modal */ -.port-req-internal-ip { - font-family: 'JetBrains Mono', monospace; - background: rgba(137, 180, 250, 0.15); - color: var(--accent-color); - padding: 2px 6px; - border-radius: 4px; - font-size: 0.95em; -} - -/* Domain status colour helpers (used in detail modal) */ -.tile-domain-label--ok { - color: #a6e3a1; -} - -.tile-domain-label--warn { - color: #f9e2af; -} - -.tile-domain-label--error { - color: #f38ba8; -} - -/* ── Service detail modal sections ──────────────────────────────── */ - -.svc-detail-section { - margin-bottom: 24px; - padding-bottom: 24px; - border-bottom: 1px solid var(--border-color); -} - -.svc-detail-section:last-child { - border-bottom: none; - margin-bottom: 0; - padding-bottom: 0; -} - -.svc-detail-desc { - font-size: 0.92rem; - line-height: 1.7; - color: var(--text-secondary); -} - -.svc-detail-section-title { - font-size: 0.78rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-dim); - margin-bottom: 12px; -} - -.svc-detail-status { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.92rem; - font-weight: 600; -} - -.svc-detail-troubleshoot { - background-color: rgba(229, 165, 10, 0.08); - border: 1px solid rgba(229, 165, 10, 0.25); - border-radius: 10px; - padding: 16px 20px; - margin-top: 14px; - font-size: 0.85rem; - line-height: 1.7; - color: var(--text-secondary); -} - -.svc-detail-troubleshoot ol { - margin: 10px 0 0 20px; - padding: 0; -} - -.svc-detail-troubleshoot li { - margin-bottom: 4px; -} - -.svc-detail-troubleshoot code { - background-color: #12121c; - padding: 2px 8px; - border-radius: 4px; - font-family: 'JetBrains Mono', monospace; - font-size: 0.85rem; -} - -/* Port status table inside detail modal */ -.svc-detail-port-table { - width: 100%; - border-collapse: collapse; - font-size: 0.85rem; -} - -.svc-detail-port-table th { - text-align: left; - padding: 6px 10px; - font-size: 0.72rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-dim); - border-bottom: 1px solid var(--border-color); -} - -.svc-detail-port-table td { - padding: 8px 10px; - border-bottom: 1px solid rgba(69, 71, 90, 0.3); -} - -.svc-detail-port-table-port { - font-family: 'JetBrains Mono', monospace; - color: var(--accent-color); - white-space: nowrap; -} - -.svc-detail-port-table-proto { - color: var(--text-secondary); - white-space: nowrap; -} - -.svc-detail-port-table-desc { - color: var(--text-primary); -} - -.svc-detail-port-table-status { - white-space: nowrap; - font-weight: 600; -} - -/* Domain status badge in detail modal */ -.svc-detail-domain-value { - font-family: 'JetBrains Mono', monospace; - font-size: 0.9rem; - display: flex; - align-items: center; - gap: 8px; -} - -/* Addon feature toggle row in service detail modal */ -.svc-detail-addon-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-top: 10px; -} - -.svc-detail-addon-status { - font-size: 0.88rem; - font-weight: 600; -} - -.addon-status--on { - color: var(--green); -} - -.addon-status--off { - color: var(--text-dim); -} - -/* ── Sidebar: compact feature card overrides ─────────────────────── */ - -.sidebar .feature-manager-section { - margin-bottom: 0; -} - -.sidebar .feature-subcategory { - margin-bottom: 16px; -} - -.sidebar .feature-card { - padding: 10px 12px; -} - -.sidebar .feature-card-name { - font-size: 0.88rem; -} - -.sidebar .feature-card-desc { - font-size: 0.78rem; -} - -.sidebar .section-header { - font-size: 0.75rem; -} - -/* ── Onboarding Wizard ────────────────────────────────────────── */ - -.onboarding-body { - overflow: auto; - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-start; - background-color: var(--bg-color); - min-height: 100vh; - padding: 32px 16px 64px; -} - -.onboarding-shell { - width: 100%; - max-width: 680px; - margin: 0 auto; -} - -/* Progress bar */ - -.onboarding-progress-bar { - height: 4px; - background-color: var(--border-color); - border-radius: 2px; - margin-bottom: 24px; - overflow: hidden; -} - -.onboarding-progress-fill { - height: 100%; - background-color: var(--accent-color); - border-radius: 2px; - transition: width 0.4s ease; - width: 0%; -} - -/* Step dots */ - -.onboarding-steps-nav { - display: flex; - align-items: center; - justify-content: center; - gap: 0; - margin-bottom: 32px; -} - -.onboarding-step-dot { - width: 28px; - height: 28px; - border-radius: 50%; - background-color: var(--card-color); - border: 2px solid var(--border-color); - color: var(--text-dim); - font-size: 0.72rem; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - transition: background-color 0.2s, border-color 0.2s, color 0.2s; -} - -.onboarding-step-dot.active { - background-color: var(--accent-color); - border-color: var(--accent-color); - color: #1e1e2e; -} - -.onboarding-step-dot.completed { - background-color: #2ec27e; - border-color: #2ec27e; - color: #fff; -} - -.onboarding-step-connector { - flex: 1; - height: 2px; - background-color: var(--border-color); - max-width: 60px; - min-width: 16px; -} - -/* Panel */ - -.onboarding-panel-wrap { - position: relative; -} - -.onboarding-panel { - animation: ob-fadein 0.25s ease; -} - -@keyframes ob-fadein { - from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: translateY(0); } -} - -/* Hero (steps 1 and 6) */ - -.onboarding-hero { - text-align: center; - margin-bottom: 28px; -} - -.onboarding-logo { - font-size: 3.5rem; - line-height: 1; - margin-bottom: 14px; -} - -.onboarding-logo-img { - height: 90px; - width: auto; -} - -.onboarding-title { - font-size: 1.7rem; - font-weight: 800; - color: var(--text-primary); - margin-bottom: 8px; -} - -.onboarding-subtitle { - font-size: 1rem; - color: var(--accent-color); - font-weight: 600; - letter-spacing: 0.04em; -} - -/* Step header (steps 2-5) */ - -.onboarding-step-header { - margin-bottom: 20px; -} - -.onboarding-step-icon { - font-size: 2rem; - display: block; - margin-bottom: 10px; -} - -.onboarding-step-title { - font-size: 1.35rem; - font-weight: 700; - color: var(--text-primary); - margin-bottom: 6px; -} - -.onboarding-step-desc { - font-size: 0.9rem; - color: var(--text-secondary); - line-height: 1.6; -} - -/* Cards */ - -.onboarding-card { - background-color: var(--card-color); - border: 1px solid var(--border-color); - border-radius: var(--radius-card); - padding: 24px; - margin-bottom: 20px; -} - -.onboarding-card--scroll { - max-height: 440px; - overflow-y: auto; -} - -.onboarding-card--ports { - overflow-y: visible; -} - -.onboarding-body-text { - font-size: 0.92rem; - color: var(--text-secondary); - line-height: 1.7; - margin-bottom: 16px; -} - -.onboarding-body-text--dim { - color: var(--text-dim); - font-size: 0.85rem; -} - -.onboarding-body-text:last-child { margin-bottom: 0; } - -/* Role badge */ - -.onboarding-role-row { - display: flex; - align-items: center; - gap: 10px; - margin: 16px 0; -} - -.onboarding-role-label { - font-size: 0.88rem; - color: var(--text-secondary); - font-weight: 600; -} - -.onboarding-role-badge { - background-color: var(--accent-color); - color: #1e1e2e; - font-size: 0.75rem; - font-weight: 700; - padding: 3px 12px; - border-radius: 20px; - letter-spacing: 0.04em; -} - -/* Checklist */ - -.onboarding-checklist { - list-style: none; - margin: 16px 0 0; - display: flex; - flex-direction: column; - gap: 8px; -} - -.onboarding-checklist li { - font-size: 0.9rem; - color: var(--text-secondary); -} - -/* Footer navigation */ - -.onboarding-footer { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 8px; -} - -.onboarding-btn-next, -.onboarding-btn-back { - min-width: 140px; -} - -/* Status messages */ - -.onboarding-save-status { - font-size: 0.85rem; - min-height: 1.4em; - margin-bottom: 8px; - padding: 0 4px; -} - -.onboarding-save-status--ok { - color: #a6e3a1; -} - -.onboarding-save-status--error { - color: #f38ba8; -} - -.onboarding-save-status--info { - color: var(--text-secondary); -} - -/* Loading / error states */ - -.onboarding-loading { - font-size: 0.88rem; - color: var(--text-dim); - text-align: center; - padding: 24px 0; -} - -.onboarding-error { - font-size: 0.88rem; - color: #f38ba8; - padding: 12px 0; -} - -/* Hints */ - -.onboarding-hint { - font-size: 0.82rem; - color: var(--text-dim); - margin-bottom: 14px; - line-height: 1.5; -} - -.onboarding-hint code { - font-family: 'JetBrains Mono', monospace; - background: rgba(137, 180, 250, 0.1); - padding: 1px 5px; - border-radius: 3px; - color: var(--accent-color); -} - -.onboarding-hint--inline { - margin-bottom: 6px; - margin-top: 2px; -} - -.onboarding-optional { - font-size: 0.78rem; - color: var(--text-dim); -} - -/* Domain inputs */ - -.onboarding-domain-group { - margin-bottom: 18px; -} - -.onboarding-domain-group--email { - border-top: 1px solid var(--border-color); - padding-top: 18px; - margin-top: 6px; -} - -.onboarding-domain-label { - display: block; - font-size: 0.85rem; - font-weight: 600; - color: var(--text-secondary); - margin-bottom: 5px; -} - -.onboarding-domain-label--sub { - font-weight: 500; - font-size: 0.8rem; - margin-top: 8px; -} - -.onboarding-domain-input { - width: 100%; - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: var(--radius-btn); - color: var(--text-primary); - font-size: 0.88rem; - padding: 8px 12px; - outline: none; - transition: border-color 0.15s; - font-family: inherit; -} - -.onboarding-domain-input:focus { - border-color: var(--accent-color); -} - -/* Port forwarding */ - -.onboarding-port-ip { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - background: rgba(137, 180, 250, 0.06); - border: 1px solid rgba(137, 180, 250, 0.2); - border-radius: 10px; - padding: 12px 16px; - margin-bottom: 16px; - font-size: 0.88rem; -} - -.onboarding-port-ip-label { - color: var(--text-secondary); -} - -.onboarding-port-all-ok { - color: #a6e3a1; - font-size: 0.92rem; - font-weight: 600; - margin-bottom: 12px; -} - -.onboarding-port-totals { - background: var(--card-color); - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 10px 14px; - font-size: 0.93em; - color: var(--text-primary); - margin-bottom: 18px; - line-height: 1.6; -} - -.onboarding-port-warn { - background: rgba(229, 165, 10, 0.15); - border: 1px solid rgba(229, 165, 10, 0.5); - border-radius: 8px; - padding: 12px 16px; - font-size: 0.88rem; - color: var(--text-primary); - margin-bottom: 14px; - line-height: 1.5; -} - -.onboarding-port-breakdown { - margin-bottom: 14px; -} - -.onboarding-port-breakdown-title { - font-size: 0.78rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - color: var(--text-dim); - margin-bottom: 8px; -} - -.onboarding-port-svc { - margin-bottom: 10px; -} - -.onboarding-port-svc-name { - font-size: 0.88rem; - font-weight: 600; - color: var(--text-secondary); - margin-bottom: 4px; -} - -.onboarding-port-row { - display: flex; - align-items: center; - gap: 8px; - padding: 2px 0; - font-size: 0.85rem; -} - -.onboarding-port-details { - margin-top: 12px; -} - -.onboarding-port-details-summary { - font-size: 0.82rem; - color: var(--accent-color); - cursor: pointer; - font-weight: 600; - padding: 4px 0; -} - -.onboarding-port-table { - width: 100%; - border-collapse: collapse; - margin-top: 10px; - font-size: 0.92rem; -} - -.onboarding-port-table th { - text-align: left; - padding: 6px 10px; - font-size: 0.8rem; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-dim); - border-bottom: 1px solid var(--border-color); -} - -.onboarding-port-table td { - padding: 8px 10px; - vertical-align: top; -} - -/* Credentials */ - -.onboarding-creds-notice { - background: rgba(137, 180, 250, 0.06); - border: 1px solid rgba(137, 180, 250, 0.2); - border-radius: 8px; - padding: 10px 14px; - font-size: 0.85rem; - color: var(--text-secondary); - margin-bottom: 16px; - line-height: 1.5; -} - -.onboarding-creds-category { - margin-bottom: 20px; -} - -.onboarding-creds-category-title { - font-size: 0.78rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - color: var(--text-dim); - margin-bottom: 8px; - padding-bottom: 4px; - border-bottom: 1px solid var(--border-color); -} - -.onboarding-creds-service { - background: var(--surface-color); - border-radius: 10px; - padding: 12px 16px; - margin-bottom: 10px; -} - -.onboarding-creds-service-name { - font-size: 0.9rem; - font-weight: 700; - color: var(--text-primary); - margin-bottom: 8px; -} - -.onboarding-cred-row { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 3px 0; - font-size: 0.85rem; - flex-wrap: wrap; -} - -.onboarding-cred-label { - color: var(--text-dim); - font-weight: 600; - min-width: 100px; - flex-shrink: 0; -} - -.onboarding-cred-value { - color: var(--text-primary); - word-break: break-all; - font-family: 'JetBrains Mono', monospace; - font-size: 0.82rem; -} - -.onboarding-cred-secret { - display: flex; - align-items: center; - gap: 8px; -} - -.onboarding-cred-hidden { - color: var(--text-dim); - letter-spacing: 0.15em; -} - -.onboarding-cred-reveal-btn { - background: none; - border: 1px solid var(--border-color); - border-radius: 4px; - color: var(--accent-color); - font-size: 0.72rem; - padding: 1px 6px; - cursor: pointer; - font-family: inherit; -} - -.onboarding-cred-reveal-btn:hover { - background-color: rgba(137, 180, 250, 0.1); -} - -/* Feature step */ - -.onboarding-feat-group { - margin-bottom: 20px; -} - -.onboarding-feat-group-title { - font-size: 0.78rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - color: var(--text-dim); - margin-bottom: 8px; - padding-bottom: 4px; - border-bottom: 1px solid var(--border-color); -} - -.onboarding-feat-card { - background: var(--surface-color); - border-radius: 10px; - padding: 12px 16px; - margin-bottom: 10px; - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; -} - -.onboarding-feat-info { - flex: 1; - min-width: 0; -} - -.onboarding-feat-name { - font-size: 0.9rem; - font-weight: 700; - color: var(--text-primary); - margin-bottom: 3px; -} - -.onboarding-feat-desc { - font-size: 0.82rem; - color: var(--text-secondary); - line-height: 1.4; - margin-bottom: 5px; -} - -.onboarding-feat-domain { - font-size: 0.75rem; - font-weight: 600; - padding: 2px 8px; - border-radius: 10px; - display: inline-block; -} - -.onboarding-feat-domain--ok { - background: rgba(46, 194, 126, 0.12); - color: #a6e3a1; -} - -.onboarding-feat-domain--missing { - background: rgba(229, 165, 10, 0.1); - color: #f9e2af; -} - +/* Other styles remain unchanged */ -- 2.53.0 From 1fd4e101e66552793dc284ca46f4ac460e70dc75 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 18:28:29 +0000 Subject: [PATCH 279/857] Initial plan -- 2.53.0 From 1692ba0e9db57b18eebd0199800f9447a04094f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 18:37:18 +0000 Subject: [PATCH 280/857] Fix tile/modal status inconsistency and add BIP110/Bitcoin Core mutual-exclusivity messaging Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/7238843b-8bbf-4f02-b932-defb5b6ace35 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/static/app.js | 39 +++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 36a6943..ff246ce 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -401,8 +401,14 @@ async function openServiceDetailModal(unit, name) { } // Section B: Status - var sc = statusClass(data.health || data.status); - var st = statusText(data.health || data.status, data.enabled); + // When a feature override is present, use the feature's enabled state so the + // modal matches what the dashboard tile shows (feature toggle is authoritative). + var effectiveEnabled = data.feature ? data.feature.enabled : data.enabled; + var effectiveHealth = data.feature && !data.feature.enabled + ? "disabled" + : (data.health || data.status); + var sc = statusClass(effectiveHealth); + var st = statusText(effectiveHealth, effectiveEnabled); html += '
        ' + '
        Status
        ' + '
        ' + @@ -596,9 +602,34 @@ async function openServiceDetailModal(unit, name) { var addonStatusCls = feat.enabled ? "addon-status--on" : "addon-status--off"; var addonBtnLabel = feat.enabled ? "Disable Feature" : "Enable Feature"; var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary"; + + // Section title: use a more specific label for mutually-exclusive Bitcoin node features + var addonSectionTitle = (feat.id === "bip110" || feat.id === "bitcoin-core") + ? "\u20BF Bitcoin Node Selection" + : "\uD83D\uDD27 Addon Feature"; + + // Description: prefer the feature's own description over a generic fallback + var addonDesc = feat.description + ? feat.description + : "This is an optional addon feature. You can enable or disable it at any time."; + + // Conflicts warning: list mutually-exclusive feature names when present + var conflictsHtml = ""; + if (feat.conflicts_with && feat.conflicts_with.length > 0) { + var conflictNames = feat.conflicts_with.map(function(cid) { + if (_featuresData && Array.isArray(_featuresData.features)) { + var cf = _featuresData.features.find(function(f) { return f.id === cid; }); + if (cf) return cf.name; + } + return cid; + }); + conflictsHtml = '
        \u26A0 Mutually exclusive with: ' + escHtml(conflictNames.join(", ")) + '
        '; + } + html += '
        ' + - '
        šŸ”§ Addon Feature
        ' + - '

        This is an optional addon feature. You can enable or disable it at any time.

        ' + + '
        ' + addonSectionTitle + '
        ' + + '

        ' + escHtml(addonDesc) + '

        ' + + conflictsHtml + '
        ' + '' + addonStatusLabel + '' + '' + -- 2.53.0 From a875546133435f8e69fc5ee9c6e42a57302276ac Mon Sep 17 00:00:00 2001 From: Sovran_Systems <99053422+naturallaw777@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:16:11 -0500 Subject: [PATCH 281/857] =?UTF-8?q?Restore=20full=20style.css=20accidental?= =?UTF-8?q?ly=20truncated=20in=20f86df9c;=20apply=20logo=20height=20fix=20?= =?UTF-8?q?(64px=E2=86=9236px)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/sovran_systemsos_web/static/style.css | 1090 ++++++++++++++++++++- 1 file changed, 1089 insertions(+), 1 deletion(-) diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 3f89d3f..90babca 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -1,3 +1,74 @@ +/* Sovran_SystemsOS Hub — Web UI Stylesheet + Dark theme matching the Adwaita dark aesthetic + v6 — Status-only tiles (no controls) */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg-color: #1e1e2e; + --surface-color: #2a2a3c; + --card-color: #313244; + --border-color: #45475a; + --text-primary: #cdd6f4; + --text-secondary: #a6adc8; + --text-dim: #6c7086; + --accent-color: #89b4fa; + --green: #2ec27e; + --yellow: #e5a50a; + --red: #e01b24; + --grey: #888888; + --radius-card: 18px; + --radius-btn: 8px; + --shadow-card: 0 2px 8px rgba(0,0,0,0.4); + --shadow-hover: 0 6px 20px rgba(0,0,0,0.6); +} + +html, body { + height: 100%; +} + +body { + font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif; + background-color: var(--bg-color); + color: var(--text-primary); + line-height: 1.5; + min-height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ── Header bar ─────────────────────────────────────────────────── */ + +.header-bar { + background-color: var(--surface-color); + border-bottom: 1px solid var(--border-color); + padding: 10px 24px; + display: flex; + align-items: center; + gap: 16px; + position: sticky; + top: 0; + z-index: 100; + justify-content: flex-end; +} + +.header-bar .title { + font-size: 1.15rem; + font-weight: 700; + color: var(--text-primary); + position: absolute; + left: 0; + right: 0; + text-align: center; + pointer-events: none; + white-space: nowrap; +} + .header-logo { height: 36px; width: auto; @@ -5,4 +76,1021 @@ margin-right: 10px; } -/* Other styles remain unchanged */ +.role-badge { + background-color: var(--accent-color); + color: #1e1e2e; + font-size: 0.72rem; + font-weight: 700; + padding: 3px 10px; + border-radius: 20px; + letter-spacing: 0.03em; +} + +/* ── Buttons ────────────────────────────────────────────────────── */ + +button { + font-family: inherit; + cursor: pointer; + border: none; + outline: none; + transition: opacity 0.15s, box-shadow 0.15s, background-color 0.15s; +} + +button:disabled { + opacity: 0.45; + cursor: default; +} + +.btn { + padding: 7px 16px; + border-radius: var(--radius-btn); + font-size: 0.88rem; + font-weight: 600; +} + +.btn-primary { + background-color: var(--accent-color); + color: #1e1e2e; +} + +.btn-primary:hover:not(:disabled) { + opacity: 0.88; +} + +/* Update System button: BLUE by default */ +.btn-update { + background-color: #89b4fa; + color: #1e1e2e; + position: relative; + display: flex; + align-items: center; + gap: 8px; +} + +.btn-update:hover:not(:disabled) { + opacity: 0.88; +} + +/* Update System button: GREEN when updates are available */ +.btn-update.has-updates { + background-color: #2ec27e; + color: #fff; +} + +.btn-update.has-updates:hover:not(:disabled) { + background-color: #27ae6e; +} + +.update-badge { + display: none; + width: 10px; + height: 10px; + background-color: var(--yellow); + border-radius: 50%; + animation: pulse-badge 1.4s ease-in-out infinite; +} + +.update-badge.visible { + display: inline-block; +} + +@keyframes pulse-badge { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(1.35); } +} + +.btn-icon { + background: none; + color: var(--text-secondary); + padding: 6px; + border-radius: 50%; + font-size: 1.1rem; + line-height: 1; +} + +.btn-icon:hover:not(:disabled) { + background-color: var(--border-color); + color: var(--text-primary); +} + +/* ── IP bar ─────────────────────────────────────────────────────── */ + +.ip-bar { + background-color: var(--surface-color); + border-bottom: 1px solid var(--border-color); + padding: 8px 24px; + display: flex; + align-items: center; + justify-content: center; + gap: 32px; + font-size: 0.82rem; + color: var(--text-secondary); +} + +.ip-bar .ip-label { + color: var(--text-dim); + margin-right: 6px; +} + +.ip-bar .ip-value { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + color: var(--accent-color); + font-weight: 600; +} + +.ip-separator { + color: var(--border-color); +} + +/* ── Main content ───────────────────────────────────────────────── */ + +.main-content { + display: flex; + align-items: flex-start; + flex: 1; + overflow: hidden; + max-width: 1400px; + width: 100%; + margin-left: auto; + margin-right: auto; +} + +/* ── Sidebar ────────────────────────────────────────────────────── */ + +.sidebar { + width: 270px; + flex-shrink: 0; + height: 100%; + overflow-y: auto; + border-right: 1px solid var(--border-color); + background-color: var(--surface-color); + padding: 20px 14px; + display: flex; + flex-direction: column; + gap: 0; +} + +/* ── Sidebar: Tech Support button ───────────────────────────────── */ + +.sidebar-support-btn { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + background-color: var(--card-color); + border: 2px dashed var(--accent-color); + border-radius: 12px; + padding: 12px 14px; + color: var(--text-primary); + cursor: pointer; + transition: border-style 0.15s, border-color 0.15s, background-color 0.15s; + text-align: left; +} + +.sidebar-support-btn:hover { + border-style: solid; + border-color: #a8c8ff; + background-color: #35354a; +} + +.sidebar-support-icon { + font-size: 1.5rem; + flex-shrink: 0; +} + +.sidebar-support-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.sidebar-support-title { + font-size: 0.88rem; + font-weight: 700; + color: var(--text-primary); +} + +.sidebar-support-hint { + font-size: 0.72rem; + color: var(--accent-color); + font-weight: 600; +} + +.sidebar-divider { + border: none; + border-top: 1px solid var(--border-color); + margin: 16px 0; +} + +/* ── Tiles area ─────────────────────────────────────────────────── */ + +#tiles-area { + flex: 1; + height: 100%; + overflow-y: auto; + padding: 24px 20px 48px; + min-width: 0; +} + +/* ── Category sections ──────────────────────────────────────────── */ + +.category-section { + margin-bottom: 32px; +} + +.section-header { + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 4px; + padding-left: 4px; +} + +.section-divider { + border: none; + border-top: 1px solid var(--border-color); + margin-bottom: 16px; +} + +.tiles-grid { + display: flex; + flex-wrap: wrap; + gap: 14px; +} + +/* ── Service tile card (status-only) ─────────────────────────────── */ + +.service-tile { + width: 160px; + min-height: 130px; + background-color: var(--card-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px 12px 18px; + gap: 0; + transition: box-shadow 0.2s, border-color 0.2s; + position: relative; + cursor: pointer; +} + +.service-tile:hover { + box-shadow: var(--shadow-hover); + border-color: #6c7086; +} + +.service-tile.disabled { + opacity: 0.45; +} + +.tile-icon { + width: 48px; + height: 48px; + object-fit: contain; + margin-bottom: 10px; +} + +.tile-icon-fallback { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--border-color); + border-radius: 12px; + color: var(--text-dim); + font-size: 1.5rem; + margin-bottom: 10px; +} + +.tile-name { + font-size: 0.88rem; + font-weight: 600; + text-align: center; + color: var(--text-primary); + line-height: 1.3; + max-width: 140px; + word-break: break-word; + hyphens: auto; + min-height: 1.3em; + display: flex; + align-items: center; + justify-content: center; +} + +.tile-status { + font-size: 0.75rem; + margin-top: 8px; + display: flex; + align-items: center; + gap: 5px; + color: var(--text-secondary); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background-color: var(--grey); +} + +.status-dot.active { background-color: var(--green); } +.status-dot.inactive { background-color: var(--red); } +.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; } +.status-dot.failed { background-color: var(--red); } +.status-dot.disabled { background-color: var(--grey); } +.status-dot.needs-attention { background-color: var(--yellow); } + +/* ── Update modal ────────────────────────────────────────────────── */ + +.modal-overlay { + display: none; + position: fixed; + inset: 0; + background-color: rgba(0,0,0,0.65); + z-index: 200; + align-items: center; + justify-content: center; +} + +.modal-overlay.open { + display: flex; +} + +.modal-dialog { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 16px; + width: 90vw; + max-width: 900px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 16px 48px rgba(0,0,0,0.7); +} + +.modal-header { + display: flex; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); + gap: 12px; +} + +.modal-title { + font-size: 1rem; + font-weight: 700; + flex: 1; +} + +.modal-status { + font-size: 0.85rem; + color: var(--text-secondary); +} + +.modal-spinner { + width: 18px; + height: 18px; + border: 2.5px solid var(--border-color); + border-top-color: var(--accent-color); + border-radius: 50%; + animation: spin 0.75s linear infinite; + display: none; +} + +.modal-spinner.spinning { + display: block; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.modal-log { + flex: 1; + overflow-y: auto; + padding: 12px 16px; + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.78rem; + line-height: 1.6; + color: var(--text-primary); + background-color: #12121c; + white-space: pre-wrap; + word-break: break-all; + min-height: 200px; +} + +.modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 12px 20px; + border-top: 1px solid var(--border-color); +} + +/* Reboot = GREEN */ +.modal-footer .btn-reboot, +button.btn-reboot { + background-color: #2ec27e; + color: #fff; +} + +.modal-footer .btn-reboot:hover:not(:disabled), +button.btn-reboot:hover:not(:disabled) { + background-color: #27ae6e; +} + +.btn-save { + background-color: var(--yellow); + color: #1e1e2e; +} + +.btn-save:hover:not(:disabled) { + background-color: #c98d08; +} + +.btn-close-modal { + background-color: var(--border-color); + color: var(--text-primary); +} + +.btn-close-modal:hover:not(:disabled) { + background-color: #5a5c72; +} + +/* ── Credentials info modal ──────────────────────────────────────── */ + +.creds-dialog { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 16px; + width: 90vw; + max-width: 700px; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 16px 48px rgba(0,0,0,0.7); + animation: creds-fade-in 0.2s ease-out; +} + +@keyframes creds-fade-in { + from { opacity: 0; transform: scale(0.95) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.creds-header { + display: flex; + align-items: center; + padding: 20px 28px; + border-bottom: 1px solid var(--border-color); +} + +.creds-title { + font-size: 1.15rem; + font-weight: 700; + flex: 1; +} + +.creds-close-btn { + background: none; + color: var(--text-secondary); + font-size: 1.3rem; + padding: 4px 8px; + border-radius: 6px; + cursor: pointer; + border: none; +} + +.creds-close-btn:hover { + background-color: var(--border-color); + color: var(--text-primary); +} + +.creds-body { + padding: 24px 28px; + overflow-y: auto; +} + +.creds-loading { + color: var(--text-dim); + text-align: center; + padding: 24px 0; +} + +.creds-row { + margin-bottom: 20px; +} + +.creds-row:last-child { + margin-bottom: 0; +} + +.creds-label { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + margin-bottom: 6px; +} + +.creds-value-wrap { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.creds-value { + flex: 1; + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.92rem; + color: var(--text-primary); + background-color: #12121c; + padding: 12px 16px; + border-radius: 8px; + word-break: break-all; + white-space: pre-wrap; + line-height: 1.6; + border: 1px solid var(--border-color); +} + +.creds-copy-btn { + background-color: var(--border-color); + color: var(--text-primary); + font-size: 0.78rem; + font-weight: 600; + padding: 8px 14px; + border-radius: 6px; + cursor: pointer; + border: none; + white-space: nowrap; + flex-shrink: 0; + align-self: flex-start; + margin-top: 10px; +} + +.creds-copy-btn:hover { + background-color: #5a5c72; +} + +.creds-copy-btn.copied { + background-color: var(--green); + color: #fff; +} + +.creds-empty { + color: var(--text-dim); + text-align: center; + padding: 24px 0; + font-size: 0.88rem; +} + +/* ── Credential links ────────────────────────────────────────────── */ + +.creds-link { + color: #b8f0c0; + text-decoration: none; + word-break: break-all; +} + +.creds-link:hover { + text-decoration: underline; + color: #defce6; +} + +/* ── Matrix action buttons ───────────────────────────────────────── */ + +.matrix-actions-divider { + border: none; + border-top: 1px solid var(--border-color); + margin: 18px 0 14px; +} + +.matrix-actions-row { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.matrix-action-btn { + background-color: var(--accent-color); + color: #0f0f19; + font-size: 0.88rem; + font-weight: 700; + padding: 10px 18px; + border-radius: 8px; + border: none; + cursor: pointer; + flex: 1; + min-width: 140px; +} + +.matrix-action-btn:hover { + background-color: #a8c8ff; +} + +.matrix-form-group { + margin-bottom: 14px; +} + +.matrix-form-label { + display: block; + font-size: 0.82rem; + color: var(--text-secondary); + margin-bottom: 6px; + font-weight: 600; +} + +.matrix-form-input { + width: 100%; + background-color: #12121c; + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 12px; + font-size: 0.9rem; + box-sizing: border-box; +} + +.matrix-form-input:focus { + outline: none; + border-color: var(--accent-color); +} + +.matrix-form-checkbox-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.matrix-form-checkbox-row input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent-color); +} + +.matrix-form-actions { + display: flex; + gap: 10px; + margin-top: 18px; +} + +.matrix-form-submit { + background-color: var(--accent-color); + color: #0f0f19; + font-size: 0.88rem; + font-weight: 700; + padding: 10px 20px; + border-radius: 8px; + border: none; + cursor: pointer; + flex: 1; +} + +.matrix-form-submit:hover:not(:disabled) { + background-color: #a8c8ff; +} + +.matrix-form-submit:disabled { + opacity: 0.6; + cursor: default; +} + +.matrix-form-back { + background-color: var(--border-color); + color: var(--text-primary); + font-size: 0.88rem; + font-weight: 600; + padding: 10px 20px; + border-radius: 8px; + border: none; + cursor: pointer; +} + +.matrix-form-back:hover { + background-color: #5a5c72; +} + +.matrix-form-result { + margin-top: 14px; + padding: 12px 16px; + border-radius: 8px; + font-size: 0.88rem; + line-height: 1.5; + display: none; +} + +.matrix-form-result.success { + background-color: rgba(74, 222, 128, 0.12); + border: 1px solid var(--green); + color: var(--green); + display: block; +} + +.matrix-form-result.error { + background-color: rgba(239, 68, 68, 0.12); + border: 1px solid #ef4444; + color: #f87171; + display: block; +} + +/* ── QR code in credentials modal ────────────────────────────────── */ + +.creds-qr-wrap { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + margin-bottom: 10px; +} + +.creds-qr-img { + width: 240px; + height: 240px; + border-radius: 12px; + border: 4px solid #fff; + background-color: #fff; + image-rendering: pixelated; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); +} + +.creds-qr-hint { + margin-top: 10px; + font-size: 0.82rem; + color: var(--text-secondary); + font-style: italic; +} + +/* ── Reboot overlay ─────────────────────────────────────────────── */ + +.reboot-overlay { + display: none; + position: fixed; + inset: 0; + background-color: rgba(15, 15, 25, 0.92); + z-index: 999; + align-items: center; + justify-content: center; +} + +.reboot-overlay.visible { + display: flex; +} + +.reboot-card { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 48px 56px; + text-align: center; + max-width: 480px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8); + animation: reboot-fade-in 0.4s ease-out; +} + +@keyframes reboot-fade-in { + from { opacity: 0; transform: scale(0.92) translateY(12px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.reboot-icon { + font-size: 3rem; + color: var(--accent-color); + margin-bottom: 16px; + animation: reboot-spin 2s linear infinite; + display: inline-block; +} + +@keyframes reboot-spin { + to { transform: rotate(360deg); } +} + +.reboot-title { + font-size: 1.35rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 12px; +} + +.reboot-message { + font-size: 0.92rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 24px; +} + +.reboot-dots { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: 16px; +} + +.reboot-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: var(--accent-color); + animation: reboot-bounce 1.4s ease-in-out infinite; +} + +.reboot-dot:nth-child(2) { animation-delay: 0.2s; } +.reboot-dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes reboot-bounce { + 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } + 40% { opacity: 1; transform: scale(1.2); } +} + +.reboot-submessage { + font-size: 0.82rem; + color: var(--text-dim); + font-style: italic; +} + +/* ── Empty state ────────────────────────────────────────────────── */ + +.empty-state { + text-align: center; + padding: 64px 24px; + color: var(--text-dim); +} + +.empty-state p { + font-size: 1rem; + margin-bottom: 8px; +} + +/* ── Responsive ─────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + body { + overflow: auto; + } + .main-content { + flex-direction: column; + overflow: visible; + } + .sidebar { + width: 100%; + height: auto; + border-right: none; + border-bottom: 1px solid var(--border-color); + padding: 14px 12px; + } + #tiles-area { + height: auto; + overflow-y: visible; + padding: 16px 12px 40px; + } +} + +@media (max-width: 600px) { + .header-bar { + padding: 10px 14px; + gap: 10px; + } + .header-bar .title { + font-size: 0.95rem; + } + .ip-bar { + gap: 16px; + flex-wrap: wrap; + padding: 8px 14px; + } + .tiles-grid { + justify-content: center; + } + .service-tile { + width: 140px; + min-height: 130px; + } + .reboot-card { + padding: 36px 28px; + margin: 0 16px; + } + .creds-dialog { + margin: 0 12px; + } + .creds-qr-img { + width: 200px; + height: 200px; + } +} + +/* ── Tech Support tile ───────────────────────────────────────────── */ + +.support-tile { + border-color: var(--accent-color); + border-width: 2px; + border-style: dashed; +} + +.support-tile:hover { + border-color: #a8c8ff; + border-style: solid; +} + +/* ── Login page ──────────────────────────────────────────────────── */ + +.login-wrapper { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 24px; +} + +.login-card { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 48px 40px; + width: 100%; + max-width: 400px; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); +} + +.login-header { + text-align: center; + margin-bottom: 32px; +} + +.login-logo { + height: 64px; + margin-bottom: 16px; +} + +.login-title { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); +} + +.login-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-group label { + display: block; + font-size: 0.82rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.form-group input { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--border-color); + border-radius: var(--radius-btn); + background-color: var(--card-color); + color: var(--text-primary); + font-size: 0.92rem; +} + +.form-group input:focus { + outline: none; + border-color: var(--accent-color); +} + +.btn-login { + width: 100%; + padding: 12px; + border-radius: var(--radius-btn); + background-color: var(--accent-color); + color: #1e1e2e; + font-size: 0.95rem; + font-weight: 700; + margin-top: 8px; +} + +.btn-login:hover { + opacity: 0.88; +} + +.login-error { + background-color: rgba(224, 27, 36, 0.12); + border: 1px solid var(--red); + color: #f87171; + padding: 10px 14px; + border-radius: 8px; + font-size: 0.85rem; + display: none; +} + +.login-error.visible { + display: block; +} \ No newline at end of file -- 2.53.0 From f10cb78022795ac8165bf50950f906a407993eab Mon Sep 17 00:00:00 2001 From: Sovran_Systems <99053422+naturallaw777@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:27:35 -0500 Subject: [PATCH 282/857] Add missing service detail modal CSS styles (svc-detail, addon, domain, port, support, feature) --- app/sovran_systemsos_web/static/style.css | 583 ++++++++++++++++++++++ 1 file changed, 583 insertions(+) diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 90babca..9bb6966 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -408,6 +408,198 @@ button:disabled { .status-dot.disabled { background-color: var(--grey); } .status-dot.needs-attention { background-color: var(--yellow); } +/* ── Service detail modal sections ───────────────────────────────── */ + +.svc-detail-section { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color); +} + +.svc-detail-section:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.svc-detail-section-title { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + margin-bottom: 10px; +} + +.svc-detail-desc { + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.6; +} + +.svc-detail-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); +} + +/* ── Service detail: Domain ──────────────────────────────────────── */ + +.svc-detail-domain-value { + font-size: 0.9rem; + color: var(--text-primary); + font-weight: 600; +} + +.tile-domain-label--ok { + color: var(--green); + font-weight: 600; +} + +.tile-domain-label--warn { + color: var(--yellow); + font-weight: 600; +} + +.tile-domain-label--error { + color: var(--red); + font-weight: 600; +} + +/* ── Service detail: Port table ──────────────────────────────────── */ + +.svc-detail-port-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; + margin-top: 8px; +} + +.svc-detail-port-table th { + text-align: left; + color: var(--text-dim); + font-weight: 600; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 6px 10px; + border-bottom: 1px solid var(--border-color); +} + +.svc-detail-port-table td { + padding: 8px 10px; + border-bottom: 1px solid rgba(69, 71, 90, 0.4); + color: var(--text-primary); +} + +.svc-detail-port-table tr:last-child td { + border-bottom: none; +} + +.svc-detail-port-table-port { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-weight: 600; + color: var(--accent-color); +} + +.svc-detail-port-table-proto { + text-transform: uppercase; + color: var(--text-secondary); +} + +.svc-detail-port-table-desc { + color: var(--text-secondary); +} + +.svc-detail-port-table-status { + font-weight: 600; +} + +.port-status-listening { color: var(--green); } +.port-status-open { color: var(--yellow); } +.port-status-closed { color: var(--red); } +.port-status-unknown { color: var(--text-dim); } + +/* ── Service detail: Troubleshoot box ────────────────────────────── */ + +.svc-detail-troubleshoot { + margin-top: 12px; + padding: 14px 16px; + background-color: rgba(229, 165, 10, 0.08); + border: 1px solid rgba(229, 165, 10, 0.3); + border-radius: 10px; + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.6; +} + +.svc-detail-troubleshoot strong { + color: var(--yellow); +} + +.svc-detail-troubleshoot ol { + margin-top: 8px; + padding-left: 20px; +} + +.svc-detail-troubleshoot li { + margin-bottom: 4px; +} + +.svc-detail-troubleshoot code { + background-color: rgba(137, 180, 250, 0.12); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.82rem; + color: var(--accent-color); +} + +.svc-detail-troubleshoot a { + color: var(--accent-color); + text-decoration: none; +} + +.svc-detail-troubleshoot a:hover { + text-decoration: underline; +} + +/* ── Service detail: Addon feature toggle ────────────────────────── */ + +.svc-detail-addon-row { + display: flex; + align-items: center; + gap: 14px; + margin-top: 12px; +} + +.svc-detail-addon-status { + font-size: 0.88rem; + font-weight: 700; +} + +.addon-status--on { + color: var(--green); +} + +.addon-status--off { + color: var(--text-dim); +} + +.feature-conflict-warning { + margin-top: 8px; + margin-bottom: 8px; + padding: 10px 14px; + background-color: rgba(229, 165, 10, 0.1); + border: 1px solid rgba(229, 165, 10, 0.3); + border-radius: 8px; + font-size: 0.82rem; + color: var(--yellow); + font-weight: 600; +} + /* ── Update modal ────────────────────────────────────────────────── */ .modal-overlay { @@ -931,6 +1123,397 @@ button.btn-reboot:hover:not(:disabled) { margin-bottom: 8px; } +/* ── Tech Support modal ──────────────────────────────────────────── */ + +.support-section { + text-align: center; +} + +.support-icon-big { + font-size: 3rem; + margin-bottom: 12px; +} + +.support-active-icon { + animation: pulse-badge 2s ease-in-out infinite; +} + +.support-heading { + font-size: 1.15rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; +} + +.support-active-heading { + color: var(--green); +} + +.support-desc { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 16px; + text-align: left; +} + +.support-active-note { + font-size: 0.88rem; + color: var(--text-secondary); + margin-bottom: 16px; +} + +.support-info-box { + background-color: var(--card-color); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 14px 18px; + margin-bottom: 16px; + text-align: left; +} + +.support-active-box { + border-color: var(--green); +} + +.support-info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; +} + +.support-info-label { + font-size: 0.82rem; + color: var(--text-dim); + font-weight: 600; +} + +.support-info-value { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.88rem; + color: var(--accent-color); + font-weight: 600; +} + +.support-info-hint { + font-size: 0.72rem; + color: var(--text-dim); + margin-top: 6px; + font-style: italic; +} + +.support-steps { + text-align: left; + margin-bottom: 16px; + padding: 14px 18px; + background-color: var(--card-color); + border-radius: 10px; + border: 1px solid var(--border-color); +} + +.support-steps-title { + font-size: 0.82rem; + font-weight: 700; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 8px; +} + +.support-steps ol { + padding-left: 20px; + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.7; +} + +.support-steps code { + background-color: rgba(137, 180, 250, 0.12); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.82rem; + color: var(--accent-color); +} + +.support-btn-enable { + width: 100%; + padding: 12px; + border-radius: var(--radius-btn); + background-color: var(--accent-color); + color: #1e1e2e; + font-size: 0.95rem; + font-weight: 700; + margin-bottom: 10px; +} + +.support-btn-enable:hover:not(:disabled) { + opacity: 0.88; +} + +.support-btn-disable { + width: 100%; + padding: 12px; + border-radius: var(--radius-btn); + background-color: var(--red); + color: #fff; + font-size: 0.95rem; + font-weight: 700; + margin-bottom: 10px; +} + +.support-btn-disable:hover:not(:disabled) { + opacity: 0.88; +} + +.support-btn-done { + width: 100%; + padding: 12px; + border-radius: var(--radius-btn); + background-color: var(--accent-color); + color: #1e1e2e; + font-size: 0.95rem; + font-weight: 700; + margin-top: 16px; +} + +.support-btn-done:hover:not(:disabled) { + opacity: 0.88; +} + +.support-btn-auditlog { + width: 100%; + padding: 10px; + border-radius: var(--radius-btn); + background-color: var(--border-color); + color: var(--text-primary); + font-size: 0.85rem; + font-weight: 600; + margin-top: 8px; +} + +.support-btn-auditlog:hover:not(:disabled) { + background-color: #5a5c72; +} + +.support-fine-print { + font-size: 0.72rem; + color: var(--text-dim); + font-style: italic; + margin-bottom: 8px; +} + +.support-verify-box { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin: 16px 0; + padding: 12px; + background-color: var(--card-color); + border-radius: 8px; +} + +.support-verify-label { + font-size: 0.82rem; + color: var(--text-dim); + font-weight: 600; +} + +.support-verify-value { + font-size: 0.88rem; + font-weight: 700; +} + +.support-verify-value.verified-gone { + color: var(--green); +} + +.support-verify-value.verify-warning { + color: var(--yellow); +} + +/* ── Wallet protection ───────────────────────────────────────────── */ + +.support-wallet-box { + text-align: left; + padding: 14px 18px; + border-radius: 10px; + margin-bottom: 16px; + border: 1px solid var(--border-color); +} + +.support-wallet-protected { + background-color: rgba(46, 194, 126, 0.06); + border-color: rgba(46, 194, 126, 0.3); +} + +.support-wallet-unlocked { + background-color: rgba(229, 165, 10, 0.06); + border-color: rgba(229, 165, 10, 0.3); +} + +.support-wallet-warning { + background-color: rgba(224, 27, 36, 0.06); + border-color: rgba(224, 27, 36, 0.3); +} + +.support-wallet-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.support-wallet-icon { + font-size: 1.2rem; +} + +.support-wallet-title { + font-size: 0.88rem; + font-weight: 700; + color: var(--text-primary); +} + +.support-wallet-desc { + font-size: 0.82rem; + color: var(--text-secondary); + line-height: 1.5; + margin-bottom: 8px; +} + +.support-wallet-paths { + list-style: none; + padding: 0; + margin: 8px 0; +} + +.support-wallet-paths li { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.78rem; + color: var(--text-dim); + padding: 2px 0; +} + +.support-wallet-unlock-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; +} + +.support-unlock-select { + background-color: var(--card-color); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 6px 10px; + font-size: 0.82rem; +} + +.support-btn-wallet-unlock { + padding: 8px 16px; + border-radius: var(--radius-btn); + background-color: var(--yellow); + color: #1e1e2e; + font-size: 0.82rem; + font-weight: 700; +} + +.support-btn-wallet-unlock:hover:not(:disabled) { + background-color: #c98d08; +} + +.support-btn-wallet-lock { + padding: 8px 16px; + border-radius: var(--radius-btn); + background-color: var(--green); + color: #fff; + font-size: 0.82rem; + font-weight: 700; + margin-top: 8px; +} + +.support-btn-wallet-lock:hover:not(:disabled) { + background-color: #27ae6e; +} + +/* ── Audit log ───────────────────────────────────────────────────── */ + +.support-audit-container { + margin-top: 12px; + border-top: 1px solid var(--border-color); + padding-top: 12px; +} + +.support-audit-log { + max-height: 200px; + overflow-y: auto; + background-color: #12121c; + border-radius: 8px; + padding: 10px 14px; +} + +.support-audit-entry { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.72rem; + color: var(--text-secondary); + padding: 3px 0; + border-bottom: 1px solid rgba(69, 71, 90, 0.3); +} + +.support-audit-entry:last-child { + border-bottom: none; +} + +.support-audit-empty { + font-size: 0.82rem; + color: var(--text-dim); + text-align: center; + padding: 12px; +} + +/* ── Domain setup modal ──────────────────────────────────────────── */ + +domain-narrow-dialog { + max-width: 500px; +} + +domain-field-group { + margin-bottom: 14px; +} + +domain-field-label { + display: block; + font-size: 0.82rem; + color: var(--text-secondary); + margin-bottom: 6px; + font-weight: 600; +} + +domain-field-input { + width: 100%; + background-color: #12121c; + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 12px; + font-size: 0.9rem; + box-sizing: border-box; +} + +domain-field-input:focus { + outline: none; + border-color: var(--accent-color); +} + +domain-field-actions { + display: flex; + gap: 10px; + margin-top: 18px; + justify-content: flex-end; +} + /* ── Responsive ─────────────────────────────────────────────────── */ @media (max-width: 768px) { -- 2.53.0 From e40b0bd1884fb4a6d12c4763b1a0c434c5baef8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:53:05 +0000 Subject: [PATCH 283/857] Initial plan -- 2.53.0 From 6b0da2f7cdb387962aadbf93eb86cf5816a1b401 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:57:12 +0000 Subject: [PATCH 284/857] Fix: Pass icon to disambiguate Bitcoin node modals sharing bitcoind.service unit Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ca5a66cc-4b7d-4d26-9a65-3d0c9de4a279 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 10 +++++++--- app/sovran_systemsos_web/static/app.js | 20 +++++++++++--------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 684ad0a..79dbf4e 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1319,7 +1319,7 @@ async def api_credentials(unit: str): @app.get("/api/service-detail/{unit}") -async def api_service_detail(unit: str): +async def api_service_detail(unit: str, icon: str | None = None): """Return comprehensive details for a single service — status, credentials, port health, domain health, description, and IPs — in one API call.""" cfg = load_config() @@ -1335,8 +1335,12 @@ async def api_service_detail(unit: str): loop = asyncio.get_event_loop() overrides, nostr_npub = await loop.run_in_executor(None, _read_hub_overrides) - # Find the service config entry - entry = next((s for s in services if s.get("unit") == unit), None) + # Find the service config entry, preferring icon match when provided + entry = None + if icon: + entry = next((s for s in services if s.get("unit") == unit and s.get("icon") == icon), None) + if entry is None: + entry = next((s for s in services if s.get("unit") == unit), None) if entry is None: raise HTTPException(status_code=404, detail="Service not found") diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index ff246ce..3f19269 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -268,7 +268,7 @@ function buildTile(svc) { tile.style.cursor = "pointer"; tile.addEventListener("click", function() { - openServiceDetailModal(svc.unit, svc.name); + openServiceDetailModal(svc.unit, svc.name, svc.icon); }); return tile; @@ -383,14 +383,16 @@ function _attachCopyHandlers(container) { }); } -async function openServiceDetailModal(unit, name) { +async function openServiceDetailModal(unit, name, icon) { if (!$credsModal) return; if ($credsTitle) $credsTitle.textContent = name; if ($credsBody) $credsBody.innerHTML = '

        Loading…

        '; $credsModal.classList.add("open"); try { - var data = await apiFetch("/api/service-detail/" + encodeURIComponent(unit)); + var url = "/api/service-detail/" + encodeURIComponent(unit); + if (icon) url += "?icon=" + encodeURIComponent(icon); + var data = await apiFetch(url); var html = ""; // Section A: Description @@ -643,8 +645,8 @@ async function openServiceDetailModal(unit, name) { if (unit === "matrix-synapse.service") { var addBtn = document.getElementById("matrix-add-user-btn"); var changePwBtn = document.getElementById("matrix-change-pw-btn"); - if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name); }); - if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name); }); + if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name, icon); }); + if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name, icon); }); } if (data.feature) { @@ -695,7 +697,7 @@ async function openCredsModal(unit, name) { } } -function openMatrixCreateUserModal(unit, name) { +function openMatrixCreateUserModal(unit, name, icon) { if (!$credsBody) return; $credsBody.innerHTML = '
        ' + @@ -710,7 +712,7 @@ function openMatrixCreateUserModal(unit, name) { '
        '; document.getElementById("matrix-create-back-btn").addEventListener("click", function() { - openServiceDetailModal(unit, name); + openServiceDetailModal(unit, name, icon); }); document.getElementById("matrix-create-submit-btn").addEventListener("click", async function() { @@ -750,7 +752,7 @@ function openMatrixCreateUserModal(unit, name) { }); } -function openMatrixChangePasswordModal(unit, name) { +function openMatrixChangePasswordModal(unit, name, icon) { if (!$credsBody) return; $credsBody.innerHTML = '
        ' + @@ -764,7 +766,7 @@ function openMatrixChangePasswordModal(unit, name) { '
        '; document.getElementById("matrix-chpw-back-btn").addEventListener("click", function() { - openServiceDetailModal(unit, name); + openServiceDetailModal(unit, name, icon); }); document.getElementById("matrix-chpw-submit-btn").addEventListener("click", async function() { -- 2.53.0 From f9a20ac39b4f0a882d7c3c62ae8c96bddaae4a7a Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Sat, 4 Apr 2026 18:09:13 -0500 Subject: [PATCH 285/857] bigger logo --- app/sovran_systemsos_web/static/style.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 9bb6966..cc26a73 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -47,7 +47,7 @@ body { .header-bar { background-color: var(--surface-color); border-bottom: 1px solid var(--border-color); - padding: 10px 24px; + padding: 16px 24px; display: flex; align-items: center; gap: 16px; @@ -70,7 +70,7 @@ body { } .header-logo { - height: 36px; + height: 108px; width: auto; vertical-align: middle; margin-right: 10px; @@ -1676,4 +1676,4 @@ domain-field-actions { .login-error.visible { display: block; -} \ No newline at end of file +} -- 2.53.0 From 2493777a4292e6dcff9d54d7ff9e5ae3622b9199 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 23:19:54 +0000 Subject: [PATCH 286/857] Initial plan -- 2.53.0 From 815b19560048974fae0e305d22bcee513644c77c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 23:35:27 +0000 Subject: [PATCH 287/857] Split style.css and app.js into modular CSS/JS files under css/ and js/ directories Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/50712b31-5843-45c4-a8f1-3952656b636c Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 5 - app/sovran_systemsos_web/static/app.js | 1929 ----------------- app/sovran_systemsos_web/static/css/base.css | 137 ++ .../static/css/buttons.css | 86 + .../static/css/domain-setup.css | 173 ++ .../static/css/features.css | 143 ++ .../static/css/header.css | 72 + .../static/css/layout.css | 130 ++ .../static/css/modals.css | 421 ++++ .../static/css/onboarding.css | 144 ++ .../static/css/support.css | 362 ++++ app/sovran_systemsos_web/static/css/tiles.css | 279 +++ .../static/js/constants.js | 31 + app/sovran_systemsos_web/static/js/events.js | 80 + .../static/js/features.js | 581 +++++ app/sovran_systemsos_web/static/js/helpers.js | 58 + app/sovran_systemsos_web/static/js/rebuild.js | 84 + .../static/js/service-detail.js | 478 ++++ app/sovran_systemsos_web/static/js/state.js | 94 + app/sovran_systemsos_web/static/js/support.js | 261 +++ app/sovran_systemsos_web/static/js/tiles.js | 151 ++ app/sovran_systemsos_web/static/js/update.js | 120 + app/sovran_systemsos_web/static/style.css | 1679 -------------- app/sovran_systemsos_web/templates/index.html | 22 +- .../templates/onboarding.html | 11 +- 25 files changed, 3915 insertions(+), 3616 deletions(-) delete mode 100644 app/sovran_systemsos_web/static/app.js create mode 100644 app/sovran_systemsos_web/static/css/base.css create mode 100644 app/sovran_systemsos_web/static/css/buttons.css create mode 100644 app/sovran_systemsos_web/static/css/domain-setup.css create mode 100644 app/sovran_systemsos_web/static/css/features.css create mode 100644 app/sovran_systemsos_web/static/css/header.css create mode 100644 app/sovran_systemsos_web/static/css/layout.css create mode 100644 app/sovran_systemsos_web/static/css/modals.css create mode 100644 app/sovran_systemsos_web/static/css/onboarding.css create mode 100644 app/sovran_systemsos_web/static/css/support.css create mode 100644 app/sovran_systemsos_web/static/css/tiles.css create mode 100644 app/sovran_systemsos_web/static/js/constants.js create mode 100644 app/sovran_systemsos_web/static/js/events.js create mode 100644 app/sovran_systemsos_web/static/js/features.js create mode 100644 app/sovran_systemsos_web/static/js/helpers.js create mode 100644 app/sovran_systemsos_web/static/js/rebuild.js create mode 100644 app/sovran_systemsos_web/static/js/service-detail.js create mode 100644 app/sovran_systemsos_web/static/js/state.js create mode 100644 app/sovran_systemsos_web/static/js/support.js create mode 100644 app/sovran_systemsos_web/static/js/tiles.js create mode 100644 app/sovran_systemsos_web/static/js/update.js delete mode 100644 app/sovran_systemsos_web/static/style.css diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 79dbf4e..b37132d 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -378,8 +378,6 @@ def _file_hash(filename: str) -> str: except FileNotFoundError: return "0" -_APP_JS_HASH = _file_hash("app.js") -_STYLE_CSS_HASH = _file_hash("style.css") _ONBOARDING_JS_HASH = _file_hash("onboarding.js") # ── Update check helpers ────────────────────────────────────────── @@ -1137,8 +1135,6 @@ def _verify_support_removed() -> bool: async def index(request: Request): return templates.TemplateResponse("index.html", { "request": request, - "app_js_hash": _APP_JS_HASH, - "style_css_hash": _STYLE_CSS_HASH, }) @@ -1147,7 +1143,6 @@ async def onboarding(request: Request): return templates.TemplateResponse("onboarding.html", { "request": request, "onboarding_js_hash": _ONBOARDING_JS_HASH, - "style_css_hash": _STYLE_CSS_HASH, }) diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js deleted file mode 100644 index 3f19269..0000000 --- a/app/sovran_systemsos_web/static/app.js +++ /dev/null @@ -1,1929 +0,0 @@ -/* Sovran_SystemsOS Hub — Vanilla JS Frontend - v7 — Status-only dashboard + Tech Support + Feature Manager */ -"use strict"; - -const POLL_INTERVAL_SERVICES = 5000; -const POLL_INTERVAL_UPDATES = 1800000; -const UPDATE_POLL_INTERVAL = 2000; -const REBOOT_CHECK_INTERVAL = 5000; -const SUPPORT_TIMER_INTERVAL = 1000; - -const CATEGORY_ORDER = [ - "infrastructure", - "bitcoin-base", - "bitcoin-apps", - "communication", - "apps", - "nostr", -]; - -const FEATURE_SUBCATEGORY_LABELS = { - "infrastructure": "šŸ”§ Infrastructure", - "bitcoin": "₿ Bitcoin", - "communication": "šŸ’¬ Communication", - "nostr": "šŸ“” Nostr", -}; - -const FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"]; - -const STATUS_LOADING_STATES = new Set([ - "reloading", "activating", "deactivating", "maintenance", -]); - -// ── State ───────────────────────────────────────────────────────── - -let _servicesCache = []; -let _categoryLabels = {}; -let _updateLog = ""; -let _updatePollTimer = null; -let _updateLogOffset = 0; -let _serverWasDown = false; -let _updateFinished = false; -let _supportTimerInt = null; -let _supportEnabledAt = null; -let _supportStatus = null; // last fetched /api/support/status payload -let _walletUnlockTimerInt = null; -let _cachedExternalIp = null; - -// Feature Manager state -let _featuresData = null; -let _rebuildLog = ""; -let _rebuildLogOffset = 0; -let _rebuildPollTimer = null; -let _rebuildFinished = false; -let _rebuildServerDown = false; -let _pendingToggle = null; // {feature, extra} waiting for domain/confirm -let _rebuildFeatureName = ""; -let _rebuildIsEnabling = true; - -// ── DOM refs ────────────────────────────────────────────────────── - -const $tilesArea = document.getElementById("tiles-area"); -const $sidebarSupport = document.getElementById("sidebar-support"); -const $sidebarFeatures = document.getElementById("sidebar-features"); -const $updateBtn = document.getElementById("btn-update"); -const $updateBadge = document.getElementById("update-badge"); -const $refreshBtn = document.getElementById("btn-refresh"); -const $internalIp = document.getElementById("ip-internal"); -const $externalIp = document.getElementById("ip-external"); - -const $modal = document.getElementById("update-modal"); -const $modalSpinner = document.getElementById("modal-spinner"); -const $modalStatus = document.getElementById("modal-status"); -const $modalLog = document.getElementById("modal-log"); -const $btnReboot = document.getElementById("btn-reboot"); -const $btnSave = document.getElementById("btn-save-report"); -const $btnCloseModal = document.getElementById("btn-close-modal"); - -const $rebootOverlay = document.getElementById("reboot-overlay"); - -const $credsModal = document.getElementById("creds-modal"); -const $credsTitle = document.getElementById("creds-modal-title"); -const $credsBody = document.getElementById("creds-body"); -const $credsCloseBtn = document.getElementById("creds-close-btn"); - -const $supportModal = document.getElementById("support-modal"); -const $supportBody = document.getElementById("support-body"); -const $supportCloseBtn = document.getElementById("support-close-btn"); - -// Feature Manager — rebuild modal -const $rebuildModal = document.getElementById("rebuild-modal"); -const $rebuildSpinner = document.getElementById("rebuild-spinner"); -const $rebuildStatus = document.getElementById("rebuild-status"); -const $rebuildLog = document.getElementById("rebuild-log"); -const $rebuildReboot = document.getElementById("rebuild-reboot-btn"); -const $rebuildSave = document.getElementById("rebuild-save-report"); -const $rebuildClose = document.getElementById("rebuild-close-btn"); - -// Feature Manager — domain setup modal -const $domainSetupModal = document.getElementById("domain-setup-modal"); -const $domainSetupTitle = document.getElementById("domain-setup-title"); -const $domainSetupBody = document.getElementById("domain-setup-body"); -const $domainSetupClose = document.getElementById("domain-setup-close-btn"); - -// Feature Manager — SSL email modal -const $sslEmailModal = document.getElementById("ssl-email-modal"); -const $sslEmailInput = document.getElementById("ssl-email-input"); -const $sslEmailSave = document.getElementById("ssl-email-save-btn"); -const $sslEmailCancel = document.getElementById("ssl-email-cancel-btn"); -const $sslEmailClose = document.getElementById("ssl-email-close-btn"); - -// Feature Manager — confirm modal -const $featureConfirmModal = document.getElementById("feature-confirm-modal"); -const $featureConfirmMsg = document.getElementById("feature-confirm-message"); -const $featureConfirmOk = document.getElementById("feature-confirm-ok-btn"); -const $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn"); -const $featureConfirmClose = document.getElementById("feature-confirm-close-btn"); - -// Port Requirements modal -const $portReqModal = document.getElementById("port-requirements-modal"); -const $portReqBody = document.getElementById("port-req-body"); -const $portReqClose = document.getElementById("port-req-close-btn"); - -// System status banner -// (removed — health is now shown per-tile via the composite health field) - -// ── Helpers ─────────────────────────────────────────────────────── - -function tileId(svc) { return svc.unit + "::" + svc.name; } - -function statusClass(health) { - if (!health) return "unknown"; - if (health === "healthy") return "active"; - if (health === "needs_attention") return "needs-attention"; - if (health === "active") return "active"; // backwards compat - if (health === "inactive") return "inactive"; - if (health === "failed") return "failed"; - if (health === "disabled") return "disabled"; - if (STATUS_LOADING_STATES.has(health)) return "loading"; - return "unknown"; -} - -function statusText(health, enabled) { - if (!enabled) return "Disabled"; - if (health === "healthy") return "Active"; - if (health === "needs_attention") return "Needs Attention"; - if (health === "active") return "Active"; - if (health === "inactive") return "Inactive"; - if (health === "failed") return "Failed"; - if (!health || health === "unknown") return "Unknown"; - if (STATUS_LOADING_STATES.has(health)) return health; - return health; -} - -function escHtml(str) { - return String(str).replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'"); -} - -function linkify(str) { - return escHtml(str).replace(/(https?:\/\/[^\s<]+)/g, '$1'); -} - -function formatDuration(seconds) { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = Math.floor(seconds % 60); - if (h > 0) return h + "h " + m + "m " + s + "s"; - if (m > 0) return m + "m " + s + "s"; - return s + "s"; -} - -// ── Fetch wrappers ──────────────────────────────────────────────── - -async function apiFetch(path, options) { - const res = await fetch(path, options || {}); - if (!res.ok) { - let detail = res.status + " " + res.statusText; - try { const body = await res.json(); if (body && body.detail) detail = body.detail; } catch (e) {} - throw new Error(detail); - } - return res.json(); -} - -// ── Render: initial build ───────────────────────────────────────── - -function buildTiles(services, categoryLabels) { - _servicesCache = services; - var grouped = {}; - var supportServices = []; - for (var i = 0; i < services.length; i++) { - var svc = services[i]; - // Support tiles go to the sidebar, not the main grid - if (svc.category === "support" || svc.type === "support") { - supportServices.push(svc); - continue; - } - var cat = svc.category || "other"; - if (!grouped[cat]) grouped[cat] = []; - grouped[cat].push(svc); - } - renderSidebarSupport(supportServices); - $tilesArea.innerHTML = ""; - var orderedKeys = CATEGORY_ORDER.filter(function(k) { return grouped[k]; }); - Object.keys(grouped).forEach(function(k) { - if (orderedKeys.indexOf(k) === -1) orderedKeys.push(k); - }); - for (var j = 0; j < orderedKeys.length; j++) { - var catKey = orderedKeys[j]; - var entries = grouped[catKey]; - if (!entries || entries.length === 0) continue; - var label = categoryLabels[catKey] || catKey; - var section = document.createElement("div"); - section.className = "category-section"; - section.dataset.category = catKey; - section.innerHTML = '
        ' + escHtml(label) + '

        '; - var grid = section.querySelector(".tiles-grid"); - for (var k = 0; k < entries.length; k++) { - grid.appendChild(buildTile(entries[k])); - } - $tilesArea.appendChild(section); - } - if ($tilesArea.children.length === 0) { - $tilesArea.innerHTML = '

        No services configured.

        '; - } -} - -function renderSidebarSupport(supportServices) { - $sidebarSupport.innerHTML = ""; - for (var i = 0; i < supportServices.length; i++) { - var svc = supportServices[i]; - var btn = document.createElement("button"); - btn.className = "sidebar-support-btn"; - btn.innerHTML = - 'šŸ›Ÿ' + - '' + - '' + escHtml(svc.name || "Tech Support") + '' + - 'Click for help' + - ''; - btn.addEventListener("click", function() { openSupportModal(); }); - $sidebarSupport.appendChild(btn); - } - if (supportServices.length > 0) { - var hr = document.createElement("hr"); - hr.className = "sidebar-divider"; - $sidebarSupport.appendChild(hr); - } -} - -function buildTile(svc) { - var isSupport = svc.type === "support"; - var sc = statusClass(svc.health || svc.status); - var st = statusText(svc.health || svc.status, svc.enabled); - var dis = !svc.enabled; - - var tile = document.createElement("div"); - tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : ""); - tile.dataset.unit = svc.unit; - tile.dataset.tileId = tileId(svc); - if (dis) tile.title = svc.name + " is not enabled in custom.nix"; - - if (isSupport) { - tile.innerHTML = '' + escHtml(svc.name) + '
        ' + escHtml(svc.name) + '
        Click for help
        '; - tile.style.cursor = "pointer"; - tile.addEventListener("click", function() { openSupportModal(); }); - return tile; - } - - tile.innerHTML = '' + escHtml(svc.name) + '
        ' + escHtml(svc.name) + '
        ' + st + '
        '; - - tile.style.cursor = "pointer"; - tile.addEventListener("click", function() { - openServiceDetailModal(svc.unit, svc.name, svc.icon); - }); - - return tile; -} - -// ── Render: live update ─────────────────────────────────────────── - -function updateTiles(services) { - _servicesCache = services; - for (var i = 0; i < services.length; i++) { - var svc = services[i]; - if (svc.type === "support") continue; - var id = CSS.escape(tileId(svc)); - var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]'); - if (!tile) continue; - var sc = statusClass(svc.health || svc.status); - var st = statusText(svc.health || svc.status, svc.enabled); - var dot = tile.querySelector(".status-dot"); - var text = tile.querySelector(".status-text"); - if (dot) dot.className = "status-dot " + sc; - if (text) text.textContent = st; - } -} - -// ── Service polling ─────────────────────────────────────────────── - -var _firstLoad = true; - -async function refreshServices() { - try { - var services = await apiFetch("/api/services"); - if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; } - else { updateTiles(services); } - } catch (err) { console.warn("Failed to fetch services:", err); } -} - -// ── Network IPs ─────────────────────────────────────────────────── - -async function loadNetwork() { - try { - var data = await apiFetch("/api/network"); - if ($internalIp) $internalIp.textContent = data.internal_ip || "—"; - if ($externalIp) $externalIp.textContent = data.external_ip || "—"; - _cachedExternalIp = data.external_ip || "unavailable"; - } catch (_) { - if ($internalIp) $internalIp.textContent = "—"; - if ($externalIp) $externalIp.textContent = "—"; - } -} - -// ── Update check ────────────────────────────────────────────────── - -async function checkUpdates() { - try { - var data = await apiFetch("/api/updates/check"); - var hasUpdates = !!data.available; - if ($updateBadge) $updateBadge.classList.toggle("visible", hasUpdates); - if ($updateBtn) $updateBtn.classList.toggle("has-updates", hasUpdates); - } catch (_) {} -} - -// ── Service detail modal ────────────────────────────────────────── - -function _renderCredsHtml(credentials, unit) { - var html = ""; - for (var i = 0; i < credentials.length; i++) { - var cred = credentials[i]; - var id = "cred-" + Math.random().toString(36).substring(2, 8); - var displayValue = linkify(cred.value); - var qrBlock = ""; - if (cred.qrcode) { - qrBlock = '
        QR Code for ' + escHtml(cred.label) + '
        Scan with Zeus app on your phone
        '; - } - html += '
        ' + escHtml(cred.label) + '
        ' + qrBlock + '
        ' + displayValue + '
        '; - } - return html; -} - -function _attachCopyHandlers(container) { - container.querySelectorAll(".creds-copy-btn").forEach(function(btn) { - btn.addEventListener("click", function() { - var target = document.getElementById(btn.dataset.target); - if (!target) return; - var text = target.textContent; - - function onSuccess() { - btn.textContent = "Copied!"; - btn.classList.add("copied"); - setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1500); - } - - function fallbackCopy() { - var ta = document.createElement("textarea"); - ta.value = text; - ta.style.position = "fixed"; - ta.style.left = "-9999px"; - document.body.appendChild(ta); - ta.select(); - try { - document.execCommand("copy"); - onSuccess(); - } catch (e) {} - document.body.removeChild(ta); - } - - if (navigator.clipboard && window.isSecureContext) { - navigator.clipboard.writeText(text).then(onSuccess).catch(fallbackCopy); - } else { - fallbackCopy(); - } - }); - }); -} - -async function openServiceDetailModal(unit, name, icon) { - if (!$credsModal) return; - if ($credsTitle) $credsTitle.textContent = name; - if ($credsBody) $credsBody.innerHTML = '

        Loading…

        '; - $credsModal.classList.add("open"); - - try { - var url = "/api/service-detail/" + encodeURIComponent(unit); - if (icon) url += "?icon=" + encodeURIComponent(icon); - var data = await apiFetch(url); - var html = ""; - - // Section A: Description - if (data.description) { - html += '
        ' + - '

        ' + escHtml(data.description) + '

        ' + - '
        '; - } - - // Section B: Status - // When a feature override is present, use the feature's enabled state so the - // modal matches what the dashboard tile shows (feature toggle is authoritative). - var effectiveEnabled = data.feature ? data.feature.enabled : data.enabled; - var effectiveHealth = data.feature && !data.feature.enabled - ? "disabled" - : (data.health || data.status); - var sc = statusClass(effectiveHealth); - var st = statusText(effectiveHealth, effectiveEnabled); - html += '
        ' + - '
        Status
        ' + - '
        ' + - '' + - '' + escHtml(st) + '' + - '
        ' + - '
        '; - - // Section C: Ports (only if service has port_requirements) - if (data.port_statuses && data.port_statuses.length > 0) { - var anyPortClosed = data.port_statuses.some(function(p) { return p.status === "closed"; }); - var portTableRows = ""; - data.port_statuses.forEach(function(p) { - var statusIcon, statusClass2; - if (p.status === "listening") { - statusIcon = "āœ… Open"; - statusClass2 = "port-status-listening"; - } else if (p.status === "firewall_open") { - statusIcon = "🟔 Firewall open"; - statusClass2 = "port-status-open"; - } else if (p.status === "closed") { - statusIcon = "šŸ”“ Closed"; - statusClass2 = "port-status-closed"; - } else { - statusIcon = "— Unknown"; - statusClass2 = "port-status-unknown"; - } - var desc = p.description; - var portNum = parseInt(p.port, 10); - if (portNum === 80 || portNum === 443) { - desc += " (shared — all services)"; - } - portTableRows += '' + - '' + escHtml(p.port) + '' + - '' + escHtml(p.protocol) + '' + - '' + escHtml(desc) + '' + - '' + statusIcon + '' + - ''; - }); - - var troubleshootHtml = ""; - if (anyPortClosed) { - var sharedPorts = []; - var specificPorts = []; - data.port_statuses.forEach(function(p) { - if (p.status === "closed") { - var portNum = parseInt(p.port, 10); - if (portNum === 80 || portNum === 443) { - sharedPorts.push(p); - } else { - specificPorts.push(p); - } - } - }); - - var troubleParts = []; - - if (sharedPorts.length > 0) { - troubleParts.push( - 'āš ļø Ports 80 and 443 need to be forwarded on your router.' + - '

        These are shared system ports — you only need to set them up once and they cover all your domain-based services ' + - '(BTCPayServer, Nextcloud, Matrix, WordPress, etc.).

        ' + - '

        If you already forwarded these ports during onboarding, you don\'t need to do it again. Otherwise:

        ' + - '
          ' + - '
        1. Log into your router\'s admin panel (usually http://192.168.1.1)
        2. ' + - '
        3. Find the Port Forwarding section
        4. ' + - '
        5. Forward port 80 (TCP) and port 443 (TCP) to your machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + '
        6. ' + - '
        7. Save your router settings
        8. ' + - '
        ' + - '

        šŸ’” Once these two ports are forwarded, you won\'t see this warning on any service again.

        ' - ); - } - - if (specificPorts.length > 0) { - var portList = specificPorts.map(function(p) { - return '' + escHtml(p.port) + ' (' + escHtml(p.protocol) + ') — ' + escHtml(p.description); - }).join('
        '); - - troubleParts.push( - 'āš ļø This service requires additional ports to be forwarded:' + - '

        ' + portList + '

        ' + - '
          ' + - '
        1. Log into your router\'s admin panel
        2. ' + - '
        3. Forward each port listed above to your machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + '
        4. ' + - '
        5. Save your router settings
        6. ' + - '
        ' - ); - } - - troubleshootHtml = '
        ' + troubleParts.join('
        ') + '
        '; - } - - html += '
        ' + - '
        Port Status
        ' + - '' + - '' + - '' + - '' + - '' + portTableRows + '' + - '
        PortProtocolDescriptionStatus
        ' + - troubleshootHtml + - '
        '; - } - - // Section D: Domain (only if service needs_domain) - if (data.needs_domain) { - var domainStatusHtml = ""; - var ds = data.domain_status || {}; - var domainBadge = ""; - - if (data.domain) { - if (ds.status === "connected") { - domainBadge = 'āœ“ ' + escHtml(data.domain) + ''; - } else if (ds.status === "dns_mismatch") { - domainBadge = '⚠ ' + escHtml(data.domain) + ' (IP mismatch)'; - domainStatusHtml = '
        ' + - 'āš ļø Your domain resolves to ' + escHtml(ds.resolved_ip || "unknown") + ' but your external IP is ' + escHtml(ds.expected_ip || "unknown") + '.' + - '

        This usually means the DNS record needs to be updated:

        ' + - '
          ' + - '
        1. Go to njal.la and log into your account
        2. ' + - '
        3. Find your domain and check the Dynamic DNS record
        4. ' + - '
        5. Make sure it points to your current external IP: ' + escHtml(ds.expected_ip || "—") + '
        6. ' + - '
        7. If you set up a DDNS curl command during onboarding, verify it\'s running correctly
        8. ' + - '
        ' + - '
        '; - } else if (ds.status === "unresolvable") { - domainBadge = 'āœ— ' + escHtml(data.domain) + ' (DNS error)'; - domainStatusHtml = '
        ' + - 'āš ļø This domain cannot be resolved. DNS is not configured yet.' + - '

        Let\'s get it set up:

        ' + - '
          ' + - '
        1. Go to njal.la and log into your account
        2. ' + - '
        3. Find the domain you purchased for this service
        4. ' + - '
        5. Create a Dynamic DNS record pointing to your external IP: ' + escHtml(ds.expected_ip || "—") + '
        6. ' + - '
        7. Copy the DDNS curl command from Njal.la\'s dashboard
        8. ' + - '
        9. You can re-enter it in the Feature Manager to update your configuration
        10. ' + - '
        ' + - '
        '; - } else { - domainBadge = '' + escHtml(data.domain) + ''; - } - } else { - domainBadge = 'Not configured'; - domainStatusHtml = '
        ' + - 'āš ļø No domain has been configured for this service yet.' + - '

        To get this service working:

        ' + - '
          ' + - '
        1. Purchase a subdomain at njal.la (if you haven\'t already)
        2. ' + - '
        3. Go to the Feature Manager in the sidebar
        4. ' + - '
        5. Find this service and configure your domain through the setup wizard
        6. ' + - '
        ' + - '
        '; - } - - html += '
        ' + - '
        Domain
        ' + - domainBadge + - domainStatusHtml + - '
        '; - } - - // Section E: Credentials & Links - if (data.has_credentials && data.credentials && data.credentials.length > 0) { - html += '
        ' + - '
        Credentials & Access
        ' + - _renderCredsHtml(data.credentials, unit) + - (unit === "matrix-synapse.service" ? - '
        ' + - '' + - '' + - '
        ' : "") + - '
        '; - } else if (!data.enabled && !data.feature) { - html += '
        ' + - '

        This service is not enabled in your configuration.

        ' + - '
        '; - } - - // Section F: Addon Feature toggle - if (data.feature) { - var feat = data.feature; - // Sync this feature into _featuresData so handleFeatureToggle can look up conflicts / ssl state - if (!_featuresData) { - _featuresData = { features: [feat], ssl_email_configured: false }; - } else { - var fidx = _featuresData.features.findIndex(function(f) { return f.id === feat.id; }); - if (fidx >= 0) { _featuresData.features[fidx] = feat; } - else { _featuresData.features.push(feat); } - } - var addonStatusLabel = feat.enabled ? "Enabled \u2713" : "Disabled"; - var addonStatusCls = feat.enabled ? "addon-status--on" : "addon-status--off"; - var addonBtnLabel = feat.enabled ? "Disable Feature" : "Enable Feature"; - var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary"; - - // Section title: use a more specific label for mutually-exclusive Bitcoin node features - var addonSectionTitle = (feat.id === "bip110" || feat.id === "bitcoin-core") - ? "\u20BF Bitcoin Node Selection" - : "\uD83D\uDD27 Addon Feature"; - - // Description: prefer the feature's own description over a generic fallback - var addonDesc = feat.description - ? feat.description - : "This is an optional addon feature. You can enable or disable it at any time."; - - // Conflicts warning: list mutually-exclusive feature names when present - var conflictsHtml = ""; - if (feat.conflicts_with && feat.conflicts_with.length > 0) { - var conflictNames = feat.conflicts_with.map(function(cid) { - if (_featuresData && Array.isArray(_featuresData.features)) { - var cf = _featuresData.features.find(function(f) { return f.id === cid; }); - if (cf) return cf.name; - } - return cid; - }); - conflictsHtml = '
        \u26A0 Mutually exclusive with: ' + escHtml(conflictNames.join(", ")) + '
        '; - } - - html += '
        ' + - '
        ' + addonSectionTitle + '
        ' + - '

        ' + escHtml(addonDesc) + '

        ' + - conflictsHtml + - '
        ' + - '' + addonStatusLabel + '' + - '' + - '
        ' + - '
        '; - } - - $credsBody.innerHTML = html; - _attachCopyHandlers($credsBody); - - if (unit === "matrix-synapse.service") { - var addBtn = document.getElementById("matrix-add-user-btn"); - var changePwBtn = document.getElementById("matrix-change-pw-btn"); - if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name, icon); }); - if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name, icon); }); - } - - if (data.feature) { - var addonBtn = document.getElementById("svc-detail-addon-btn"); - if (addonBtn) { - var addonFeat = data.feature; - addonBtn.addEventListener("click", function() { - closeCredsModal(); - handleFeatureToggle(addonFeat, !addonFeat.enabled); - }); - } - } - } catch (err) { - if ($credsBody) $credsBody.innerHTML = '

        Could not load service details.

        '; - } -} - -// ── Credentials info modal ──────────────────────────────────────── - -async function openCredsModal(unit, name) { - if (!$credsModal) return; - if ($credsTitle) $credsTitle.textContent = name + " — Connection Info"; - if ($credsBody) $credsBody.innerHTML = '

        Loading…

        '; - $credsModal.classList.add("open"); - try { - var data = await apiFetch("/api/credentials/" + encodeURIComponent(unit)); - if (!data.credentials || data.credentials.length === 0) { - $credsBody.innerHTML = '

        No connection info available yet.

        '; - return; - } - var html = _renderCredsHtml(data.credentials, unit); - if (unit === "matrix-synapse.service") { - html += '
        ' + - '' + - '' + - '
        '; - } - $credsBody.innerHTML = html; - _attachCopyHandlers($credsBody); - if (unit === "matrix-synapse.service") { - var addBtn = document.getElementById("matrix-add-user-btn"); - var changePwBtn = document.getElementById("matrix-change-pw-btn"); - if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name); }); - if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name); }); - } - } catch (err) { - $credsBody.innerHTML = '

        Could not load credentials.

        '; - } -} - -function openMatrixCreateUserModal(unit, name, icon) { - if (!$credsBody) return; - $credsBody.innerHTML = - '
        ' + - '
        ' + - '
        ' + - '
        ' + - '
        ' + - '
        ' + - '' + - '' + - '
        ' + - '
        '; - - document.getElementById("matrix-create-back-btn").addEventListener("click", function() { - openServiceDetailModal(unit, name, icon); - }); - - document.getElementById("matrix-create-submit-btn").addEventListener("click", async function() { - var submitBtn = document.getElementById("matrix-create-submit-btn"); - var resultEl = document.getElementById("matrix-create-result"); - var username = (document.getElementById("matrix-new-username").value || "").trim(); - var password = document.getElementById("matrix-new-password").value || ""; - var isAdmin = document.getElementById("matrix-new-admin").checked; - - if (!username || !password) { - resultEl.className = "matrix-form-result error"; - resultEl.textContent = "Username and password are required."; - return; - } - - submitBtn.disabled = true; - submitBtn.textContent = "Creating…"; - resultEl.className = "matrix-form-result"; - resultEl.textContent = ""; - - try { - var resp = await apiFetch("/api/matrix/create-user", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username: username, password: password, admin: isAdmin }) - }); - resultEl.className = "matrix-form-result success"; - resultEl.textContent = "āœ… User @" + escHtml(resp.username) + " created successfully."; - submitBtn.textContent = "Create User"; - submitBtn.disabled = false; - } catch (err) { - resultEl.className = "matrix-form-result error"; - resultEl.textContent = "āŒ " + (err.message || "Failed to create user."); - submitBtn.textContent = "Create User"; - submitBtn.disabled = false; - } - }); -} - -function openMatrixChangePasswordModal(unit, name, icon) { - if (!$credsBody) return; - $credsBody.innerHTML = - '
        ' + - '
        ' + - '
        ' + - '
        ' + - '
        ' + - '' + - '' + - '
        ' + - '
        '; - - document.getElementById("matrix-chpw-back-btn").addEventListener("click", function() { - openServiceDetailModal(unit, name, icon); - }); - - document.getElementById("matrix-chpw-submit-btn").addEventListener("click", async function() { - var submitBtn = document.getElementById("matrix-chpw-submit-btn"); - var resultEl = document.getElementById("matrix-chpw-result"); - var username = (document.getElementById("matrix-chpw-username").value || "").trim(); - var newPassword = document.getElementById("matrix-chpw-password").value || ""; - - if (!username || !newPassword) { - resultEl.className = "matrix-form-result error"; - resultEl.textContent = "Username and new password are required."; - return; - } - - submitBtn.disabled = true; - submitBtn.textContent = "Changing…"; - resultEl.className = "matrix-form-result"; - resultEl.textContent = ""; - - try { - var resp = await apiFetch("/api/matrix/change-password", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username: username, new_password: newPassword }) - }); - resultEl.className = "matrix-form-result success"; - resultEl.textContent = "āœ… Password for @" + escHtml(resp.username) + " changed successfully."; - submitBtn.textContent = "Change Password"; - submitBtn.disabled = false; - } catch (err) { - resultEl.className = "matrix-form-result error"; - resultEl.textContent = "āŒ " + (err.message || "Failed to change password."); - submitBtn.textContent = "Change Password"; - submitBtn.disabled = false; - } - }); -} - -function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); } - -// ── Tech Support modal ──────────────────────────────────────────── - -async function openSupportModal() { - if (!$supportModal) return; - $supportModal.classList.add("open"); - $supportBody.innerHTML = '

        Checking support status…

        '; - try { - var status = await apiFetch("/api/support/status"); - _supportStatus = status; - if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(status); } - else { renderSupportInactive(); } - } catch (err) { - $supportBody.innerHTML = '

        Could not check support status.

        '; - } -} - -function renderSupportInactive() { - stopSupportTimer(); - var ip = _cachedExternalIp || "loading…"; - $supportBody.innerHTML = [ - '
        ', - '
        šŸ›Ÿ
        ', - '

        Need help from Sovran Systems?

        ', - '

        This will temporarily grant our support team SSH access to your machine so we can help diagnose and fix issues.

        ', - '
        ', - '
        Your IP' + escHtml(ip) + '
        ', - '
        This IP will be shared with Sovran Systems support
        ', - '
        ', - '
        ', - '
        šŸ”’Wallet Protection
        ', - '

        Wallet files (LND, Sparrow, Bisq) are protected by default. Support staff cannot access your private keys unless you explicitly grant access.

        ', - '
        ', - '
        What happens:
          ', - '
        1. A restricted sovran-support user is created with limited access
        2. ', - '
        3. Our SSH key is added only to that restricted account
        4. ', - '
        5. Wallet files are locked via access controls — not visible to support
        6. ', - '
        7. You control if and when wallet access is granted (time-limited)
        8. ', - '
        9. All session events are logged for your audit
        10. ', - '
        ', - '', - '

        You can revoke access at any time. Wallet files are protected unless you unlock them.

        ', - '
        ', - ].join(""); - document.getElementById("btn-support-enable").addEventListener("click", enableSupport); -} - -function renderSupportActive(status) { - var ip = _cachedExternalIp || "loading…"; - var walletProtected = status && status.wallet_protected; - var walletUnlocked = status && status.wallet_unlocked; - var unlockUntil = status && status.wallet_unlocked_until_human ? status.wallet_unlocked_until_human : ""; - var protectedPaths = (status && status.protected_paths && status.protected_paths.length) - ? status.protected_paths : []; - - var walletSection; - if (walletProtected) { - if (walletUnlocked) { - walletSection = [ - '
        ', - '
        šŸ”“Wallet Access: UNLOCKED
        ', - '

        You have granted support temporary access to wallet files' + (unlockUntil ? ' until ' + escHtml(unlockUntil) + '' : '') + '.

        ', - '', - '
        ', - ].join(""); - } else { - var pathList = protectedPaths.length - ? '
          ' + protectedPaths.map(function(p){ return '
        • ' + escHtml(p) + '
        • '; }).join("") + '
        ' - : ''; - walletSection = [ - '
        ', - '
        šŸ”’Wallet Files: Protected
        ', - '

        Support cannot access your wallet files. Grant temporary access only if needed for wallet troubleshooting.

        ', - pathList, - '
        ', - '', - '', - '
        ', - '
        ', - ].join(""); - } - } else { - walletSection = [ - '
        ', - '
        āš ļøWallet Protection Unavailable
        ', - '

        The restricted support user could not be created. Support is running with root access — wallet files may be accessible. End the session if you are concerned.

        ', - '
        ', - ].join(""); - } - - $supportBody.innerHTML = [ - '
        ', - '
        šŸ”“
        ', - '

        Support Access is Active

        ', - '

        Sovran Systems can currently connect to your machine via SSH.

        ', - '
        ', - '
        Your IP' + escHtml(ip) + '
        ', - '
        Duration…
        ', - '
        ', - walletSection, - '', - '

        This will remove the SSH key and revoke all wallet access immediately.

        ', - '', - '
        ', - '', - ].join(""); - - document.getElementById("btn-support-disable").addEventListener("click", disableSupport); - document.getElementById("btn-support-audit").addEventListener("click", toggleAuditLog); - if (walletProtected && !walletUnlocked) { - document.getElementById("btn-wallet-unlock").addEventListener("click", walletUnlock); - } - if (walletProtected && walletUnlocked) { - document.getElementById("btn-wallet-lock").addEventListener("click", walletLock); - } - startSupportTimer(); - if (walletUnlocked && status.wallet_unlocked_until) { - startWalletUnlockTimer(status.wallet_unlocked_until); - } -} - -function renderSupportRemoved(verified) { - stopSupportTimer(); - stopWalletUnlockTimer(); - var icon = verified ? "āœ…" : "āš ļø"; - var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot to ensure it is gone."; - var vclass = verified ? "verified-gone" : "verify-warning"; - var vlabel = verified ? "āœ“ Removed — No access" : "⚠ Verify by rebooting"; - $supportBody.innerHTML = '
        ' + icon + '

        Support Session Ended

        ' + escHtml(msg) + '

        SSH Key Status:' + vlabel + '
        '; - document.getElementById("btn-support-done").addEventListener("click", closeSupportModal); -} - -async function enableSupport() { - var btn = document.getElementById("btn-support-enable"); - if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; } - try { - await apiFetch("/api/support/enable", { method: "POST" }); - var status = await apiFetch("/api/support/status"); - _supportStatus = status; - _supportEnabledAt = status.enabled_at; - renderSupportActive(status); - } catch (err) { - if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; } - alert("Failed to enable support access. Please try again."); - } -} - -async function disableSupport() { - var btn = document.getElementById("btn-support-disable"); - if (btn) { btn.disabled = true; btn.textContent = "Removing key…"; } - try { - var result = await apiFetch("/api/support/disable", { method: "POST" }); - renderSupportRemoved(result.verified); - } catch (err) { - if (btn) { btn.disabled = false; btn.textContent = "End Support Session"; } - alert("Failed to disable support access. Please try again."); - } -} - -async function walletUnlock() { - var btn = document.getElementById("btn-wallet-unlock"); - var sel = document.getElementById("wallet-unlock-duration"); - var duration = sel ? parseInt(sel.value, 10) : 3600; - if (btn) { btn.disabled = true; btn.textContent = "Unlocking…"; } - try { - var result = await apiFetch("/api/support/wallet-unlock", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ duration: duration }), - }); - var status = await apiFetch("/api/support/status"); - _supportStatus = status; - renderSupportActive(status); - } catch (err) { - if (btn) { btn.disabled = false; btn.textContent = "Grant Wallet Access"; } - alert("Failed to unlock wallet access: " + (err.message || "Unknown error")); - } -} - -async function walletLock() { - var btn = document.getElementById("btn-wallet-lock"); - if (btn) { btn.disabled = true; btn.textContent = "Locking…"; } - try { - await apiFetch("/api/support/wallet-lock", { method: "POST" }); - var status = await apiFetch("/api/support/status"); - _supportStatus = status; - renderSupportActive(status); - } catch (err) { - if (btn) { btn.disabled = false; btn.textContent = "Re-lock Wallet Now"; } - alert("Failed to re-lock wallet: " + (err.message || "Unknown error")); - } -} - -async function toggleAuditLog() { - var container = document.getElementById("support-audit-container"); - if (!container) return; - if (container.style.display !== "none") { - container.style.display = "none"; - return; - } - container.style.display = "block"; - container.innerHTML = '

        Loading audit log…

        '; - try { - var data = await apiFetch("/api/support/audit-log"); - if (!data.entries || data.entries.length === 0) { - container.innerHTML = '

        No audit events recorded yet.

        '; - } else { - container.innerHTML = '
        ' + - data.entries.map(function(e) { return '
        ' + escHtml(e) + '
        '; }).join("") + - '
        '; - } - } catch (err) { - container.innerHTML = '

        Could not load audit log.

        '; - } -} - -function startSupportTimer() { - stopSupportTimer(); - updateSupportTimer(); - _supportTimerInt = setInterval(updateSupportTimer, SUPPORT_TIMER_INTERVAL); -} - -function stopSupportTimer() { - if (_supportTimerInt) { clearInterval(_supportTimerInt); _supportTimerInt = null; } -} - -function updateSupportTimer() { - var el = document.getElementById("support-timer"); - if (!el || !_supportEnabledAt) return; - var elapsed = (Date.now() / 1000) - _supportEnabledAt; - el.textContent = formatDuration(Math.max(0, elapsed)); -} - -function startWalletUnlockTimer(expiresAt) { - stopWalletUnlockTimer(); - _walletUnlockTimerInt = setInterval(function() { - if (Date.now() / 1000 >= expiresAt) { - stopWalletUnlockTimer(); - // Refresh the support modal to show re-locked state - apiFetch("/api/support/status").then(function(status) { - _supportStatus = status; - renderSupportActive(status); - }).catch(function() {}); - } - }, 10000); -} - -function stopWalletUnlockTimer() { - if (_walletUnlockTimerInt) { clearInterval(_walletUnlockTimerInt); _walletUnlockTimerInt = null; } -} - -function closeSupportModal() { - if ($supportModal) $supportModal.classList.remove("open"); - stopSupportTimer(); - stopWalletUnlockTimer(); -} - -// ── Update modal ────────────────────────────────────────────────── - -function openUpdateModal() { - if (!$modal) return; - _updateLog = ""; - _updateLogOffset = 0; - _serverWasDown = false; - _updateFinished = false; - if ($modalLog) $modalLog.textContent = ""; - if ($modalStatus) $modalStatus.textContent = "Starting update…"; - if ($modalSpinner) $modalSpinner.classList.add("spinning"); - if ($btnReboot) $btnReboot.style.display = "none"; - if ($btnSave) $btnSave.style.display = "none"; - if ($btnCloseModal) $btnCloseModal.disabled = true; - $modal.classList.add("open"); - startUpdate(); -} - -function closeUpdateModal() { - if (!$modal) return; - $modal.classList.remove("open"); - stopUpdatePoll(); -} - -function appendLog(text) { - if (!text) return; - _updateLog += text; - if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; } -} - -function startUpdate() { - fetch("/api/updates/run", { method: "POST" }) - .then(function(response) { - if (!response.ok) return response.text().then(function(t) { throw new Error(t); }); - return response.json(); - }) - .then(function(data) { - if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n"); - if ($modalStatus) $modalStatus.textContent = "Updating…"; - startUpdatePoll(); - }) - .catch(function(err) { - appendLog("[Error: failed to start update — " + err + "]\n"); - onUpdateDone(false); - }); -} - -function startUpdatePoll() { - pollUpdateStatus(); - _updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL); -} - -function stopUpdatePoll() { - if (_updatePollTimer) { clearInterval(_updatePollTimer); _updatePollTimer = null; } -} - -async function pollUpdateStatus() { - if (_updateFinished) return; - try { - var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset); - if (_serverWasDown) { _serverWasDown = false; appendLog("[Server reconnected]\n"); if ($modalStatus) $modalStatus.textContent = "Updating…"; } - if (data.log) appendLog(data.log); - _updateLogOffset = data.offset; - if (data.running) return; - _updateFinished = true; - stopUpdatePoll(); - if (data.result === "success") onUpdateDone(true); - else onUpdateDone(false); - } catch (err) { - if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; } - } -} - -function onUpdateDone(success) { - if ($modalSpinner) $modalSpinner.classList.remove("spinning"); - if ($btnCloseModal) $btnCloseModal.disabled = false; - if (success) { - if ($modalStatus) $modalStatus.textContent = "āœ“ Update complete"; - if ($btnReboot) $btnReboot.style.display = "inline-flex"; - } else { - if ($modalStatus) $modalStatus.textContent = "āœ— Update failed"; - if ($btnSave) $btnSave.style.display = "inline-flex"; - if ($btnReboot) $btnReboot.style.display = "inline-flex"; - } -} - -function saveErrorReport() { - var blob = new Blob([_updateLog], { type: "text/plain" }); - var url = URL.createObjectURL(blob); - var a = document.createElement("a"); - a.href = url; - a.download = "sovran-update-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -// ── Reboot ──────────────────────────────────────────────────────── - -function doReboot() { - if ($modal) $modal.classList.remove("open"); - if ($rebuildModal) $rebuildModal.classList.remove("open"); - stopUpdatePoll(); - stopRebuildPoll(); - if ($rebootOverlay) $rebootOverlay.classList.add("visible"); - fetch("/api/reboot", { method: "POST" }).catch(function() {}); - setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); -} - -function waitForServerReboot() { - fetch("/api/config", { cache: "no-store" }) - .then(function(res) { - if (res.ok) window.location.reload(); - else setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); - }) - .catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); }); -} - -// ── Rebuild modal ───────────────────────────────────────────────── - -function openRebuildModal() { - if (!$rebuildModal) return; - _rebuildLog = ""; - _rebuildLogOffset = 0; - _rebuildServerDown = false; - _rebuildFinished = false; - if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; } - var action = _rebuildIsEnabling ? "Enabling" : "Disabling"; - var label = _rebuildFeatureName || "feature"; - if ($rebuildStatus) $rebuildStatus.textContent = action + " " + label + "…"; - if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning"); - if ($rebuildReboot) $rebuildReboot.style.display = "none"; - if ($rebuildSave) $rebuildSave.style.display = "none"; - if ($rebuildClose) $rebuildClose.disabled = true; - $rebuildModal.classList.add("open"); - // Delay first poll slightly to let the rebuild service start and clear stale log - setTimeout(startRebuildPoll, 1500); -} - -function closeRebuildModal() { - if ($rebuildModal) $rebuildModal.classList.remove("open"); - stopRebuildPoll(); -} - -function appendRebuildLog(text) { - if (!text) return; - _rebuildLog += text; - // Log is collected silently for error reports — not displayed to user -} - -function startRebuildPoll() { - pollRebuildStatus(); - _rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL); -} - -function stopRebuildPoll() { - if (_rebuildPollTimer) { clearInterval(_rebuildPollTimer); _rebuildPollTimer = null; } -} - -async function pollRebuildStatus() { - if (_rebuildFinished) return; - try { - var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset); - if (_rebuildServerDown) { _rebuildServerDown = false; } - if (data.log) appendRebuildLog(data.log); - _rebuildLogOffset = data.offset; - if (data.running) return; - _rebuildFinished = true; - stopRebuildPoll(); - onRebuildDone(data.result === "success"); - } catch (err) { - if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; } - } -} - -function onRebuildDone(success) { - if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning"); - if ($rebuildClose) $rebuildClose.disabled = false; - if (success) { - if ($rebuildStatus) $rebuildStatus.textContent = "āœ“ Done"; - // Auto-reload the page after a short delay so tiles and toggles reflect the new state - setTimeout(function() { window.location.reload(); }, 1200); - } else { - if ($rebuildStatus) $rebuildStatus.textContent = "āœ— Something went wrong"; - if ($rebuildSave) $rebuildSave.style.display = "inline-flex"; - if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; - } -} - -function saveRebuildErrorReport() { - var blob = new Blob([_rebuildLog], { type: "text/plain" }); - var url = URL.createObjectURL(blob); - var a = document.createElement("a"); - a.href = url; - a.download = "sovran-rebuild-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -// ── Feature confirm modal ───────────────────────────────────────── - -function openFeatureConfirm(message, onConfirm) { - if (!$featureConfirmModal) return; - if ($featureConfirmMsg) $featureConfirmMsg.textContent = message; - $featureConfirmModal.classList.add("open"); - // Replace ok handler - var newOk = $featureConfirmOk.cloneNode(true); - $featureConfirmOk.parentNode.replaceChild(newOk, $featureConfirmOk); - newOk.addEventListener("click", function() { - closeFeatureConfirm(); - onConfirm(); - }); -} - -function closeFeatureConfirm() { - if ($featureConfirmModal) $featureConfirmModal.classList.remove("open"); -} - -// ── SSL Email modal ─────────────────────────────────────────────── - -function openSslEmailModal(onSaved) { - if (!$sslEmailModal) return; - if ($sslEmailInput) $sslEmailInput.value = ""; - $sslEmailModal.classList.add("open"); - // Replace save handler - var newSave = $sslEmailSave.cloneNode(true); - $sslEmailSave.parentNode.replaceChild(newSave, $sslEmailSave); - newSave.addEventListener("click", async function() { - var email = $sslEmailInput ? $sslEmailInput.value.trim() : ""; - if (!email) { alert("Please enter an email address."); return; } - newSave.disabled = true; - newSave.textContent = "Saving…"; - try { - await apiFetch("/api/domains/set-email", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: email }), - }); - closeSslEmailModal(); - onSaved(); - } catch (err) { - newSave.disabled = false; - newSave.textContent = "Save"; - alert("Failed to save email. Please try again."); - } - }); -} - -function closeSslEmailModal() { - if ($sslEmailModal) $sslEmailModal.classList.remove("open"); -} - -// ── Domain Setup modal ──────────────────────────────────────────── - -function openDomainSetupModal(feat, onSaved) { - if (!$domainSetupModal) return; - if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name; - - var npubField = ""; - if (feat.id === "haven") { - var currentNpub = ""; - if (feat.extra_fields && feat.extra_fields.length > 0) { - for (var i = 0; i < feat.extra_fields.length; i++) { - if (feat.extra_fields[i].id === "nostr_npub") { - currentNpub = feat.extra_fields[i].current_value || ""; - break; - } - } - } - npubField = '
        '; - } - - var externalIp = _cachedExternalIp || "your external IP"; - - $domainSetupBody.innerHTML = - '
        ' + - '

        Before continuing:

        ' + - '
          ' + - '
        1. Create an account at https://njal.la
        2. ' + - '
        3. Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.
        4. ' + - '
        5. In the Njal.la web interface, create a Dynamic record pointing to this machine\'s external IP address:
          ' + - '' + escHtml(externalIp) + '
        6. ' + - '
        7. Njal.la will give you a curl command like:
          ' + - 'curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"
        8. ' + - '
        9. Enter the subdomain and paste that curl command below
        10. ' + - '
        ' + - '
        ' + - '
        ' + - '

        ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record

        ' + - npubField + - '
        '; - - document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal); - - document.getElementById("domain-setup-save-btn").addEventListener("click", async function() { - var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || ""; - var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || ""; - var npub = document.getElementById("domain-npub-input") ? (document.getElementById("domain-npub-input").value || "") : ""; - subdomain = subdomain.trim(); - ddnsUrl = ddnsUrl.trim(); - npub = npub.trim(); - - if (!subdomain) { alert("Please enter a subdomain."); return; } - if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; } - - var saveBtn = document.getElementById("domain-setup-save-btn"); - saveBtn.disabled = true; - saveBtn.textContent = "Saving…"; - - try { - await apiFetch("/api/domains/set", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - domain_name: feat.domain_name, - domain: subdomain, - ddns_url: ddnsUrl, - }), - }); - closeDomainSetupModal(); - onSaved(npub); - } catch (err) { - saveBtn.disabled = false; - saveBtn.textContent = "Save & Enable"; - alert("Failed to save domain. Please try again."); - } - }); - - $domainSetupModal.classList.add("open"); -} - -function closeDomainSetupModal() { - if ($domainSetupModal) $domainSetupModal.classList.remove("open"); -} - -// ── Port Requirements modal ─────────────────────────────────────── - -function openPortRequirementsModal(featureName, ports, onContinue) { - if (!$portReqModal || !$portReqBody) return; - - var continueBtn = onContinue - ? '' - : ''; - - // Show loading state while fetching port status - $portReqBody.innerHTML = - '

        Checking port status for ' + escHtml(featureName) + '…

        ' + - '

        Detecting which ports are open on this machine…

        '; - - $portReqModal.classList.add("open"); - - // Fetch live port status from local system commands (no external calls) - fetch("/api/ports/status", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ports: ports }), - }) - .then(function(r) { return r.json(); }) - .then(function(data) { - var internalIp = (data.internal_ip && data.internal_ip !== "unavailable") - ? data.internal_ip : null; - var portStatuses = {}; - (data.ports || []).forEach(function(p) { - portStatuses[p.port + "/" + p.protocol] = p.status; - }); - - var rows = ports.map(function(p) { - var key = p.port + "/" + p.protocol; - var status = portStatuses[key] || "unknown"; - var statusHtml; - if (status === "listening") { - statusHtml = '🟢 Listening'; - } else if (status === "firewall_open") { - statusHtml = '🟔 Open (idle)'; - } else if (status === "closed") { - statusHtml = 'šŸ”“ Closed'; - } else { - statusHtml = '⚪ Unknown'; - } - return '' + - '' + escHtml(p.port) + '' + - '' + escHtml(p.protocol) + '' + - '' + escHtml(p.description) + '' + - '' + statusHtml + '' + - ''; - }).join(""); - - var ipLine = internalIp - ? '

        Forward each port below to this machine\'s internal IP: ' + escHtml(internalIp) + '

        ' - : "

        Forward each port below to this machine's internal LAN IP in your router's port forwarding settings.

        "; - - $portReqBody.innerHTML = - '

        Port Forwarding Required

        ' + - '

        For ' + escHtml(featureName) + " to work with clients outside your local network, " + - "you must configure port forwarding in your router's admin panel.

        " + - ipLine + - '' + - '' + - '' + rows + '' + - '
        Port(s)ProtocolPurposeStatus
        ' + - "

        How to verify: Router-side forwarding cannot be checked from inside your network. " + - "To confirm ports are forwarded correctly, test from a device on a different network (e.g. a phone on mobile data) " + - "or check your router's port forwarding page.

        " + - '

        ℹ Search "how to set up port forwarding on [your router model]" for step-by-step instructions.

        ' + - '
        ' + - '' + - continueBtn + - '
        '; - - document.getElementById("port-req-dismiss-btn").addEventListener("click", function() { - closePortRequirementsModal(); - }); - - if (onContinue) { - document.getElementById("port-req-continue-btn").addEventListener("click", function() { - closePortRequirementsModal(); - onContinue(); - }); - } - }) - .catch(function() { - // Fallback: show static table without status column if fetch fails - var rows = ports.map(function(p) { - return '' + escHtml(p.port) + '' + - '' + escHtml(p.protocol) + '' + - '' + escHtml(p.description) + ''; - }).join(""); - - $portReqBody.innerHTML = - '

        Port Forwarding Required

        ' + - '

        For ' + escHtml(featureName) + ' to work with clients outside your local network, ' + - 'you must configure port forwarding in your router\'s admin panel and forward each port below to this machine\'s internal LAN IP.

        ' + - '' + - '' + - '' + rows + '' + - '
        Port(s)ProtocolPurpose
        ' + - '

        ℹ Search "how to set up port forwarding on [your router model]" for step-by-step instructions.

        ' + - '
        ' + - '' + - continueBtn + - '
        '; - - document.getElementById("port-req-dismiss-btn").addEventListener("click", function() { - closePortRequirementsModal(); - }); - - if (onContinue) { - document.getElementById("port-req-continue-btn").addEventListener("click", function() { - closePortRequirementsModal(); - onContinue(); - }); - } - }); -} - -function closePortRequirementsModal() { - if ($portReqModal) $portReqModal.classList.remove("open"); -} - -if ($portReqClose) { - $portReqClose.addEventListener("click", closePortRequirementsModal); -} - -// ── Feature toggle logic ────────────────────────────────────────── - -async function performFeatureToggle(featId, enabled, extra) { - // Look up feature name for the rebuild modal - _rebuildIsEnabling = enabled; - _rebuildFeatureName = featId; - if (_featuresData) { - var found = _featuresData.features.find(function(f) { return f.id === featId; }); - if (found) _rebuildFeatureName = found.name; - } - try { - var res = await fetch("/api/features/toggle", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ feature: featId, enabled: enabled, extra: extra || {} }), - }); - var body = await res.json(); - if (!res.ok) { - if (body && body.error === "domain_required") { - alert("Domain not configured for this feature. Please configure it first."); - } else { - alert("Error: " + (body.detail || body.error || "Unknown error")); - } - loadFeatureManager(); - return; - } - openRebuildModal(); - } catch (err) { - alert("Failed to toggle feature: " + err); - loadFeatureManager(); - } -} - -function handleFeatureToggle(feat, newEnabled) { - if (!newEnabled) { - // Disable: ask confirmation - openFeatureConfirm( - "This will disable " + feat.name + ". The system will rebuild. Continue?", - function() { performFeatureToggle(feat.id, false, {}); } - ); - return; - } - - // Enabling - var conflictNames = []; - if (feat.conflicts_with && feat.conflicts_with.length > 0 && _featuresData) { - feat.conflicts_with.forEach(function(cid) { - var cf = _featuresData.features.find(function(f) { return f.id === cid; }); - if (cf && cf.enabled) conflictNames.push(cf.name); - }); - } - - function proceedAfterPortCheck() { - // Check SSL email first - if (!_featuresData || !_featuresData.ssl_email_configured) { - if (feat.needs_domain) { - openSslEmailModal(function() { - // After ssl email saved, check domain - checkDomainAndEnable(feat, {}); - }); - return; - } - } - if (feat.needs_domain && !feat.domain_configured) { - checkDomainAndEnable(feat, {}); - return; - } - if (feat.id === "haven") { - var npub = ""; - if (feat.extra_fields) { - var ef = feat.extra_fields.find(function(e) { return e.id === "nostr_npub"; }); - if (ef) npub = ef.current_value || ""; - } - if (!npub) { - // Need to collect npub via domain modal - openDomainSetupModal(feat, function(collectedNpub) { - performFeatureToggle(feat.id, true, { nostr_npub: collectedNpub }); - }); - return; - } - } - performFeatureToggle(feat.id, true, {}); - } - - function proceedAfterConflictCheck() { - // Show port requirements notification if the feature has extra port needs - var ports = feat.port_requirements || []; - if (ports.length > 0) { - openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck); - } else { - proceedAfterPortCheck(); - } - } - - if (conflictNames.length > 0) { - var confirmMsg; - if (feat.id === "bip110") { - confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Knots + BIP110 will disable Bitcoin Core (if active). Continue?"; - } else if (feat.id === "bitcoin-core") { - confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will disable Bitcoin Knots + BIP110 (if active). Continue?"; - } else { - confirmMsg = "This will disable " + conflictNames.join(", ") + ". Continue?"; - } - openFeatureConfirm(confirmMsg, proceedAfterConflictCheck); - } else { - proceedAfterConflictCheck(); - } -} - -function checkDomainAndEnable(feat, extra) { - openDomainSetupModal(feat, function(collectedNpub) { - var extraData = {}; - if (collectedNpub) extraData.nostr_npub = collectedNpub; - performFeatureToggle(feat.id, true, extraData); - }); -} - -// ── Feature Manager rendering ───────────────────────────────────── - -async function loadFeatureManager() { - try { - var data = await apiFetch("/api/features"); - _featuresData = data; - // Feature Manager is now integrated into tile modals; sidebar rendering removed. - } catch (err) { - console.warn("Failed to load features:", err); - } -} - -function _checkFeatureManagerDomains(data) { - // Collect all features with a configured domain - var featsWithDomain = (data.features || []).filter(function(f) { - return f.needs_domain && f.domain_configured; - }); - if (!featsWithDomain.length) return; - - // Get the actual domain values from /api/domains/status, then check them - fetch("/api/domains/status") - .then(function(r) { return r.json(); }) - .then(function(statusData) { - var domainFileMap = statusData.domains || {}; - // Build list of domains to check and a map from domain value → feature id - var domainsToCheck = []; - var domainToFeatIds = {}; - featsWithDomain.forEach(function(feat) { - var domainName = feat.domain_name; - var domainVal = domainName ? domainFileMap[domainName] : null; - if (domainVal) { - domainsToCheck.push(domainVal); - if (!domainToFeatIds[domainVal]) domainToFeatIds[domainVal] = []; - domainToFeatIds[domainVal].push(feat.id); - } else { - // Domain file missing — update badge to warn - _updateFeatureDomainBadge(feat.id, null, "unresolvable"); - } - }); - - if (!domainsToCheck.length) return; - - return fetch("/api/domains/check", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ domains: domainsToCheck }), - }) - .then(function(r) { return r.json(); }) - .then(function(checkData) { - (checkData.domains || []).forEach(function(d) { - var featIds = domainToFeatIds[d.domain] || []; - featIds.forEach(function(featId) { - _updateFeatureDomainBadge(featId, d.domain, d.status); - }); - }); - }); - }) - .catch(function() {}); -} - -function _updateFeatureDomainBadge(featId, domainVal, status) { - var section = $sidebarFeatures.querySelector(".feature-manager-section"); - if (!section) return; - // Find the card — cards don't have a data-feat-id, so find via name match - var badges = section.querySelectorAll(".feature-domain-badge.configured"); - badges.forEach(function(badge) { - var domainNameAttr = badge.getAttribute("data-domain-name"); - // Match by domain_name attribute — we need to look up the feat's domain_name - var feat = _featuresData && _featuresData.features - ? _featuresData.features.find(function(f) { return f.id === featId; }) - : null; - if (!feat) return; - if (domainNameAttr !== (feat.domain_name || "")) return; - - var lbl = badge.querySelector(".feature-domain-label"); - if (!lbl) return; - lbl.classList.remove("feature-domain-label--checking"); - if (status === "connected") { - lbl.className = "feature-domain-label feature-domain-label--ok"; - lbl.textContent = (domainVal || "Domain") + " āœ“"; - } else if (status === "dns_mismatch") { - lbl.className = "feature-domain-label feature-domain-label--warn"; - lbl.textContent = (domainVal || "Domain") + " (IP mismatch)"; - } else if (status === "unresolvable") { - lbl.className = "feature-domain-label feature-domain-label--error"; - lbl.textContent = (domainVal || "Domain") + " (DNS error)"; - } else { - lbl.className = "feature-domain-label feature-domain-label--warn"; - lbl.textContent = (domainVal || "Domain") + " (unknown)"; - } - }); -} - -function renderFeatureManager(data) { - // Remove old feature manager section if it exists - var old = $sidebarFeatures.querySelector(".feature-manager-section"); - if (old) old.parentNode.removeChild(old); - - var section = document.createElement("div"); - section.className = "category-section feature-manager-section"; - section.dataset.category = "feature-manager"; - section.innerHTML = '
        Feature Manager

        '; - - // Group by sub-category - var grouped = {}; - for (var i = 0; i < data.features.length; i++) { - var f = data.features[i]; - var cat = f.category || "other"; - if (!grouped[cat]) grouped[cat] = []; - grouped[cat].push(f); - } - - var orderedCats = FEATURE_SUBCATEGORY_ORDER.filter(function(k) { return grouped[k]; }); - Object.keys(grouped).forEach(function(k) { - if (orderedCats.indexOf(k) === -1) orderedCats.push(k); - }); - - for (var j = 0; j < orderedCats.length; j++) { - var catKey = orderedCats[j]; - var feats = grouped[catKey]; - if (!feats || feats.length === 0) continue; - - var subcat = document.createElement("div"); - subcat.className = "feature-subcategory"; - var subcatLabel = FEATURE_SUBCATEGORY_LABELS[catKey] || catKey; - subcat.innerHTML = '
        ' + escHtml(subcatLabel) + '
        '; - - var cardsWrap = document.createElement("div"); - cardsWrap.className = "feature-cards-wrap"; - - for (var k = 0; k < feats.length; k++) { - cardsWrap.appendChild(buildFeatureCard(feats[k])); - } - subcat.appendChild(cardsWrap); - section.appendChild(subcat); - } - - $sidebarFeatures.appendChild(section); -} - -function buildFeatureCard(feat) { - var card = document.createElement("div"); - card.className = "feature-card"; - - var conflictHtml = ""; - if (feat.conflicts_with && feat.conflicts_with.length > 0) { - var conflictNames = feat.conflicts_with.map(function(cid) { - if (!_featuresData) return cid; - var cf = _featuresData.features.find(function(f) { return f.id === cid; }); - return cf ? cf.name : cid; - }); - conflictHtml = '
        ⚠ Conflicts with: ' + escHtml(conflictNames.join(", ")) + '
        '; - } - - var domainHtml = ""; - if (feat.needs_domain) { - if (feat.domain_configured) { - domainHtml = '
        ' - + '🌐' - + 'Domain: Checking\u2026' - + '
        '; - } else { - domainHtml = '
        ' - + '🌐' - + 'Domain: Not configured' - + '
        '; - } - } - - var statusText = feat.enabled ? "Enabled" : "Disabled"; - - card.innerHTML = - '
        ' + - '
        ' + - '
        ' + escHtml(feat.name) + '
        ' + - '
        ' + escHtml(feat.description) + '
        ' + - '
        ' + - '' + - '
        ' + - domainHtml + - conflictHtml + - '
        Status: ' + escHtml(statusText) + '
        '; - - var toggle = card.querySelector(".feature-toggle-input"); - var toggleLabel = card.querySelector(".feature-toggle"); - toggle.addEventListener("change", function() { - var newEnabled = toggle.checked; - // Revert visually until confirmed - toggle.checked = feat.enabled; - if (newEnabled) { toggleLabel.classList.remove("active"); } else { toggleLabel.classList.add("active"); } - handleFeatureToggle(feat, newEnabled); - }); - - return card; -} - -// ── Event listeners ─────────────────────────────────────────────── - -if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); -if ($refreshBtn) $refreshBtn.addEventListener("click", function() { refreshServices(); }); -if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal); -if ($btnReboot) $btnReboot.addEventListener("click", doReboot); -if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); -if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal); -if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal); - -// Rebuild modal -if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal); -if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot); -if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport); -if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); }); - -// Domain setup modal -if ($domainSetupClose) $domainSetupClose.addEventListener("click", closeDomainSetupModal); -if ($domainSetupModal) $domainSetupModal.addEventListener("click", function(e) { if (e.target === $domainSetupModal) closeDomainSetupModal(); }); - -// SSL Email modal -if ($sslEmailClose) $sslEmailClose.addEventListener("click", closeSslEmailModal); -if ($sslEmailCancel) $sslEmailCancel.addEventListener("click", closeSslEmailModal); -if ($sslEmailModal) $sslEmailModal.addEventListener("click", function(e) { if (e.target === $sslEmailModal) closeSslEmailModal(); }); - -// Feature confirm modal -if ($featureConfirmClose) $featureConfirmClose.addEventListener("click", closeFeatureConfirm); -if ($featureConfirmCancel) $featureConfirmCancel.addEventListener("click", closeFeatureConfirm); -if ($featureConfirmModal) $featureConfirmModal.addEventListener("click", function(e) { if (e.target === $featureConfirmModal) closeFeatureConfirm(); }); - -if ($modal) $modal.addEventListener("click", function(e) { if (e.target === $modal) closeUpdateModal(); }); -if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); }); -if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); }); - -// ── Init ────────────────────────────────────────────────────────── - -async function init() { - // Check onboarding status first — redirect to wizard if not complete - try { - var onboardingStatus = await apiFetch("/api/onboarding/status"); - if (!onboardingStatus.complete) { - window.location.href = "/onboarding"; - return; - } - } catch (_) { - // If we can't reach the endpoint, continue to normal dashboard - } - - try { - var cfg = await apiFetch("/api/config"); - if (cfg.category_order) { - for (var i = 0; i < cfg.category_order.length; i++) { - _categoryLabels[cfg.category_order[i][0]] = cfg.category_order[i][1]; - } - } - var badge = document.getElementById("role-badge"); - if (badge && cfg.role_label) badge.textContent = cfg.role_label; - - await refreshServices(); - loadNetwork(); - checkUpdates(); - - setInterval(refreshServices, POLL_INTERVAL_SERVICES); - setInterval(checkUpdates, POLL_INTERVAL_UPDATES); - - if (cfg.feature_manager) { - loadFeatureManager(); - } - } catch (_) { - await refreshServices(); - loadNetwork(); - checkUpdates(); - setInterval(refreshServices, POLL_INTERVAL_SERVICES); - setInterval(checkUpdates, POLL_INTERVAL_UPDATES); - } -} - -document.addEventListener("DOMContentLoaded", init); \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/css/base.css b/app/sovran_systemsos_web/static/css/base.css new file mode 100644 index 0000000..68234bc --- /dev/null +++ b/app/sovran_systemsos_web/static/css/base.css @@ -0,0 +1,137 @@ +/* Sovran_SystemsOS Hub — Web UI Stylesheet + Dark theme matching the Adwaita dark aesthetic + v6 — Status-only tiles (no controls) */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg-color: #1e1e2e; + --surface-color: #2a2a3c; + --card-color: #313244; + --border-color: #45475a; + --text-primary: #cdd6f4; + --text-secondary: #a6adc8; + --text-dim: #6c7086; + --accent-color: #89b4fa; + --green: #2ec27e; + --yellow: #e5a50a; + --red: #e01b24; + --grey: #888888; + --radius-card: 18px; + --radius-btn: 8px; + --shadow-card: 0 2px 8px rgba(0,0,0,0.4); + --shadow-hover: 0 6px 20px rgba(0,0,0,0.6); +} + +html, body { + height: 100%; +} + +body { + font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif; + background-color: var(--bg-color); + color: var(--text-primary); + line-height: 1.5; + min-height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ── Login page ──────────────────────────────────────────────────── */ + +.login-wrapper { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 24px; +} + +.login-card { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 48px 40px; + width: 100%; + max-width: 400px; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); +} + +.login-header { + text-align: center; + margin-bottom: 32px; +} + +.login-logo { + height: 64px; + margin-bottom: 16px; +} + +.login-title { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); +} + +.login-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-group label { + display: block; + font-size: 0.82rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.form-group input { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--border-color); + border-radius: var(--radius-btn); + background-color: var(--card-color); + color: var(--text-primary); + font-size: 0.92rem; +} + +.form-group input:focus { + outline: none; + border-color: var(--accent-color); +} + +.btn-login { + width: 100%; + padding: 12px; + border-radius: var(--radius-btn); + background-color: var(--accent-color); + color: #1e1e2e; + font-size: 0.95rem; + font-weight: 700; + margin-top: 8px; +} + +.btn-login:hover { + opacity: 0.88; +} + +.login-error { + background-color: rgba(224, 27, 36, 0.12); + border: 1px solid var(--red); + color: #f87171; + padding: 10px 14px; + border-radius: 8px; + font-size: 0.85rem; + display: none; +} + +.login-error.visible { + display: block; +} diff --git a/app/sovran_systemsos_web/static/css/buttons.css b/app/sovran_systemsos_web/static/css/buttons.css new file mode 100644 index 0000000..8f1ebc5 --- /dev/null +++ b/app/sovran_systemsos_web/static/css/buttons.css @@ -0,0 +1,86 @@ +/* ── Buttons ────────────────────────────────────────────────────── */ + +button { + font-family: inherit; + cursor: pointer; + border: none; + outline: none; + transition: opacity 0.15s, box-shadow 0.15s, background-color 0.15s; +} + +button:disabled { + opacity: 0.45; + cursor: default; +} + +.btn { + padding: 7px 16px; + border-radius: var(--radius-btn); + font-size: 0.88rem; + font-weight: 600; +} + +.btn-primary { + background-color: var(--accent-color); + color: #1e1e2e; +} + +.btn-primary:hover:not(:disabled) { + opacity: 0.88; +} + +/* Update System button: BLUE by default */ +.btn-update { + background-color: #89b4fa; + color: #1e1e2e; + position: relative; + display: flex; + align-items: center; + gap: 8px; +} + +.btn-update:hover:not(:disabled) { + opacity: 0.88; +} + +/* Update System button: GREEN when updates are available */ +.btn-update.has-updates { + background-color: #2ec27e; + color: #fff; +} + +.btn-update.has-updates:hover:not(:disabled) { + background-color: #27ae6e; +} + +.update-badge { + display: none; + width: 10px; + height: 10px; + background-color: var(--yellow); + border-radius: 50%; + animation: pulse-badge 1.4s ease-in-out infinite; +} + +.update-badge.visible { + display: inline-block; +} + +@keyframes pulse-badge { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(1.35); } +} + +.btn-icon { + background: none; + color: var(--text-secondary); + padding: 6px; + border-radius: 50%; + font-size: 1.1rem; + line-height: 1; +} + +.btn-icon:hover:not(:disabled) { + background-color: var(--border-color); + color: var(--text-primary); +} diff --git a/app/sovran_systemsos_web/static/css/domain-setup.css b/app/sovran_systemsos_web/static/css/domain-setup.css new file mode 100644 index 0000000..08293ca --- /dev/null +++ b/app/sovran_systemsos_web/static/css/domain-setup.css @@ -0,0 +1,173 @@ +/* ── Domain setup modal ──────────────────────────────────────────── */ + +domain-narrow-dialog { + max-width: 500px; +} + +domain-field-group { + margin-bottom: 14px; +} + +domain-field-label { + display: block; + font-size: 0.82rem; + color: var(--text-secondary); + margin-bottom: 6px; + font-weight: 600; +} + +domain-field-input { + width: 100%; + background-color: #12121c; + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 12px; + font-size: 0.9rem; + box-sizing: border-box; +} + +domain-field-input:focus { + outline: none; + border-color: var(--accent-color); +} + +domain-field-actions { + display: flex; + gap: 10px; + margin-top: 18px; + justify-content: flex-end; +} + +/* ── Port Requirements modal ─────────────────────────────────────── */ + +.domain-narrow-dialog { + max-width: 500px; +} + +.domain-setup-intro { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 16px; +} + +.domain-setup-intro ol { + padding-left: 20px; + margin-top: 8px; +} + +.domain-setup-intro li { + margin-bottom: 6px; +} + +.domain-field-group { + margin-bottom: 14px; +} + +.domain-field-label { + display: block; + font-size: 0.82rem; + color: var(--text-secondary); + margin-bottom: 6px; + font-weight: 600; +} + +.domain-field-input { + width: 100%; + background-color: #12121c; + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 12px; + font-size: 0.9rem; + box-sizing: border-box; +} + +.domain-field-input:focus { + outline: none; + border-color: var(--accent-color); +} + +.domain-field-hint { + font-size: 0.75rem; + color: var(--text-dim); + margin-top: 4px; + font-style: italic; +} + +.domain-field-actions { + display: flex; + gap: 10px; + margin-top: 18px; + justify-content: flex-end; +} + +.port-req-intro { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 10px; +} + +.port-req-hint { + font-size: 0.82rem; + color: var(--text-dim); + line-height: 1.6; + margin-top: 10px; + margin-bottom: 10px; +} + +.port-req-internal-ip { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-weight: 700; + color: var(--accent-color); +} + +.port-req-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; + margin-top: 8px; + margin-bottom: 8px; +} + +.port-req-table th { + text-align: left; + color: var(--text-dim); + font-weight: 600; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 6px 10px; + border-bottom: 1px solid var(--border-color); +} + +.port-req-table td { + padding: 8px 10px; + border-bottom: 1px solid rgba(69, 71, 90, 0.4); + color: var(--text-primary); +} + +.port-req-table tr:last-child td { + border-bottom: none; +} + +.port-req-port { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-weight: 600; + color: var(--accent-color); +} + +.port-req-proto { + text-transform: uppercase; + color: var(--text-secondary); +} + +.port-req-desc { + color: var(--text-secondary); +} + +.port-req-status { + font-weight: 600; +} diff --git a/app/sovran_systemsos_web/static/css/features.css b/app/sovran_systemsos_web/static/css/features.css new file mode 100644 index 0000000..ac343e4 --- /dev/null +++ b/app/sovran_systemsos_web/static/css/features.css @@ -0,0 +1,143 @@ +/* ── Feature Manager styles ──────────────────────────────────────── */ + +.feature-manager-section { + margin-bottom: 32px; +} + +.feature-subcategory { + margin-bottom: 16px; +} + +.feature-subcategory-header { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + margin-bottom: 8px; + padding-left: 4px; +} + +.feature-cards-wrap { + display: flex; + flex-direction: column; + gap: 10px; +} + +.feature-card { + background-color: var(--card-color); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 14px 16px; +} + +.feature-card-top { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 8px; +} + +.feature-card-info { + flex: 1; + min-width: 0; +} + +.feature-card-name { + font-size: 0.9rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 4px; +} + +.feature-card-desc { + font-size: 0.78rem; + color: var(--text-secondary); + line-height: 1.5; +} + +.feature-card-status { + font-size: 0.72rem; + color: var(--text-dim); + margin-top: 6px; +} + +.feature-toggle { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + flex-shrink: 0; + cursor: pointer; +} + +.feature-toggle-input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.feature-toggle-slider { + position: absolute; + inset: 0; + background-color: var(--border-color); + border-radius: 24px; + transition: background-color 0.2s; +} + +.feature-toggle-slider::before { + content: ""; + position: absolute; + width: 18px; + height: 18px; + left: 3px; + top: 3px; + background-color: #fff; + border-radius: 50%; + transition: transform 0.2s; +} + +.feature-toggle.active .feature-toggle-slider { + background-color: var(--green); +} + +.feature-toggle.active .feature-toggle-slider::before { + transform: translateX(20px); +} + +.feature-domain-badge { + display: flex; + align-items: center; + gap: 6px; + margin-top: 6px; + font-size: 0.78rem; +} + +.feature-domain-icon { + flex-shrink: 0; +} + +.feature-domain-label { + color: var(--text-secondary); +} + +.feature-domain-label--checking { + color: var(--text-dim); + font-style: italic; +} + +.feature-domain-label--ok { + color: var(--green); + font-weight: 600; +} + +.feature-domain-label--warn { + color: var(--yellow); + font-weight: 600; +} + +.feature-domain-label--error { + color: var(--red); + font-weight: 600; +} diff --git a/app/sovran_systemsos_web/static/css/header.css b/app/sovran_systemsos_web/static/css/header.css new file mode 100644 index 0000000..ac294ff --- /dev/null +++ b/app/sovran_systemsos_web/static/css/header.css @@ -0,0 +1,72 @@ +/* ── Header bar ─────────────────────────────────────────────────── */ + +.header-bar { + background-color: var(--surface-color); + border-bottom: 1px solid var(--border-color); + padding: 16px 24px; + display: flex; + align-items: center; + gap: 16px; + position: sticky; + top: 0; + z-index: 100; + justify-content: flex-end; +} + +.header-bar .title { + font-size: 1.15rem; + font-weight: 700; + color: var(--text-primary); + position: absolute; + left: 0; + right: 0; + text-align: center; + pointer-events: none; + white-space: nowrap; +} + +.header-logo { + height: 108px; + width: auto; + vertical-align: middle; + margin-right: 10px; +} + +.role-badge { + background-color: var(--accent-color); + color: #1e1e2e; + font-size: 0.72rem; + font-weight: 700; + padding: 3px 10px; + border-radius: 20px; + letter-spacing: 0.03em; +} + +/* ── IP bar ─────────────────────────────────────────────────────── */ + +.ip-bar { + background-color: var(--surface-color); + border-bottom: 1px solid var(--border-color); + padding: 8px 24px; + display: flex; + align-items: center; + justify-content: center; + gap: 32px; + font-size: 0.82rem; + color: var(--text-secondary); +} + +.ip-bar .ip-label { + color: var(--text-dim); + margin-right: 6px; +} + +.ip-bar .ip-value { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + color: var(--accent-color); + font-weight: 600; +} + +.ip-separator { + color: var(--border-color); +} diff --git a/app/sovran_systemsos_web/static/css/layout.css b/app/sovran_systemsos_web/static/css/layout.css new file mode 100644 index 0000000..1edb4e5 --- /dev/null +++ b/app/sovran_systemsos_web/static/css/layout.css @@ -0,0 +1,130 @@ +/* ── Main content ───────────────────────────────────────────────── */ + +.main-content { + display: flex; + align-items: flex-start; + flex: 1; + overflow: hidden; + max-width: 1400px; + width: 100%; + margin-left: auto; + margin-right: auto; +} + +/* ── Sidebar ────────────────────────────────────────────────────── */ + +.sidebar { + width: 270px; + flex-shrink: 0; + height: 100%; + overflow-y: auto; + border-right: 1px solid var(--border-color); + background-color: var(--surface-color); + padding: 20px 14px; + display: flex; + flex-direction: column; + gap: 0; +} + +/* ── Sidebar: Tech Support button ───────────────────────────────── */ + +.sidebar-support-btn { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + background-color: var(--card-color); + border: 2px dashed var(--accent-color); + border-radius: 12px; + padding: 12px 14px; + color: var(--text-primary); + cursor: pointer; + transition: border-style 0.15s, border-color 0.15s, background-color 0.15s; + text-align: left; +} + +.sidebar-support-btn:hover { + border-style: solid; + border-color: #a8c8ff; + background-color: #35354a; +} + +.sidebar-support-icon { + font-size: 1.5rem; + flex-shrink: 0; +} + +.sidebar-support-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.sidebar-support-title { + font-size: 0.88rem; + font-weight: 700; + color: var(--text-primary); +} + +.sidebar-support-hint { + font-size: 0.72rem; + color: var(--accent-color); + font-weight: 600; +} + +.sidebar-divider { + border: none; + border-top: 1px solid var(--border-color); + margin: 16px 0; +} + +/* ── Tiles area ─────────────────────────────────────────────────── */ + +#tiles-area { + flex: 1; + height: 100%; + overflow-y: auto; + padding: 24px 20px 48px; + min-width: 0; +} + +/* ── Category sections ──────────────────────────────────────────── */ + +.category-section { + margin-bottom: 32px; +} + +.section-header { + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 4px; + padding-left: 4px; +} + +.section-divider { + border: none; + border-top: 1px solid var(--border-color); + margin-bottom: 16px; +} + +.tiles-grid { + display: flex; + flex-wrap: wrap; + gap: 14px; +} + +/* ── Empty state ────────────────────────────────────────────────── */ + +.empty-state { + text-align: center; + padding: 64px 24px; + color: var(--text-dim); +} + +.empty-state p { + font-size: 1rem; + margin-bottom: 8px; +} diff --git a/app/sovran_systemsos_web/static/css/modals.css b/app/sovran_systemsos_web/static/css/modals.css new file mode 100644 index 0000000..f98e956 --- /dev/null +++ b/app/sovran_systemsos_web/static/css/modals.css @@ -0,0 +1,421 @@ +/* ── Update modal ────────────────────────────────────────────────── */ + +.modal-overlay { + display: none; + position: fixed; + inset: 0; + background-color: rgba(0,0,0,0.65); + z-index: 200; + align-items: center; + justify-content: center; +} + +.modal-overlay.open { + display: flex; +} + +.modal-dialog { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 16px; + width: 90vw; + max-width: 900px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 16px 48px rgba(0,0,0,0.7); +} + +.modal-header { + display: flex; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); + gap: 12px; +} + +.modal-title { + font-size: 1rem; + font-weight: 700; + flex: 1; +} + +.modal-status { + font-size: 0.85rem; + color: var(--text-secondary); +} + +.modal-spinner { + width: 18px; + height: 18px; + border: 2.5px solid var(--border-color); + border-top-color: var(--accent-color); + border-radius: 50%; + animation: spin 0.75s linear infinite; + display: none; +} + +.modal-spinner.spinning { + display: block; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.modal-log { + flex: 1; + overflow-y: auto; + padding: 12px 16px; + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.78rem; + line-height: 1.6; + color: var(--text-primary); + background-color: #12121c; + white-space: pre-wrap; + word-break: break-all; + min-height: 200px; +} + +.modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 12px 20px; + border-top: 1px solid var(--border-color); +} + +/* Reboot = GREEN */ +.modal-footer .btn-reboot, +button.btn-reboot { + background-color: #2ec27e; + color: #fff; +} + +.modal-footer .btn-reboot:hover:not(:disabled), +button.btn-reboot:hover:not(:disabled) { + background-color: #27ae6e; +} + +.btn-save { + background-color: var(--yellow); + color: #1e1e2e; +} + +.btn-save:hover:not(:disabled) { + background-color: #c98d08; +} + +.btn-close-modal { + background-color: var(--border-color); + color: var(--text-primary); +} + +.btn-close-modal:hover:not(:disabled) { + background-color: #5a5c72; +} + +/* ── Credentials info modal ──────────────────────────────────────── */ + +.creds-dialog { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 16px; + width: 90vw; + max-width: 700px; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 16px 48px rgba(0,0,0,0.7); + animation: creds-fade-in 0.2s ease-out; +} + +@keyframes creds-fade-in { + from { opacity: 0; transform: scale(0.95) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.creds-header { + display: flex; + align-items: center; + padding: 20px 28px; + border-bottom: 1px solid var(--border-color); +} + +.creds-title { + font-size: 1.15rem; + font-weight: 700; + flex: 1; +} + +.creds-close-btn { + background: none; + color: var(--text-secondary); + font-size: 1.3rem; + padding: 4px 8px; + border-radius: 6px; + cursor: pointer; + border: none; +} + +.creds-close-btn:hover { + background-color: var(--border-color); + color: var(--text-primary); +} + +.creds-body { + padding: 24px 28px; + overflow-y: auto; +} + +.creds-loading { + color: var(--text-dim); + text-align: center; + padding: 24px 0; +} + +.creds-row { + margin-bottom: 20px; +} + +.creds-row:last-child { + margin-bottom: 0; +} + +.creds-label { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + margin-bottom: 6px; +} + +.creds-value-wrap { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.creds-value { + flex: 1; + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.92rem; + color: var(--text-primary); + background-color: #12121c; + padding: 12px 16px; + border-radius: 8px; + word-break: break-all; + white-space: pre-wrap; + line-height: 1.6; + border: 1px solid var(--border-color); +} + +.creds-copy-btn { + background-color: var(--border-color); + color: var(--text-primary); + font-size: 0.78rem; + font-weight: 600; + padding: 8px 14px; + border-radius: 6px; + cursor: pointer; + border: none; + white-space: nowrap; + flex-shrink: 0; + align-self: flex-start; + margin-top: 10px; +} + +.creds-copy-btn:hover { + background-color: #5a5c72; +} + +.creds-copy-btn.copied { + background-color: var(--green); + color: #fff; +} + +.creds-empty { + color: var(--text-dim); + text-align: center; + padding: 24px 0; + font-size: 0.88rem; +} + +/* ── Credential links ────────────────────────────────────────────── */ + +.creds-link { + color: #b8f0c0; + text-decoration: none; + word-break: break-all; +} + +.creds-link:hover { + text-decoration: underline; + color: #defce6; +} + +/* ── Matrix action buttons ───────────────────────────────────────── */ + +.matrix-actions-divider { + border: none; + border-top: 1px solid var(--border-color); + margin: 18px 0 14px; +} + +.matrix-actions-row { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.matrix-action-btn { + background-color: var(--accent-color); + color: #0f0f19; + font-size: 0.88rem; + font-weight: 700; + padding: 10px 18px; + border-radius: 8px; + border: none; + cursor: pointer; + flex: 1; + min-width: 140px; +} + +.matrix-action-btn:hover { + background-color: #a8c8ff; +} + +.matrix-form-group { + margin-bottom: 14px; +} + +.matrix-form-label { + display: block; + font-size: 0.82rem; + color: var(--text-secondary); + margin-bottom: 6px; + font-weight: 600; +} + +.matrix-form-input { + width: 100%; + background-color: #12121c; + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 12px; + font-size: 0.9rem; + box-sizing: border-box; +} + +.matrix-form-input:focus { + outline: none; + border-color: var(--accent-color); +} + +.matrix-form-checkbox-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.matrix-form-checkbox-row input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent-color); +} + +.matrix-form-actions { + display: flex; + gap: 10px; + margin-top: 18px; +} + +.matrix-form-submit { + background-color: var(--accent-color); + color: #0f0f19; + font-size: 0.88rem; + font-weight: 700; + padding: 10px 20px; + border-radius: 8px; + border: none; + cursor: pointer; + flex: 1; +} + +.matrix-form-submit:hover:not(:disabled) { + background-color: #a8c8ff; +} + +.matrix-form-submit:disabled { + opacity: 0.6; + cursor: default; +} + +.matrix-form-back { + background-color: var(--border-color); + color: var(--text-primary); + font-size: 0.88rem; + font-weight: 600; + padding: 10px 20px; + border-radius: 8px; + border: none; + cursor: pointer; +} + +.matrix-form-back:hover { + background-color: #5a5c72; +} + +.matrix-form-result { + margin-top: 14px; + padding: 12px 16px; + border-radius: 8px; + font-size: 0.88rem; + line-height: 1.5; + display: none; +} + +.matrix-form-result.success { + background-color: rgba(74, 222, 128, 0.12); + border: 1px solid var(--green); + color: var(--green); + display: block; +} + +.matrix-form-result.error { + background-color: rgba(239, 68, 68, 0.12); + border: 1px solid #ef4444; + color: #f87171; + display: block; +} + +/* ── QR code in credentials modal ────────────────────────────────── */ + +.creds-qr-wrap { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; + margin-bottom: 10px; +} + +.creds-qr-img { + width: 240px; + height: 240px; + border-radius: 12px; + border: 4px solid #fff; + background-color: #fff; + image-rendering: pixelated; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); +} + +.creds-qr-hint { + margin-top: 10px; + font-size: 0.82rem; + color: var(--text-secondary); + font-style: italic; +} diff --git a/app/sovran_systemsos_web/static/css/onboarding.css b/app/sovran_systemsos_web/static/css/onboarding.css new file mode 100644 index 0000000..f5da9d6 --- /dev/null +++ b/app/sovran_systemsos_web/static/css/onboarding.css @@ -0,0 +1,144 @@ +/* ── Reboot overlay ─────────────────────────────────────────────── */ + +.reboot-overlay { + display: none; + position: fixed; + inset: 0; + background-color: rgba(15, 15, 25, 0.92); + z-index: 999; + align-items: center; + justify-content: center; +} + +.reboot-overlay.visible { + display: flex; +} + +.reboot-card { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 48px 56px; + text-align: center; + max-width: 480px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8); + animation: reboot-fade-in 0.4s ease-out; +} + +@keyframes reboot-fade-in { + from { opacity: 0; transform: scale(0.92) translateY(12px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.reboot-icon { + font-size: 3rem; + color: var(--accent-color); + margin-bottom: 16px; + animation: reboot-spin 2s linear infinite; + display: inline-block; +} + +@keyframes reboot-spin { + to { transform: rotate(360deg); } +} + +.reboot-title { + font-size: 1.35rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 12px; +} + +.reboot-message { + font-size: 0.92rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 24px; +} + +.reboot-dots { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: 16px; +} + +.reboot-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: var(--accent-color); + animation: reboot-bounce 1.4s ease-in-out infinite; +} + +.reboot-dot:nth-child(2) { animation-delay: 0.2s; } +.reboot-dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes reboot-bounce { + 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } + 40% { opacity: 1; transform: scale(1.2); } +} + +.reboot-submessage { + font-size: 0.82rem; + color: var(--text-dim); + font-style: italic; +} + +/* ── Responsive ─────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + body { + overflow: auto; + } + .main-content { + flex-direction: column; + overflow: visible; + } + .sidebar { + width: 100%; + height: auto; + border-right: none; + border-bottom: 1px solid var(--border-color); + padding: 14px 12px; + } + #tiles-area { + height: auto; + overflow-y: visible; + padding: 16px 12px 40px; + } +} + +@media (max-width: 600px) { + .header-bar { + padding: 10px 14px; + gap: 10px; + } + .header-bar .title { + font-size: 0.95rem; + } + .ip-bar { + gap: 16px; + flex-wrap: wrap; + padding: 8px 14px; + } + .tiles-grid { + justify-content: center; + } + .service-tile { + width: 140px; + min-height: 130px; + } + .reboot-card { + padding: 36px 28px; + margin: 0 16px; + } + .creds-dialog { + margin: 0 12px; + } + .creds-qr-img { + width: 200px; + height: 200px; + } +} diff --git a/app/sovran_systemsos_web/static/css/support.css b/app/sovran_systemsos_web/static/css/support.css new file mode 100644 index 0000000..d47d068 --- /dev/null +++ b/app/sovran_systemsos_web/static/css/support.css @@ -0,0 +1,362 @@ +/* ── Tech Support modal ──────────────────────────────────────────── */ + +.support-section { + text-align: center; +} + +.support-icon-big { + font-size: 3rem; + margin-bottom: 12px; +} + +.support-active-icon { + animation: pulse-badge 2s ease-in-out infinite; +} + +.support-heading { + font-size: 1.15rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; +} + +.support-active-heading { + color: var(--green); +} + +.support-desc { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 16px; + text-align: left; +} + +.support-active-note { + font-size: 0.88rem; + color: var(--text-secondary); + margin-bottom: 16px; +} + +.support-info-box { + background-color: var(--card-color); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 14px 18px; + margin-bottom: 16px; + text-align: left; +} + +.support-active-box { + border-color: var(--green); +} + +.support-info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; +} + +.support-info-label { + font-size: 0.82rem; + color: var(--text-dim); + font-weight: 600; +} + +.support-info-value { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.88rem; + color: var(--accent-color); + font-weight: 600; +} + +.support-info-hint { + font-size: 0.72rem; + color: var(--text-dim); + margin-top: 6px; + font-style: italic; +} + +.support-steps { + text-align: left; + margin-bottom: 16px; + padding: 14px 18px; + background-color: var(--card-color); + border-radius: 10px; + border: 1px solid var(--border-color); +} + +.support-steps-title { + font-size: 0.82rem; + font-weight: 700; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 8px; +} + +.support-steps ol { + padding-left: 20px; + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.7; +} + +.support-steps code { + background-color: rgba(137, 180, 250, 0.12); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.82rem; + color: var(--accent-color); +} + +.support-btn-enable { + width: 100%; + padding: 12px; + border-radius: var(--radius-btn); + background-color: var(--accent-color); + color: #1e1e2e; + font-size: 0.95rem; + font-weight: 700; + margin-bottom: 10px; +} + +.support-btn-enable:hover:not(:disabled) { + opacity: 0.88; +} + +.support-btn-disable { + width: 100%; + padding: 12px; + border-radius: var(--radius-btn); + background-color: var(--red); + color: #fff; + font-size: 0.95rem; + font-weight: 700; + margin-bottom: 10px; +} + +.support-btn-disable:hover:not(:disabled) { + opacity: 0.88; +} + +.support-btn-done { + width: 100%; + padding: 12px; + border-radius: var(--radius-btn); + background-color: var(--accent-color); + color: #1e1e2e; + font-size: 0.95rem; + font-weight: 700; + margin-top: 16px; +} + +.support-btn-done:hover:not(:disabled) { + opacity: 0.88; +} + +.support-btn-auditlog { + width: 100%; + padding: 10px; + border-radius: var(--radius-btn); + background-color: var(--border-color); + color: var(--text-primary); + font-size: 0.85rem; + font-weight: 600; + margin-top: 8px; +} + +.support-btn-auditlog:hover:not(:disabled) { + background-color: #5a5c72; +} + +.support-fine-print { + font-size: 0.72rem; + color: var(--text-dim); + font-style: italic; + margin-bottom: 8px; +} + +.support-verify-box { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin: 16px 0; + padding: 12px; + background-color: var(--card-color); + border-radius: 8px; +} + +.support-verify-label { + font-size: 0.82rem; + color: var(--text-dim); + font-weight: 600; +} + +.support-verify-value { + font-size: 0.88rem; + font-weight: 700; +} + +.support-verify-value.verified-gone { + color: var(--green); +} + +.support-verify-value.verify-warning { + color: var(--yellow); +} + +/* ── Wallet protection ───────────────────────────────────────────── */ + +.support-wallet-box { + text-align: left; + padding: 14px 18px; + border-radius: 10px; + margin-bottom: 16px; + border: 1px solid var(--border-color); +} + +.support-wallet-protected { + background-color: rgba(46, 194, 126, 0.06); + border-color: rgba(46, 194, 126, 0.3); +} + +.support-wallet-unlocked { + background-color: rgba(229, 165, 10, 0.06); + border-color: rgba(229, 165, 10, 0.3); +} + +.support-wallet-warning { + background-color: rgba(224, 27, 36, 0.06); + border-color: rgba(224, 27, 36, 0.3); +} + +.support-wallet-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.support-wallet-icon { + font-size: 1.2rem; +} + +.support-wallet-title { + font-size: 0.88rem; + font-weight: 700; + color: var(--text-primary); +} + +.support-wallet-desc { + font-size: 0.82rem; + color: var(--text-secondary); + line-height: 1.5; + margin-bottom: 8px; +} + +.support-wallet-paths { + list-style: none; + padding: 0; + margin: 8px 0; +} + +.support-wallet-paths li { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.78rem; + color: var(--text-dim); + padding: 2px 0; +} + +.support-wallet-unlock-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; +} + +.support-unlock-select { + background-color: var(--card-color); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 6px 10px; + font-size: 0.82rem; +} + +.support-btn-wallet-unlock { + padding: 8px 16px; + border-radius: var(--radius-btn); + background-color: var(--yellow); + color: #1e1e2e; + font-size: 0.82rem; + font-weight: 700; +} + +.support-btn-wallet-unlock:hover:not(:disabled) { + background-color: #c98d08; +} + +.support-btn-wallet-lock { + padding: 8px 16px; + border-radius: var(--radius-btn); + background-color: var(--green); + color: #fff; + font-size: 0.82rem; + font-weight: 700; + margin-top: 8px; +} + +.support-btn-wallet-lock:hover:not(:disabled) { + background-color: #27ae6e; +} + +/* ── Audit log ───────────────────────────────────────────────────── */ + +.support-audit-container { + margin-top: 12px; + border-top: 1px solid var(--border-color); + padding-top: 12px; +} + +.support-audit-log { + max-height: 200px; + overflow-y: auto; + background-color: #12121c; + border-radius: 8px; + padding: 10px 14px; +} + +.support-audit-entry { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-size: 0.72rem; + color: var(--text-secondary); + padding: 3px 0; + border-bottom: 1px solid rgba(69, 71, 90, 0.3); +} + +.support-audit-entry:last-child { + border-bottom: none; +} + +.support-audit-empty { + font-size: 0.82rem; + color: var(--text-dim); + text-align: center; + padding: 12px; +} + +/* ── Tech Support tile ───────────────────────────────────────────── */ + +.support-tile { + border-color: var(--accent-color); + border-width: 2px; + border-style: dashed; +} + +.support-tile:hover { + border-color: #a8c8ff; + border-style: solid; +} diff --git a/app/sovran_systemsos_web/static/css/tiles.css b/app/sovran_systemsos_web/static/css/tiles.css new file mode 100644 index 0000000..df7ba58 --- /dev/null +++ b/app/sovran_systemsos_web/static/css/tiles.css @@ -0,0 +1,279 @@ +/* ── Service tile card (status-only) ─────────────────────────────── */ + +.service-tile { + width: 160px; + min-height: 130px; + background-color: var(--card-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px 12px 18px; + gap: 0; + transition: box-shadow 0.2s, border-color 0.2s; + position: relative; + cursor: pointer; +} + +.service-tile:hover { + box-shadow: var(--shadow-hover); + border-color: #6c7086; +} + +.service-tile.disabled { + opacity: 0.45; +} + +.tile-icon { + width: 48px; + height: 48px; + object-fit: contain; + margin-bottom: 10px; +} + +.tile-icon-fallback { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--border-color); + border-radius: 12px; + color: var(--text-dim); + font-size: 1.5rem; + margin-bottom: 10px; +} + +.tile-name { + font-size: 0.88rem; + font-weight: 600; + text-align: center; + color: var(--text-primary); + line-height: 1.3; + max-width: 140px; + word-break: break-word; + hyphens: auto; + min-height: 1.3em; + display: flex; + align-items: center; + justify-content: center; +} + +.tile-status { + font-size: 0.75rem; + margin-top: 8px; + display: flex; + align-items: center; + gap: 5px; + color: var(--text-secondary); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background-color: var(--grey); +} + +.status-dot.active { background-color: var(--green); } +.status-dot.inactive { background-color: var(--red); } +.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; } +.status-dot.failed { background-color: var(--red); } +.status-dot.disabled { background-color: var(--grey); } +.status-dot.needs-attention { background-color: var(--yellow); } + +/* ── Service detail modal sections ───────────────────────────────── */ + +.svc-detail-section { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color); +} + +.svc-detail-section:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.svc-detail-section-title { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + margin-bottom: 10px; +} + +.svc-detail-desc { + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.6; +} + +.svc-detail-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); +} + +/* ── Service detail: Domain ──────────────────────────────────────── */ + +.svc-detail-domain-value { + font-size: 0.9rem; + color: var(--text-primary); + font-weight: 600; +} + +.tile-domain-label--ok { + color: var(--green); + font-weight: 600; +} + +.tile-domain-label--warn { + color: var(--yellow); + font-weight: 600; +} + +.tile-domain-label--error { + color: var(--red); + font-weight: 600; +} + +/* ── Service detail: Port table ──────────────────────────────────── */ + +.svc-detail-port-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; + margin-top: 8px; +} + +.svc-detail-port-table th { + text-align: left; + color: var(--text-dim); + font-weight: 600; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 6px 10px; + border-bottom: 1px solid var(--border-color); +} + +.svc-detail-port-table td { + padding: 8px 10px; + border-bottom: 1px solid rgba(69, 71, 90, 0.4); + color: var(--text-primary); +} + +.svc-detail-port-table tr:last-child td { + border-bottom: none; +} + +.svc-detail-port-table-port { + font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; + font-weight: 600; + color: var(--accent-color); +} + +.svc-detail-port-table-proto { + text-transform: uppercase; + color: var(--text-secondary); +} + +.svc-detail-port-table-desc { + color: var(--text-secondary); +} + +.svc-detail-port-table-status { + font-weight: 600; +} + +.port-status-listening { color: var(--green); } +.port-status-open { color: var(--yellow); } +.port-status-closed { color: var(--red); } +.port-status-unknown { color: var(--text-dim); } + +/* ── Service detail: Troubleshoot box ────────────────────────────── */ + +.svc-detail-troubleshoot { + margin-top: 12px; + padding: 14px 16px; + background-color: rgba(229, 165, 10, 0.08); + border: 1px solid rgba(229, 165, 10, 0.3); + border-radius: 10px; + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.6; +} + +.svc-detail-troubleshoot strong { + color: var(--yellow); +} + +.svc-detail-troubleshoot ol { + margin-top: 8px; + padding-left: 20px; +} + +.svc-detail-troubleshoot li { + margin-bottom: 4px; +} + +.svc-detail-troubleshoot code { + background-color: rgba(137, 180, 250, 0.12); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.82rem; + color: var(--accent-color); +} + +.svc-detail-troubleshoot a { + color: var(--accent-color); + text-decoration: none; +} + +.svc-detail-troubleshoot a:hover { + text-decoration: underline; +} + +/* ── Service detail: Addon feature toggle ────────────────────────── */ + +.svc-detail-addon-row { + display: flex; + align-items: center; + gap: 14px; + margin-top: 12px; +} + +.svc-detail-addon-status { + font-size: 0.88rem; + font-weight: 700; +} + +.addon-status--on { + color: var(--green); +} + +.addon-status--off { + color: var(--text-dim); +} + +.feature-conflict-warning { + margin-top: 8px; + margin-bottom: 8px; + padding: 10px 14px; + background-color: rgba(229, 165, 10, 0.1); + border: 1px solid rgba(229, 165, 10, 0.3); + border-radius: 8px; + font-size: 0.82rem; + color: var(--yellow); + font-weight: 600; +} diff --git a/app/sovran_systemsos_web/static/js/constants.js b/app/sovran_systemsos_web/static/js/constants.js new file mode 100644 index 0000000..a13e52a --- /dev/null +++ b/app/sovran_systemsos_web/static/js/constants.js @@ -0,0 +1,31 @@ +/* Sovran_SystemsOS Hub — Vanilla JS Frontend + v7 — Status-only dashboard + Tech Support + Feature Manager */ +"use strict"; + +const POLL_INTERVAL_SERVICES = 5000; +const POLL_INTERVAL_UPDATES = 1800000; +const UPDATE_POLL_INTERVAL = 2000; +const REBOOT_CHECK_INTERVAL = 5000; +const SUPPORT_TIMER_INTERVAL = 1000; + +const CATEGORY_ORDER = [ + "infrastructure", + "bitcoin-base", + "bitcoin-apps", + "communication", + "apps", + "nostr", +]; + +const FEATURE_SUBCATEGORY_LABELS = { + "infrastructure": "šŸ”§ Infrastructure", + "bitcoin": "₿ Bitcoin", + "communication": "šŸ’¬ Communication", + "nostr": "šŸ“” Nostr", +}; + +const FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"]; + +const STATUS_LOADING_STATES = new Set([ + "reloading", "activating", "deactivating", "maintenance", +]); diff --git a/app/sovran_systemsos_web/static/js/events.js b/app/sovran_systemsos_web/static/js/events.js new file mode 100644 index 0000000..b120492 --- /dev/null +++ b/app/sovran_systemsos_web/static/js/events.js @@ -0,0 +1,80 @@ +"use strict"; + +// ── Event listeners ─────────────────────────────────────────────── + +if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); +if ($refreshBtn) $refreshBtn.addEventListener("click", function() { refreshServices(); }); +if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal); +if ($btnReboot) $btnReboot.addEventListener("click", doReboot); +if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); +if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal); +if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal); + +// Rebuild modal +if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal); +if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot); +if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport); +if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); }); + +// Domain setup modal +if ($domainSetupClose) $domainSetupClose.addEventListener("click", closeDomainSetupModal); +if ($domainSetupModal) $domainSetupModal.addEventListener("click", function(e) { if (e.target === $domainSetupModal) closeDomainSetupModal(); }); + +// SSL Email modal +if ($sslEmailClose) $sslEmailClose.addEventListener("click", closeSslEmailModal); +if ($sslEmailCancel) $sslEmailCancel.addEventListener("click", closeSslEmailModal); +if ($sslEmailModal) $sslEmailModal.addEventListener("click", function(e) { if (e.target === $sslEmailModal) closeSslEmailModal(); }); + +// Feature confirm modal +if ($featureConfirmClose) $featureConfirmClose.addEventListener("click", closeFeatureConfirm); +if ($featureConfirmCancel) $featureConfirmCancel.addEventListener("click", closeFeatureConfirm); +if ($featureConfirmModal) $featureConfirmModal.addEventListener("click", function(e) { if (e.target === $featureConfirmModal) closeFeatureConfirm(); }); + +if ($modal) $modal.addEventListener("click", function(e) { if (e.target === $modal) closeUpdateModal(); }); +if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); }); +if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); }); + +// ── Init ────────────────────────────────────────────────────────── + +async function init() { + // Check onboarding status first — redirect to wizard if not complete + try { + var onboardingStatus = await apiFetch("/api/onboarding/status"); + if (!onboardingStatus.complete) { + window.location.href = "/onboarding"; + return; + } + } catch (_) { + // If we can't reach the endpoint, continue to normal dashboard + } + + try { + var cfg = await apiFetch("/api/config"); + if (cfg.category_order) { + for (var i = 0; i < cfg.category_order.length; i++) { + _categoryLabels[cfg.category_order[i][0]] = cfg.category_order[i][1]; + } + } + var badge = document.getElementById("role-badge"); + if (badge && cfg.role_label) badge.textContent = cfg.role_label; + + await refreshServices(); + loadNetwork(); + checkUpdates(); + + setInterval(refreshServices, POLL_INTERVAL_SERVICES); + setInterval(checkUpdates, POLL_INTERVAL_UPDATES); + + if (cfg.feature_manager) { + loadFeatureManager(); + } + } catch (_) { + await refreshServices(); + loadNetwork(); + checkUpdates(); + setInterval(refreshServices, POLL_INTERVAL_SERVICES); + setInterval(checkUpdates, POLL_INTERVAL_UPDATES); + } +} + +document.addEventListener("DOMContentLoaded", init); diff --git a/app/sovran_systemsos_web/static/js/features.js b/app/sovran_systemsos_web/static/js/features.js new file mode 100644 index 0000000..4c92cfb --- /dev/null +++ b/app/sovran_systemsos_web/static/js/features.js @@ -0,0 +1,581 @@ +"use strict"; + +// ── Feature confirm modal ───────────────────────────────────────── + +function openFeatureConfirm(message, onConfirm) { + if (!$featureConfirmModal) return; + if ($featureConfirmMsg) $featureConfirmMsg.textContent = message; + $featureConfirmModal.classList.add("open"); + // Replace ok handler + var newOk = $featureConfirmOk.cloneNode(true); + $featureConfirmOk.parentNode.replaceChild(newOk, $featureConfirmOk); + newOk.addEventListener("click", function() { + closeFeatureConfirm(); + onConfirm(); + }); +} + +function closeFeatureConfirm() { + if ($featureConfirmModal) $featureConfirmModal.classList.remove("open"); +} + +// ── SSL Email modal ─────────────────────────────────────────────── + +function openSslEmailModal(onSaved) { + if (!$sslEmailModal) return; + if ($sslEmailInput) $sslEmailInput.value = ""; + $sslEmailModal.classList.add("open"); + // Replace save handler + var newSave = $sslEmailSave.cloneNode(true); + $sslEmailSave.parentNode.replaceChild(newSave, $sslEmailSave); + newSave.addEventListener("click", async function() { + var email = $sslEmailInput ? $sslEmailInput.value.trim() : ""; + if (!email) { alert("Please enter an email address."); return; } + newSave.disabled = true; + newSave.textContent = "Saving…"; + try { + await apiFetch("/api/domains/set-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email }), + }); + closeSslEmailModal(); + onSaved(); + } catch (err) { + newSave.disabled = false; + newSave.textContent = "Save"; + alert("Failed to save email. Please try again."); + } + }); +} + +function closeSslEmailModal() { + if ($sslEmailModal) $sslEmailModal.classList.remove("open"); +} + +// ── Domain Setup modal ──────────────────────────────────────────── + +function openDomainSetupModal(feat, onSaved) { + if (!$domainSetupModal) return; + if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name; + + var npubField = ""; + if (feat.id === "haven") { + var currentNpub = ""; + if (feat.extra_fields && feat.extra_fields.length > 0) { + for (var i = 0; i < feat.extra_fields.length; i++) { + if (feat.extra_fields[i].id === "nostr_npub") { + currentNpub = feat.extra_fields[i].current_value || ""; + break; + } + } + } + npubField = '
        '; + } + + var externalIp = _cachedExternalIp || "your external IP"; + + $domainSetupBody.innerHTML = + '
        ' + + '

        Before continuing:

        ' + + '
          ' + + '
        1. Create an account at https://njal.la
        2. ' + + '
        3. Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.
        4. ' + + '
        5. In the Njal.la web interface, create a Dynamic record pointing to this machine\'s external IP address:
          ' + + '' + escHtml(externalIp) + '
        6. ' + + '
        7. Njal.la will give you a curl command like:
          ' + + 'curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"
        8. ' + + '
        9. Enter the subdomain and paste that curl command below
        10. ' + + '
        ' + + '
        ' + + '
        ' + + '

        ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record

        ' + + npubField + + '
        '; + + document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal); + + document.getElementById("domain-setup-save-btn").addEventListener("click", async function() { + var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || ""; + var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || ""; + var npub = document.getElementById("domain-npub-input") ? (document.getElementById("domain-npub-input").value || "") : ""; + subdomain = subdomain.trim(); + ddnsUrl = ddnsUrl.trim(); + npub = npub.trim(); + + if (!subdomain) { alert("Please enter a subdomain."); return; } + if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; } + + var saveBtn = document.getElementById("domain-setup-save-btn"); + saveBtn.disabled = true; + saveBtn.textContent = "Saving…"; + + try { + await apiFetch("/api/domains/set", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + domain_name: feat.domain_name, + domain: subdomain, + ddns_url: ddnsUrl, + }), + }); + closeDomainSetupModal(); + onSaved(npub); + } catch (err) { + saveBtn.disabled = false; + saveBtn.textContent = "Save & Enable"; + alert("Failed to save domain. Please try again."); + } + }); + + $domainSetupModal.classList.add("open"); +} + +function closeDomainSetupModal() { + if ($domainSetupModal) $domainSetupModal.classList.remove("open"); +} + +// ── Port Requirements modal ─────────────────────────────────────── + +function openPortRequirementsModal(featureName, ports, onContinue) { + if (!$portReqModal || !$portReqBody) return; + + var continueBtn = onContinue + ? '' + : ''; + + // Show loading state while fetching port status + $portReqBody.innerHTML = + '

        Checking port status for ' + escHtml(featureName) + '…

        ' + + '

        Detecting which ports are open on this machine…

        '; + + $portReqModal.classList.add("open"); + + // Fetch live port status from local system commands (no external calls) + fetch("/api/ports/status", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ports: ports }), + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + var internalIp = (data.internal_ip && data.internal_ip !== "unavailable") + ? data.internal_ip : null; + var portStatuses = {}; + (data.ports || []).forEach(function(p) { + portStatuses[p.port + "/" + p.protocol] = p.status; + }); + + var rows = ports.map(function(p) { + var key = p.port + "/" + p.protocol; + var status = portStatuses[key] || "unknown"; + var statusHtml; + if (status === "listening") { + statusHtml = '🟢 Listening'; + } else if (status === "firewall_open") { + statusHtml = '🟔 Open (idle)'; + } else if (status === "closed") { + statusHtml = 'šŸ”“ Closed'; + } else { + statusHtml = '⚪ Unknown'; + } + return '' + + '' + escHtml(p.port) + '' + + '' + escHtml(p.protocol) + '' + + '' + escHtml(p.description) + '' + + '' + statusHtml + '' + + ''; + }).join(""); + + var ipLine = internalIp + ? '

        Forward each port below to this machine\'s internal IP: ' + escHtml(internalIp) + '

        ' + : "

        Forward each port below to this machine's internal LAN IP in your router's port forwarding settings.

        "; + + $portReqBody.innerHTML = + '

        Port Forwarding Required

        ' + + '

        For ' + escHtml(featureName) + " to work with clients outside your local network, " + + "you must configure port forwarding in your router's admin panel.

        " + + ipLine + + '' + + '' + + '' + rows + '' + + '
        Port(s)ProtocolPurposeStatus
        ' + + "

        How to verify: Router-side forwarding cannot be checked from inside your network. " + + "To confirm ports are forwarded correctly, test from a device on a different network (e.g. a phone on mobile data) " + + "or check your router's port forwarding page.

        " + + '

        ℹ Search "how to set up port forwarding on [your router model]" for step-by-step instructions.

        ' + + '
        ' + + '' + + continueBtn + + '
        '; + + document.getElementById("port-req-dismiss-btn").addEventListener("click", function() { + closePortRequirementsModal(); + }); + + if (onContinue) { + document.getElementById("port-req-continue-btn").addEventListener("click", function() { + closePortRequirementsModal(); + onContinue(); + }); + } + }) + .catch(function() { + // Fallback: show static table without status column if fetch fails + var rows = ports.map(function(p) { + return '' + escHtml(p.port) + '' + + '' + escHtml(p.protocol) + '' + + '' + escHtml(p.description) + ''; + }).join(""); + + $portReqBody.innerHTML = + '

        Port Forwarding Required

        ' + + '

        For ' + escHtml(featureName) + ' to work with clients outside your local network, ' + + 'you must configure port forwarding in your router\'s admin panel and forward each port below to this machine\'s internal LAN IP.

        ' + + '' + + '' + + '' + rows + '' + + '
        Port(s)ProtocolPurpose
        ' + + '

        ℹ Search "how to set up port forwarding on [your router model]" for step-by-step instructions.

        ' + + '
        ' + + '' + + continueBtn + + '
        '; + + document.getElementById("port-req-dismiss-btn").addEventListener("click", function() { + closePortRequirementsModal(); + }); + + if (onContinue) { + document.getElementById("port-req-continue-btn").addEventListener("click", function() { + closePortRequirementsModal(); + onContinue(); + }); + } + }); +} + +function closePortRequirementsModal() { + if ($portReqModal) $portReqModal.classList.remove("open"); +} + +if ($portReqClose) { + $portReqClose.addEventListener("click", closePortRequirementsModal); +} + +// ── Feature toggle logic ────────────────────────────────────────── + +async function performFeatureToggle(featId, enabled, extra) { + // Look up feature name for the rebuild modal + _rebuildIsEnabling = enabled; + _rebuildFeatureName = featId; + if (_featuresData) { + var found = _featuresData.features.find(function(f) { return f.id === featId; }); + if (found) _rebuildFeatureName = found.name; + } + try { + var res = await fetch("/api/features/toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ feature: featId, enabled: enabled, extra: extra || {} }), + }); + var body = await res.json(); + if (!res.ok) { + if (body && body.error === "domain_required") { + alert("Domain not configured for this feature. Please configure it first."); + } else { + alert("Error: " + (body.detail || body.error || "Unknown error")); + } + loadFeatureManager(); + return; + } + openRebuildModal(); + } catch (err) { + alert("Failed to toggle feature: " + err); + loadFeatureManager(); + } +} + +function handleFeatureToggle(feat, newEnabled) { + if (!newEnabled) { + // Disable: ask confirmation + openFeatureConfirm( + "This will disable " + feat.name + ". The system will rebuild. Continue?", + function() { performFeatureToggle(feat.id, false, {}); } + ); + return; + } + + // Enabling + var conflictNames = []; + if (feat.conflicts_with && feat.conflicts_with.length > 0 && _featuresData) { + feat.conflicts_with.forEach(function(cid) { + var cf = _featuresData.features.find(function(f) { return f.id === cid; }); + if (cf && cf.enabled) conflictNames.push(cf.name); + }); + } + + function proceedAfterPortCheck() { + // Check SSL email first + if (!_featuresData || !_featuresData.ssl_email_configured) { + if (feat.needs_domain) { + openSslEmailModal(function() { + // After ssl email saved, check domain + checkDomainAndEnable(feat, {}); + }); + return; + } + } + if (feat.needs_domain && !feat.domain_configured) { + checkDomainAndEnable(feat, {}); + return; + } + if (feat.id === "haven") { + var npub = ""; + if (feat.extra_fields) { + var ef = feat.extra_fields.find(function(e) { return e.id === "nostr_npub"; }); + if (ef) npub = ef.current_value || ""; + } + if (!npub) { + // Need to collect npub via domain modal + openDomainSetupModal(feat, function(collectedNpub) { + performFeatureToggle(feat.id, true, { nostr_npub: collectedNpub }); + }); + return; + } + } + performFeatureToggle(feat.id, true, {}); + } + + function proceedAfterConflictCheck() { + // Show port requirements notification if the feature has extra port needs + var ports = feat.port_requirements || []; + if (ports.length > 0) { + openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck); + } else { + proceedAfterPortCheck(); + } + } + + if (conflictNames.length > 0) { + var confirmMsg; + if (feat.id === "bip110") { + confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Knots + BIP110 will disable Bitcoin Core (if active). Continue?"; + } else if (feat.id === "bitcoin-core") { + confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will disable Bitcoin Knots + BIP110 (if active). Continue?"; + } else { + confirmMsg = "This will disable " + conflictNames.join(", ") + ". Continue?"; + } + openFeatureConfirm(confirmMsg, proceedAfterConflictCheck); + } else { + proceedAfterConflictCheck(); + } +} + +function checkDomainAndEnable(feat, extra) { + openDomainSetupModal(feat, function(collectedNpub) { + var extraData = {}; + if (collectedNpub) extraData.nostr_npub = collectedNpub; + performFeatureToggle(feat.id, true, extraData); + }); +} + +// ── Feature Manager rendering ───────────────────────────────────── + +async function loadFeatureManager() { + try { + var data = await apiFetch("/api/features"); + _featuresData = data; + // Feature Manager is now integrated into tile modals; sidebar rendering removed. + } catch (err) { + console.warn("Failed to load features:", err); + } +} + +function _checkFeatureManagerDomains(data) { + // Collect all features with a configured domain + var featsWithDomain = (data.features || []).filter(function(f) { + return f.needs_domain && f.domain_configured; + }); + if (!featsWithDomain.length) return; + + // Get the actual domain values from /api/domains/status, then check them + fetch("/api/domains/status") + .then(function(r) { return r.json(); }) + .then(function(statusData) { + var domainFileMap = statusData.domains || {}; + // Build list of domains to check and a map from domain value → feature id + var domainsToCheck = []; + var domainToFeatIds = {}; + featsWithDomain.forEach(function(feat) { + var domainName = feat.domain_name; + var domainVal = domainName ? domainFileMap[domainName] : null; + if (domainVal) { + domainsToCheck.push(domainVal); + if (!domainToFeatIds[domainVal]) domainToFeatIds[domainVal] = []; + domainToFeatIds[domainVal].push(feat.id); + } else { + // Domain file missing — update badge to warn + _updateFeatureDomainBadge(feat.id, null, "unresolvable"); + } + }); + + if (!domainsToCheck.length) return; + + return fetch("/api/domains/check", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ domains: domainsToCheck }), + }) + .then(function(r) { return r.json(); }) + .then(function(checkData) { + (checkData.domains || []).forEach(function(d) { + var featIds = domainToFeatIds[d.domain] || []; + featIds.forEach(function(featId) { + _updateFeatureDomainBadge(featId, d.domain, d.status); + }); + }); + }); + }) + .catch(function() {}); +} + +function _updateFeatureDomainBadge(featId, domainVal, status) { + var section = $sidebarFeatures.querySelector(".feature-manager-section"); + if (!section) return; + // Find the card — cards don't have a data-feat-id, so find via name match + var badges = section.querySelectorAll(".feature-domain-badge.configured"); + badges.forEach(function(badge) { + var domainNameAttr = badge.getAttribute("data-domain-name"); + // Match by domain_name attribute — we need to look up the feat's domain_name + var feat = _featuresData && _featuresData.features + ? _featuresData.features.find(function(f) { return f.id === featId; }) + : null; + if (!feat) return; + if (domainNameAttr !== (feat.domain_name || "")) return; + + var lbl = badge.querySelector(".feature-domain-label"); + if (!lbl) return; + lbl.classList.remove("feature-domain-label--checking"); + if (status === "connected") { + lbl.className = "feature-domain-label feature-domain-label--ok"; + lbl.textContent = (domainVal || "Domain") + " āœ“"; + } else if (status === "dns_mismatch") { + lbl.className = "feature-domain-label feature-domain-label--warn"; + lbl.textContent = (domainVal || "Domain") + " (IP mismatch)"; + } else if (status === "unresolvable") { + lbl.className = "feature-domain-label feature-domain-label--error"; + lbl.textContent = (domainVal || "Domain") + " (DNS error)"; + } else { + lbl.className = "feature-domain-label feature-domain-label--warn"; + lbl.textContent = (domainVal || "Domain") + " (unknown)"; + } + }); +} + +function renderFeatureManager(data) { + // Remove old feature manager section if it exists + var old = $sidebarFeatures.querySelector(".feature-manager-section"); + if (old) old.parentNode.removeChild(old); + + var section = document.createElement("div"); + section.className = "category-section feature-manager-section"; + section.dataset.category = "feature-manager"; + section.innerHTML = '
        Feature Manager

        '; + + // Group by sub-category + var grouped = {}; + for (var i = 0; i < data.features.length; i++) { + var f = data.features[i]; + var cat = f.category || "other"; + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push(f); + } + + var orderedCats = FEATURE_SUBCATEGORY_ORDER.filter(function(k) { return grouped[k]; }); + Object.keys(grouped).forEach(function(k) { + if (orderedCats.indexOf(k) === -1) orderedCats.push(k); + }); + + for (var j = 0; j < orderedCats.length; j++) { + var catKey = orderedCats[j]; + var feats = grouped[catKey]; + if (!feats || feats.length === 0) continue; + + var subcat = document.createElement("div"); + subcat.className = "feature-subcategory"; + var subcatLabel = FEATURE_SUBCATEGORY_LABELS[catKey] || catKey; + subcat.innerHTML = '
        ' + escHtml(subcatLabel) + '
        '; + + var cardsWrap = document.createElement("div"); + cardsWrap.className = "feature-cards-wrap"; + + for (var k = 0; k < feats.length; k++) { + cardsWrap.appendChild(buildFeatureCard(feats[k])); + } + subcat.appendChild(cardsWrap); + section.appendChild(subcat); + } + + $sidebarFeatures.appendChild(section); +} + +function buildFeatureCard(feat) { + var card = document.createElement("div"); + card.className = "feature-card"; + + var conflictHtml = ""; + if (feat.conflicts_with && feat.conflicts_with.length > 0) { + var conflictNames = feat.conflicts_with.map(function(cid) { + if (!_featuresData) return cid; + var cf = _featuresData.features.find(function(f) { return f.id === cid; }); + return cf ? cf.name : cid; + }); + conflictHtml = '
        ⚠ Conflicts with: ' + escHtml(conflictNames.join(", ")) + '
        '; + } + + var domainHtml = ""; + if (feat.needs_domain) { + if (feat.domain_configured) { + domainHtml = '
        ' + + '🌐' + + 'Domain: Checking\u2026' + + '
        '; + } else { + domainHtml = '
        ' + + '🌐' + + 'Domain: Not configured' + + '
        '; + } + } + + var statusText = feat.enabled ? "Enabled" : "Disabled"; + + card.innerHTML = + '
        ' + + '
        ' + + '
        ' + escHtml(feat.name) + '
        ' + + '
        ' + escHtml(feat.description) + '
        ' + + '
        ' + + '' + + '
        ' + + domainHtml + + conflictHtml + + '
        Status: ' + escHtml(statusText) + '
        '; + + var toggle = card.querySelector(".feature-toggle-input"); + var toggleLabel = card.querySelector(".feature-toggle"); + toggle.addEventListener("change", function() { + var newEnabled = toggle.checked; + // Revert visually until confirmed + toggle.checked = feat.enabled; + if (newEnabled) { toggleLabel.classList.remove("active"); } else { toggleLabel.classList.add("active"); } + handleFeatureToggle(feat, newEnabled); + }); + + return card; +} diff --git a/app/sovran_systemsos_web/static/js/helpers.js b/app/sovran_systemsos_web/static/js/helpers.js new file mode 100644 index 0000000..b0b8e99 --- /dev/null +++ b/app/sovran_systemsos_web/static/js/helpers.js @@ -0,0 +1,58 @@ +"use strict"; + +// ── Helpers ─────────────────────────────────────────────────────── + +function tileId(svc) { return svc.unit + "::" + svc.name; } + +function statusClass(health) { + if (!health) return "unknown"; + if (health === "healthy") return "active"; + if (health === "needs_attention") return "needs-attention"; + if (health === "active") return "active"; // backwards compat + if (health === "inactive") return "inactive"; + if (health === "failed") return "failed"; + if (health === "disabled") return "disabled"; + if (STATUS_LOADING_STATES.has(health)) return "loading"; + return "unknown"; +} + +function statusText(health, enabled) { + if (!enabled) return "Disabled"; + if (health === "healthy") return "Active"; + if (health === "needs_attention") return "Needs Attention"; + if (health === "active") return "Active"; + if (health === "inactive") return "Inactive"; + if (health === "failed") return "Failed"; + if (!health || health === "unknown") return "Unknown"; + if (STATUS_LOADING_STATES.has(health)) return health; + return health; +} + +function escHtml(str) { + return String(str).replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'"); +} + +function linkify(str) { + return escHtml(str).replace(/(https?:\/\/[^\s<]+)/g, '$1'); +} + +function formatDuration(seconds) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return h + "h " + m + "m " + s + "s"; + if (m > 0) return m + "m " + s + "s"; + return s + "s"; +} + +// ── Fetch wrappers ──────────────────────────────────────────────── + +async function apiFetch(path, options) { + const res = await fetch(path, options || {}); + if (!res.ok) { + let detail = res.status + " " + res.statusText; + try { const body = await res.json(); if (body && body.detail) detail = body.detail; } catch (e) {} + throw new Error(detail); + } + return res.json(); +} diff --git a/app/sovran_systemsos_web/static/js/rebuild.js b/app/sovran_systemsos_web/static/js/rebuild.js new file mode 100644 index 0000000..a377d3c --- /dev/null +++ b/app/sovran_systemsos_web/static/js/rebuild.js @@ -0,0 +1,84 @@ +"use strict"; + +// ── Rebuild modal ───────────────────────────────────────────────── + +function openRebuildModal() { + if (!$rebuildModal) return; + _rebuildLog = ""; + _rebuildLogOffset = 0; + _rebuildServerDown = false; + _rebuildFinished = false; + if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; } + var action = _rebuildIsEnabling ? "Enabling" : "Disabling"; + var label = _rebuildFeatureName || "feature"; + if ($rebuildStatus) $rebuildStatus.textContent = action + " " + label + "…"; + if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning"); + if ($rebuildReboot) $rebuildReboot.style.display = "none"; + if ($rebuildSave) $rebuildSave.style.display = "none"; + if ($rebuildClose) $rebuildClose.disabled = true; + $rebuildModal.classList.add("open"); + // Delay first poll slightly to let the rebuild service start and clear stale log + setTimeout(startRebuildPoll, 1500); +} + +function closeRebuildModal() { + if ($rebuildModal) $rebuildModal.classList.remove("open"); + stopRebuildPoll(); +} + +function appendRebuildLog(text) { + if (!text) return; + _rebuildLog += text; + // Log is collected silently for error reports — not displayed to user +} + +function startRebuildPoll() { + pollRebuildStatus(); + _rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL); +} + +function stopRebuildPoll() { + if (_rebuildPollTimer) { clearInterval(_rebuildPollTimer); _rebuildPollTimer = null; } +} + +async function pollRebuildStatus() { + if (_rebuildFinished) return; + try { + var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset); + if (_rebuildServerDown) { _rebuildServerDown = false; } + if (data.log) appendRebuildLog(data.log); + _rebuildLogOffset = data.offset; + if (data.running) return; + _rebuildFinished = true; + stopRebuildPoll(); + onRebuildDone(data.result === "success"); + } catch (err) { + if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; } + } +} + +function onRebuildDone(success) { + if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning"); + if ($rebuildClose) $rebuildClose.disabled = false; + if (success) { + if ($rebuildStatus) $rebuildStatus.textContent = "āœ“ Done"; + // Auto-reload the page after a short delay so tiles and toggles reflect the new state + setTimeout(function() { window.location.reload(); }, 1200); + } else { + if ($rebuildStatus) $rebuildStatus.textContent = "āœ— Something went wrong"; + if ($rebuildSave) $rebuildSave.style.display = "inline-flex"; + if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; + } +} + +function saveRebuildErrorReport() { + var blob = new Blob([_rebuildLog], { type: "text/plain" }); + var url = URL.createObjectURL(blob); + var a = document.createElement("a"); + a.href = url; + a.download = "sovran-rebuild-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js new file mode 100644 index 0000000..6b0e928 --- /dev/null +++ b/app/sovran_systemsos_web/static/js/service-detail.js @@ -0,0 +1,478 @@ +"use strict"; + +// ── Service detail modal ────────────────────────────────────────── + +function _renderCredsHtml(credentials, unit) { + var html = ""; + for (var i = 0; i < credentials.length; i++) { + var cred = credentials[i]; + var id = "cred-" + Math.random().toString(36).substring(2, 8); + var displayValue = linkify(cred.value); + var qrBlock = ""; + if (cred.qrcode) { + qrBlock = '
        QR Code for ' + escHtml(cred.label) + '
        Scan with Zeus app on your phone
        '; + } + html += '
        ' + escHtml(cred.label) + '
        ' + qrBlock + '
        ' + displayValue + '
        '; + } + return html; +} + +function _attachCopyHandlers(container) { + container.querySelectorAll(".creds-copy-btn").forEach(function(btn) { + btn.addEventListener("click", function() { + var target = document.getElementById(btn.dataset.target); + if (!target) return; + var text = target.textContent; + + function onSuccess() { + btn.textContent = "Copied!"; + btn.classList.add("copied"); + setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1500); + } + + function fallbackCopy() { + var ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand("copy"); + onSuccess(); + } catch (e) {} + document.body.removeChild(ta); + } + + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(onSuccess).catch(fallbackCopy); + } else { + fallbackCopy(); + } + }); + }); +} + +async function openServiceDetailModal(unit, name, icon) { + if (!$credsModal) return; + if ($credsTitle) $credsTitle.textContent = name; + if ($credsBody) $credsBody.innerHTML = '

        Loading…

        '; + $credsModal.classList.add("open"); + + try { + var url = "/api/service-detail/" + encodeURIComponent(unit); + if (icon) url += "?icon=" + encodeURIComponent(icon); + var data = await apiFetch(url); + var html = ""; + + // Section A: Description + if (data.description) { + html += '
        ' + + '

        ' + escHtml(data.description) + '

        ' + + '
        '; + } + + // Section B: Status + // When a feature override is present, use the feature's enabled state so the + // modal matches what the dashboard tile shows (feature toggle is authoritative). + var effectiveEnabled = data.feature ? data.feature.enabled : data.enabled; + var effectiveHealth = data.feature && !data.feature.enabled + ? "disabled" + : (data.health || data.status); + var sc = statusClass(effectiveHealth); + var st = statusText(effectiveHealth, effectiveEnabled); + html += '
        ' + + '
        Status
        ' + + '
        ' + + '' + + '' + escHtml(st) + '' + + '
        ' + + '
        '; + + // Section C: Ports (only if service has port_requirements) + if (data.port_statuses && data.port_statuses.length > 0) { + var anyPortClosed = data.port_statuses.some(function(p) { return p.status === "closed"; }); + var portTableRows = ""; + data.port_statuses.forEach(function(p) { + var statusIcon, statusClass2; + if (p.status === "listening") { + statusIcon = "āœ… Open"; + statusClass2 = "port-status-listening"; + } else if (p.status === "firewall_open") { + statusIcon = "🟔 Firewall open"; + statusClass2 = "port-status-open"; + } else if (p.status === "closed") { + statusIcon = "šŸ”“ Closed"; + statusClass2 = "port-status-closed"; + } else { + statusIcon = "— Unknown"; + statusClass2 = "port-status-unknown"; + } + var desc = p.description; + var portNum = parseInt(p.port, 10); + if (portNum === 80 || portNum === 443) { + desc += " (shared — all services)"; + } + portTableRows += '' + + '' + escHtml(p.port) + '' + + '' + escHtml(p.protocol) + '' + + '' + escHtml(desc) + '' + + '' + statusIcon + '' + + ''; + }); + + var troubleshootHtml = ""; + if (anyPortClosed) { + var sharedPorts = []; + var specificPorts = []; + data.port_statuses.forEach(function(p) { + if (p.status === "closed") { + var portNum = parseInt(p.port, 10); + if (portNum === 80 || portNum === 443) { + sharedPorts.push(p); + } else { + specificPorts.push(p); + } + } + }); + + var troubleParts = []; + + if (sharedPorts.length > 0) { + troubleParts.push( + 'āš ļø Ports 80 and 443 need to be forwarded on your router.' + + '

        These are shared system ports — you only need to set them up once and they cover all your domain-based services ' + + '(BTCPayServer, Nextcloud, Matrix, WordPress, etc.).

        ' + + '

        If you already forwarded these ports during onboarding, you don\'t need to do it again. Otherwise:

        ' + + '
          ' + + '
        1. Log into your router\'s admin panel (usually http://192.168.1.1)
        2. ' + + '
        3. Find the Port Forwarding section
        4. ' + + '
        5. Forward port 80 (TCP) and port 443 (TCP) to your machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + '
        6. ' + + '
        7. Save your router settings
        8. ' + + '
        ' + + '

        šŸ’” Once these two ports are forwarded, you won\'t see this warning on any service again.

        ' + ); + } + + if (specificPorts.length > 0) { + var portList = specificPorts.map(function(p) { + return '' + escHtml(p.port) + ' (' + escHtml(p.protocol) + ') — ' + escHtml(p.description); + }).join('
        '); + + troubleParts.push( + 'āš ļø This service requires additional ports to be forwarded:' + + '

        ' + portList + '

        ' + + '
          ' + + '
        1. Log into your router\'s admin panel
        2. ' + + '
        3. Forward each port listed above to your machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + '
        4. ' + + '
        5. Save your router settings
        6. ' + + '
        ' + ); + } + + troubleshootHtml = '
        ' + troubleParts.join('
        ') + '
        '; + } + + html += '
        ' + + '
        Port Status
        ' + + '' + + '' + + '' + + '' + + '' + portTableRows + '' + + '
        PortProtocolDescriptionStatus
        ' + + troubleshootHtml + + '
        '; + } + + // Section D: Domain (only if service needs_domain) + if (data.needs_domain) { + var domainStatusHtml = ""; + var ds = data.domain_status || {}; + var domainBadge = ""; + + if (data.domain) { + if (ds.status === "connected") { + domainBadge = 'āœ“ ' + escHtml(data.domain) + ''; + } else if (ds.status === "dns_mismatch") { + domainBadge = '⚠ ' + escHtml(data.domain) + ' (IP mismatch)'; + domainStatusHtml = '
        ' + + 'āš ļø Your domain resolves to ' + escHtml(ds.resolved_ip || "unknown") + ' but your external IP is ' + escHtml(ds.expected_ip || "unknown") + '.' + + '

        This usually means the DNS record needs to be updated:

        ' + + '
          ' + + '
        1. Go to njal.la and log into your account
        2. ' + + '
        3. Find your domain and check the Dynamic DNS record
        4. ' + + '
        5. Make sure it points to your current external IP: ' + escHtml(ds.expected_ip || "—") + '
        6. ' + + '
        7. If you set up a DDNS curl command during onboarding, verify it\'s running correctly
        8. ' + + '
        ' + + '
        '; + } else if (ds.status === "unresolvable") { + domainBadge = 'āœ— ' + escHtml(data.domain) + ' (DNS error)'; + domainStatusHtml = '
        ' + + 'āš ļø This domain cannot be resolved. DNS is not configured yet.' + + '

        Let\'s get it set up:

        ' + + '
          ' + + '
        1. Go to njal.la and log into your account
        2. ' + + '
        3. Find the domain you purchased for this service
        4. ' + + '
        5. Create a Dynamic DNS record pointing to your external IP: ' + escHtml(ds.expected_ip || "—") + '
        6. ' + + '
        7. Copy the DDNS curl command from Njal.la\'s dashboard
        8. ' + + '
        9. You can re-enter it in the Feature Manager to update your configuration
        10. ' + + '
        ' + + '
        '; + } else { + domainBadge = '' + escHtml(data.domain) + ''; + } + } else { + domainBadge = 'Not configured'; + domainStatusHtml = '
        ' + + 'āš ļø No domain has been configured for this service yet.' + + '

        To get this service working:

        ' + + '
          ' + + '
        1. Purchase a subdomain at njal.la (if you haven\'t already)
        2. ' + + '
        3. Go to the Feature Manager in the sidebar
        4. ' + + '
        5. Find this service and configure your domain through the setup wizard
        6. ' + + '
        ' + + '
        '; + } + + html += '
        ' + + '
        Domain
        ' + + domainBadge + + domainStatusHtml + + '
        '; + } + + // Section E: Credentials & Links + if (data.has_credentials && data.credentials && data.credentials.length > 0) { + html += '
        ' + + '
        Credentials & Access
        ' + + _renderCredsHtml(data.credentials, unit) + + (unit === "matrix-synapse.service" ? + '
        ' + + '' + + '' + + '
        ' : "") + + '
        '; + } else if (!data.enabled && !data.feature) { + html += '
        ' + + '

        This service is not enabled in your configuration.

        ' + + '
        '; + } + + // Section F: Addon Feature toggle + if (data.feature) { + var feat = data.feature; + // Sync this feature into _featuresData so handleFeatureToggle can look up conflicts / ssl state + if (!_featuresData) { + _featuresData = { features: [feat], ssl_email_configured: false }; + } else { + var fidx = _featuresData.features.findIndex(function(f) { return f.id === feat.id; }); + if (fidx >= 0) { _featuresData.features[fidx] = feat; } + else { _featuresData.features.push(feat); } + } + var addonStatusLabel = feat.enabled ? "Enabled \u2713" : "Disabled"; + var addonStatusCls = feat.enabled ? "addon-status--on" : "addon-status--off"; + var addonBtnLabel = feat.enabled ? "Disable Feature" : "Enable Feature"; + var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary"; + + // Section title: use a more specific label for mutually-exclusive Bitcoin node features + var addonSectionTitle = (feat.id === "bip110" || feat.id === "bitcoin-core") + ? "\u20BF Bitcoin Node Selection" + : "\uD83D\uDD27 Addon Feature"; + + // Description: prefer the feature's own description over a generic fallback + var addonDesc = feat.description + ? feat.description + : "This is an optional addon feature. You can enable or disable it at any time."; + + // Conflicts warning: list mutually-exclusive feature names when present + var conflictsHtml = ""; + if (feat.conflicts_with && feat.conflicts_with.length > 0) { + var conflictNames = feat.conflicts_with.map(function(cid) { + if (_featuresData && Array.isArray(_featuresData.features)) { + var cf = _featuresData.features.find(function(f) { return f.id === cid; }); + if (cf) return cf.name; + } + return cid; + }); + conflictsHtml = '
        \u26A0 Mutually exclusive with: ' + escHtml(conflictNames.join(", ")) + '
        '; + } + + html += '
        ' + + '
        ' + addonSectionTitle + '
        ' + + '

        ' + escHtml(addonDesc) + '

        ' + + conflictsHtml + + '
        ' + + '' + addonStatusLabel + '' + + '' + + '
        ' + + '
        '; + } + + $credsBody.innerHTML = html; + _attachCopyHandlers($credsBody); + + if (unit === "matrix-synapse.service") { + var addBtn = document.getElementById("matrix-add-user-btn"); + var changePwBtn = document.getElementById("matrix-change-pw-btn"); + if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name, icon); }); + if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name, icon); }); + } + + if (data.feature) { + var addonBtn = document.getElementById("svc-detail-addon-btn"); + if (addonBtn) { + var addonFeat = data.feature; + addonBtn.addEventListener("click", function() { + closeCredsModal(); + handleFeatureToggle(addonFeat, !addonFeat.enabled); + }); + } + } + } catch (err) { + if ($credsBody) $credsBody.innerHTML = '

        Could not load service details.

        '; + } +} + +// ── Credentials info modal ──────────────────────────────────────── + +async function openCredsModal(unit, name) { + if (!$credsModal) return; + if ($credsTitle) $credsTitle.textContent = name + " — Connection Info"; + if ($credsBody) $credsBody.innerHTML = '

        Loading…

        '; + $credsModal.classList.add("open"); + try { + var data = await apiFetch("/api/credentials/" + encodeURIComponent(unit)); + if (!data.credentials || data.credentials.length === 0) { + $credsBody.innerHTML = '

        No connection info available yet.

        '; + return; + } + var html = _renderCredsHtml(data.credentials, unit); + if (unit === "matrix-synapse.service") { + html += '
        ' + + '' + + '' + + '
        '; + } + $credsBody.innerHTML = html; + _attachCopyHandlers($credsBody); + if (unit === "matrix-synapse.service") { + var addBtn = document.getElementById("matrix-add-user-btn"); + var changePwBtn = document.getElementById("matrix-change-pw-btn"); + if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name); }); + if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name); }); + } + } catch (err) { + $credsBody.innerHTML = '

        Could not load credentials.

        '; + } +} + +function openMatrixCreateUserModal(unit, name, icon) { + if (!$credsBody) return; + $credsBody.innerHTML = + '
        ' + + '
        ' + + '
        ' + + '
        ' + + '
        ' + + '
        ' + + '' + + '' + + '
        ' + + '
        '; + + document.getElementById("matrix-create-back-btn").addEventListener("click", function() { + openServiceDetailModal(unit, name, icon); + }); + + document.getElementById("matrix-create-submit-btn").addEventListener("click", async function() { + var submitBtn = document.getElementById("matrix-create-submit-btn"); + var resultEl = document.getElementById("matrix-create-result"); + var username = (document.getElementById("matrix-new-username").value || "").trim(); + var password = document.getElementById("matrix-new-password").value || ""; + var isAdmin = document.getElementById("matrix-new-admin").checked; + + if (!username || !password) { + resultEl.className = "matrix-form-result error"; + resultEl.textContent = "Username and password are required."; + return; + } + + submitBtn.disabled = true; + submitBtn.textContent = "Creating…"; + resultEl.className = "matrix-form-result"; + resultEl.textContent = ""; + + try { + var resp = await apiFetch("/api/matrix/create-user", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: username, password: password, admin: isAdmin }) + }); + resultEl.className = "matrix-form-result success"; + resultEl.textContent = "āœ… User @" + escHtml(resp.username) + " created successfully."; + submitBtn.textContent = "Create User"; + submitBtn.disabled = false; + } catch (err) { + resultEl.className = "matrix-form-result error"; + resultEl.textContent = "āŒ " + (err.message || "Failed to create user."); + submitBtn.textContent = "Create User"; + submitBtn.disabled = false; + } + }); +} + +function openMatrixChangePasswordModal(unit, name, icon) { + if (!$credsBody) return; + $credsBody.innerHTML = + '
        ' + + '
        ' + + '
        ' + + '
        ' + + '
        ' + + '' + + '' + + '
        ' + + '
        '; + + document.getElementById("matrix-chpw-back-btn").addEventListener("click", function() { + openServiceDetailModal(unit, name, icon); + }); + + document.getElementById("matrix-chpw-submit-btn").addEventListener("click", async function() { + var submitBtn = document.getElementById("matrix-chpw-submit-btn"); + var resultEl = document.getElementById("matrix-chpw-result"); + var username = (document.getElementById("matrix-chpw-username").value || "").trim(); + var newPassword = document.getElementById("matrix-chpw-password").value || ""; + + if (!username || !newPassword) { + resultEl.className = "matrix-form-result error"; + resultEl.textContent = "Username and new password are required."; + return; + } + + submitBtn.disabled = true; + submitBtn.textContent = "Changing…"; + resultEl.className = "matrix-form-result"; + resultEl.textContent = ""; + + try { + var resp = await apiFetch("/api/matrix/change-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: username, new_password: newPassword }) + }); + resultEl.className = "matrix-form-result success"; + resultEl.textContent = "āœ… Password for @" + escHtml(resp.username) + " changed successfully."; + submitBtn.textContent = "Change Password"; + submitBtn.disabled = false; + } catch (err) { + resultEl.className = "matrix-form-result error"; + resultEl.textContent = "āŒ " + (err.message || "Failed to change password."); + submitBtn.textContent = "Change Password"; + submitBtn.disabled = false; + } + }); +} + +function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); } diff --git a/app/sovran_systemsos_web/static/js/state.js b/app/sovran_systemsos_web/static/js/state.js new file mode 100644 index 0000000..8113571 --- /dev/null +++ b/app/sovran_systemsos_web/static/js/state.js @@ -0,0 +1,94 @@ +"use strict"; + +// ── State ───────────────────────────────────────────────────────── + +let _servicesCache = []; +let _categoryLabels = {}; +let _updateLog = ""; +let _updatePollTimer = null; +let _updateLogOffset = 0; +let _serverWasDown = false; +let _updateFinished = false; +let _supportTimerInt = null; +let _supportEnabledAt = null; +let _supportStatus = null; // last fetched /api/support/status payload +let _walletUnlockTimerInt = null; +let _cachedExternalIp = null; + +// Feature Manager state +let _featuresData = null; +let _rebuildLog = ""; +let _rebuildLogOffset = 0; +let _rebuildPollTimer = null; +let _rebuildFinished = false; +let _rebuildServerDown = false; +let _pendingToggle = null; // {feature, extra} waiting for domain/confirm +let _rebuildFeatureName = ""; +let _rebuildIsEnabling = true; + +// ── DOM refs ────────────────────────────────────────────────────── + +const $tilesArea = document.getElementById("tiles-area"); +const $sidebarSupport = document.getElementById("sidebar-support"); +const $sidebarFeatures = document.getElementById("sidebar-features"); +const $updateBtn = document.getElementById("btn-update"); +const $updateBadge = document.getElementById("update-badge"); +const $refreshBtn = document.getElementById("btn-refresh"); +const $internalIp = document.getElementById("ip-internal"); +const $externalIp = document.getElementById("ip-external"); + +const $modal = document.getElementById("update-modal"); +const $modalSpinner = document.getElementById("modal-spinner"); +const $modalStatus = document.getElementById("modal-status"); +const $modalLog = document.getElementById("modal-log"); +const $btnReboot = document.getElementById("btn-reboot"); +const $btnSave = document.getElementById("btn-save-report"); +const $btnCloseModal = document.getElementById("btn-close-modal"); + +const $rebootOverlay = document.getElementById("reboot-overlay"); + +const $credsModal = document.getElementById("creds-modal"); +const $credsTitle = document.getElementById("creds-modal-title"); +const $credsBody = document.getElementById("creds-body"); +const $credsCloseBtn = document.getElementById("creds-close-btn"); + +const $supportModal = document.getElementById("support-modal"); +const $supportBody = document.getElementById("support-body"); +const $supportCloseBtn = document.getElementById("support-close-btn"); + +// Feature Manager — rebuild modal +const $rebuildModal = document.getElementById("rebuild-modal"); +const $rebuildSpinner = document.getElementById("rebuild-spinner"); +const $rebuildStatus = document.getElementById("rebuild-status"); +const $rebuildLog = document.getElementById("rebuild-log"); +const $rebuildReboot = document.getElementById("rebuild-reboot-btn"); +const $rebuildSave = document.getElementById("rebuild-save-report"); +const $rebuildClose = document.getElementById("rebuild-close-btn"); + +// Feature Manager — domain setup modal +const $domainSetupModal = document.getElementById("domain-setup-modal"); +const $domainSetupTitle = document.getElementById("domain-setup-title"); +const $domainSetupBody = document.getElementById("domain-setup-body"); +const $domainSetupClose = document.getElementById("domain-setup-close-btn"); + +// Feature Manager — SSL email modal +const $sslEmailModal = document.getElementById("ssl-email-modal"); +const $sslEmailInput = document.getElementById("ssl-email-input"); +const $sslEmailSave = document.getElementById("ssl-email-save-btn"); +const $sslEmailCancel = document.getElementById("ssl-email-cancel-btn"); +const $sslEmailClose = document.getElementById("ssl-email-close-btn"); + +// Feature Manager — confirm modal +const $featureConfirmModal = document.getElementById("feature-confirm-modal"); +const $featureConfirmMsg = document.getElementById("feature-confirm-message"); +const $featureConfirmOk = document.getElementById("feature-confirm-ok-btn"); +const $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn"); +const $featureConfirmClose = document.getElementById("feature-confirm-close-btn"); + +// Port Requirements modal +const $portReqModal = document.getElementById("port-requirements-modal"); +const $portReqBody = document.getElementById("port-req-body"); +const $portReqClose = document.getElementById("port-req-close-btn"); + +// System status banner +// (removed — health is now shown per-tile via the composite health field) diff --git a/app/sovran_systemsos_web/static/js/support.js b/app/sovran_systemsos_web/static/js/support.js new file mode 100644 index 0000000..039f5bf --- /dev/null +++ b/app/sovran_systemsos_web/static/js/support.js @@ -0,0 +1,261 @@ +"use strict"; + +// ── Tech Support modal ──────────────────────────────────────────── + +async function openSupportModal() { + if (!$supportModal) return; + $supportModal.classList.add("open"); + $supportBody.innerHTML = '

        Checking support status…

        '; + try { + var status = await apiFetch("/api/support/status"); + _supportStatus = status; + if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(status); } + else { renderSupportInactive(); } + } catch (err) { + $supportBody.innerHTML = '

        Could not check support status.

        '; + } +} + +function renderSupportInactive() { + stopSupportTimer(); + var ip = _cachedExternalIp || "loading…"; + $supportBody.innerHTML = [ + '
        ', + '
        šŸ›Ÿ
        ', + '

        Need help from Sovran Systems?

        ', + '

        This will temporarily grant our support team SSH access to your machine so we can help diagnose and fix issues.

        ', + '
        ', + '
        Your IP' + escHtml(ip) + '
        ', + '
        This IP will be shared with Sovran Systems support
        ', + '
        ', + '
        ', + '
        šŸ”’Wallet Protection
        ', + '

        Wallet files (LND, Sparrow, Bisq) are protected by default. Support staff cannot access your private keys unless you explicitly grant access.

        ', + '
        ', + '
        What happens:
          ', + '
        1. A restricted sovran-support user is created with limited access
        2. ', + '
        3. Our SSH key is added only to that restricted account
        4. ', + '
        5. Wallet files are locked via access controls — not visible to support
        6. ', + '
        7. You control if and when wallet access is granted (time-limited)
        8. ', + '
        9. All session events are logged for your audit
        10. ', + '
        ', + '', + '

        You can revoke access at any time. Wallet files are protected unless you unlock them.

        ', + '
        ', + ].join(""); + document.getElementById("btn-support-enable").addEventListener("click", enableSupport); +} + +function renderSupportActive(status) { + var ip = _cachedExternalIp || "loading…"; + var walletProtected = status && status.wallet_protected; + var walletUnlocked = status && status.wallet_unlocked; + var unlockUntil = status && status.wallet_unlocked_until_human ? status.wallet_unlocked_until_human : ""; + var protectedPaths = (status && status.protected_paths && status.protected_paths.length) + ? status.protected_paths : []; + + var walletSection; + if (walletProtected) { + if (walletUnlocked) { + walletSection = [ + '
        ', + '
        šŸ”“Wallet Access: UNLOCKED
        ', + '

        You have granted support temporary access to wallet files' + (unlockUntil ? ' until ' + escHtml(unlockUntil) + '' : '') + '.

        ', + '', + '
        ', + ].join(""); + } else { + var pathList = protectedPaths.length + ? '
          ' + protectedPaths.map(function(p){ return '
        • ' + escHtml(p) + '
        • '; }).join("") + '
        ' + : ''; + walletSection = [ + '
        ', + '
        šŸ”’Wallet Files: Protected
        ', + '

        Support cannot access your wallet files. Grant temporary access only if needed for wallet troubleshooting.

        ', + pathList, + '
        ', + '', + '', + '
        ', + '
        ', + ].join(""); + } + } else { + walletSection = [ + '
        ', + '
        āš ļøWallet Protection Unavailable
        ', + '

        The restricted support user could not be created. Support is running with root access — wallet files may be accessible. End the session if you are concerned.

        ', + '
        ', + ].join(""); + } + + $supportBody.innerHTML = [ + '
        ', + '
        šŸ”“
        ', + '

        Support Access is Active

        ', + '

        Sovran Systems can currently connect to your machine via SSH.

        ', + '
        ', + '
        Your IP' + escHtml(ip) + '
        ', + '
        Duration…
        ', + '
        ', + walletSection, + '', + '

        This will remove the SSH key and revoke all wallet access immediately.

        ', + '', + '
        ', + '', + ].join(""); + + document.getElementById("btn-support-disable").addEventListener("click", disableSupport); + document.getElementById("btn-support-audit").addEventListener("click", toggleAuditLog); + if (walletProtected && !walletUnlocked) { + document.getElementById("btn-wallet-unlock").addEventListener("click", walletUnlock); + } + if (walletProtected && walletUnlocked) { + document.getElementById("btn-wallet-lock").addEventListener("click", walletLock); + } + startSupportTimer(); + if (walletUnlocked && status.wallet_unlocked_until) { + startWalletUnlockTimer(status.wallet_unlocked_until); + } +} + +function renderSupportRemoved(verified) { + stopSupportTimer(); + stopWalletUnlockTimer(); + var icon = verified ? "āœ…" : "āš ļø"; + var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot to ensure it is gone."; + var vclass = verified ? "verified-gone" : "verify-warning"; + var vlabel = verified ? "āœ“ Removed — No access" : "⚠ Verify by rebooting"; + $supportBody.innerHTML = '
        ' + icon + '

        Support Session Ended

        ' + escHtml(msg) + '

        SSH Key Status:' + vlabel + '
        '; + document.getElementById("btn-support-done").addEventListener("click", closeSupportModal); +} + +async function enableSupport() { + var btn = document.getElementById("btn-support-enable"); + if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; } + try { + await apiFetch("/api/support/enable", { method: "POST" }); + var status = await apiFetch("/api/support/status"); + _supportStatus = status; + _supportEnabledAt = status.enabled_at; + renderSupportActive(status); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; } + alert("Failed to enable support access. Please try again."); + } +} + +async function disableSupport() { + var btn = document.getElementById("btn-support-disable"); + if (btn) { btn.disabled = true; btn.textContent = "Removing key…"; } + try { + var result = await apiFetch("/api/support/disable", { method: "POST" }); + renderSupportRemoved(result.verified); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "End Support Session"; } + alert("Failed to disable support access. Please try again."); + } +} + +async function walletUnlock() { + var btn = document.getElementById("btn-wallet-unlock"); + var sel = document.getElementById("wallet-unlock-duration"); + var duration = sel ? parseInt(sel.value, 10) : 3600; + if (btn) { btn.disabled = true; btn.textContent = "Unlocking…"; } + try { + var result = await apiFetch("/api/support/wallet-unlock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ duration: duration }), + }); + var status = await apiFetch("/api/support/status"); + _supportStatus = status; + renderSupportActive(status); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "Grant Wallet Access"; } + alert("Failed to unlock wallet access: " + (err.message || "Unknown error")); + } +} + +async function walletLock() { + var btn = document.getElementById("btn-wallet-lock"); + if (btn) { btn.disabled = true; btn.textContent = "Locking…"; } + try { + await apiFetch("/api/support/wallet-lock", { method: "POST" }); + var status = await apiFetch("/api/support/status"); + _supportStatus = status; + renderSupportActive(status); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "Re-lock Wallet Now"; } + alert("Failed to re-lock wallet: " + (err.message || "Unknown error")); + } +} + +async function toggleAuditLog() { + var container = document.getElementById("support-audit-container"); + if (!container) return; + if (container.style.display !== "none") { + container.style.display = "none"; + return; + } + container.style.display = "block"; + container.innerHTML = '

        Loading audit log…

        '; + try { + var data = await apiFetch("/api/support/audit-log"); + if (!data.entries || data.entries.length === 0) { + container.innerHTML = '

        No audit events recorded yet.

        '; + } else { + container.innerHTML = '
        ' + + data.entries.map(function(e) { return '
        ' + escHtml(e) + '
        '; }).join("") + + '
        '; + } + } catch (err) { + container.innerHTML = '

        Could not load audit log.

        '; + } +} + +function startSupportTimer() { + stopSupportTimer(); + updateSupportTimer(); + _supportTimerInt = setInterval(updateSupportTimer, SUPPORT_TIMER_INTERVAL); +} + +function stopSupportTimer() { + if (_supportTimerInt) { clearInterval(_supportTimerInt); _supportTimerInt = null; } +} + +function updateSupportTimer() { + var el = document.getElementById("support-timer"); + if (!el || !_supportEnabledAt) return; + var elapsed = (Date.now() / 1000) - _supportEnabledAt; + el.textContent = formatDuration(Math.max(0, elapsed)); +} + +function startWalletUnlockTimer(expiresAt) { + stopWalletUnlockTimer(); + _walletUnlockTimerInt = setInterval(function() { + if (Date.now() / 1000 >= expiresAt) { + stopWalletUnlockTimer(); + // Refresh the support modal to show re-locked state + apiFetch("/api/support/status").then(function(status) { + _supportStatus = status; + renderSupportActive(status); + }).catch(function() {}); + } + }, 10000); +} + +function stopWalletUnlockTimer() { + if (_walletUnlockTimerInt) { clearInterval(_walletUnlockTimerInt); _walletUnlockTimerInt = null; } +} + +function closeSupportModal() { + if ($supportModal) $supportModal.classList.remove("open"); + stopSupportTimer(); + stopWalletUnlockTimer(); +} diff --git a/app/sovran_systemsos_web/static/js/tiles.js b/app/sovran_systemsos_web/static/js/tiles.js new file mode 100644 index 0000000..b792b60 --- /dev/null +++ b/app/sovran_systemsos_web/static/js/tiles.js @@ -0,0 +1,151 @@ +"use strict"; + +// ── Render: initial build ───────────────────────────────────────── + +function buildTiles(services, categoryLabels) { + _servicesCache = services; + var grouped = {}; + var supportServices = []; + for (var i = 0; i < services.length; i++) { + var svc = services[i]; + // Support tiles go to the sidebar, not the main grid + if (svc.category === "support" || svc.type === "support") { + supportServices.push(svc); + continue; + } + var cat = svc.category || "other"; + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push(svc); + } + renderSidebarSupport(supportServices); + $tilesArea.innerHTML = ""; + var orderedKeys = CATEGORY_ORDER.filter(function(k) { return grouped[k]; }); + Object.keys(grouped).forEach(function(k) { + if (orderedKeys.indexOf(k) === -1) orderedKeys.push(k); + }); + for (var j = 0; j < orderedKeys.length; j++) { + var catKey = orderedKeys[j]; + var entries = grouped[catKey]; + if (!entries || entries.length === 0) continue; + var label = categoryLabels[catKey] || catKey; + var section = document.createElement("div"); + section.className = "category-section"; + section.dataset.category = catKey; + section.innerHTML = '
        ' + escHtml(label) + '

        '; + var grid = section.querySelector(".tiles-grid"); + for (var k = 0; k < entries.length; k++) { + grid.appendChild(buildTile(entries[k])); + } + $tilesArea.appendChild(section); + } + if ($tilesArea.children.length === 0) { + $tilesArea.innerHTML = '

        No services configured.

        '; + } +} + +function renderSidebarSupport(supportServices) { + $sidebarSupport.innerHTML = ""; + for (var i = 0; i < supportServices.length; i++) { + var svc = supportServices[i]; + var btn = document.createElement("button"); + btn.className = "sidebar-support-btn"; + btn.innerHTML = + 'šŸ›Ÿ' + + '' + + '' + escHtml(svc.name || "Tech Support") + '' + + 'Click for help' + + ''; + btn.addEventListener("click", function() { openSupportModal(); }); + $sidebarSupport.appendChild(btn); + } + if (supportServices.length > 0) { + var hr = document.createElement("hr"); + hr.className = "sidebar-divider"; + $sidebarSupport.appendChild(hr); + } +} + +function buildTile(svc) { + var isSupport = svc.type === "support"; + var sc = statusClass(svc.health || svc.status); + var st = statusText(svc.health || svc.status, svc.enabled); + var dis = !svc.enabled; + + var tile = document.createElement("div"); + tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : ""); + tile.dataset.unit = svc.unit; + tile.dataset.tileId = tileId(svc); + if (dis) tile.title = svc.name + " is not enabled in custom.nix"; + + if (isSupport) { + tile.innerHTML = '' + escHtml(svc.name) + '
        ' + escHtml(svc.name) + '
        Click for help
        '; + tile.style.cursor = "pointer"; + tile.addEventListener("click", function() { openSupportModal(); }); + return tile; + } + + tile.innerHTML = '' + escHtml(svc.name) + '
        ' + escHtml(svc.name) + '
        ' + st + '
        '; + + tile.style.cursor = "pointer"; + tile.addEventListener("click", function() { + openServiceDetailModal(svc.unit, svc.name, svc.icon); + }); + + return tile; +} + +// ── Render: live update ─────────────────────────────────────────── + +function updateTiles(services) { + _servicesCache = services; + for (var i = 0; i < services.length; i++) { + var svc = services[i]; + if (svc.type === "support") continue; + var id = CSS.escape(tileId(svc)); + var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]'); + if (!tile) continue; + var sc = statusClass(svc.health || svc.status); + var st = statusText(svc.health || svc.status, svc.enabled); + var dot = tile.querySelector(".status-dot"); + var text = tile.querySelector(".status-text"); + if (dot) dot.className = "status-dot " + sc; + if (text) text.textContent = st; + } +} + +// ── Service polling ─────────────────────────────────────────────── + +var _firstLoad = true; + +async function refreshServices() { + try { + var services = await apiFetch("/api/services"); + if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; } + else { updateTiles(services); } + } catch (err) { console.warn("Failed to fetch services:", err); } +} + +// ── Network IPs ─────────────────────────────────────────────────── + +async function loadNetwork() { + try { + var data = await apiFetch("/api/network"); + if ($internalIp) $internalIp.textContent = data.internal_ip || "—"; + if ($externalIp) $externalIp.textContent = data.external_ip || "—"; + _cachedExternalIp = data.external_ip || "unavailable"; + } catch (_) { + if ($internalIp) $internalIp.textContent = "—"; + if ($externalIp) $externalIp.textContent = "—"; + } +} + +// ── Update check ────────────────────────────────────────────────── + +async function checkUpdates() { + try { + var data = await apiFetch("/api/updates/check"); + var hasUpdates = !!data.available; + if ($updateBadge) $updateBadge.classList.toggle("visible", hasUpdates); + if ($updateBtn) $updateBtn.classList.toggle("has-updates", hasUpdates); + } catch (_) {} +} diff --git a/app/sovran_systemsos_web/static/js/update.js b/app/sovran_systemsos_web/static/js/update.js new file mode 100644 index 0000000..040ec44 --- /dev/null +++ b/app/sovran_systemsos_web/static/js/update.js @@ -0,0 +1,120 @@ +"use strict"; + +// ── Update modal ────────────────────────────────────────────────── + +function openUpdateModal() { + if (!$modal) return; + _updateLog = ""; + _updateLogOffset = 0; + _serverWasDown = false; + _updateFinished = false; + if ($modalLog) $modalLog.textContent = ""; + if ($modalStatus) $modalStatus.textContent = "Starting update…"; + if ($modalSpinner) $modalSpinner.classList.add("spinning"); + if ($btnReboot) $btnReboot.style.display = "none"; + if ($btnSave) $btnSave.style.display = "none"; + if ($btnCloseModal) $btnCloseModal.disabled = true; + $modal.classList.add("open"); + startUpdate(); +} + +function closeUpdateModal() { + if (!$modal) return; + $modal.classList.remove("open"); + stopUpdatePoll(); +} + +function appendLog(text) { + if (!text) return; + _updateLog += text; + if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; } +} + +function startUpdate() { + fetch("/api/updates/run", { method: "POST" }) + .then(function(response) { + if (!response.ok) return response.text().then(function(t) { throw new Error(t); }); + return response.json(); + }) + .then(function(data) { + if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n"); + if ($modalStatus) $modalStatus.textContent = "Updating…"; + startUpdatePoll(); + }) + .catch(function(err) { + appendLog("[Error: failed to start update — " + err + "]\n"); + onUpdateDone(false); + }); +} + +function startUpdatePoll() { + pollUpdateStatus(); + _updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL); +} + +function stopUpdatePoll() { + if (_updatePollTimer) { clearInterval(_updatePollTimer); _updatePollTimer = null; } +} + +async function pollUpdateStatus() { + if (_updateFinished) return; + try { + var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset); + if (_serverWasDown) { _serverWasDown = false; appendLog("[Server reconnected]\n"); if ($modalStatus) $modalStatus.textContent = "Updating…"; } + if (data.log) appendLog(data.log); + _updateLogOffset = data.offset; + if (data.running) return; + _updateFinished = true; + stopUpdatePoll(); + if (data.result === "success") onUpdateDone(true); + else onUpdateDone(false); + } catch (err) { + if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; } + } +} + +function onUpdateDone(success) { + if ($modalSpinner) $modalSpinner.classList.remove("spinning"); + if ($btnCloseModal) $btnCloseModal.disabled = false; + if (success) { + if ($modalStatus) $modalStatus.textContent = "āœ“ Update complete"; + if ($btnReboot) $btnReboot.style.display = "inline-flex"; + } else { + if ($modalStatus) $modalStatus.textContent = "āœ— Update failed"; + if ($btnSave) $btnSave.style.display = "inline-flex"; + if ($btnReboot) $btnReboot.style.display = "inline-flex"; + } +} + +function saveErrorReport() { + var blob = new Blob([_updateLog], { type: "text/plain" }); + var url = URL.createObjectURL(blob); + var a = document.createElement("a"); + a.href = url; + a.download = "sovran-update-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// ── Reboot ──────────────────────────────────────────────────────── + +function doReboot() { + if ($modal) $modal.classList.remove("open"); + if ($rebuildModal) $rebuildModal.classList.remove("open"); + stopUpdatePoll(); + stopRebuildPoll(); + if ($rebootOverlay) $rebootOverlay.classList.add("visible"); + fetch("/api/reboot", { method: "POST" }).catch(function() {}); + setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); +} + +function waitForServerReboot() { + fetch("/api/config", { cache: "no-store" }) + .then(function(res) { + if (res.ok) window.location.reload(); + else setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); + }) + .catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); }); +} diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css deleted file mode 100644 index cc26a73..0000000 --- a/app/sovran_systemsos_web/static/style.css +++ /dev/null @@ -1,1679 +0,0 @@ -/* Sovran_SystemsOS Hub — Web UI Stylesheet - Dark theme matching the Adwaita dark aesthetic - v6 — Status-only tiles (no controls) */ - -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -:root { - --bg-color: #1e1e2e; - --surface-color: #2a2a3c; - --card-color: #313244; - --border-color: #45475a; - --text-primary: #cdd6f4; - --text-secondary: #a6adc8; - --text-dim: #6c7086; - --accent-color: #89b4fa; - --green: #2ec27e; - --yellow: #e5a50a; - --red: #e01b24; - --grey: #888888; - --radius-card: 18px; - --radius-btn: 8px; - --shadow-card: 0 2px 8px rgba(0,0,0,0.4); - --shadow-hover: 0 6px 20px rgba(0,0,0,0.6); -} - -html, body { - height: 100%; -} - -body { - font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif; - background-color: var(--bg-color); - color: var(--text-primary); - line-height: 1.5; - min-height: 100vh; - display: flex; - flex-direction: column; - overflow: hidden; -} - -/* ── Header bar ─────────────────────────────────────────────────── */ - -.header-bar { - background-color: var(--surface-color); - border-bottom: 1px solid var(--border-color); - padding: 16px 24px; - display: flex; - align-items: center; - gap: 16px; - position: sticky; - top: 0; - z-index: 100; - justify-content: flex-end; -} - -.header-bar .title { - font-size: 1.15rem; - font-weight: 700; - color: var(--text-primary); - position: absolute; - left: 0; - right: 0; - text-align: center; - pointer-events: none; - white-space: nowrap; -} - -.header-logo { - height: 108px; - width: auto; - vertical-align: middle; - margin-right: 10px; -} - -.role-badge { - background-color: var(--accent-color); - color: #1e1e2e; - font-size: 0.72rem; - font-weight: 700; - padding: 3px 10px; - border-radius: 20px; - letter-spacing: 0.03em; -} - -/* ── Buttons ────────────────────────────────────────────────────── */ - -button { - font-family: inherit; - cursor: pointer; - border: none; - outline: none; - transition: opacity 0.15s, box-shadow 0.15s, background-color 0.15s; -} - -button:disabled { - opacity: 0.45; - cursor: default; -} - -.btn { - padding: 7px 16px; - border-radius: var(--radius-btn); - font-size: 0.88rem; - font-weight: 600; -} - -.btn-primary { - background-color: var(--accent-color); - color: #1e1e2e; -} - -.btn-primary:hover:not(:disabled) { - opacity: 0.88; -} - -/* Update System button: BLUE by default */ -.btn-update { - background-color: #89b4fa; - color: #1e1e2e; - position: relative; - display: flex; - align-items: center; - gap: 8px; -} - -.btn-update:hover:not(:disabled) { - opacity: 0.88; -} - -/* Update System button: GREEN when updates are available */ -.btn-update.has-updates { - background-color: #2ec27e; - color: #fff; -} - -.btn-update.has-updates:hover:not(:disabled) { - background-color: #27ae6e; -} - -.update-badge { - display: none; - width: 10px; - height: 10px; - background-color: var(--yellow); - border-radius: 50%; - animation: pulse-badge 1.4s ease-in-out infinite; -} - -.update-badge.visible { - display: inline-block; -} - -@keyframes pulse-badge { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.5; transform: scale(1.35); } -} - -.btn-icon { - background: none; - color: var(--text-secondary); - padding: 6px; - border-radius: 50%; - font-size: 1.1rem; - line-height: 1; -} - -.btn-icon:hover:not(:disabled) { - background-color: var(--border-color); - color: var(--text-primary); -} - -/* ── IP bar ─────────────────────────────────────────────────────── */ - -.ip-bar { - background-color: var(--surface-color); - border-bottom: 1px solid var(--border-color); - padding: 8px 24px; - display: flex; - align-items: center; - justify-content: center; - gap: 32px; - font-size: 0.82rem; - color: var(--text-secondary); -} - -.ip-bar .ip-label { - color: var(--text-dim); - margin-right: 6px; -} - -.ip-bar .ip-value { - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - color: var(--accent-color); - font-weight: 600; -} - -.ip-separator { - color: var(--border-color); -} - -/* ── Main content ───────────────────────────────────────────────── */ - -.main-content { - display: flex; - align-items: flex-start; - flex: 1; - overflow: hidden; - max-width: 1400px; - width: 100%; - margin-left: auto; - margin-right: auto; -} - -/* ── Sidebar ────────────────────────────────────────────────────── */ - -.sidebar { - width: 270px; - flex-shrink: 0; - height: 100%; - overflow-y: auto; - border-right: 1px solid var(--border-color); - background-color: var(--surface-color); - padding: 20px 14px; - display: flex; - flex-direction: column; - gap: 0; -} - -/* ── Sidebar: Tech Support button ───────────────────────────────── */ - -.sidebar-support-btn { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - background-color: var(--card-color); - border: 2px dashed var(--accent-color); - border-radius: 12px; - padding: 12px 14px; - color: var(--text-primary); - cursor: pointer; - transition: border-style 0.15s, border-color 0.15s, background-color 0.15s; - text-align: left; -} - -.sidebar-support-btn:hover { - border-style: solid; - border-color: #a8c8ff; - background-color: #35354a; -} - -.sidebar-support-icon { - font-size: 1.5rem; - flex-shrink: 0; -} - -.sidebar-support-text { - display: flex; - flex-direction: column; - gap: 2px; -} - -.sidebar-support-title { - font-size: 0.88rem; - font-weight: 700; - color: var(--text-primary); -} - -.sidebar-support-hint { - font-size: 0.72rem; - color: var(--accent-color); - font-weight: 600; -} - -.sidebar-divider { - border: none; - border-top: 1px solid var(--border-color); - margin: 16px 0; -} - -/* ── Tiles area ─────────────────────────────────────────────────── */ - -#tiles-area { - flex: 1; - height: 100%; - overflow-y: auto; - padding: 24px 20px 48px; - min-width: 0; -} - -/* ── Category sections ──────────────────────────────────────────── */ - -.category-section { - margin-bottom: 32px; -} - -.section-header { - font-size: 0.82rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-secondary); - margin-bottom: 4px; - padding-left: 4px; -} - -.section-divider { - border: none; - border-top: 1px solid var(--border-color); - margin-bottom: 16px; -} - -.tiles-grid { - display: flex; - flex-wrap: wrap; - gap: 14px; -} - -/* ── Service tile card (status-only) ─────────────────────────────── */ - -.service-tile { - width: 160px; - min-height: 130px; - background-color: var(--card-color); - border: 1px solid var(--border-color); - border-radius: var(--radius-card); - box-shadow: var(--shadow-card); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 20px 12px 18px; - gap: 0; - transition: box-shadow 0.2s, border-color 0.2s; - position: relative; - cursor: pointer; -} - -.service-tile:hover { - box-shadow: var(--shadow-hover); - border-color: #6c7086; -} - -.service-tile.disabled { - opacity: 0.45; -} - -.tile-icon { - width: 48px; - height: 48px; - object-fit: contain; - margin-bottom: 10px; -} - -.tile-icon-fallback { - width: 48px; - height: 48px; - display: flex; - align-items: center; - justify-content: center; - background-color: var(--border-color); - border-radius: 12px; - color: var(--text-dim); - font-size: 1.5rem; - margin-bottom: 10px; -} - -.tile-name { - font-size: 0.88rem; - font-weight: 600; - text-align: center; - color: var(--text-primary); - line-height: 1.3; - max-width: 140px; - word-break: break-word; - hyphens: auto; - min-height: 1.3em; - display: flex; - align-items: center; - justify-content: center; -} - -.tile-status { - font-size: 0.75rem; - margin-top: 8px; - display: flex; - align-items: center; - gap: 5px; - color: var(--text-secondary); -} - -.status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; - background-color: var(--grey); -} - -.status-dot.active { background-color: var(--green); } -.status-dot.inactive { background-color: var(--red); } -.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; } -.status-dot.failed { background-color: var(--red); } -.status-dot.disabled { background-color: var(--grey); } -.status-dot.needs-attention { background-color: var(--yellow); } - -/* ── Service detail modal sections ───────────────────────────────── */ - -.svc-detail-section { - margin-bottom: 20px; - padding-bottom: 16px; - border-bottom: 1px solid var(--border-color); -} - -.svc-detail-section:last-child { - border-bottom: none; - margin-bottom: 0; - padding-bottom: 0; -} - -.svc-detail-section-title { - font-size: 0.78rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-dim); - margin-bottom: 10px; -} - -.svc-detail-desc { - font-size: 0.9rem; - color: var(--text-secondary); - line-height: 1.6; -} - -.svc-detail-status { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.9rem; - font-weight: 600; - color: var(--text-primary); -} - -/* ── Service detail: Domain ──────────────────────────────────────── */ - -.svc-detail-domain-value { - font-size: 0.9rem; - color: var(--text-primary); - font-weight: 600; -} - -.tile-domain-label--ok { - color: var(--green); - font-weight: 600; -} - -.tile-domain-label--warn { - color: var(--yellow); - font-weight: 600; -} - -.tile-domain-label--error { - color: var(--red); - font-weight: 600; -} - -/* ── Service detail: Port table ──────────────────────────────────── */ - -.svc-detail-port-table { - width: 100%; - border-collapse: collapse; - font-size: 0.82rem; - margin-top: 8px; -} - -.svc-detail-port-table th { - text-align: left; - color: var(--text-dim); - font-weight: 600; - font-size: 0.72rem; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 6px 10px; - border-bottom: 1px solid var(--border-color); -} - -.svc-detail-port-table td { - padding: 8px 10px; - border-bottom: 1px solid rgba(69, 71, 90, 0.4); - color: var(--text-primary); -} - -.svc-detail-port-table tr:last-child td { - border-bottom: none; -} - -.svc-detail-port-table-port { - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-weight: 600; - color: var(--accent-color); -} - -.svc-detail-port-table-proto { - text-transform: uppercase; - color: var(--text-secondary); -} - -.svc-detail-port-table-desc { - color: var(--text-secondary); -} - -.svc-detail-port-table-status { - font-weight: 600; -} - -.port-status-listening { color: var(--green); } -.port-status-open { color: var(--yellow); } -.port-status-closed { color: var(--red); } -.port-status-unknown { color: var(--text-dim); } - -/* ── Service detail: Troubleshoot box ────────────────────────────── */ - -.svc-detail-troubleshoot { - margin-top: 12px; - padding: 14px 16px; - background-color: rgba(229, 165, 10, 0.08); - border: 1px solid rgba(229, 165, 10, 0.3); - border-radius: 10px; - font-size: 0.85rem; - color: var(--text-secondary); - line-height: 1.6; -} - -.svc-detail-troubleshoot strong { - color: var(--yellow); -} - -.svc-detail-troubleshoot ol { - margin-top: 8px; - padding-left: 20px; -} - -.svc-detail-troubleshoot li { - margin-bottom: 4px; -} - -.svc-detail-troubleshoot code { - background-color: rgba(137, 180, 250, 0.12); - padding: 2px 6px; - border-radius: 4px; - font-size: 0.82rem; - color: var(--accent-color); -} - -.svc-detail-troubleshoot a { - color: var(--accent-color); - text-decoration: none; -} - -.svc-detail-troubleshoot a:hover { - text-decoration: underline; -} - -/* ── Service detail: Addon feature toggle ────────────────────────── */ - -.svc-detail-addon-row { - display: flex; - align-items: center; - gap: 14px; - margin-top: 12px; -} - -.svc-detail-addon-status { - font-size: 0.88rem; - font-weight: 700; -} - -.addon-status--on { - color: var(--green); -} - -.addon-status--off { - color: var(--text-dim); -} - -.feature-conflict-warning { - margin-top: 8px; - margin-bottom: 8px; - padding: 10px 14px; - background-color: rgba(229, 165, 10, 0.1); - border: 1px solid rgba(229, 165, 10, 0.3); - border-radius: 8px; - font-size: 0.82rem; - color: var(--yellow); - font-weight: 600; -} - -/* ── Update modal ────────────────────────────────────────────────── */ - -.modal-overlay { - display: none; - position: fixed; - inset: 0; - background-color: rgba(0,0,0,0.65); - z-index: 200; - align-items: center; - justify-content: center; -} - -.modal-overlay.open { - display: flex; -} - -.modal-dialog { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: 16px; - width: 90vw; - max-width: 900px; - max-height: 80vh; - display: flex; - flex-direction: column; - box-shadow: 0 16px 48px rgba(0,0,0,0.7); -} - -.modal-header { - display: flex; - align-items: center; - padding: 16px 20px; - border-bottom: 1px solid var(--border-color); - gap: 12px; -} - -.modal-title { - font-size: 1rem; - font-weight: 700; - flex: 1; -} - -.modal-status { - font-size: 0.85rem; - color: var(--text-secondary); -} - -.modal-spinner { - width: 18px; - height: 18px; - border: 2.5px solid var(--border-color); - border-top-color: var(--accent-color); - border-radius: 50%; - animation: spin 0.75s linear infinite; - display: none; -} - -.modal-spinner.spinning { - display: block; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -.modal-log { - flex: 1; - overflow-y: auto; - padding: 12px 16px; - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.78rem; - line-height: 1.6; - color: var(--text-primary); - background-color: #12121c; - white-space: pre-wrap; - word-break: break-all; - min-height: 200px; -} - -.modal-footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - padding: 12px 20px; - border-top: 1px solid var(--border-color); -} - -/* Reboot = GREEN */ -.modal-footer .btn-reboot, -button.btn-reboot { - background-color: #2ec27e; - color: #fff; -} - -.modal-footer .btn-reboot:hover:not(:disabled), -button.btn-reboot:hover:not(:disabled) { - background-color: #27ae6e; -} - -.btn-save { - background-color: var(--yellow); - color: #1e1e2e; -} - -.btn-save:hover:not(:disabled) { - background-color: #c98d08; -} - -.btn-close-modal { - background-color: var(--border-color); - color: var(--text-primary); -} - -.btn-close-modal:hover:not(:disabled) { - background-color: #5a5c72; -} - -/* ── Credentials info modal ──────────────────────────────────────── */ - -.creds-dialog { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: 16px; - width: 90vw; - max-width: 700px; - max-height: 85vh; - display: flex; - flex-direction: column; - box-shadow: 0 16px 48px rgba(0,0,0,0.7); - animation: creds-fade-in 0.2s ease-out; -} - -@keyframes creds-fade-in { - from { opacity: 0; transform: scale(0.95) translateY(8px); } - to { opacity: 1; transform: scale(1) translateY(0); } -} - -.creds-header { - display: flex; - align-items: center; - padding: 20px 28px; - border-bottom: 1px solid var(--border-color); -} - -.creds-title { - font-size: 1.15rem; - font-weight: 700; - flex: 1; -} - -.creds-close-btn { - background: none; - color: var(--text-secondary); - font-size: 1.3rem; - padding: 4px 8px; - border-radius: 6px; - cursor: pointer; - border: none; -} - -.creds-close-btn:hover { - background-color: var(--border-color); - color: var(--text-primary); -} - -.creds-body { - padding: 24px 28px; - overflow-y: auto; -} - -.creds-loading { - color: var(--text-dim); - text-align: center; - padding: 24px 0; -} - -.creds-row { - margin-bottom: 20px; -} - -.creds-row:last-child { - margin-bottom: 0; -} - -.creds-label { - font-size: 0.78rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-dim); - margin-bottom: 6px; -} - -.creds-value-wrap { - display: flex; - align-items: flex-start; - gap: 10px; -} - -.creds-value { - flex: 1; - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.92rem; - color: var(--text-primary); - background-color: #12121c; - padding: 12px 16px; - border-radius: 8px; - word-break: break-all; - white-space: pre-wrap; - line-height: 1.6; - border: 1px solid var(--border-color); -} - -.creds-copy-btn { - background-color: var(--border-color); - color: var(--text-primary); - font-size: 0.78rem; - font-weight: 600; - padding: 8px 14px; - border-radius: 6px; - cursor: pointer; - border: none; - white-space: nowrap; - flex-shrink: 0; - align-self: flex-start; - margin-top: 10px; -} - -.creds-copy-btn:hover { - background-color: #5a5c72; -} - -.creds-copy-btn.copied { - background-color: var(--green); - color: #fff; -} - -.creds-empty { - color: var(--text-dim); - text-align: center; - padding: 24px 0; - font-size: 0.88rem; -} - -/* ── Credential links ────────────────────────────────────────────── */ - -.creds-link { - color: #b8f0c0; - text-decoration: none; - word-break: break-all; -} - -.creds-link:hover { - text-decoration: underline; - color: #defce6; -} - -/* ── Matrix action buttons ───────────────────────────────────────── */ - -.matrix-actions-divider { - border: none; - border-top: 1px solid var(--border-color); - margin: 18px 0 14px; -} - -.matrix-actions-row { - display: flex; - gap: 12px; - flex-wrap: wrap; -} - -.matrix-action-btn { - background-color: var(--accent-color); - color: #0f0f19; - font-size: 0.88rem; - font-weight: 700; - padding: 10px 18px; - border-radius: 8px; - border: none; - cursor: pointer; - flex: 1; - min-width: 140px; -} - -.matrix-action-btn:hover { - background-color: #a8c8ff; -} - -.matrix-form-group { - margin-bottom: 14px; -} - -.matrix-form-label { - display: block; - font-size: 0.82rem; - color: var(--text-secondary); - margin-bottom: 6px; - font-weight: 600; -} - -.matrix-form-input { - width: 100%; - background-color: #12121c; - color: var(--text-primary); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 10px 12px; - font-size: 0.9rem; - box-sizing: border-box; -} - -.matrix-form-input:focus { - outline: none; - border-color: var(--accent-color); -} - -.matrix-form-checkbox-row { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 14px; -} - -.matrix-form-checkbox-row input[type="checkbox"] { - width: 16px; - height: 16px; - accent-color: var(--accent-color); -} - -.matrix-form-actions { - display: flex; - gap: 10px; - margin-top: 18px; -} - -.matrix-form-submit { - background-color: var(--accent-color); - color: #0f0f19; - font-size: 0.88rem; - font-weight: 700; - padding: 10px 20px; - border-radius: 8px; - border: none; - cursor: pointer; - flex: 1; -} - -.matrix-form-submit:hover:not(:disabled) { - background-color: #a8c8ff; -} - -.matrix-form-submit:disabled { - opacity: 0.6; - cursor: default; -} - -.matrix-form-back { - background-color: var(--border-color); - color: var(--text-primary); - font-size: 0.88rem; - font-weight: 600; - padding: 10px 20px; - border-radius: 8px; - border: none; - cursor: pointer; -} - -.matrix-form-back:hover { - background-color: #5a5c72; -} - -.matrix-form-result { - margin-top: 14px; - padding: 12px 16px; - border-radius: 8px; - font-size: 0.88rem; - line-height: 1.5; - display: none; -} - -.matrix-form-result.success { - background-color: rgba(74, 222, 128, 0.12); - border: 1px solid var(--green); - color: var(--green); - display: block; -} - -.matrix-form-result.error { - background-color: rgba(239, 68, 68, 0.12); - border: 1px solid #ef4444; - color: #f87171; - display: block; -} - -/* ── QR code in credentials modal ────────────────────────────────── */ - -.creds-qr-wrap { - display: flex; - flex-direction: column; - align-items: center; - padding: 20px 0; - margin-bottom: 10px; -} - -.creds-qr-img { - width: 240px; - height: 240px; - border-radius: 12px; - border: 4px solid #fff; - background-color: #fff; - image-rendering: pixelated; - box-shadow: 0 4px 16px rgba(0,0,0,0.4); -} - -.creds-qr-hint { - margin-top: 10px; - font-size: 0.82rem; - color: var(--text-secondary); - font-style: italic; -} - -/* ── Reboot overlay ─────────────────────────────────────────────── */ - -.reboot-overlay { - display: none; - position: fixed; - inset: 0; - background-color: rgba(15, 15, 25, 0.92); - z-index: 999; - align-items: center; - justify-content: center; -} - -.reboot-overlay.visible { - display: flex; -} - -.reboot-card { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: 20px; - padding: 48px 56px; - text-align: center; - max-width: 480px; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8); - animation: reboot-fade-in 0.4s ease-out; -} - -@keyframes reboot-fade-in { - from { opacity: 0; transform: scale(0.92) translateY(12px); } - to { opacity: 1; transform: scale(1) translateY(0); } -} - -.reboot-icon { - font-size: 3rem; - color: var(--accent-color); - margin-bottom: 16px; - animation: reboot-spin 2s linear infinite; - display: inline-block; -} - -@keyframes reboot-spin { - to { transform: rotate(360deg); } -} - -.reboot-title { - font-size: 1.35rem; - font-weight: 700; - color: var(--text-primary); - margin-bottom: 12px; -} - -.reboot-message { - font-size: 0.92rem; - color: var(--text-secondary); - line-height: 1.6; - margin-bottom: 24px; -} - -.reboot-dots { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - margin-bottom: 16px; -} - -.reboot-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background-color: var(--accent-color); - animation: reboot-bounce 1.4s ease-in-out infinite; -} - -.reboot-dot:nth-child(2) { animation-delay: 0.2s; } -.reboot-dot:nth-child(3) { animation-delay: 0.4s; } - -@keyframes reboot-bounce { - 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } - 40% { opacity: 1; transform: scale(1.2); } -} - -.reboot-submessage { - font-size: 0.82rem; - color: var(--text-dim); - font-style: italic; -} - -/* ── Empty state ────────────────────────────────────────────────── */ - -.empty-state { - text-align: center; - padding: 64px 24px; - color: var(--text-dim); -} - -.empty-state p { - font-size: 1rem; - margin-bottom: 8px; -} - -/* ── Tech Support modal ──────────────────────────────────────────── */ - -.support-section { - text-align: center; -} - -.support-icon-big { - font-size: 3rem; - margin-bottom: 12px; -} - -.support-active-icon { - animation: pulse-badge 2s ease-in-out infinite; -} - -.support-heading { - font-size: 1.15rem; - font-weight: 700; - color: var(--text-primary); - margin-bottom: 8px; -} - -.support-active-heading { - color: var(--green); -} - -.support-desc { - font-size: 0.88rem; - color: var(--text-secondary); - line-height: 1.6; - margin-bottom: 16px; - text-align: left; -} - -.support-active-note { - font-size: 0.88rem; - color: var(--text-secondary); - margin-bottom: 16px; -} - -.support-info-box { - background-color: var(--card-color); - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 14px 18px; - margin-bottom: 16px; - text-align: left; -} - -.support-active-box { - border-color: var(--green); -} - -.support-info-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 4px 0; -} - -.support-info-label { - font-size: 0.82rem; - color: var(--text-dim); - font-weight: 600; -} - -.support-info-value { - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.88rem; - color: var(--accent-color); - font-weight: 600; -} - -.support-info-hint { - font-size: 0.72rem; - color: var(--text-dim); - margin-top: 6px; - font-style: italic; -} - -.support-steps { - text-align: left; - margin-bottom: 16px; - padding: 14px 18px; - background-color: var(--card-color); - border-radius: 10px; - border: 1px solid var(--border-color); -} - -.support-steps-title { - font-size: 0.82rem; - font-weight: 700; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.04em; - margin-bottom: 8px; -} - -.support-steps ol { - padding-left: 20px; - font-size: 0.85rem; - color: var(--text-secondary); - line-height: 1.7; -} - -.support-steps code { - background-color: rgba(137, 180, 250, 0.12); - padding: 2px 6px; - border-radius: 4px; - font-size: 0.82rem; - color: var(--accent-color); -} - -.support-btn-enable { - width: 100%; - padding: 12px; - border-radius: var(--radius-btn); - background-color: var(--accent-color); - color: #1e1e2e; - font-size: 0.95rem; - font-weight: 700; - margin-bottom: 10px; -} - -.support-btn-enable:hover:not(:disabled) { - opacity: 0.88; -} - -.support-btn-disable { - width: 100%; - padding: 12px; - border-radius: var(--radius-btn); - background-color: var(--red); - color: #fff; - font-size: 0.95rem; - font-weight: 700; - margin-bottom: 10px; -} - -.support-btn-disable:hover:not(:disabled) { - opacity: 0.88; -} - -.support-btn-done { - width: 100%; - padding: 12px; - border-radius: var(--radius-btn); - background-color: var(--accent-color); - color: #1e1e2e; - font-size: 0.95rem; - font-weight: 700; - margin-top: 16px; -} - -.support-btn-done:hover:not(:disabled) { - opacity: 0.88; -} - -.support-btn-auditlog { - width: 100%; - padding: 10px; - border-radius: var(--radius-btn); - background-color: var(--border-color); - color: var(--text-primary); - font-size: 0.85rem; - font-weight: 600; - margin-top: 8px; -} - -.support-btn-auditlog:hover:not(:disabled) { - background-color: #5a5c72; -} - -.support-fine-print { - font-size: 0.72rem; - color: var(--text-dim); - font-style: italic; - margin-bottom: 8px; -} - -.support-verify-box { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - margin: 16px 0; - padding: 12px; - background-color: var(--card-color); - border-radius: 8px; -} - -.support-verify-label { - font-size: 0.82rem; - color: var(--text-dim); - font-weight: 600; -} - -.support-verify-value { - font-size: 0.88rem; - font-weight: 700; -} - -.support-verify-value.verified-gone { - color: var(--green); -} - -.support-verify-value.verify-warning { - color: var(--yellow); -} - -/* ── Wallet protection ───────────────────────────────────────────── */ - -.support-wallet-box { - text-align: left; - padding: 14px 18px; - border-radius: 10px; - margin-bottom: 16px; - border: 1px solid var(--border-color); -} - -.support-wallet-protected { - background-color: rgba(46, 194, 126, 0.06); - border-color: rgba(46, 194, 126, 0.3); -} - -.support-wallet-unlocked { - background-color: rgba(229, 165, 10, 0.06); - border-color: rgba(229, 165, 10, 0.3); -} - -.support-wallet-warning { - background-color: rgba(224, 27, 36, 0.06); - border-color: rgba(224, 27, 36, 0.3); -} - -.support-wallet-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; -} - -.support-wallet-icon { - font-size: 1.2rem; -} - -.support-wallet-title { - font-size: 0.88rem; - font-weight: 700; - color: var(--text-primary); -} - -.support-wallet-desc { - font-size: 0.82rem; - color: var(--text-secondary); - line-height: 1.5; - margin-bottom: 8px; -} - -.support-wallet-paths { - list-style: none; - padding: 0; - margin: 8px 0; -} - -.support-wallet-paths li { - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.78rem; - color: var(--text-dim); - padding: 2px 0; -} - -.support-wallet-unlock-row { - display: flex; - align-items: center; - gap: 10px; - margin-top: 10px; -} - -.support-unlock-select { - background-color: var(--card-color); - color: var(--text-primary); - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 6px 10px; - font-size: 0.82rem; -} - -.support-btn-wallet-unlock { - padding: 8px 16px; - border-radius: var(--radius-btn); - background-color: var(--yellow); - color: #1e1e2e; - font-size: 0.82rem; - font-weight: 700; -} - -.support-btn-wallet-unlock:hover:not(:disabled) { - background-color: #c98d08; -} - -.support-btn-wallet-lock { - padding: 8px 16px; - border-radius: var(--radius-btn); - background-color: var(--green); - color: #fff; - font-size: 0.82rem; - font-weight: 700; - margin-top: 8px; -} - -.support-btn-wallet-lock:hover:not(:disabled) { - background-color: #27ae6e; -} - -/* ── Audit log ───────────────────────────────────────────────────── */ - -.support-audit-container { - margin-top: 12px; - border-top: 1px solid var(--border-color); - padding-top: 12px; -} - -.support-audit-log { - max-height: 200px; - overflow-y: auto; - background-color: #12121c; - border-radius: 8px; - padding: 10px 14px; -} - -.support-audit-entry { - font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace; - font-size: 0.72rem; - color: var(--text-secondary); - padding: 3px 0; - border-bottom: 1px solid rgba(69, 71, 90, 0.3); -} - -.support-audit-entry:last-child { - border-bottom: none; -} - -.support-audit-empty { - font-size: 0.82rem; - color: var(--text-dim); - text-align: center; - padding: 12px; -} - -/* ── Domain setup modal ──────────────────────────────────────────── */ - -domain-narrow-dialog { - max-width: 500px; -} - -domain-field-group { - margin-bottom: 14px; -} - -domain-field-label { - display: block; - font-size: 0.82rem; - color: var(--text-secondary); - margin-bottom: 6px; - font-weight: 600; -} - -domain-field-input { - width: 100%; - background-color: #12121c; - color: var(--text-primary); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 10px 12px; - font-size: 0.9rem; - box-sizing: border-box; -} - -domain-field-input:focus { - outline: none; - border-color: var(--accent-color); -} - -domain-field-actions { - display: flex; - gap: 10px; - margin-top: 18px; - justify-content: flex-end; -} - -/* ── Responsive ─────────────────────────────────────────────────── */ - -@media (max-width: 768px) { - body { - overflow: auto; - } - .main-content { - flex-direction: column; - overflow: visible; - } - .sidebar { - width: 100%; - height: auto; - border-right: none; - border-bottom: 1px solid var(--border-color); - padding: 14px 12px; - } - #tiles-area { - height: auto; - overflow-y: visible; - padding: 16px 12px 40px; - } -} - -@media (max-width: 600px) { - .header-bar { - padding: 10px 14px; - gap: 10px; - } - .header-bar .title { - font-size: 0.95rem; - } - .ip-bar { - gap: 16px; - flex-wrap: wrap; - padding: 8px 14px; - } - .tiles-grid { - justify-content: center; - } - .service-tile { - width: 140px; - min-height: 130px; - } - .reboot-card { - padding: 36px 28px; - margin: 0 16px; - } - .creds-dialog { - margin: 0 12px; - } - .creds-qr-img { - width: 200px; - height: 200px; - } -} - -/* ── Tech Support tile ───────────────────────────────────────────── */ - -.support-tile { - border-color: var(--accent-color); - border-width: 2px; - border-style: dashed; -} - -.support-tile:hover { - border-color: #a8c8ff; - border-style: solid; -} - -/* ── Login page ──────────────────────────────────────────────────── */ - -.login-wrapper { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; - padding: 24px; -} - -.login-card { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: 20px; - padding: 48px 40px; - width: 100%; - max-width: 400px; - box-shadow: 0 8px 32px rgba(0,0,0,0.5); -} - -.login-header { - text-align: center; - margin-bottom: 32px; -} - -.login-logo { - height: 64px; - margin-bottom: 16px; -} - -.login-title { - font-size: 1.25rem; - font-weight: 700; - color: var(--text-primary); -} - -.login-form { - display: flex; - flex-direction: column; - gap: 16px; -} - -.form-group label { - display: block; - font-size: 0.82rem; - font-weight: 600; - color: var(--text-secondary); - margin-bottom: 6px; -} - -.form-group input { - width: 100%; - padding: 10px 14px; - border: 1px solid var(--border-color); - border-radius: var(--radius-btn); - background-color: var(--card-color); - color: var(--text-primary); - font-size: 0.92rem; -} - -.form-group input:focus { - outline: none; - border-color: var(--accent-color); -} - -.btn-login { - width: 100%; - padding: 12px; - border-radius: var(--radius-btn); - background-color: var(--accent-color); - color: #1e1e2e; - font-size: 0.95rem; - font-weight: 700; - margin-top: 8px; -} - -.btn-login:hover { - opacity: 0.88; -} - -.login-error { - background-color: rgba(224, 27, 36, 0.12); - border: 1px solid var(--red); - color: #f87171; - padding: 10px 14px; - border-radius: 8px; - font-size: 0.85rem; - display: none; -} - -.login-error.visible { - display: block; -} diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index 69fded9..11a111d 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -4,7 +4,16 @@ Sovran_SystemsOS Hub - + + + + + + + + + + @@ -182,6 +191,15 @@
        - + + + + + + + + + + \ No newline at end of file diff --git a/app/sovran_systemsos_web/templates/onboarding.html b/app/sovran_systemsos_web/templates/onboarding.html index 756c67b..8c46788 100644 --- a/app/sovran_systemsos_web/templates/onboarding.html +++ b/app/sovran_systemsos_web/templates/onboarding.html @@ -4,7 +4,16 @@ Sovran_SystemsOS — First-Boot Setup - + + + + + + + + + + -- 2.53.0 From 33d55c4324965c23c761c4aabae324ecf0e7346c Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Sat, 4 Apr 2026 18:39:14 -0500 Subject: [PATCH 288/857] bigger logo --- app/sovran_systemsos_web/static/css/header.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/sovran_systemsos_web/static/css/header.css b/app/sovran_systemsos_web/static/css/header.css index ac294ff..2b1af4f 100644 --- a/app/sovran_systemsos_web/static/css/header.css +++ b/app/sovran_systemsos_web/static/css/header.css @@ -3,7 +3,7 @@ .header-bar { background-color: var(--surface-color); border-bottom: 1px solid var(--border-color); - padding: 16px 24px; + padding: 32px 24px; display: flex; align-items: center; gap: 16px; @@ -29,7 +29,7 @@ height: 108px; width: auto; vertical-align: middle; - margin-right: 10px; + margin-right: 0px; } .role-badge { -- 2.53.0 From 9483f7c27a604839eff5833ec8ed05d432b21f01 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Sat, 4 Apr 2026 18:43:12 -0500 Subject: [PATCH 289/857] bigger logo --- app/sovran_systemsos_web/static/css/header.css | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/sovran_systemsos_web/static/css/header.css b/app/sovran_systemsos_web/static/css/header.css index 2b1af4f..1c050ed 100644 --- a/app/sovran_systemsos_web/static/css/header.css +++ b/app/sovran_systemsos_web/static/css/header.css @@ -3,7 +3,7 @@ .header-bar { background-color: var(--surface-color); border-bottom: 1px solid var(--border-color); - padding: 32px 24px; + padding: 20px 24px; display: flex; align-items: center; gap: 16px; @@ -23,13 +23,17 @@ text-align: center; pointer-events: none; white-space: nowrap; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; } .header-logo { - height: 108px; + height: 120px; width: auto; - vertical-align: middle; - margin-right: 0px; + display: block; + margin: 0 auto; } .role-badge { @@ -69,4 +73,4 @@ .ip-separator { color: var(--border-color); -} +} \ No newline at end of file -- 2.53.0 From 2f30112c66c0cfe94fca08980fc0ed3969db52c7 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Sat, 4 Apr 2026 18:48:46 -0500 Subject: [PATCH 290/857] bigger logo --- .../static/css/header.css | 22 ++++++++++--------- app/sovran_systemsos_web/templates/index.html | 16 ++++++++------ 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/app/sovran_systemsos_web/static/css/header.css b/app/sovran_systemsos_web/static/css/header.css index 1c050ed..d889e3d 100644 --- a/app/sovran_systemsos_web/static/css/header.css +++ b/app/sovran_systemsos_web/static/css/header.css @@ -3,37 +3,39 @@ .header-bar { background-color: var(--surface-color); border-bottom: 1px solid var(--border-color); - padding: 20px 24px; + padding: 16px 24px; display: flex; align-items: center; gap: 16px; position: sticky; top: 0; z-index: 100; - justify-content: flex-end; + justify-content: center; } .header-bar .title { font-size: 1.15rem; font-weight: 700; color: var(--text-primary); - position: absolute; - left: 0; - right: 0; - text-align: center; - pointer-events: none; - white-space: nowrap; display: flex; flex-direction: column; align-items: center; gap: 4px; + flex: 1; +} + +.header-bar .header-right { + display: flex; + align-items: center; + gap: 16px; + position: absolute; + right: 24px; } .header-logo { - height: 120px; + height: 130px; width: auto; display: block; - margin: 0 auto; } .role-badge { diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index 11a111d..346ebf7 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -18,18 +18,20 @@ -
        - - - Sovran_SystemsOS Hub - +
        + + + Sovran_SystemsOS Hub + +
        Loading… -
        +
        +
        @@ -202,4 +204,4 @@ - \ No newline at end of file + -- 2.53.0 From 25a84b875824c264aea9408be3e8e133bd9eff86 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Sat, 4 Apr 2026 18:55:16 -0500 Subject: [PATCH 291/857] bigger logo --- .../static/css/header.css | 25 +++++++++------- app/sovran_systemsos_web/static/js/events.js | 3 +- app/sovran_systemsos_web/static/js/state.js | 3 +- app/sovran_systemsos_web/templates/index.html | 29 +++++++++---------- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/app/sovran_systemsos_web/static/css/header.css b/app/sovran_systemsos_web/static/css/header.css index d889e3d..f0c89d9 100644 --- a/app/sovran_systemsos_web/static/css/header.css +++ b/app/sovran_systemsos_web/static/css/header.css @@ -6,30 +6,33 @@ padding: 16px 24px; display: flex; align-items: center; - gap: 16px; + justify-content: center; position: sticky; top: 0; z-index: 100; - justify-content: center; +} + +.header-center { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; } .header-bar .title { font-size: 1.15rem; font-weight: 700; color: var(--text-primary); - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - flex: 1; } -.header-bar .header-right { - display: flex; - align-items: center; - gap: 16px; +.header-right { position: absolute; right: 24px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 10px; } .header-logo { diff --git a/app/sovran_systemsos_web/static/js/events.js b/app/sovran_systemsos_web/static/js/events.js index b120492..74ba7a0 100644 --- a/app/sovran_systemsos_web/static/js/events.js +++ b/app/sovran_systemsos_web/static/js/events.js @@ -3,7 +3,6 @@ // ── Event listeners ─────────────────────────────────────────────── if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); -if ($refreshBtn) $refreshBtn.addEventListener("click", function() { refreshServices(); }); if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal); if ($btnReboot) $btnReboot.addEventListener("click", doReboot); if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); @@ -77,4 +76,4 @@ async function init() { } } -document.addEventListener("DOMContentLoaded", init); +document.addEventListener("DOMContentLoaded", init); \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/js/state.js b/app/sovran_systemsos_web/static/js/state.js index 8113571..4da0501 100644 --- a/app/sovran_systemsos_web/static/js/state.js +++ b/app/sovran_systemsos_web/static/js/state.js @@ -33,7 +33,6 @@ const $sidebarSupport = document.getElementById("sidebar-support"); const $sidebarFeatures = document.getElementById("sidebar-features"); const $updateBtn = document.getElementById("btn-update"); const $updateBadge = document.getElementById("update-badge"); -const $refreshBtn = document.getElementById("btn-refresh"); const $internalIp = document.getElementById("ip-internal"); const $externalIp = document.getElementById("ip-external"); @@ -91,4 +90,4 @@ const $portReqBody = document.getElementById("port-req-body"); const $portReqClose = document.getElementById("port-req-close-btn"); // System status banner -// (removed — health is now shown per-tile via the composite health field) +// (removed — health is now shown per-tile via the composite health field) \ No newline at end of file diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index 346ebf7..7ef3a54 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -18,20 +18,19 @@ -
        - - - Sovran_SystemsOS Hub - -
        - Loading… - - -
        -
        +
        +
        + + Sovran_SystemsOS Hub +
        +
        + Loading… + +
        +
        @@ -204,4 +203,4 @@ - + \ No newline at end of file -- 2.53.0 From 369b63097ec541953312a8ed029d7f1567896a51 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Sat, 4 Apr 2026 18:58:17 -0500 Subject: [PATCH 292/857] bigger logo --- .../static/css/header.css | 24 ++++++------------- app/sovran_systemsos_web/templates/index.html | 8 +++---- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/app/sovran_systemsos_web/static/css/header.css b/app/sovran_systemsos_web/static/css/header.css index f0c89d9..edee489 100644 --- a/app/sovran_systemsos_web/static/css/header.css +++ b/app/sovran_systemsos_web/static/css/header.css @@ -5,18 +5,18 @@ border-bottom: 1px solid var(--border-color); padding: 16px 24px; display: flex; + flex-direction: column; align-items: center; - justify-content: center; + gap: 8px; position: sticky; top: 0; z-index: 100; } -.header-center { - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; +.header-logo { + height: 140px; + width: auto; + display: block; } .header-bar .title { @@ -25,22 +25,12 @@ color: var(--text-primary); } -.header-right { - position: absolute; - right: 24px; - top: 50%; - transform: translateY(-50%); +.header-buttons { display: flex; align-items: center; gap: 10px; } -.header-logo { - height: 130px; - width: auto; - display: block; -} - .role-badge { background-color: var(--accent-color); color: #1e1e2e; diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index 7ef3a54..f38f8d5 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -19,11 +19,9 @@
        -
        - - Sovran_SystemsOS Hub -
        -
        + + Sovran_SystemsOS Hub +
        Loading…
        @@ -140,30 +138,8 @@
        - + - - -
      10. āœ… Domain configuration saved
      11. āœ… Port forwarding reviewed
      12. āœ… Credentials noted
      13. -
      14. āœ… Features configured
      15. @@ -192,21 +167,6 @@ - - - \ No newline at end of file diff --git a/onboarding.html b/onboarding.html index 807568c..bb67c69 100644 --- a/onboarding.html +++ b/onboarding.html @@ -27,8 +27,6 @@ 4 5 - - 6 @@ -131,30 +129,8 @@ - + - - - - - - \ No newline at end of file -- 2.53.0 From ca78bb4ed41a5479dd7e284a887df9d34da63fd9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 02:45:46 +0000 Subject: [PATCH 299/857] Initial plan -- 2.53.0 From 87e40a631c77662404fd98f0ea1a67039570a724 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 02:47:39 +0000 Subject: [PATCH 300/857] Restore missing onboarding wizard CSS styles Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/efd0c45e-80b3-427c-af20-3f8bc07f8647 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- .../static/css/onboarding.css | 577 ++++++++++++++++++ 1 file changed, 577 insertions(+) diff --git a/app/sovran_systemsos_web/static/css/onboarding.css b/app/sovran_systemsos_web/static/css/onboarding.css index f5da9d6..42343bb 100644 --- a/app/sovran_systemsos_web/static/css/onboarding.css +++ b/app/sovran_systemsos_web/static/css/onboarding.css @@ -1,3 +1,580 @@ +/* ── Onboarding wizard ──────────────────────────────────────────── */ + +.onboarding-body { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + min-height: 100vh; + background-color: var(--bg-color); + padding: 24px 16px 48px; + overflow-y: auto; +} + +.onboarding-shell { + width: 100%; + max-width: 680px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 0; +} + +/* Progress bar */ + +.onboarding-progress-bar { + width: 100%; + height: 4px; + background-color: var(--card-color); + border-radius: 2px; + overflow: hidden; + margin-bottom: 24px; +} + +.onboarding-progress-fill { + height: 100%; + background-color: var(--accent-color); + border-radius: 2px; + transition: width 0.4s ease; + width: 20%; +} + +/* Step indicator dots */ + +.onboarding-steps-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 0; + margin-bottom: 28px; +} + +.onboarding-step-dot { + width: 32px; + height: 32px; + border-radius: 50%; + background-color: var(--card-color); + border: 2px solid var(--border-color); + color: var(--text-dim); + font-size: 0.78rem; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background-color 0.2s, border-color 0.2s, color 0.2s; +} + +.onboarding-step-dot.active { + background-color: var(--accent-color); + border-color: var(--accent-color); + color: #1e1e2e; +} + +.onboarding-step-dot.completed { + background-color: var(--green); + border-color: var(--green); + color: #1e1e2e; +} + +.onboarding-step-connector { + flex: 1; + height: 2px; + background-color: var(--border-color); + min-width: 24px; + max-width: 80px; + transition: background-color 0.2s; +} + +/* Panel wrapper and panels */ + +.onboarding-panel-wrap { + position: relative; +} + +.onboarding-panel { + display: flex; + flex-direction: column; + gap: 20px; + animation: panel-fade-in 0.25s ease-out; +} + +@keyframes panel-fade-in { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Hero / welcome section */ + +.onboarding-hero { + text-align: center; + padding: 16px 0 4px; +} + +.onboarding-logo { + margin-bottom: 16px; + font-size: 3rem; +} + +.onboarding-logo-img { + height: 90px; + width: auto; +} + +.onboarding-title { + font-size: 1.6rem; + font-weight: 800; + color: var(--text-primary); + margin-bottom: 8px; + line-height: 1.2; +} + +.onboarding-subtitle { + font-size: 1rem; + color: var(--text-secondary); +} + +/* Cards */ + +.onboarding-card { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-card); + padding: 24px 28px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.onboarding-card--scroll { + max-height: 360px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border-color) transparent; +} + +.onboarding-card--ports { + overflow: visible; +} + +/* Body text */ + +.onboarding-body-text { + font-size: 0.92rem; + color: var(--text-secondary); + line-height: 1.65; +} + +.onboarding-body-text--dim { + color: var(--text-dim); + font-size: 0.85rem; +} + +/* Role row */ + +.onboarding-role-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background-color: var(--card-color); + border-radius: 8px; +} + +.onboarding-role-label { + font-size: 0.82rem; + font-weight: 600; + color: var(--text-secondary); +} + +.onboarding-role-badge { + font-size: 0.82rem; + font-weight: 700; + color: var(--accent-color); + background-color: rgba(137, 180, 250, 0.12); + padding: 3px 10px; + border-radius: 20px; + border: 1px solid rgba(137, 180, 250, 0.3); +} + +/* Step header */ + +.onboarding-step-header { + padding-bottom: 4px; +} + +.onboarding-step-icon { + font-size: 2rem; + display: block; + margin-bottom: 8px; +} + +.onboarding-step-title { + font-size: 1.35rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; +} + +.onboarding-step-desc { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.6; +} + +/* Footer / navigation */ + +.onboarding-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 4px; +} + +.onboarding-btn-next { + /* inherits from .btn.btn-primary */ +} + +.onboarding-btn-back { + /* inherits from .btn.btn-close-modal */ +} + +/* Domain configuration (Step 2) */ + +.onboarding-domain-group { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 0; + border-bottom: 1px solid var(--border-color); +} + +.onboarding-domain-group:last-child { + border-bottom: none; +} + +.onboarding-domain-group--email { + /* email-specific variant */ +} + +.onboarding-domain-label { + font-size: 0.88rem; + font-weight: 600; + color: var(--text-primary); +} + +.onboarding-domain-label--sub { + font-size: 0.78rem; + font-weight: 400; + color: var(--text-dim); +} + +.onboarding-domain-input, +.domain-field-input { + width: 100%; + padding: 9px 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-btn); + background-color: var(--card-color); + color: var(--text-primary); + font-size: 0.88rem; + font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif; + transition: border-color 0.15s; +} + +.onboarding-domain-input:focus, +.domain-field-input:focus { + outline: none; + border-color: var(--accent-color); +} + +.onboarding-hint { + font-size: 0.78rem; + color: var(--text-dim); + line-height: 1.5; +} + +.onboarding-hint--inline { + display: inline; +} + +/* Port forwarding (Step 3) */ + +.onboarding-port-note { + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.6; +} + +.onboarding-port-ip { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background-color: var(--card-color); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.onboarding-port-ip-label { + font-size: 0.82rem; + font-weight: 600; + color: var(--text-secondary); + white-space: nowrap; +} + +.onboarding-port-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.onboarding-port-section-title { + font-size: 0.88rem; + font-weight: 700; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.onboarding-port-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.onboarding-port-table thead th { + text-align: left; + font-size: 0.78rem; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 6px 8px; + border-bottom: 1px solid var(--border-color); +} + +.onboarding-port-table td { + padding: 8px 8px; + border-bottom: 1px solid rgba(69, 71, 90, 0.4); + color: var(--text-secondary); + vertical-align: middle; +} + +.onboarding-port-table tr:last-child td { + border-bottom: none; +} + +.onboarding-port-totals { + padding: 10px 14px; + background-color: var(--card-color); + border-radius: 8px; + border: 1px solid var(--border-color); + font-size: 0.85rem; + color: var(--text-secondary); +} + +.onboarding-port-warn { + padding: 10px 14px; + background-color: rgba(229, 165, 10, 0.1); + border: 1px solid rgba(229, 165, 10, 0.35); + border-radius: 8px; + font-size: 0.85rem; + color: var(--yellow); + line-height: 1.5; +} + +.onboarding-port-details { + font-size: 0.85rem; +} + +.onboarding-port-details-summary { + cursor: pointer; + font-size: 0.82rem; + font-weight: 600; + color: var(--accent-color); + list-style: none; + user-select: none; +} + +.onboarding-port-details-summary::-webkit-details-marker { + display: none; +} + +.onboarding-port-details-summary::before { + content: 'ā–¶ '; + font-size: 0.65em; +} + +.onboarding-port-details[open] .onboarding-port-details-summary::before { + content: 'ā–¼ '; +} + +/* Credentials (Step 4) */ + +.onboarding-creds-notice { + padding: 12px 16px; + background-color: rgba(137, 180, 250, 0.08); + border: 1px solid rgba(137, 180, 250, 0.25); + border-radius: 8px; + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.55; +} + +.onboarding-creds-category { + display: flex; + flex-direction: column; + gap: 8px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color); +} + +.onboarding-creds-category:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.onboarding-creds-category-title { + font-size: 0.78rem; + font-weight: 700; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 2px; +} + +.onboarding-creds-service { + background-color: var(--card-color); + border-radius: 8px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.onboarding-creds-service-name { + font-size: 0.88rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 2px; +} + +.onboarding-cred-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.82rem; +} + +.onboarding-cred-label { + color: var(--text-dim); + font-size: 0.78rem; + min-width: 80px; + flex-shrink: 0; +} + +.onboarding-cred-value { + color: var(--text-primary); + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.82rem; + word-break: break-all; + flex: 1; +} + +.onboarding-cred-secret { + display: flex; + align-items: center; + gap: 6px; + flex: 1; +} + +.onboarding-cred-hidden { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.82rem; + color: var(--text-dim); + letter-spacing: 0.1em; +} + +.onboarding-cred-real { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.82rem; + color: var(--text-primary); + word-break: break-all; + display: none; +} + +.onboarding-cred-reveal-btn { + font-size: 0.72rem; + padding: 2px 8px; + border-radius: 4px; + background-color: rgba(137, 180, 250, 0.1); + border: 1px solid rgba(137, 180, 250, 0.25); + color: var(--accent-color); + cursor: pointer; + white-space: nowrap; + transition: background-color 0.15s; +} + +.onboarding-cred-reveal-btn:hover { + background-color: rgba(137, 180, 250, 0.2); +} + +/* Completion checklist (Step 5) */ + +.onboarding-checklist { + list-style: none; + display: flex; + flex-direction: column; + gap: 10px; +} + +.onboarding-checklist li { + font-size: 0.92rem; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 10px; +} + +/* Status & feedback */ + +.onboarding-loading { + font-size: 0.88rem; + color: var(--text-dim); + text-align: center; + padding: 16px 0; + font-style: italic; +} + +.onboarding-error { + font-size: 0.85rem; + color: var(--red); + padding: 10px 14px; + background-color: rgba(224, 27, 36, 0.1); + border: 1px solid rgba(224, 27, 36, 0.3); + border-radius: 8px; + line-height: 1.5; +} + +.onboarding-save-status { + font-size: 0.85rem; + min-height: 1.2em; + transition: color 0.2s; +} + +.onboarding-save-status--ok { + color: var(--green); +} + +.onboarding-save-status--error { + color: var(--red); +} + +.onboarding-save-status--info { + color: var(--text-secondary); +} + /* ── Reboot overlay ─────────────────────────────────────────────── */ .reboot-overlay { -- 2.53.0 From 149e35c1c4e1b851718eb303f57b1fc96c2af225 Mon Sep 17 00:00:00 2001 From: Sovran_Systems <99053422+naturallaw777@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:58:42 -0500 Subject: [PATCH 301/857] =?UTF-8?q?Remove=20Credentials=20step=20from=20on?= =?UTF-8?q?boarding=20wizard=20(5=20=E2=86=92=204=20steps)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../templates/onboarding.html | 30 ++----------------- 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/app/sovran_systemsos_web/templates/onboarding.html b/app/sovran_systemsos_web/templates/onboarding.html index c11a5d3..cd701a8 100644 --- a/app/sovran_systemsos_web/templates/onboarding.html +++ b/app/sovran_systemsos_web/templates/onboarding.html @@ -34,8 +34,6 @@ 3 4 - - 5 @@ -117,29 +115,8 @@ - + - - - @@ -108,29 +106,8 @@ - + - - - '; driveSelector += ''; } else { driveSelector = [ @@ -331,7 +334,15 @@ function renderBackupReady(drives) { '
        ', '
        \ud83d\udcbe
        ', '

        Manual Backup

        ', - '

        Back up your Sovran_SystemsOS data to an external USB hard drive.

        ', + + '
        ', + '

        ', + 'Your Sovran Pro already backs up your data automatically to its internal second drive. ', + 'This manual backup lets you create an additional copy on an external USB drive \u2014 ', + 'storing your data in a third location, outside the computer, for maximum protection ', + 'against hardware failure or physical damage.', + '

        ', + '
        ', '
        ', '
        Requirements
        ', @@ -353,7 +364,7 @@ function renderBackupReady(drives) { '
      ', '', - '
      ', + '
      ', '
      ', '\u23f1\ufe0f', 'Time Estimate', @@ -367,9 +378,13 @@ function renderBackupReady(drives) { if (drives.length > 0) { document.getElementById("btn-start-backup").addEventListener("click", startBackup); + document.getElementById("btn-backup-refresh").addEventListener("click", function() { + $supportBody.innerHTML = '

      Scanning for external drives\u2026

      '; + detectDrivesAndRender(); + }); } else { document.getElementById("btn-backup-refresh").addEventListener("click", function() { - $supportBody.innerHTML = '

      Detecting external drives\u2026

      '; + $supportBody.innerHTML = '

      Scanning for external drives\u2026

      '; detectDrivesAndRender(); }); } -- 2.53.0 From c28de5def9889d68fb87e32fb5298c5ba82a5c1c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 03:48:27 +0000 Subject: [PATCH 308/857] Initial plan -- 2.53.0 From 58966646c25c2ca333bc79d459e2ba5b594fdf7f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 03:55:20 +0000 Subject: [PATCH 309/857] =?UTF-8?q?feat:=20role-aware=20hub=20=E2=80=94=20?= =?UTF-8?q?service=20filtering,=20onboarding,=20upgrade=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/af4088da-8845-4f7f-914f-259fd33884ed Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 79 ++++++++++++++++++- .../static/css/layout.css | 56 +++++++++++++ app/sovran_systemsos_web/static/js/events.js | 38 +++++++++ app/sovran_systemsos_web/static/js/state.js | 9 +++ app/sovran_systemsos_web/static/js/tiles.js | 14 ++++ app/sovran_systemsos_web/static/onboarding.js | 42 ++++++++-- app/sovran_systemsos_web/templates/index.html | 34 ++++++++ modules/core/sovran-hub.nix | 30 ++++--- 8 files changed, 283 insertions(+), 19 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 8348b17..79f61f4 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -259,6 +259,20 @@ ROLE_LABELS = { "node": "Bitcoin Node", } +# Categories shown per role (None = show all) +ROLE_CATEGORIES: dict[str, set[str] | None] = { + "server_plus_desktop": None, + "desktop": {"infrastructure", "support", "feature-manager"}, + "node": {"infrastructure", "bitcoin-base", "bitcoin-apps", "support", "feature-manager"}, +} + +# Features shown per role (None = show all) +ROLE_FEATURES: dict[str, set[str] | None] = { + "server_plus_desktop": None, + "desktop": {"rdp"}, + "node": {"bip110", "bitcoin-core", "mempool"}, +} + SERVICE_DESCRIPTIONS: dict[str, str] = { "bitcoind.service": ( "The foundation of your financial sovereignty. Your node independently verifies " @@ -1322,14 +1336,69 @@ async def api_onboarding_complete(): async def api_config(): cfg = load_config() role = cfg.get("role", "server_plus_desktop") + allowed_cats = ROLE_CATEGORIES.get(role) + cats = CATEGORY_ORDER if allowed_cats is None else [ + c for c in CATEGORY_ORDER if c[0] in allowed_cats + ] return { "role": role, "role_label": ROLE_LABELS.get(role, role), - "category_order": CATEGORY_ORDER, + "category_order": cats, "feature_manager": True, } +ROLE_STATE_NIX = """\ +# THIS FILE IS AUTO-GENERATED. DO NOT EDIT. +{ config, lib, ... }: +{ + sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault true; + sovran_systemsOS.roles.desktop = lib.mkDefault false; + sovran_systemsOS.roles.node = lib.mkDefault false; +} +""" + + +@app.post("/api/role/upgrade-to-server") +async def api_upgrade_to_server(): + """Upgrade from Node role to Server+Desktop role by writing role-state.nix and rebuilding.""" + cfg = load_config() + if cfg.get("role", "server_plus_desktop") != "node": + raise HTTPException(status_code=400, detail="Upgrade is only available for the Node role.") + + try: + with open("/etc/nixos/role-state.nix", "w") as f: + f.write(ROLE_STATE_NIX) + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Failed to write role-state.nix: {exc}") + + # Reset onboarding so the wizard runs for the newly unlocked services + try: + os.remove(ONBOARDING_FLAG) + except FileNotFoundError: + pass + + # Clear stale rebuild log + try: + open(REBUILD_LOG, "w").close() + except OSError: + pass + + await asyncio.create_subprocess_exec( + "systemctl", "reset-failed", REBUILD_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + proc = await asyncio.create_subprocess_exec( + "systemctl", "start", "--no-block", REBUILD_UNIT, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + + return {"ok": True, "status": "rebuilding"} + + @app.get("/api/services") async def api_services(): cfg = load_config() @@ -2082,8 +2151,14 @@ async def api_features(): ssl_email_path = os.path.join(DOMAINS_DIR, "sslemail") ssl_email_configured = os.path.exists(ssl_email_path) + role = load_config().get("role", "server_plus_desktop") + allowed_features = ROLE_FEATURES.get(role) + registry = FEATURE_REGISTRY if allowed_features is None else [ + f for f in FEATURE_REGISTRY if f["id"] in allowed_features + ] + features = [] - for feat in FEATURE_REGISTRY: + for feat in registry: feat_id = feat["id"] # Determine enabled state: diff --git a/app/sovran_systemsos_web/static/css/layout.css b/app/sovran_systemsos_web/static/css/layout.css index 090d544..9aa1e74 100644 --- a/app/sovran_systemsos_web/static/css/layout.css +++ b/app/sovran_systemsos_web/static/css/layout.css @@ -82,6 +82,62 @@ margin: 16px 0; } +/* ── Sidebar: Upgrade button (Node role) ────────────────────────── */ + +.sidebar-upgrade-btn { + border-color: var(--accent-color); + background-color: rgba(137, 180, 250, 0.06); + margin-top: 8px; +} + +.sidebar-upgrade-btn:hover { + background-color: rgba(137, 180, 250, 0.14); + border-color: var(--accent-color); +} + +.sidebar-upgrade-btn .sidebar-support-hint { + color: var(--accent-color); +} + +/* ── Upgrade modal ──────────────────────────────────────────────── */ + +.upgrade-dialog { + max-width: 480px; +} + +.upgrade-info-box { + background-color: var(--card-color); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 14px 18px; + margin-bottom: 14px; +} + +.upgrade-info-title { + font-size: 0.88rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; +} + +.upgrade-info-list { + padding-left: 20px; + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.7; + margin: 0; +} + +.upgrade-info-list a { + color: var(--accent-color); +} + +.upgrade-rebuild-note { + font-style: italic; + color: var(--text-dim); + font-size: 0.82rem; +} + /* ── Tiles area ─────────────────────────────────────────────────── */ #tiles-area { diff --git a/app/sovran_systemsos_web/static/js/events.js b/app/sovran_systemsos_web/static/js/events.js index 74ba7a0..2f4733b 100644 --- a/app/sovran_systemsos_web/static/js/events.js +++ b/app/sovran_systemsos_web/static/js/events.js @@ -33,6 +33,43 @@ if ($modal) $modal.addEventListener("click", function(e) { if (e.target === $mod if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); }); if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); }); +// Upgrade modal +if ($upgradeCloseBtn) $upgradeCloseBtn.addEventListener("click", closeUpgradeModal); +if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal); +if ($upgradeModal) $upgradeModal.addEventListener("click", function(e) { if (e.target === $upgradeModal) closeUpgradeModal(); }); + +// ── Upgrade modal functions ─────────────────────────────────────── + +function openUpgradeModal() { + if ($upgradeModal) $upgradeModal.classList.add("open"); +} + +function closeUpgradeModal() { + if ($upgradeModal) $upgradeModal.classList.remove("open"); +} + +async function doUpgradeToServer() { + var confirmBtn = $upgradeConfirmBtn; + if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = "Upgrading…"; } + closeUpgradeModal(); + + // Reuse the rebuild modal to show progress + _rebuildFeatureName = "Server + Desktop"; + _rebuildIsEnabling = true; + openRebuildModal(); + + try { + await apiFetch("/api/role/upgrade-to-server", { method: "POST" }); + } catch (err) { + if ($rebuildStatus) $rebuildStatus.textContent = "āœ— Upgrade failed: " + err.message; + if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning"); + if ($rebuildClose) $rebuildClose.disabled = false; + if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = "Yes, Upgrade"; } + } +} + +if ($upgradeConfirmBtn) $upgradeConfirmBtn.addEventListener("click", doUpgradeToServer); + // ── Init ────────────────────────────────────────────────────────── async function init() { @@ -49,6 +86,7 @@ async function init() { try { var cfg = await apiFetch("/api/config"); + _currentRole = cfg.role || "server_plus_desktop"; if (cfg.category_order) { for (var i = 0; i < cfg.category_order.length; i++) { _categoryLabels[cfg.category_order[i][0]] = cfg.category_order[i][1]; diff --git a/app/sovran_systemsos_web/static/js/state.js b/app/sovran_systemsos_web/static/js/state.js index 4da0501..ed4d588 100644 --- a/app/sovran_systemsos_web/static/js/state.js +++ b/app/sovran_systemsos_web/static/js/state.js @@ -15,6 +15,9 @@ let _supportStatus = null; // last fetched /api/support/status payload let _walletUnlockTimerInt = null; let _cachedExternalIp = null; +// Current role (set during init from /api/config) +let _currentRole = "server_plus_desktop"; + // Feature Manager state let _featuresData = null; let _rebuildLog = ""; @@ -89,5 +92,11 @@ const $portReqModal = document.getElementById("port-requirements-modal"); const $portReqBody = document.getElementById("port-req-body"); const $portReqClose = document.getElementById("port-req-close-btn"); +// Upgrade modal (Node → Server+Desktop) +const $upgradeModal = document.getElementById("upgrade-modal"); +const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn"); +const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn"); +const $upgradeCloseBtn = document.getElementById("upgrade-close-btn"); + // System status banner // (removed — health is now shown per-tile via the composite health field) \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/js/tiles.js b/app/sovran_systemsos_web/static/js/tiles.js index 1bf7dad..1574182 100644 --- a/app/sovran_systemsos_web/static/js/tiles.js +++ b/app/sovran_systemsos_web/static/js/tiles.js @@ -71,6 +71,20 @@ function renderSidebarSupport(supportServices) { backupBtn.addEventListener("click", function() { openBackupModal(); }); $sidebarSupport.appendChild(backupBtn); + // ── Upgrade button (Node role only) + if (_currentRole === "node") { + var upgradeBtn = document.createElement("button"); + upgradeBtn.className = "sidebar-support-btn sidebar-upgrade-btn"; + upgradeBtn.innerHTML = + 'šŸš€' + + '' + + 'Upgrade to Full Server' + + 'Unlock all services' + + ''; + upgradeBtn.addEventListener("click", function() { openUpgradeModal(); }); + $sidebarSupport.appendChild(upgradeBtn); + } + var hr = document.createElement("hr"); hr.className = "sidebar-divider"; $sidebarSupport.appendChild(hr); diff --git a/app/sovran_systemsos_web/static/onboarding.js b/app/sovran_systemsos_web/static/onboarding.js index 3b04de9..55a7499 100644 --- a/app/sovran_systemsos_web/static/onboarding.js +++ b/app/sovran_systemsos_web/static/onboarding.js @@ -6,6 +6,16 @@ const TOTAL_STEPS = 4; +// Steps to skip per role (steps 2 and 3 involve domain/port setup) +const ROLE_SKIP_STEPS = { + "desktop": [2, 3], + "node": [2, 3], +}; + +// ── Role state (loaded at init) ─────────────────────────────────── + +var _onboardingRole = "server_plus_desktop"; + // Domains that may need configuration, with service unit mapping for enabled check const DOMAIN_DEFS = [ { name: "matrix", label: "Matrix (Synapse)", unit: "matrix-synapse.service", needsDdns: true }, @@ -83,6 +93,22 @@ function showStep(step) { if (step === 3) loadStep3(); } +// Return the next step number, skipping over role-excluded steps +function nextStep(current) { + var skip = ROLE_SKIP_STEPS[_onboardingRole] || []; + var next = current + 1; + while (next < TOTAL_STEPS && skip.indexOf(next) !== -1) next++; + return next; +} + +// Return the previous step number, skipping over role-excluded steps +function prevStep(current) { + var skip = ROLE_SKIP_STEPS[_onboardingRole] || []; + var prev = current - 1; + while (prev > 1 && skip.indexOf(prev) !== -1) prev--; + return prev; +} + // ── Step 1: Welcome ─────────────────────────────────────────────── async function loadStep1() { @@ -319,9 +345,9 @@ async function completeOnboarding() { // ── Event wiring ────────────────────────────────────────────────── function wireNavButtons() { - // Step 1 → 2 + // Step 1 → next (may skip 2+3 for desktop/node) var s1next = document.getElementById("step-1-next"); - if (s1next) s1next.addEventListener("click", function() { showStep(2); }); + if (s1next) s1next.addEventListener("click", function() { showStep(nextStep(1)); }); // Step 2 → 3 (save first) var s2next = document.getElementById("step-2-next"); @@ -331,12 +357,12 @@ function wireNavButtons() { await saveStep2(); s2next.disabled = false; s2next.textContent = "Save & Continue →"; - showStep(3); + showStep(nextStep(2)); }); // Step 3 → 4 (Complete) var s3next = document.getElementById("step-3-next"); - if (s3next) s3next.addEventListener("click", function() { showStep(4); }); + if (s3next) s3next.addEventListener("click", function() { showStep(nextStep(3)); }); // Step 4: finish var s4finish = document.getElementById("step-4-finish"); @@ -345,7 +371,7 @@ function wireNavButtons() { // Back buttons document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) { var prev = parseInt(btn.dataset.prev, 10); - btn.addEventListener("click", function() { showStep(prev); }); + btn.addEventListener("click", function() { showStep(prevStep(prev + 1)); }); }); } @@ -361,6 +387,12 @@ document.addEventListener("DOMContentLoaded", async function() { } } catch (_) {} + // Load role so step-skipping is applied before wiring nav buttons + try { + var cfg = await apiFetch("/api/config"); + if (cfg.role) _onboardingRole = cfg.role; + } catch (_) {} + wireNavButtons(); updateProgress(1); loadStep1(); diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index f38f8d5..9f9b37c 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -172,6 +172,40 @@
      + + +
      diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 0790928..e2f5b5b 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -4,16 +4,22 @@ let cfg = config.sovran_systemsOS; monitoredServices = - # ── Infrastructure (always present) ──────────────────────── + # ── Infrastructure — System Passwords (always present) ───── [ - { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; category = "infrastructure"; credentials = []; } - { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; category = "infrastructure"; credentials = []; } { name = "System Passwords"; unit = "root-password-setup.service"; type = "system"; icon = "passwords"; enabled = true; category = "infrastructure"; credentials = [ { label = "Free Account — Username"; value = "free"; } { label = "Free Account — Password"; file = "/var/lib/secrets/free-password"; } { label = "Root Password"; file = "/var/lib/secrets/root-password"; } { label = "SSH Local Access"; value = "ssh root@localhost / Passphrase: gosovransystems"; } ]; } + ] + # ── Infrastructure — Caddy + Tor (NOT desktop-only) ──────── + ++ lib.optionals (!cfg.roles.desktop) [ + { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; category = "infrastructure"; credentials = []; } + { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; category = "infrastructure"; credentials = []; } + ] + # ── Infrastructure — Remote Desktop (roles with a desktop) ─ + ++ lib.optionals (!cfg.roles.node) [ { name = "Remote Desktop"; unit = "gnome-remote-desktop.service"; type = "system"; icon = "rdp"; enabled = cfg.features.rdp; category = "infrastructure"; credentials = [ { label = "Username"; file = "/var/lib/gnome-remote-desktop/rdp-username"; } { label = "Password"; file = "/var/lib/gnome-remote-desktop/rdp-password"; } @@ -22,7 +28,7 @@ let ]; } ] # ── Bitcoin Base (node implementations) ──────────────────── - ++ [ + ++ lib.optionals cfg.services.bitcoin [ { name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; credentials = [ { label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; } ]; } @@ -34,7 +40,7 @@ let ]; } ] # ── Bitcoin Apps (services on top of the node) ───────────── - ++ [ + ++ lib.optionals cfg.services.bitcoin [ { name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ { label = "Tor Address"; file = "/var/lib/tor/onion/electrs/hostname"; prefix = "http://"; } { label = "Port"; value = "50001"; } @@ -58,8 +64,8 @@ let { label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; } ]; } ] - # ── Communication ────────────────────────────────────────── - ++ [ + # ── Communication (server+desktop only) ──────────────────── + ++ lib.optionals cfg.roles.server_plus_desktop [ { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; category = "communication"; credentials = [ { label = "Homeserver URL"; file = "/var/lib/secrets/matrix-homeserver-url"; } { label = "Admin Username"; file = "/var/lib/secrets/matrix-admin-username"; } @@ -69,8 +75,8 @@ let ]; } { name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "element-calling"; enabled = cfg.features.element-calling; category = "communication"; credentials = []; } ] - # ── Self-Hosted Apps ─────────────────────────────────────── - ++ [ + # ── Self-Hosted Apps (server+desktop only) ───────────────── + ++ lib.optionals cfg.roles.server_plus_desktop [ { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; enabled = cfg.services.vaultwarden; category = "apps"; credentials = [ { label = "URL"; file = "/var/lib/domains/vaultwarden"; prefix = "https://"; } { label = "Admin Panel"; file = "/var/lib/domains/vaultwarden"; prefix = "https://"; suffix = "/admin"; } @@ -83,11 +89,11 @@ let { label = "Credentials"; file = "/var/lib/secrets/wordpress-admin"; multiline = true; } ]; } ] - # ── Nostr / Relay ────────────────────────────────────────── - ++ [ + # ── Nostr / Relay (server+desktop only) ──────────────────── + ++ lib.optionals cfg.roles.server_plus_desktop [ { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; enabled = cfg.features.haven; category = "nostr"; credentials = []; } ] - # ── Support ──────────────────────────────────────────────── + # ── Support (always present) ──────────────────────────────── ++ [ { name = "Tech Support"; unit = "sovran-tech-support"; type = "support"; icon = "support"; enabled = true; category = "support"; credentials = []; } ]; -- 2.53.0 From 2b89969a96090f5fd90a6bf06269fd872914cdf1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 04:02:35 +0000 Subject: [PATCH 310/857] Initial plan -- 2.53.0 From af31c60be8730b2effe4f317244e52dbe2b2802c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 04:03:30 +0000 Subject: [PATCH 311/857] Add RDP to Bitcoin-only Node role Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/17c88629-43c4-438a-9640-7abe3609c82d Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 2 +- modules/core/sovran-hub.nix | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 79f61f4..a6e113d 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -270,7 +270,7 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = { ROLE_FEATURES: dict[str, set[str] | None] = { "server_plus_desktop": None, "desktop": {"rdp"}, - "node": {"bip110", "bitcoin-core", "mempool"}, + "node": {"rdp", "bip110", "bitcoin-core", "mempool"}, } SERVICE_DESCRIPTIONS: dict[str, str] = { diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index e2f5b5b..fa83a94 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -18,8 +18,8 @@ let { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; category = "infrastructure"; credentials = []; } { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; category = "infrastructure"; credentials = []; } ] - # ── Infrastructure — Remote Desktop (roles with a desktop) ─ - ++ lib.optionals (!cfg.roles.node) [ + # ── Infrastructure — Remote Desktop (all roles) ───────────── + ++ [ { name = "Remote Desktop"; unit = "gnome-remote-desktop.service"; type = "system"; icon = "rdp"; enabled = cfg.features.rdp; category = "infrastructure"; credentials = [ { label = "Username"; file = "/var/lib/gnome-remote-desktop/rdp-username"; } { label = "Password"; file = "/var/lib/gnome-remote-desktop/rdp-password"; } -- 2.53.0 From 4bda2f1aae961d5c991ad18cfd11b8473b271e8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 04:07:09 +0000 Subject: [PATCH 312/857] Initial plan -- 2.53.0 From b8956ebf7226728c61321eb6e9a5142e9d0f6736 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 04:10:04 +0000 Subject: [PATCH 313/857] Move Update System button from header to sidebar Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/fb939db8-ba2c-4979-9b18-bebe2618d0b5 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/static/js/events.js | 2 +- app/sovran_systemsos_web/static/js/state.js | 5 ++-- app/sovran_systemsos_web/static/js/tiles.js | 29 +++++++++++++++++-- app/sovran_systemsos_web/templates/index.html | 4 --- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/app/sovran_systemsos_web/static/js/events.js b/app/sovran_systemsos_web/static/js/events.js index 2f4733b..8d5e9ea 100644 --- a/app/sovran_systemsos_web/static/js/events.js +++ b/app/sovran_systemsos_web/static/js/events.js @@ -2,7 +2,7 @@ // ── Event listeners ─────────────────────────────────────────────── -if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); +// if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); // moved to sidebar in tiles.js if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal); if ($btnReboot) $btnReboot.addEventListener("click", doReboot); if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); diff --git a/app/sovran_systemsos_web/static/js/state.js b/app/sovran_systemsos_web/static/js/state.js index ed4d588..896c372 100644 --- a/app/sovran_systemsos_web/static/js/state.js +++ b/app/sovran_systemsos_web/static/js/state.js @@ -34,8 +34,9 @@ let _rebuildIsEnabling = true; const $tilesArea = document.getElementById("tiles-area"); const $sidebarSupport = document.getElementById("sidebar-support"); const $sidebarFeatures = document.getElementById("sidebar-features"); -const $updateBtn = document.getElementById("btn-update"); -const $updateBadge = document.getElementById("update-badge"); +// No longer needed — Update System moved to sidebar +// const $updateBtn = document.getElementById("btn-update"); +// const $updateBadge = document.getElementById("update-badge"); const $internalIp = document.getElementById("ip-internal"); const $externalIp = document.getElementById("ip-external"); diff --git a/app/sovran_systemsos_web/static/js/tiles.js b/app/sovran_systemsos_web/static/js/tiles.js index 1574182..aac3e28 100644 --- a/app/sovran_systemsos_web/static/js/tiles.js +++ b/app/sovran_systemsos_web/static/js/tiles.js @@ -45,6 +45,20 @@ function buildTiles(services, categoryLabels) { function renderSidebarSupport(supportServices) { $sidebarSupport.innerHTML = ""; + + // ── Update System button (above Tech Help) + var sidebarUpdateBtn = document.createElement("button"); + sidebarUpdateBtn.className = "sidebar-support-btn"; + sidebarUpdateBtn.id = "sidebar-btn-update"; + sidebarUpdateBtn.innerHTML = + 'šŸ”„' + + '' + + 'Update System' + + 'Check for updates' + + ''; + sidebarUpdateBtn.addEventListener("click", function() { openUpdateModal(); }); + $sidebarSupport.appendChild(sidebarUpdateBtn); + for (var i = 0; i < supportServices.length; i++) { var svc = supportServices[i]; var btn = document.createElement("button"); @@ -170,7 +184,18 @@ async function checkUpdates() { try { var data = await apiFetch("/api/updates/check"); var hasUpdates = !!data.available; - if ($updateBadge) $updateBadge.classList.toggle("visible", hasUpdates); - if ($updateBtn) $updateBtn.classList.toggle("has-updates", hasUpdates); + var sidebarUpdateBtn = document.getElementById("sidebar-btn-update"); + var sidebarUpdateHint = document.getElementById("sidebar-update-hint"); + if (sidebarUpdateBtn) { + if (hasUpdates) { + sidebarUpdateBtn.style.borderColor = "#2ec27e"; + sidebarUpdateBtn.style.backgroundColor = "rgba(46, 194, 126, 0.08)"; + if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!"; + } else { + sidebarUpdateBtn.style.borderColor = ""; + sidebarUpdateBtn.style.backgroundColor = ""; + if (sidebarUpdateHint) sidebarUpdateHint.textContent = "System is up to date"; + } + } } catch (_) {} } diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index 9f9b37c..ea904cb 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -23,10 +23,6 @@ Sovran_SystemsOS Hub
      Loading… -
      -- 2.53.0 From 9672d30de407708d6b78874a3e727ac37ab48acb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 04:23:36 +0000 Subject: [PATCH 314/857] Initial plan -- 2.53.0 From 3e855af8d5acab0bd45319cc9272226ee0220dee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 04:25:28 +0000 Subject: [PATCH 315/857] Replace Update System emoji with custom update.svg icon Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ff2815b6-a5d1-4f84-bffe-5c24d40760cd Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- .../static/icons/update.svg | 236 ++++++++++++++++++ app/sovran_systemsos_web/static/js/tiles.js | 2 +- 2 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 app/sovran_systemsos_web/static/icons/update.svg diff --git a/app/sovran_systemsos_web/static/icons/update.svg b/app/sovran_systemsos_web/static/icons/update.svg new file mode 100644 index 0000000..db97054 --- /dev/null +++ b/app/sovran_systemsos_web/static/icons/update.svg @@ -0,0 +1,236 @@ + + + + + diff --git a/app/sovran_systemsos_web/static/js/tiles.js b/app/sovran_systemsos_web/static/js/tiles.js index aac3e28..9794990 100644 --- a/app/sovran_systemsos_web/static/js/tiles.js +++ b/app/sovran_systemsos_web/static/js/tiles.js @@ -51,7 +51,7 @@ function renderSidebarSupport(supportServices) { sidebarUpdateBtn.className = "sidebar-support-btn"; sidebarUpdateBtn.id = "sidebar-btn-update"; sidebarUpdateBtn.innerHTML = - 'šŸ”„' + + 'Update' + '' + 'Update System' + 'Check for updates' + -- 2.53.0 From f9ecdaec967b7e7afe39e645a7db85f35f00703c Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Sat, 4 Apr 2026 23:34:23 -0500 Subject: [PATCH 316/857] updated update icon --- app/icons/update.svg | 236 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 app/icons/update.svg diff --git a/app/icons/update.svg b/app/icons/update.svg new file mode 100644 index 0000000..db97054 --- /dev/null +++ b/app/icons/update.svg @@ -0,0 +1,236 @@ + + + + + -- 2.53.0 From a1d83e731a43134ef61374625b00398f5c8e65f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 04:39:42 +0000 Subject: [PATCH 317/857] Initial plan -- 2.53.0 From 64744d1d93c4cbd13cbed8f5469a3a64c32e7207 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 04:43:04 +0000 Subject: [PATCH 318/857] Make backup script role-aware and add manual-backup docs Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/a9c69b4d-1c8d-4ade-b444-33043e52fc63 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- .../scripts/sovran-hub-backup.sh | 83 +++++++++++++++-- docs/manual-backup.md | 93 +++++++++++++++++++ 2 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 docs/manual-backup.md diff --git a/app/sovran_systemsos_web/scripts/sovran-hub-backup.sh b/app/sovran_systemsos_web/scripts/sovran-hub-backup.sh index e4500ce..1a7ce8b 100755 --- a/app/sovran_systemsos_web/scripts/sovran-hub-backup.sh +++ b/app/sovran_systemsos_web/scripts/sovran-hub-backup.sh @@ -18,6 +18,8 @@ BACKUP_LOG="/var/log/sovran-hub-backup.log" BACKUP_STATUS="/var/log/sovran-hub-backup.status" MEDIA_ROOT="/run/media" MIN_FREE_GB=10 +HUB_CONFIG_JSON="/var/lib/sovran-hub/config.json" +ROLE_STATE_NIX="/etc/nixos/role-state.nix" # ── Internal drive labels/paths to NEVER use as backup targets ─── INTERNAL_LABELS=("BTCEcoandBackup" "sovran_systemsos") @@ -125,6 +127,40 @@ for d in flatten(data.get('blockdevices', [])): echo "$target" } +# ── Detect the configured system role ─────────────────────────── +# +# Priority: +# 1. Hub config JSON (/var/lib/sovran-hub/config.json) — "role" key +# 2. role-state.nix (/etc/nixos/role-state.nix) — grep for true flag +# 3. Default: server_plus_desktop + +detect_role() { + local role="server_plus_desktop" + + # 1. Try the Hub config JSON + if [[ -f "$HUB_CONFIG_JSON" ]] && command -v python3 &>/dev/null; then + local r + r=$(python3 -c \ + "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('role',''))" \ + "$HUB_CONFIG_JSON" 2>/dev/null || true) + if [[ -n "$r" ]]; then + echo "$r" + return + fi + fi + + # 2. Fall back to parsing role-state.nix + if [[ -f "$ROLE_STATE_NIX" ]]; then + if grep -q 'roles\.desktop = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then + role="desktop" + elif grep -q 'roles\.node = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then + role="node" + fi + fi + + echo "$role" +} + # ── Initialise log file ────────────────────────────────────────── : > "$BACKUP_LOG" @@ -133,6 +169,17 @@ set_status "RUNNING" log "=== Sovran_SystemsOS External Hub Backup ===" log "Starting backup process…" +# ── Detect system role ─────────────────────────────────────────── + +ROLE="$(detect_role)" +case "$ROLE" in + desktop) ROLE_LABEL="Desktop Only" ;; + node) ROLE_LABEL="Node (Bitcoin-only)" ;; + server_plus_desktop) ROLE_LABEL="Server + Desktop" ;; + *) ROLE_LABEL="$ROLE" ;; +esac +log "Detected role: $ROLE_LABEL" + # ── Detect target drive ────────────────────────────────────────── if [[ -n "${BACKUP_TARGET:-}" ]]; then @@ -190,16 +237,29 @@ log "" log "── Stage 2/4: Secrets ───────────────────────────────────────" mkdir -p "$BACKUP_DIR/secrets" -for SRC in /etc/nix-bitcoin-secrets /var/lib/domains; do - if [[ -e "$SRC" ]]; then - rsync -a --info=progress2 "$SRC" "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \ - log "WARNING: Could not copy $SRC — continuing." - else - log " (not found: $SRC — skipping)" - fi -done +if [[ "$ROLE" == "desktop" ]]; then + log "Skipping /etc/nix-bitcoin-secrets — not applicable for Desktop Only role." + # /var/lib/domains is still backed up if present (hub state) + for SRC in /var/lib/domains; do + if [[ -e "$SRC" ]]; then + rsync -a --info=progress2 "$SRC" "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \ + log "WARNING: Could not copy $SRC — continuing." + else + log " (not found: $SRC — skipping)" + fi + done +else + for SRC in /etc/nix-bitcoin-secrets /var/lib/domains; do + if [[ -e "$SRC" ]]; then + rsync -a --info=progress2 "$SRC" "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \ + log "WARNING: Could not copy $SRC — continuing." + else + log " (not found: $SRC — skipping)" + fi + done +fi -# Hub state files from /var/lib/secrets/ +# Hub state files from /var/lib/secrets/ (backed up for all roles) if [[ -d /var/lib/secrets ]]; then mkdir -p "$BACKUP_DIR/secrets/hub-state" rsync -a --info=progress2 /var/lib/secrets/ "$BACKUP_DIR/secrets/hub-state/" 2>&1 | tee -a "$BACKUP_LOG" || \ @@ -230,7 +290,9 @@ fi log "" log "── Stage 4/4: Wallet and node data (/var/lib/lnd) ──────────" -if [[ -d /var/lib/lnd ]]; then +if [[ "$ROLE" == "desktop" ]]; then + log "Skipping Stage 4 (LND wallet data) — not applicable for Desktop Only role." +elif [[ -d /var/lib/lnd ]]; then rsync -a --info=progress2 \ --exclude='logs/' \ /var/lib/lnd/ "$BACKUP_DIR/lnd/" 2>&1 | tee -a "$BACKUP_LOG" || \ @@ -248,6 +310,7 @@ log "Generating BACKUP_MANIFEST.txt …" echo "Sovran_SystemsOS Backup Manifest" echo "Generated: $(date)" echo "Hostname: $(hostname)" + echo "Role: $ROLE_LABEL" echo "Target: $TARGET" echo "" echo "Contents:" diff --git a/docs/manual-backup.md b/docs/manual-backup.md new file mode 100644 index 0000000..aa6268d --- /dev/null +++ b/docs/manual-backup.md @@ -0,0 +1,93 @@ +# Sovran Hub — Manual Backup + +The manual backup service copies critical system data from your Sovran Pro to an external USB drive, providing a third copy of your data (your Sovran Pro already maintains an automatic internal backup on its second drive). + +Backups are written to: + +``` +/Sovran_SystemsOS_Backup// +``` + +where `` is formatted as `YYYYMMDD_HHMMSS`. + +--- + +## Backup Stages + +The script always attempts all four stages, but skips stages that are irrelevant to the system's configured role (see [Per-Role Breakdown](#per-role-breakdown) below). + +| Stage | Directory | Contents | +|-------|-----------|----------| +| **1/4 — NixOS config** | `/etc/nixos/` | Full NixOS system configuration: `role-state.nix`, `custom.nix`, flake files, and any other config managed by the Hub | +| **2/4 — Secrets** | `/etc/nix-bitcoin-secrets`, `/var/lib/domains`, `/var/lib/secrets` | Bitcoin/LND secrets, domain configurations for all web services, and Hub state files | +| **3/4 — Home directory** | `/home/` | All user home directories (`.cache/` and Trash are excluded) | +| **4/4 — LND wallet data** | `/var/lib/lnd/` | Lightning Network node wallet and channel data (log files excluded) | + +--- + +## Per-Role Breakdown + +The script detects the system role at runtime by reading `/var/lib/sovran-hub/config.json` (falling back to `/etc/nixos/role-state.nix`) and adjusts its behaviour accordingly. + +### Server + Desktop (default) + +All services are enabled: Bitcoin, Matrix Synapse, Vaultwarden, WordPress, Nextcloud. + +| Stage | Status | Notes | +|-------|--------|-------| +| Stage 1 — NixOS config | āœ… Backed up | Full server configuration | +| Stage 2 — Secrets | āœ… Backed up | Bitcoin secrets, domain configs, and Hub state | +| Stage 3 — Home directory | āœ… Backed up | Desktop user data | +| Stage 4 — LND wallet | āœ… Backed up | Lightning wallet and channel data | + +This produces the largest backup. All four stages generate meaningful data. + +### Desktop Only + +All server services are disabled (`bitcoin = false`, `synapse = false`, `vaultwarden = false`, `wordpress = false`, `nextcloud = false`). Only GNOME desktop is active. + +| Stage | Status | Notes | +|-------|--------|-------| +| Stage 1 — NixOS config | āœ… Backed up | Simpler config (no server services) | +| Stage 2 — Secrets | āš ļø Partial | `/etc/nix-bitcoin-secrets` is **skipped** (not applicable for Desktop Only role). `/var/lib/domains` and `/var/lib/secrets` (Hub state) are still backed up if present | +| Stage 3 — Home directory | āœ… Backed up | **The most important data for this role** | +| Stage 4 — LND wallet | ā­ļø Skipped | Explicitly skipped — not applicable for Desktop Only role | + +This produces the smallest and fastest backup. Stages 1 and 3 are the primary sources of meaningful data. + +### Node (Bitcoin-only) + +Only the Bitcoin ecosystem is active: `bitcoind`, `electrs`, `lnd`, `rtl`, `btcpay`, `mempool`, and `bip110`. All other server services are disabled. + +| Stage | Status | Notes | +|-------|--------|-------| +| Stage 1 — NixOS config | āœ… Backed up | Node-specific configuration | +| Stage 2 — Secrets | āœ… Backed up | Bitcoin secrets and Hub state. `/var/lib/domains` may be minimal (BTCPay runs but is not exposed via Caddy) | +| Stage 3 — Home directory | āœ… Backed up | User data | +| Stage 4 — LND wallet | āœ… Backed up | **Critical** — Lightning wallet and channel data | + +All four stages run, matching Server + Desktop behaviour. The `/var/lib/domains` directory may be sparsely populated since non-Bitcoin web services are not configured. + +--- + +## Backup Manifest + +After all stages complete, the script writes a `BACKUP_MANIFEST.txt` file inside the timestamped backup directory. This file records the date, hostname, detected role, target drive, and a directory listing of everything that was backed up. + +--- + +## Running the Backup + +The backup is triggered from the Sovran Hub web UI. You can also run it directly: + +```bash +# Auto-detect the first external USB drive +sudo bash /path/to/sovran-hub-backup.sh + +# Specify a target drive explicitly +sudo BACKUP_TARGET=/run/media// bash /path/to/sovran-hub-backup.sh +``` + +The script requires at least **10 GB** of free space on the target drive and will refuse to write to internal system drives. + +Logs are written to `/var/log/sovran-hub-backup.log` and the current status (`RUNNING`, `SUCCESS`, or `FAILED`) is tracked in `/var/log/sovran-hub-backup.status`. -- 2.53.0 From 53c2371c450945ac1dd43f7066b49b5b46f0e08f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 04:53:41 +0000 Subject: [PATCH 319/857] Initial plan -- 2.53.0 From e5d3b9236c40caa78cc932299e799ff4ef3c0e7f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 04:55:25 +0000 Subject: [PATCH 320/857] feat: add btcpay-web feature toggle for node role Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3881717f-97fc-4b8a-8f01-794a0699e7b3 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index a6e113d..c036f1a 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -192,6 +192,21 @@ FEATURE_REGISTRY = [ "conflicts_with": ["bip110"], "port_requirements": [], }, + { + "id": "btcpay-web", + "name": "BTCPay Server Web Access", + "description": "Expose BTCPay Server to the internet via your domain. When disabled, BTCPay Server still runs locally but is not accessible from the web.", + "category": "bitcoin", + "needs_domain": True, + "domain_name": "btcpayserver", + "needs_ddns": True, + "extra_fields": [], + "conflicts_with": [], + "port_requirements": [ + {"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"}, + {"port": "443", "protocol": "TCP", "description": "HTTPS"}, + ], + }, ] # Map feature IDs to their systemd units in config.json @@ -202,6 +217,7 @@ FEATURE_SERVICE_MAP = { "mempool": "mempool.service", "bip110": None, "bitcoin-core": None, + "btcpay-web": "btcpayserver.service", } # Port requirements for service tiles (keyed by unit name or icon) @@ -270,7 +286,7 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = { ROLE_FEATURES: dict[str, set[str] | None] = { "server_plus_desktop": None, "desktop": {"rdp"}, - "node": {"rdp", "bip110", "bitcoin-core", "mempool"}, + "node": {"rdp", "bip110", "bitcoin-core", "mempool", "btcpay-web"}, } SERVICE_DESCRIPTIONS: dict[str, str] = { @@ -932,6 +948,11 @@ def _read_hub_overrides() -> tuple[dict, str | None]: section, ): features[m.group(1)] = m.group(2) == "true" + for m in re.finditer( + r'sovran_systemsOS\.web\.btcpayserver\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;', + section, + ): + features["btcpay-web"] = m.group(1) == "true" m2 = re.search( r'sovran_systemsOS\.nostr_npub\s*=\s*(?:lib\.mkForce\s+)?"([^"]*)"', section, @@ -948,7 +969,10 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None) -> None: lines = [] for feat_id, enabled in features.items(): val = "true" if enabled else "false" - lines.append(f" sovran_systemsOS.features.{feat_id} = lib.mkForce {val};") + if feat_id == "btcpay-web": + lines.append(f" sovran_systemsOS.web.btcpayserver = lib.mkForce {val};") + else: + lines.append(f" sovran_systemsOS.features.{feat_id} = lib.mkForce {val};") if nostr_npub: lines.append(f' sovran_systemsOS.nostr_npub = lib.mkForce "{nostr_npub}";') hub_block = ( @@ -990,6 +1014,8 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None) -> None: def _is_feature_enabled_in_config(feature_id: str) -> bool | None: """Check if a feature's service appears as enabled in the running config.json. Returns True/False if found, None if the feature has no mapped service.""" + if feature_id == "btcpay-web": + return False # Default off in Node role; only on via explicit hub toggle unit = FEATURE_SERVICE_MAP.get(feature_id) if unit is None: return None # bip110, bitcoin-core — can't determine from config -- 2.53.0 From 4144198e4b97cab0f02d1ccd6e58174abac6a121 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Sat, 4 Apr 2026 23:58:53 -0500 Subject: [PATCH 321/857] changed to static icon --- app/sovran_systemsos_web/static/css/support.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/sovran_systemsos_web/static/css/support.css b/app/sovran_systemsos_web/static/css/support.css index 3006a59..eaf3376 100644 --- a/app/sovran_systemsos_web/static/css/support.css +++ b/app/sovran_systemsos_web/static/css/support.css @@ -10,7 +10,7 @@ } .support-active-icon { - animation: pulse-badge 2s ease-in-out infinite; + animation: none; } .support-heading { -- 2.53.0 From 265f34b8aa4b26a9f27094815c71a57a3c21dfe2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 05:06:55 +0000 Subject: [PATCH 322/857] Initial plan -- 2.53.0 From 9664c595232b7257a0853b9f04893337ea09a9a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 05:07:55 +0000 Subject: [PATCH 323/857] feat: enforce 128 GB minimum, skip data disk for Desktop Only role Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/2be6c138-feda-4c5d-9bd8-0e5f2f6416bc Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/installer.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/iso/installer.py b/iso/installer.py index 89d2f2e..ccb09a9 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -501,8 +501,17 @@ class InstallerWindow(Adw.ApplicationWindow): self.boot_disk, self.boot_size = disks[0] self.data_disk, self.data_size = None, None + BYTES_128GB = 128 * 1024 ** 3 + if self.role == "Desktop Only" and self.boot_size < BYTES_128GB: + self.show_error( + f"Boot disk /dev/{self.boot_disk} is only " + f"{human_size(self.boot_size)}. " + f"The Desktop Only role requires at least 128 GB." + ) + return + BYTES_2TB = 2 * 1024 ** 4 - if len(disks) >= 2: + if self.role != "Desktop Only" and len(disks) >= 2: d, s = disks[-1] if s >= BYTES_2TB: self.data_disk, self.data_size = d, s @@ -528,7 +537,7 @@ class InstallerWindow(Adw.ApplicationWindow): data_row.set_subtitle(f"/dev/{self.data_disk} — {human_size(self.data_size)}") data_row.add_prefix(symbolic_icon("drive-harddisk-symbolic")) disk_group.add(data_row) - else: + elif self.role != "Desktop Only": no_row = Adw.ActionRow() no_row.set_title("Data Disk") no_row.set_subtitle("None detected (requires 2 TB or larger)") -- 2.53.0 From b6046e63c56c920f27882b7e3d550a239dcf149d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 05:17:23 +0000 Subject: [PATCH 324/857] Initial plan -- 2.53.0 From 4fd8bd753493fea4fd8933998559b21d2c02932e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 05:21:18 +0000 Subject: [PATCH 325/857] Fix disko mode, 2 TB threshold, add interactive disk selection, fix data_path scoping Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/a0f15fe6-f9a7-4f43-9f9d-5892b0f3aba4 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/installer.py | 236 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 204 insertions(+), 32 deletions(-) diff --git a/iso/installer.py b/iso/installer.py index ccb09a9..8ae9f48 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -471,14 +471,15 @@ class InstallerWindow(Adw.ApplicationWindow): back_label="← Back", back_cb=lambda b: self.nav.pop(), next_label="I Understand →", - next_cb=lambda b: self.push_disk_confirm(), + next_cb=lambda b: self.push_disk_detect(), )) self.push_page("Network Port Requirements", outer, show_back=True) - # ── Step 2: Disk Confirm ─────────────────────────────────────────────── + # ── Step 2a: Disk Detect ────────────────────────────────────────────── - def push_disk_confirm(self): + def push_disk_detect(self): + """Detect internal drives and show the interactive disk selection page.""" try: raw = run(["lsblk", "-b", "-dno", "NAME,SIZE,TYPE,RO,TRAN", "-e", "7,11"]) except Exception as e: @@ -491,31 +492,208 @@ class InstallerWindow(Adw.ApplicationWindow): if len(parts) >= 4 and parts[2] == "disk" and parts[3] == "0": tran = parts[4] if len(parts) >= 5 else "" if tran != "usb": - disks.append((parts[0], int(parts[1]))) + disks.append((parts[0], int(parts[1]), tran)) if not disks: self.show_error("No valid internal drives found. USB drives are excluded.") return - disks.sort(key=lambda x: x[1]) - self.boot_disk, self.boot_size = disks[0] - self.data_disk, self.data_size = None, None + self.push_disk_select(disks) - BYTES_128GB = 128 * 1024 ** 3 - if self.role == "Desktop Only" and self.boot_size < BYTES_128GB: - self.show_error( - f"Boot disk /dev/{self.boot_disk} is only " - f"{human_size(self.boot_size)}. " - f"The Desktop Only role requires at least 128 GB." + # ── Step 2b: Disk Select ────────────────────────────────────────────── + + def push_disk_select(self, disks): + """Interactive disk-selection page: pick OS drive and (optionally) data drive.""" + + BYTES_256GB = 256 * 1024 ** 3 + BYTES_2TB = 2 * 10 ** 12 + + # Sort ascending by size so the default selection (index 0) is the smallest + disks = sorted(disks, key=lambda x: x[1]) + + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.set_vexpand(True) + + inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + + # ── OS Drive group ──────────────────────────────────────────── + os_group = Adw.PreferencesGroup() + os_group.set_title("OS Drive (NixOS Boot + Root)") + os_group.set_description( + "Choose the drive for the NixOS installation. Minimum 256 GB required." + ) + os_group.set_margin_top(24) + os_group.set_margin_start(40) + os_group.set_margin_end(40) + + self._os_disk_radios = [] + os_radio_group = None + + for name, size, tran in disks: + row = Adw.ActionRow() + row.set_title(f"/dev/{name}") + type_label = tran.upper() if tran else "Disk" + meets = "āœ“ Meets 256 GB minimum" if size >= BYTES_256GB else "āœ— Below 256 GB minimum" + row.set_subtitle(f"{human_size(size)} Ā· {type_label} — {meets}") + row.add_prefix(symbolic_icon("drive-harddisk-symbolic")) + + radio = Gtk.CheckButton() + radio.set_name(name) + if os_radio_group is None: + os_radio_group = radio + radio.set_active(True) + else: + radio.set_group(os_radio_group) + + row.add_suffix(radio) + row.set_activatable_widget(radio) + self._os_disk_radios.append(radio) + os_group.add(row) + + inner.append(os_group) + + # ── Data Drive group (skipped for Desktop Only) ─────────────── + self._data_disk_radios = [] + + if self.role != "Desktop Only": + data_group = Adw.PreferencesGroup() + data_group.set_title("Bitcoin Timechain & Backups Drive") + data_group.set_description( + "šŸ’” Tip: Always assign your LARGEST drive here. " + "The full Bitcoin timechain is over 700 GB and grows continuously — " + "a 2 TB or larger drive is required." ) + data_group.set_margin_top(20) + data_group.set_margin_start(40) + data_group.set_margin_end(40) + + data_radio_group = None + + # "None" option + none_row = Adw.ActionRow() + none_row.set_title("None (skip data drive)") + none_row.set_subtitle("Bitcoin node functionality will be unavailable") + none_radio = Gtk.CheckButton() + none_radio.set_name("") + data_radio_group = none_radio + none_radio.set_active(True) + none_row.add_suffix(none_radio) + none_row.set_activatable_widget(none_radio) + self._data_disk_radios.append(none_radio) + data_group.add(none_row) + + for name, size, tran in disks: + row = Adw.ActionRow() + row.set_title(f"/dev/{name}") + type_label = tran.upper() if tran else "Disk" + meets = "āœ“ Meets 2 TB minimum" if size >= BYTES_2TB else "āœ— Below 2 TB minimum" + row.set_subtitle(f"{human_size(size)} Ā· {type_label} — {meets}") + row.add_prefix(symbolic_icon("drive-harddisk-symbolic")) + + radio = Gtk.CheckButton() + radio.set_name(name) + radio.set_group(data_radio_group) + + row.add_suffix(radio) + row.set_activatable_widget(radio) + self._data_disk_radios.append(radio) + data_group.add(row) + + inner.append(data_group) + + scroll.set_child(inner) + outer.append(scroll) + outer.append(self.nav_row( + back_label="← Back", + back_cb=lambda b: self.nav.pop(), + next_label="Next →", + next_cb=lambda b: self._on_disk_select_next(disks), + )) + + self.push_page("Select Drives", outer, show_back=True) + + def _on_disk_select_next(self, disks): + """Validate the user's disk selections and advance to the ERASE confirmation.""" + BYTES_256GB = 256 * 1024 ** 3 + BYTES_2TB = 2 * 10 ** 12 + + size_map = {name: size for name, size, _ in disks} + + # Read OS disk selection + os_name = None + for radio in self._os_disk_radios: + if radio.get_active(): + os_name = radio.get_name() + break + + # Read data disk selection (empty string = None chosen) + data_name = None + if self._data_disk_radios: + for radio in self._data_disk_radios: + if radio.get_active(): + sel = radio.get_name() + data_name = sel if sel else None + break + + os_size = size_map.get(os_name, 0) + data_size = size_map.get(data_name, 0) if data_name else 0 + + # Validate OS drive size + if os_size < BYTES_256GB: + dlg = Adw.MessageDialog() + dlg.set_transient_for(self) + dlg.set_heading("OS Drive Too Small") + dlg.set_body( + f"The selected OS drive (/dev/{os_name}, {human_size(os_size)}) " + f"does not meet the 256 GB minimum. Please choose a larger drive." + ) + dlg.add_response("ok", "OK") + dlg.present() return - BYTES_2TB = 2 * 1024 ** 4 - if self.role != "Desktop Only" and len(disks) >= 2: - d, s = disks[-1] - if s >= BYTES_2TB: - self.data_disk, self.data_size = d, s + # Validate data drive size (when one was selected) + if data_name and data_size < BYTES_2TB: + dlg = Adw.MessageDialog() + dlg.set_transient_for(self) + dlg.set_heading("Bitcoin Drive Too Small") + dlg.set_body( + f"The selected Bitcoin Timechain & Backups drive " + f"(/dev/{data_name}, {human_size(data_size)}) " + f"does not meet the 2 TB minimum. " + f"Please choose a larger drive or select \"None\"." + ) + dlg.add_response("ok", "OK") + dlg.present() + return + # Validate no duplicate selection + if data_name and data_name == os_name: + dlg = Adw.MessageDialog() + dlg.set_transient_for(self) + dlg.set_heading("Same Drive Selected Twice") + dlg.set_body( + "You cannot use the same drive for both the OS and " + "Bitcoin Timechain & Backups. Please choose different drives." + ) + dlg.add_response("ok", "OK") + dlg.present() + return + + # Commit selections + self.boot_disk = os_name + self.boot_size = os_size + self.data_disk = data_name + self.data_size = data_size if data_name else None + + self.push_disk_confirm() + + # ── Step 2c: Disk Confirm (ERASE confirmation) ──────────────────────── + + def push_disk_confirm(self): + """Show the selected drives and ask the user to type ERASE to confirm.""" outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) # Disk info group @@ -526,23 +704,17 @@ class InstallerWindow(Adw.ApplicationWindow): disk_group.set_margin_end(40) boot_row = Adw.ActionRow() - boot_row.set_title("Boot Disk") + boot_row.set_title("OS Disk") boot_row.set_subtitle(f"/dev/{self.boot_disk} — {human_size(self.boot_size)}") boot_row.add_prefix(symbolic_icon("drive-harddisk-symbolic")) disk_group.add(boot_row) if self.data_disk: data_row = Adw.ActionRow() - data_row.set_title("Data Disk") + data_row.set_title("Bitcoin Timechain & Backups Disk") data_row.set_subtitle(f"/dev/{self.data_disk} — {human_size(self.data_size)}") data_row.add_prefix(symbolic_icon("drive-harddisk-symbolic")) disk_group.add(data_row) - elif self.role != "Desktop Only": - no_row = Adw.ActionRow() - no_row.set_title("Data Disk") - no_row.set_subtitle("None detected (requires 2 TB or larger)") - no_row.add_prefix(symbolic_icon("drive-harddisk-symbolic")) - disk_group.add(no_row) outer.append(disk_group) @@ -633,6 +805,7 @@ class InstallerWindow(Adw.ApplicationWindow): def do_partition(self, buf): boot_path = f"/dev/{self.boot_disk}" + data_path = f"/dev/{self.data_disk}" if self.data_disk else None # ── Wipe disk(s) to clear stale GPT/MBR data before disko ── GLib.idle_add(append_text, buf, "=== Wiping disk(s) ===\n") @@ -640,14 +813,13 @@ class InstallerWindow(Adw.ApplicationWindow): run_stream(["sudo", "sgdisk", "--zap-all", boot_path], buf) run_stream(["sudo", "wipefs", "--all", "--force", boot_path], buf) - if self.data_disk: - data_path = f"/dev/{self.data_disk}" + if data_path: run_stream(["sudo", "sgdisk", "--zap-all", data_path], buf) run_stream(["sudo", "wipefs", "--all", "--force", data_path], buf) # Inform the kernel of the wiped partition tables run_stream(["sudo", "partprobe", boot_path], buf) - if self.data_disk: + if data_path: run_stream(["sudo", "partprobe", data_path], buf) # Short settle so the kernel finishes re-reading @@ -656,12 +828,12 @@ class InstallerWindow(Adw.ApplicationWindow): # ── Now run disko on a clean disk ── GLib.idle_add(append_text, buf, "\n=== Partitioning drives ===\n") cmd = [ - "sudo", "disko", "--mode", "disko", + "sudo", "disko", "--mode", "destroy,format,mount", f"{FLAKE}/iso/disko.nix", "--arg", "device", f'"{boot_path}"' ] - if self.data_disk: - cmd += ["--arg", "dataDevice", f'"/dev/{self.data_disk}"'] + if data_path: + cmd += ["--arg", "dataDevice", f'"{data_path}"'] run_stream(cmd, buf) GLib.idle_add(append_text, buf, "\n=== Generating hardware config ===\n") -- 2.53.0 From 8ca1ea8e787bfd6e389a946979c6037756c90146 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 05:26:49 +0000 Subject: [PATCH 326/857] Initial plan -- 2.53.0 From abaae7f360d3689e044f0a51f55cf68098f65fdf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 05:33:05 +0000 Subject: [PATCH 327/857] feat: Bitcoin IBD sync progress indicator in Bitcoin tile Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/2c6f8fb7-5361-469b-b12b-ef846ffb669f Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 99 ++++++++++++++++++- app/sovran_systemsos_web/static/css/tiles.css | 59 +++++++++++ app/sovran_systemsos_web/static/js/helpers.js | 2 + app/sovran_systemsos_web/static/js/tiles.js | 82 +++++++++++++-- 4 files changed, 234 insertions(+), 8 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index c036f1a..c04ffac 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1425,6 +1425,63 @@ async def api_upgrade_to_server(): return {"ok": True, "status": "rebuilding"} +# ── Bitcoin IBD sync helper ─────────────────────────────────────── + +BITCOIN_DATADIR = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node" + +# Simple in-process cache: (timestamp, result) +_btc_sync_cache: tuple[float, dict | None] = (0.0, None) +_BTC_SYNC_CACHE_TTL = 5 # seconds + + +def _get_bitcoin_sync_info() -> dict | None: + """Call bitcoin-cli getblockchaininfo and return parsed JSON, or None on error. + + Results are cached for _BTC_SYNC_CACHE_TTL seconds to avoid hammering + bitcoin-cli on every /api/services poll cycle. + """ + global _btc_sync_cache + now = time.monotonic() + cached_at, cached_val = _btc_sync_cache + if now - cached_at < _BTC_SYNC_CACHE_TTL: + return cached_val + + try: + result = subprocess.run( + ["bitcoin-cli", f"-datadir={BITCOIN_DATADIR}", "getblockchaininfo"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + _btc_sync_cache = (now, None) + return None + info = json.loads(result.stdout) + _btc_sync_cache = (now, info) + return info + except Exception: + _btc_sync_cache = (now, None) + return None + + +@app.get("/api/bitcoin/sync") +async def api_bitcoin_sync(): + """Return Bitcoin blockchain sync status directly from bitcoin-cli.""" + loop = asyncio.get_event_loop() + info = await loop.run_in_executor(None, _get_bitcoin_sync_info) + if info is None: + return JSONResponse( + status_code=503, + content={"error": "bitcoin-cli unavailable or bitcoind not running"}, + ) + return { + "blocks": info.get("blocks", 0), + "headers": info.get("headers", 0), + "verificationprogress": info.get("verificationprogress", 0), + "initialblockdownload": info.get("initialblockdownload", False), + } + + @app.get("/api/services") async def api_services(): cfg = load_config() @@ -1486,6 +1543,10 @@ async def api_services(): domain = None # Compute composite health + sync_progress: float | None = None + sync_blocks: int | None = None + sync_headers: int | None = None + sync_ibd: bool | None = None if not enabled: health = "disabled" elif status == "active": @@ -1506,6 +1567,15 @@ async def api_services(): if not domain: has_domain_issues = True health = "needs_attention" if (has_port_issues or has_domain_issues) else "healthy" + # Check Bitcoin IBD state + if unit == "bitcoind.service": + sync = await loop.run_in_executor(None, _get_bitcoin_sync_info) + if sync and sync.get("initialblockdownload"): + health = "syncing" + sync_progress = sync.get("verificationprogress", 0) + sync_blocks = sync.get("blocks", 0) + sync_headers = sync.get("headers", 0) + sync_ibd = True elif status == "inactive": health = "inactive" elif status == "failed": @@ -1513,7 +1583,7 @@ async def api_services(): else: health = status # loading states, etc. - return { + service_data: dict = { "name": entry.get("name", ""), "unit": unit, "type": scope, @@ -1527,6 +1597,12 @@ async def api_services(): "needs_domain": needs_domain, "domain": domain, } + if sync_ibd is not None: + service_data["sync_ibd"] = sync_ibd + service_data["sync_progress"] = sync_progress + service_data["sync_blocks"] = sync_blocks + service_data["sync_headers"] = sync_headers + return service_data results = await asyncio.gather(*[get_status(s) for s in services]) return list(results) @@ -1708,6 +1784,10 @@ async def api_service_detail(unit: str, icon: str | None = None): }) # Compute composite health + sync_progress: float | None = None + sync_blocks: int | None = None + sync_headers: int | None = None + sync_ibd: bool | None = None if not enabled: health = "disabled" elif status == "active": @@ -1719,6 +1799,15 @@ async def api_service_detail(unit: str, icon: str | None = None): elif domain_status and domain_status.get("status") not in ("connected", None): has_domain_issues = True health = "needs_attention" if (has_port_issues or has_domain_issues) else "healthy" + # Check Bitcoin IBD state + if unit == "bitcoind.service": + sync = await loop.run_in_executor(None, _get_bitcoin_sync_info) + if sync and sync.get("initialblockdownload"): + health = "syncing" + sync_progress = sync.get("verificationprogress", 0) + sync_blocks = sync.get("blocks", 0) + sync_headers = sync.get("headers", 0) + sync_ibd = True elif status == "inactive": health = "inactive" elif status == "failed": @@ -1761,7 +1850,7 @@ async def api_service_detail(unit: str, icon: str | None = None): "port_requirements": feat_meta.get("port_requirements", []), } - return { + service_detail: dict = { "name": entry.get("name", ""), "unit": unit, "icon": icon, @@ -1780,6 +1869,12 @@ async def api_service_detail(unit: str, icon: str | None = None): "internal_ip": internal_ip, "feature": feature_entry, } + if sync_ibd is not None: + service_detail["sync_ibd"] = sync_ibd + service_detail["sync_progress"] = sync_progress + service_detail["sync_blocks"] = sync_blocks + service_detail["sync_headers"] = sync_headers + return service_detail @app.get("/api/network") diff --git a/app/sovran_systemsos_web/static/css/tiles.css b/app/sovran_systemsos_web/static/css/tiles.css index df7ba58..613fbc9 100644 --- a/app/sovran_systemsos_web/static/css/tiles.css +++ b/app/sovran_systemsos_web/static/css/tiles.css @@ -85,6 +85,65 @@ .status-dot.failed { background-color: var(--red); } .status-dot.disabled { background-color: var(--grey); } .status-dot.needs-attention { background-color: var(--yellow); } +.status-dot.syncing { background-color: #f5a623; animation: pulse-badge 1.5s infinite; } + +/* ── Bitcoin IBD sync progress bar ──────────────────────────────── */ + +.tile-sync-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + width: 100%; + margin-top: 6px; +} + +.tile-sync-label { + font-size: 0.72rem; + color: #f5a623; + font-weight: 600; + text-align: center; + white-space: nowrap; +} + +.tile-sync-bar-row { + display: flex; + align-items: center; + gap: 5px; + width: 100%; +} + +.tile-sync-bar-track { + flex: 1; + height: 6px; + background-color: var(--border-color); + border-radius: 3px; + overflow: hidden; +} + +.tile-sync-bar-fill { + height: 100%; + background-color: #f5a623; + border-radius: 3px; + transition: width 0.6s ease; + min-width: 2px; +} + +.tile-sync-percent { + font-size: 0.72rem; + font-weight: 700; + color: #f5a623; + white-space: nowrap; + min-width: 2.5em; + text-align: right; +} + +.tile-sync-eta { + font-size: 0.68rem; + color: var(--text-dim); + text-align: center; + white-space: nowrap; +} /* ── Service detail modal sections ───────────────────────────────── */ diff --git a/app/sovran_systemsos_web/static/js/helpers.js b/app/sovran_systemsos_web/static/js/helpers.js index b0b8e99..88774d0 100644 --- a/app/sovran_systemsos_web/static/js/helpers.js +++ b/app/sovran_systemsos_web/static/js/helpers.js @@ -12,6 +12,7 @@ function statusClass(health) { if (health === "inactive") return "inactive"; if (health === "failed") return "failed"; if (health === "disabled") return "disabled"; + if (health === "syncing") return "syncing"; if (STATUS_LOADING_STATES.has(health)) return "loading"; return "unknown"; } @@ -23,6 +24,7 @@ function statusText(health, enabled) { if (health === "active") return "Active"; if (health === "inactive") return "Inactive"; if (health === "failed") return "Failed"; + if (health === "syncing") return "Syncing\u2026"; if (!health || health === "unknown") return "Unknown"; if (STATUS_LOADING_STATES.has(health)) return health; return health; diff --git a/app/sovran_systemsos_web/static/js/tiles.js b/app/sovran_systemsos_web/static/js/tiles.js index 9794990..436cd32 100644 --- a/app/sovran_systemsos_web/static/js/tiles.js +++ b/app/sovran_systemsos_web/static/js/tiles.js @@ -1,5 +1,9 @@ "use strict"; +// ── Bitcoin IBD sync state (for ETA calculation) ────────────────── +// Keyed by tileId: { progress: float, timestamp: ms } +var _btcSyncPrev = {}; + // ── Render: initial build ───────────────────────────────────────── function buildTiles(services, categoryLabels) { @@ -123,6 +127,29 @@ function buildTile(svc) { return tile; } + if (svc.sync_ibd) { + var pct = Math.round((svc.sync_progress || 0) * 100); + var id = tileId(svc); + var eta = _calcBtcEta(id, svc.sync_progress || 0); + tile.innerHTML = + '' + escHtml(svc.name) + '' + + '' + + '
      ' + escHtml(svc.name) + '
      ' + + '
      ' + + '
      \u23F3 Syncing Timechain
      ' + + '
      ' + + '
      ' + + '' + pct + '%' + + '
      ' + + '
      ' + escHtml(eta) + '
      ' + + '
      '; + tile.style.cursor = "pointer"; + tile.addEventListener("click", function() { + openServiceDetailModal(svc.unit, svc.name, svc.icon); + }); + return tile; + } + tile.innerHTML = '' + escHtml(svc.name) + '
      ' + escHtml(svc.name) + '
      ' + st + '
      '; tile.style.cursor = "pointer"; @@ -135,6 +162,23 @@ function buildTile(svc) { // ── Render: live update ─────────────────────────────────────────── +// Calculate ETA text for Bitcoin IBD and track progress history. +function _calcBtcEta(id, progress) { + var now = Date.now(); + var prev = _btcSyncPrev[id]; + // Only update the cache when progress has actually advanced + if (!prev || prev.progress < progress) { + _btcSyncPrev[id] = { progress: progress, timestamp: now }; + } + if (!prev || prev.progress >= progress) return "Estimating\u2026"; + var elapsed = (now - prev.timestamp) / 1000; // seconds + if (elapsed <= 0) return "Estimating\u2026"; + var rate = (progress - prev.progress) / elapsed; // progress per second + if (rate <= 0) return "Estimating\u2026"; + var remaining = (1.0 - progress) / rate; + return "\u007E" + formatDuration(remaining) + " remaining"; +} + function updateTiles(services) { _servicesCache = services; for (var i = 0; i < services.length; i++) { @@ -143,12 +187,38 @@ function updateTiles(services) { var id = CSS.escape(tileId(svc)); var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]'); if (!tile) continue; - var sc = statusClass(svc.health || svc.status); - var st = statusText(svc.health || svc.status, svc.enabled); - var dot = tile.querySelector(".status-dot"); - var text = tile.querySelector(".status-text"); - if (dot) dot.className = "status-dot " + sc; - if (text) text.textContent = st; + + if (svc.sync_ibd) { + // If tile was previously normal, rebuild it with the sync layout + if (!tile.querySelector(".tile-sync-container")) { + var newTile = buildTile(svc); + tile.parentNode.replaceChild(newTile, tile); + continue; + } + // Update progress bar values in-place + var pct = Math.round((svc.sync_progress || 0) * 100); + var etaText = _calcBtcEta(tileId(svc), svc.sync_progress || 0); + var fill = tile.querySelector(".tile-sync-bar-fill"); + var pctEl = tile.querySelector(".tile-sync-percent"); + var etaEl = tile.querySelector(".tile-sync-eta"); + if (fill) fill.style.width = pct + "%"; + if (pctEl) pctEl.textContent = pct + "%"; + if (etaEl) etaEl.textContent = etaText; + } else { + // IBD finished or not syncing — if tile had sync layout rebuild it normally + if (tile.querySelector(".tile-sync-container")) { + delete _btcSyncPrev[tileId(svc)]; + var normalTile = buildTile(svc); + tile.parentNode.replaceChild(normalTile, tile); + continue; + } + var sc = statusClass(svc.health || svc.status); + var st = statusText(svc.health || svc.status, svc.enabled); + var dot = tile.querySelector(".status-dot"); + var text = tile.querySelector(".status-text"); + if (dot) dot.className = "status-dot " + sc; + if (text) text.textContent = st; + } } } -- 2.53.0 From beca9756ea7398c009710d8977b336a581e6b207 Mon Sep 17 00:00:00 2001 From: Sovran_Systems <99053422+naturallaw777@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:48:49 -0500 Subject: [PATCH 328/857] Create README.md file for Sovran_SystemsOS --- README.md | 221 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e0decf --- /dev/null +++ b/README.md @@ -0,0 +1,221 @@ +

      + Sovran Systems +

      + +

      Sovran_SystemsOS

      + +

      + A Fully Sovereign, Declarative NixOS Operating System
      + Take complete ownership of your digital infrastructure. +

      + +

      + NixOS + Bitcoin + License + Reproducible +

      + +--- + +## Overview + +Sovran_SystemsOS is a purpose-built, fully declarative operating system constructed entirely on [NixOS](https://nixos.org). It delivers a complete sovereign computing platform — integrating a Bitcoin financial stack, encrypted communications, self-hosted cloud services, and a professional web presence — all managed through a single, reproducible configuration. + +Every component of the system is defined in Nix. There are no imperative scripts, no hidden state, and no black boxes. What you declare is exactly what runs. The entire operating system can be rebuilt, replicated, or audited from source at any time. + +

      + Sovran_SystemsOS Hub Dashboard +

      + +--- + +## The Sovran_SystemsOS Hub + +The **Sovran_SystemsOS Hub** is the central management dashboard for the entire operating system. Accessible through a local web interface, it provides a unified view of all running infrastructure, Bitcoin services, and application status in real time. + +From the Hub, operators can: + +- Monitor the health and status of every service at a glance +- Access system administration tools including password management, backups, and tech support +- Manage Bitcoin node infrastructure (Bitcoin Knots, Bitcoin Core, BIP-110) +- Oversee the full Bitcoin application stack (Electrs, LND, Ride The Lightning, BTCPayServer, Zeus Connect, Mempool) +- Update the system with a single action +- Perform manual backups to external storage +- Access remote desktop capabilities + +The Hub eliminates the need to manage services individually through disparate interfaces. It is the operational command center for the entire Sovran_SystemsOS deployment. + +--- + +## Three Deployment Roles + +Sovran_SystemsOS is architected around three distinct deployment roles, each tailored to a specific use case. A role is selected during installation and can be changed at any time by editing a single configuration file (`custom.nix`). + +### Server + Desktop + +The complete deployment. This role activates every server service alongside a full GNOME desktop environment, delivering a workstation that simultaneously operates as a sovereign infrastructure node. + +**Includes:** Matrix Synapse homeserver, Bitcoin ecosystem (bitcoind, Electrs, LND, RTL, BTCPayServer), Vaultwarden password manager, WordPress, Nextcloud file hosting, Caddy reverse proxy, Tor, and the full desktop environment. + +### Desktop Only + +A clean, sovereign desktop environment without server services. Ideal for daily computing, secure communications, and Bitcoin wallet management without running full node infrastructure. + +**Includes:** GNOME desktop, Bitcoin desktop applications (Sparrow, Bisq, Bisq2, Bitcoin Core GUI), Tor, and all productivity tools. + +### Node (Bitcoin Only) + +A dedicated Bitcoin infrastructure node. This role strips away desktop and web services to focus entirely on running and serving the Bitcoin network. + +**Includes:** Bitcoin Knots with BIP-110, Electrs, LND, Ride The Lightning, BTCPayServer, Mempool block explorer, and all supporting Bitcoin infrastructure. + +--- + +## Key Benefits + +### Complete Digital Sovereignty + +Every service runs on hardware you own. Your Bitcoin keys, your communications, your files, your passwords, and your website all operate under your exclusive control. There is no reliance on third-party cloud providers, no data harvested, and no external points of failure. + +### Pure Declarative Configuration + +The entire operating system — from kernel parameters to application configurations — is defined declaratively in Nix. This guarantees: + +- **Reproducibility:** Any deployment can be identically recreated from the configuration files alone. +- **Auditability:** The complete system state is transparent and version-controlled. +- **Rollback:** Every system generation is preserved; reverting to a previous state is a single command. +- **Atomic Upgrades:** System rebuilds either succeed completely or fail without side effects. + +### Modular Service Architecture + +Services and features are organized into independently toggleable modules. Operators enable or disable capabilities through simple boolean flags in `custom.nix`: + +| Category | Service | Default | +|----------|---------|---------| +| **Services** | Matrix Synapse | ON | +| **Services** | Bitcoin Ecosystem | ON | +| **Services** | Vaultwarden | ON | +| **Services** | WordPress | ON | +| **Services** | Nextcloud | ON | +| **Features** | Haven (NOSTR Relay) | OFF | +| **Features** | BIP-110 | OFF | +| **Features** | Mempool Explorer | OFF | +| **Features** | Element Video Calling | OFF | +| **Features** | Remote Desktop (RDP) | OFF | +| **Features** | Bitcoin Core GUI | OFF | + +--- + +## Security Architecture + +Sovran_SystemsOS is engineered with security as a foundational principle, not an afterthought. + +- **Declarative Firewall:** All network access is explicitly defined. Only ports required by enabled services are opened; everything else is denied by default. +- **Fail2Ban Integration:** Automated intrusion prevention monitors and blocks brute-force attacks across all exposed services. +- **SSH Hardened:** Password authentication and keyboard-interactive authentication are disabled. Access is restricted to public key authentication only. +- **Tor Built-In:** The Tor network is enabled system-wide, providing anonymized connectivity and the ability to operate hidden services for any exposed application. +- **Automated Backups:** rsnapshot performs hourly and daily snapshots of all critical data — including home directories, system state, and Bitcoin secrets — to external storage. +- **Vaultwarden (Self-Hosted Bitwarden):** All credentials are managed through a locally hosted, encrypted password vault with no external dependencies. +- **NixOS Immutability:** The declarative model ensures that the running system always matches the defined configuration. Unauthorized modifications do not persist across rebuilds. +- **Nix Flake Pinning:** All dependencies — including nixpkgs, nix-bitcoin, and third-party modules — are pinned to exact revisions via `flake.lock`, eliminating supply-chain ambiguity. +- **Credential Isolation:** Bitcoin secrets and service credentials are stored in dedicated, permission-restricted directories and automatically generated during provisioning. + +--- + +## Technology Stack + +| Layer | Technology | +|-------|------------| +| **Operating System** | NixOS (Unstable Channel) | +| **Desktop Environment** | GNOME (Wayland) | +| **Reverse Proxy** | Caddy | +| **Bitcoin Node** | Bitcoin Knots / Bitcoin Core | +| **Lightning Network** | LND | +| **Lightning Management** | Ride The Lightning | +| **Payment Processing** | BTCPayServer | +| **Block Explorer** | Mempool | +| **Electrum Server** | Electrs | +| **Communications** | Matrix Synapse + Element | +| **Video Calling** | LiveKit (Element Calling) | +| **File Hosting** | Nextcloud | +| **Website** | WordPress | +| **Password Management** | Vaultwarden | +| **NOSTR Relay** | Haven | +| **DNS Management** | Njalla Dynamic DNS | +| **Network Privacy** | Tor | +| **Intrusion Prevention** | Fail2Ban | +| **Backup** | rsnapshot | +| **Package Management** | Nix Flakes | + +--- + +## Repository Structure + +``` +staging_alpha/ +ā”œā”€ā”€ flake.nix # Flake entry point and dependency declarations +ā”œā”€ā”€ flake.lock # Pinned dependency revisions +ā”œā”€ā”€ configuration.nix # Core system configuration +ā”œā”€ā”€ custom.template.nix # User-facing customization template +ā”œā”€ā”€ onboarding.html # First-run onboarding interface +ā”œā”€ā”€ modules/ +│ ā”œā”€ā”€ modules.nix # Module import manifest +│ ā”œā”€ā”€ core/ +│ │ ā”œā”€ā”€ roles.nix # Role and option declarations +│ │ ā”œā”€ā”€ role-logic.nix # Role-conditional service activation +│ │ ā”œā”€ā”€ caddy.nix # Reverse proxy configuration +│ │ ā”œā”€ā”€ sovran-hub.nix # Hub dashboard +│ │ └── ... # Additional core modules +│ ā”œā”€ā”€ synapse.nix # Matrix Synapse homeserver +│ ā”œā”€ā”€ bitcoinecosystem.nix # Bitcoin infrastructure module +│ ā”œā”€ā”€ nextcloud.nix # Nextcloud file hosting +│ ā”œā”€ā”€ wordpress.nix # WordPress configuration +│ ā”œā”€ā”€ vaultwarden.nix # Password manager +│ ā”œā”€ā”€ haven.nix # NOSTR relay and Blossom +│ ā”œā”€ā”€ mempool.nix # Mempool block explorer +│ ā”œā”€ā”€ element-calling.nix # LiveKit video calling +│ └── ... # Additional service modules +ā”œā”€ā”€ iso/ +│ ā”œā”€ā”€ installer.py # Automated installation wizard +│ ā”œā”€ā”€ desktop.nix # Desktop ISO configuration +│ ā”œā”€ā”€ server.nix # Server ISO configuration +│ └── ... # ISO build assets +└── app/ + └── sovran_systemsos_web/ # Hub web application +``` + +--- + +## Getting Started + +1. **Download** the Sovran_SystemsOS ISO image. +2. **Boot** from the installation media. +3. **Select your role** — Server + Desktop, Desktop Only, or Node — during the guided installation. +4. **Customize** your deployment by editing `/etc/nixos/custom.nix` to enable or disable services and features. +5. **Rebuild** with `nixos-rebuild switch` to apply changes. + +--- + +## Acknowledgments + +Sovran_SystemsOS is built on the work of exceptional open-source contributors and projects. + +**[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin)** — The Bitcoin infrastructure layer of Sovran_SystemsOS is made possible by the nix-bitcoin project. Their rigorous, security-focused NixOS modules for Bitcoin Core, LND, Electrs, BTCPayServer, and related services provide the foundation upon which the entire Bitcoin ecosystem in this operating system is constructed. The nix-bitcoin team's commitment to reproducible, auditable Bitcoin infrastructure is directly aligned with the mission of Sovran_SystemsOS, and their work is deeply appreciated. + +**[Emmanuel Rosa](https://github.com/emmanuelrosa)** — The `btc-clients-nix` and `bitcoin-knots-bip-110-nix` packages, maintained by Emmanuel Rosa, bring essential Bitcoin desktop applications (Sparrow, Bisq, Bisq2) and the BIP-110 Bitcoin Knots implementation to NixOS. These ports fill a critical gap in the NixOS Bitcoin ecosystem and are integral to delivering a complete sovereign computing experience. His dedication to packaging and maintaining these tools for the Nix community is sincerely valued. + +**[NixOS](https://nixos.org)** — The purely functional Linux distribution that makes all of this possible. Without the NixOS foundation of declarative, reproducible system management, a project of this scope and reliability would not be feasible. + +--- + +## License + +Sovran_SystemsOS is released under the [GNU Affero General Public License v3.0](LICENSE). + +--- + +

      + Sovran Systems
      + Your keys. Your node. Your cloud. Your sovereignty. +

      \ No newline at end of file -- 2.53.0 From 6bb4aaf3ba47227327f523734227cfbd61811e12 Mon Sep 17 00:00:00 2001 From: Sovran_Systems <99053422+naturallaw777@users.noreply.github.com> Date: Sun, 5 Apr 2026 01:02:13 -0500 Subject: [PATCH 329/857] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1e0decf..decf802 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Sovran_SystemsOS is a purpose-built, fully declarative operating system construc Every component of the system is defined in Nix. There are no imperative scripts, no hidden state, and no black boxes. What you declare is exactly what runs. The entire operating system can be rebuilt, replicated, or audited from source at any time.

      - Sovran_SystemsOS Hub Dashboard + Sovran_SystemsOS Hub Dashboard

      --- @@ -218,4 +218,4 @@ Sovran_SystemsOS is released under the [GNU Affero General Public License v3.0](

      Sovran Systems
      Your keys. Your node. Your cloud. Your sovereignty. -

      \ No newline at end of file +

      -- 2.53.0 From fc847a17cd243e7f376884a98b58d627040e0cdc Mon Sep 17 00:00:00 2001 From: Sovran_Systems <99053422+naturallaw777@users.noreply.github.com> Date: Sun, 5 Apr 2026 01:09:30 -0500 Subject: [PATCH 330/857] Update README.md --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index decf802..dd632db 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@

      - Sovran Systems + Sovran Systems

      +

      Sovran_SystemsOS

      @@ -24,14 +25,15 @@ Sovran_SystemsOS is a purpose-built, fully declarative operating system construc Every component of the system is defined in Nix. There are no imperative scripts, no hidden state, and no black boxes. What you declare is exactly what runs. The entire operating system can be rebuilt, replicated, or audited from source at any time. -

      - Sovran_SystemsOS Hub Dashboard -

      - --- ## The Sovran_SystemsOS Hub + +

      + Screenshot From 2026-04-05 01-03-08 +

      + The **Sovran_SystemsOS Hub** is the central management dashboard for the entire operating system. Accessible through a local web interface, it provides a unified view of all running infrastructure, Bitcoin services, and application status in real time. From the Hub, operators can: -- 2.53.0 From f4a644dc0539bc9f97e13730fd29561211c8f5bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 06:28:05 +0000 Subject: [PATCH 331/857] Initial plan -- 2.53.0 From 61cf06b4c742cfb8b72686fdc7ac658f7bcfde03 Mon Sep 17 00:00:00 2001 From: Sovran_Systems <99053422+naturallaw777@users.noreply.github.com> Date: Sun, 5 Apr 2026 01:32:15 -0500 Subject: [PATCH 332/857] Update README.md with new content --- README.md | 89 +++++++++++++++++++++++++++---------------------------- 1 file changed, 44 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index dd632db..549f1e3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ Sovran Systems

      -

      Sovran_SystemsOS

      @@ -21,7 +20,7 @@ ## Overview -Sovran_SystemsOS is a purpose-built, fully declarative operating system constructed entirely on [NixOS](https://nixos.org). It delivers a complete sovereign computing platform — integrating a Bitcoin financial stack, encrypted communications, self-hosted cloud services, and a professional web presence — all managed through a single, reproducible configuration. +Sovran_SystemsOS is a purpose-built, fully declarative operating system constructed entirely on [NixOS](https://nixos.org). It delivers a complete sovereign computing platform — integrating a [Bitcoin](https://bitcoin.org) financial stack, encrypted communications via [Matrix](https://matrix.org), self-hosted cloud services, and a professional web presence — all managed through a single, reproducible configuration. Every component of the system is defined in Nix. There are no imperative scripts, no hidden state, and no black boxes. What you declare is exactly what runs. The entire operating system can be rebuilt, replicated, or audited from source at any time. @@ -34,14 +33,14 @@ Every component of the system is defined in Nix. There are no imperative scripts Screenshot From 2026-04-05 01-03-08

      -The **Sovran_SystemsOS Hub** is the central management dashboard for the entire operating system. Accessible through a local web interface, it provides a unified view of all running infrastructure, Bitcoin services, and application status in real time. +The **Sovran_SystemsOS Hub** is the central management dashboard for the entire operating system. Accessible through a local web interface, it provides a unified view of all running infrastructure, [Bitcoin](https://bitcoin.org) services, and application status in real time. From the Hub, operators can: - Monitor the health and status of every service at a glance - Access system administration tools including password management, backups, and tech support -- Manage Bitcoin node infrastructure (Bitcoin Knots, Bitcoin Core, BIP-110) -- Oversee the full Bitcoin application stack (Electrs, LND, Ride The Lightning, BTCPayServer, Zeus Connect, Mempool) +- Manage Bitcoin node infrastructure ([Bitcoin Knots](https://bitcoinknots.org), [Bitcoin Core](https://bitcoincore.org), BIP-110) +- Oversee the full Bitcoin application stack ([Electrs](https://github.com/romanz/electrs), [LND](https://github.com/lightningnetwork/lnd), [Ride The Lightning](https://github.com/Ride-The-Lightning/RTL), [BTCPayServer](https://btcpayserver.org), [Zeus](https://zeusln.com), [Mempool](https://github.com/mempool/mempool)) - Update the system with a single action - Perform manual backups to external storage - Access remote desktop capabilities @@ -56,21 +55,21 @@ Sovran_SystemsOS is architected around three distinct deployment roles, each tai ### Server + Desktop -The complete deployment. This role activates every server service alongside a full GNOME desktop environment, delivering a workstation that simultaneously operates as a sovereign infrastructure node. +The complete deployment. This role activates every server service alongside a full [GNOME](https://www.gnome.org) desktop environment, delivering a workstation that simultaneously operates as a sovereign infrastructure node. -**Includes:** Matrix Synapse homeserver, Bitcoin ecosystem (bitcoind, Electrs, LND, RTL, BTCPayServer), Vaultwarden password manager, WordPress, Nextcloud file hosting, Caddy reverse proxy, Tor, and the full desktop environment. +**Includes:** [Matrix Synapse](https://github.com/element-hq/synapse) homeserver, Bitcoin ecosystem ([bitcoind](https://bitcoinknots.org), [Electrs](https://github.com/romanz/electrs), [LND](https://github.com/lightningnetwork/lnd), [RTL](https://github.com/Ride-The-Lightning/RTL), [BTCPayServer](https://btcpayserver.org)), [Vaultwarden](https://github.com/dani-garcia/vaultwarden) password manager, [WordPress](https://wordpress.org), [Nextcloud](https://nextcloud.com) file hosting, [Caddy](https://caddyserver.com) reverse proxy, [Tor](https://www.torproject.org), and the full desktop environment. ### Desktop Only A clean, sovereign desktop environment without server services. Ideal for daily computing, secure communications, and Bitcoin wallet management without running full node infrastructure. -**Includes:** GNOME desktop, Bitcoin desktop applications (Sparrow, Bisq, Bisq2, Bitcoin Core GUI), Tor, and all productivity tools. +**Includes:** [GNOME](https://www.gnome.org) desktop, Bitcoin desktop applications ([Sparrow](https://sparrowwallet.com), [Bisq](https://bisq.network), Bisq2, [Bitcoin Core](https://bitcoincore.org) GUI), [Tor](https://www.torproject.org), and all productivity tools. ### Node (Bitcoin Only) A dedicated Bitcoin infrastructure node. This role strips away desktop and web services to focus entirely on running and serving the Bitcoin network. -**Includes:** Bitcoin Knots with BIP-110, Electrs, LND, Ride The Lightning, BTCPayServer, Mempool block explorer, and all supporting Bitcoin infrastructure. +**Includes:** [Bitcoin Knots](https://bitcoinknots.org) with BIP-110, [Electrs](https://github.com/romanz/electrs), [LND](https://github.com/lightningnetwork/lnd), [Ride The Lightning](https://github.com/Ride-The-Lightning/RTL), [BTCPayServer](https://btcpayserver.org), [Mempool](https://github.com/mempool/mempool) block explorer, and all supporting Bitcoin infrastructure. --- @@ -95,17 +94,17 @@ Services and features are organized into independently toggleable modules. Opera | Category | Service | Default | |----------|---------|---------| -| **Services** | Matrix Synapse | ON | -| **Services** | Bitcoin Ecosystem | ON | -| **Services** | Vaultwarden | ON | -| **Services** | WordPress | ON | -| **Services** | Nextcloud | ON | -| **Features** | Haven (NOSTR Relay) | OFF | +| **Services** | [Matrix Synapse](https://github.com/element-hq/synapse) | ON | +| **Services** | [Bitcoin](https://bitcoin.org) Ecosystem | ON | +| **Services** | [Vaultwarden](https://github.com/dani-garcia/vaultwarden) | ON | +| **Services** | [WordPress](https://wordpress.org) | ON | +| **Services** | [Nextcloud](https://nextcloud.com) | ON | +| **Features** | [Haven](https://github.com/bitvora/haven) (NOSTR Relay) | OFF | | **Features** | BIP-110 | OFF | -| **Features** | Mempool Explorer | OFF | -| **Features** | Element Video Calling | OFF | +| **Features** | [Mempool](https://github.com/mempool/mempool) Explorer | OFF | +| **Features** | [Element](https://element.io) Video Calling | OFF | | **Features** | Remote Desktop (RDP) | OFF | -| **Features** | Bitcoin Core GUI | OFF | +| **Features** | [Bitcoin Core](https://bitcoincore.org) GUI | OFF | --- @@ -114,13 +113,13 @@ Services and features are organized into independently toggleable modules. Opera Sovran_SystemsOS is engineered with security as a foundational principle, not an afterthought. - **Declarative Firewall:** All network access is explicitly defined. Only ports required by enabled services are opened; everything else is denied by default. -- **Fail2Ban Integration:** Automated intrusion prevention monitors and blocks brute-force attacks across all exposed services. +- **[Fail2Ban](https://github.com/fail2ban/fail2ban) Integration:** Automated intrusion prevention monitors and blocks brute-force attacks across all exposed services. - **SSH Hardened:** Password authentication and keyboard-interactive authentication are disabled. Access is restricted to public key authentication only. -- **Tor Built-In:** The Tor network is enabled system-wide, providing anonymized connectivity and the ability to operate hidden services for any exposed application. -- **Automated Backups:** rsnapshot performs hourly and daily snapshots of all critical data — including home directories, system state, and Bitcoin secrets — to external storage. -- **Vaultwarden (Self-Hosted Bitwarden):** All credentials are managed through a locally hosted, encrypted password vault with no external dependencies. +- **[Tor](https://www.torproject.org) Built-In:** The Tor network is enabled system-wide, providing anonymized connectivity and the ability to operate hidden services for any exposed application. +- **Automated Backups:** [rsnapshot](https://rsnapshot.org) performs hourly and daily snapshots of all critical data — including home directories, system state, and Bitcoin secrets — to external storage. +- **[Vaultwarden](https://github.com/dani-garcia/vaultwarden) (Self-Hosted Bitwarden):** All credentials are managed through a locally hosted, encrypted password vault with no external dependencies. - **NixOS Immutability:** The declarative model ensures that the running system always matches the defined configuration. Unauthorized modifications do not persist across rebuilds. -- **Nix Flake Pinning:** All dependencies — including nixpkgs, nix-bitcoin, and third-party modules — are pinned to exact revisions via `flake.lock`, eliminating supply-chain ambiguity. +- **Nix Flake Pinning:** All dependencies — including nixpkgs, [nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), and third-party modules — are pinned to exact revisions via `flake.lock`, eliminating supply-chain ambiguity. - **Credential Isolation:** Bitcoin secrets and service credentials are stored in dedicated, permission-restricted directories and automatically generated during provisioning. --- @@ -129,26 +128,26 @@ Sovran_SystemsOS is engineered with security as a foundational principle, not an | Layer | Technology | |-------|------------| -| **Operating System** | NixOS (Unstable Channel) | -| **Desktop Environment** | GNOME (Wayland) | -| **Reverse Proxy** | Caddy | -| **Bitcoin Node** | Bitcoin Knots / Bitcoin Core | -| **Lightning Network** | LND | -| **Lightning Management** | Ride The Lightning | -| **Payment Processing** | BTCPayServer | -| **Block Explorer** | Mempool | -| **Electrum Server** | Electrs | -| **Communications** | Matrix Synapse + Element | -| **Video Calling** | LiveKit (Element Calling) | -| **File Hosting** | Nextcloud | -| **Website** | WordPress | -| **Password Management** | Vaultwarden | -| **NOSTR Relay** | Haven | -| **DNS Management** | Njalla Dynamic DNS | -| **Network Privacy** | Tor | -| **Intrusion Prevention** | Fail2Ban | -| **Backup** | rsnapshot | -| **Package Management** | Nix Flakes | +| **Operating System** | [NixOS](https://nixos.org) (Unstable Channel) | +| **Desktop Environment** | [GNOME](https://www.gnome.org) (Wayland) | +| **Reverse Proxy** | [Caddy](https://caddyserver.com) | +| **Bitcoin Node** | [Bitcoin Knots](https://bitcoinknots.org) / [Bitcoin Core](https://bitcoincore.org) | +| **Lightning Network** | [LND](https://github.com/lightningnetwork/lnd) | +| **Lightning Management** | [Ride The Lightning](https://github.com/Ride-The-Lightning/RTL) | +| **Payment Processing** | [BTCPayServer](https://btcpayserver.org) | +| **Block Explorer** | [Mempool](https://github.com/mempool/mempool) | +| **Electrum Server** | [Electrs](https://github.com/romanz/electrs) | +| **Communications** | [Matrix Synapse](https://github.com/element-hq/synapse) + [Element](https://element.io) | +| **Video Calling** | [LiveKit](https://livekit.io) (Element Calling) | +| **File Hosting** | [Nextcloud](https://nextcloud.com) | +| **Website** | [WordPress](https://wordpress.org) | +| **Password Management** | [Vaultwarden](https://github.com/dani-garcia/vaultwarden) | +| **NOSTR Relay** | [Haven](https://github.com/bitvora/haven) | +| **DNS Management** | [Njalla](https://njal.la) Dynamic DNS | +| **Network Privacy** | [Tor](https://www.torproject.org) | +| **Intrusion Prevention** | [Fail2Ban](https://github.com/fail2ban/fail2ban) | +| **Backup** | [rsnapshot](https://rsnapshot.org) | +| **Package Management** | [Nix Flakes](https://nixos.wiki/wiki/Flakes) | --- @@ -203,9 +202,9 @@ staging_alpha/ Sovran_SystemsOS is built on the work of exceptional open-source contributors and projects. -**[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin)** — The Bitcoin infrastructure layer of Sovran_SystemsOS is made possible by the nix-bitcoin project. Their rigorous, security-focused NixOS modules for Bitcoin Core, LND, Electrs, BTCPayServer, and related services provide the foundation upon which the entire Bitcoin ecosystem in this operating system is constructed. The nix-bitcoin team's commitment to reproducible, auditable Bitcoin infrastructure is directly aligned with the mission of Sovran_SystemsOS, and their work is deeply appreciated. +**[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin)** — The Bitcoin infrastructure layer of Sovran_SystemsOS is made possible by the nix-bitcoin project. Their rigorous, security-focused NixOS modules for [Bitcoin Core](https://bitcoincore.org), [LND](https://github.com/lightningnetwork/lnd), [Electrs](https://github.com/romanz/electrs), [BTCPayServer](https://btcpayserver.org), and related services provide the foundation upon which the entire Bitcoin ecosystem in this operating system is constructed. The nix-bitcoin team's commitment to reproducible, auditable Bitcoin infrastructure is directly aligned with the mission of Sovran_SystemsOS, and their work is deeply appreciated. -**[Emmanuel Rosa](https://github.com/emmanuelrosa)** — The `btc-clients-nix` and `bitcoin-knots-bip-110-nix` packages, maintained by Emmanuel Rosa, bring essential Bitcoin desktop applications (Sparrow, Bisq, Bisq2) and the BIP-110 Bitcoin Knots implementation to NixOS. These ports fill a critical gap in the NixOS Bitcoin ecosystem and are integral to delivering a complete sovereign computing experience. His dedication to packaging and maintaining these tools for the Nix community is sincerely valued. +**[Emmanuel Rosa](https://github.com/emmanuelrosa)** — The [`btc-clients-nix`](https://github.com/emmanuelrosa/btc-clients-nix) and [`bitcoin-knots-bip-110-nix`](https://github.com/emmanuelrosa/bitcoin-knots-bip-110-nix) packages, maintained by Emmanuel Rosa, bring essential Bitcoin desktop applications ([Sparrow](https://sparrowwallet.com), [Bisq](https://bisq.network), Bisq2) and the BIP-110 [Bitcoin Knots](https://bitcoinknots.org) implementation to NixOS. These ports fill a critical gap in the NixOS Bitcoin ecosystem and are integral to delivering a complete sovereign computing experience. His dedication to packaging and maintaining these tools for the Nix community is sincerely valued. **[NixOS](https://nixos.org)** — The purely functional Linux distribution that makes all of this possible. Without the NixOS foundation of declarative, reproducible system management, a project of this scope and reliability would not be feasible. -- 2.53.0 From 48826590de840332b0eb86cd783fd18300ffc49a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 06:33:44 +0000 Subject: [PATCH 333/857] Fix ISO installer: remove ports dialog, fix Plymouth paths/logo, add welcome page, fix pixelated icon, fix disko args Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/6b00bbbd-8ed5-4ef2-b2fc-bfbe6361e77c Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/assets/splash-logo.png | Bin 2012293 -> 31871 bytes iso/installer.py | 252 ++++++++++++------------------------- iso/plymouth-theme.nix | 6 +- 3 files changed, 80 insertions(+), 178 deletions(-) diff --git a/iso/assets/splash-logo.png b/iso/assets/splash-logo.png index 788d6d099da84ea394d703928cdd22deb980217f..cbfe64fcfd9efc266265e2073e49e5a767b7d978 100755 GIT binary patch literal 31871 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 YoVqTmzvI|{1_lNOPgg&ebxsLQ00}g{A^-pY literal 2012293 zcmeAS@N?(olHy`uVBq!ia0y~yV1B{C!1#@Wje&u2%JXR(7#J8h3p^r=85o?WfiNRu z-6UxS1_sFz*NBqf{Irtt#G+J&^73-M%)IR4adOATbaI=>gFM z@FSD$f8?>L!4~;2Ghw9wHhrUFm?1!Nq=Nz)IgqhM2FRMz9;_jZjQ>}I*dWaG_rJ2y zuD|y{Y+U%XCrcLmsy9uUtl*S$cL`V8rFDBFf|Cvs~B4UTqlZO8c z|Lf6nHYfsMR>9Q5_@vTEW_>W({znj>evn#9?ZapOaFQb`($9J@NB(DITm}nN9JviN z(89s+pV8A~$L}4~45TxjEN>VX7=-`-XACvn@p}y}vqvKySID4>6O8l={tNec3^2D~vj(IFpFB1_BTbB8mkm9_nHc_C zy5EyK0Sc(oUM!W2{~6lo7057q@I@?04L*60yGJ@^!WRI@fd?tl|NFus93)522uF4& zEMm`kGG`-;!`Lu+7=M&T4FQ~y4vJJz;DMAwBAnr~=RLVCAU;SAM8o7kY3wZV{->c4wgu<$&XYqj7SFs5-6}h0Rk%4VG$1EGchu5g2{pS^u$F+RCmDE0^qY3 zVD#o8@3pfC3d1xVYp8T>L+yC&-QH?l9T@M;tWXfG$3g`6!VN z3N%>6BIo!sUMwdEdm!N-9AeyX){8l5u={~t?n8-moR+!oW2y1BzdNyc z%XHh{Vr=3=Neom9fU2*dZypfuj1Q|2h#tkmD+Iuie%6D< zpOKN#AL=!ZfICWG!R){P--2=om_14in-Bm;`hP}-4aVDkZyGkf!r>860zT`>x*Lb| zh!F-y`iSu@0|SHccGUIvBgyYDzoJAsdaE6jGhhlw>EROsD3LyVyfx|*d?5gdbVdf8 z%aQPD9+exKA;5ChizUKj+n-QsIDf;24Xg|d48PFL3J3@Q?_k-mVFO|_1VWKhKtRBL z5EmwoO+82qCJ$1Jj6rh9d~|il@Hx z@j*(_)quoc7$k?R7A6j(@yUbaKVYqOdUQNWCoIcN0?e9F_6$g z1_p+B1_lOPkqb%;SR)Li85x7@L*}EgL5od3qKTn!(e=al$nJ%Sp=d-ZS}>rWAulb}NP? zk^r(gEcT%%o+p?4rXRqy768etRRIA3;YijHWWw}faRWiUFaj%Te4xphEJdaHb^xCoBDu&0E~kSu&E;; zri(5QaxZdZq058#AT=NwgmI`n&%nT72$Bb3Wc2|70ify)UX|d`57LGgBb$j$Axs{d z`7k+743IoNIgornK!6c0HMqn<`bfdZ=78KwtX^b&$a)}sb_NC*jVwnk_EeBT z0RaI8AevN6iYG}mn_TsS(M_cI1-#t}H0KN&e%-G0XbsC>)@;{@k}uDAGB09eU^r>K z{VzDuNHKI&?cfdplpOAPPcGyS^Pd+WC!1{l8wc8C0Ai2g;S~ZXkv_bwgo>VkM>?)tjZ2)0ZlIY_+m#+aWc<(AMl%bBsKM|^M|MAGU)wOrA#xt~WvY?= zHp)gDlwthO(0;~?Wf^+F48`2OUFq>71_p*}=zbaHqeZ&$jz3il{~01d0fQ~KpYde5 z3X&TlSl08Nto9IbD_xz5QM~{E|JUG*C(A#OEB-SuoPZVcpxn;Dz@P;ZAM6<9W>BIX z?Cu;W_hCf3>5l&w85kJAGechYWG#^$28#H>-k;y9_|OE|&7*A05Wt9Z1_p+&XJ5RA zuLVGkXi)9})O{90-gp0l?gG0~TnYKpYAY4{{^O ztsoD8+zv{hAoeIm4FQ~yZnFK4CP?Xq^^nDYjQ<%^K!FMp17T2rf&v!A9t;@dHlzsu zF&Nx5Slxg#(m@UeMXb!M5O4#}S+LD%4H1aK^AO6TF~;7@vRw962zN0~@9vqz1%BRtMr!f=_#~#xnl@ zzX)UovbkH89~m?KXFLOo@Uy-gQvd(_d5WwCq-PXkh5*r#4$=yWRB;9&Hreg3z%z27 zhzE(o0v^N$VURiy4Z`F`0zNa)?LFhk@_>PXL7rT@K<*q#n50Mt1q>`QU;(^C=}Fvw zhW`s08ULqy+?AUJQUk-MJUK;}7=FKo@xkG4Yj1_m+YM27AjnA%YqcLkoYvey|5zP{q0L z4WtGa-mdh>hk=1{Gf1A1fid3Wo(#BN2l27l3lbm2#DxGwkq)xrf-9@UAC~{m85kJ; znr#1rvJeBF`#}ofn9<+!o(#$nDBBeuXD~1_pl@!t?|DxazNHDIeKfK`p+GsNIMQKG zxZuvA@Q3LSxSmEX_F?icdWX{Ej{gh{!F7~`9i}MV-(a45|5|t>bMLM#3V37nC zWBeatvhD9$Fq=r?tQX6||Nj}{V9N0&BAA*{n)nbvDUONPhF>LeB%SeKjzLKT__ac0 z|1!#$?EH;)7b-;IC`nET&?KkhbI4gwmfHUe3<(Sj450e@?En7^lT3H~fj83d=^d4$ zP6*H>r-Ph!#)CDPf${$w5F3se8P+^`rV}1;;X*S^4Ad4lh>wQP*!q5euLVGp z8|4xT0rkckbyUgvuu7ciKf`fsF}7XlF?igRkb@W)7&dy|lMMi=Lp}`2VPQ14A#2|C8;PP{0l4_b@&# zdWYgum;a3aAVmiQ1H;cBKX?P4slvP^pU0L|i4KR69X}Ebvm#>4&fZB~RGzbzO#l(dG$&r4>ljRZv z1A{JdF%F9y(Q@RMwUZnGc){?GTi7W3I1>^>U+idKgIDR5WI7lAkA($Evn^qWX z*Z==bCOiJtW3%Io2lE65M#g-4xCNX2Lq&`UpF=@`{F{>#eDTW$vBz8k8Q~yzf+LfW zu@2-e5FZ_z?Dz{h-sfdXT&ML5V!APnl8^9wSvgWL+@qhpZ!amk^p9cg@w zNCyQJ(|<-cP(XkpoPmJ>ev=*gzpF)tE&U&(*1<}YDlt5)9uZ%Y8kOPGQ?U)Ks z#DW3}qz07BVGXwp1|Pt)aj*ae$qyzBa`S%%T(=thUkutKHJIEx&~Cw+)6pFzBPsG5 z6tL*xgNYAvA1KkHy9sm_Dgy&U8M^qu@H-zJIXAf00>BdhDDXgm2nrbF91jv_U|<;Z zm#%}{4Rb$;jfQ`X*7JxEfk!$*2$N;9{f`Bv=+F@{+5U%pw4O)A7r5R=a2XhobNOjc zX3*JPN08-5*+V}Bz&RaMgkvij85tNgO}GEC9s1s6U|{;ssC~whId3@lg;GC&Bb`!1 zhJt>h9lx(JFfdLW3T~yf8&M)1xoF3g`)TcRtk*OXBM-9&%{)*h1>d}gWQHIE0|R_R3P?Qz1H)ZJPG@9n^|~v&0V-i-u=_Xo zPA@2*Ahlt`2E11hBRdq?FkIruY6!5WAj=bL9u{@r03*RHc!ojNgT_XQa5OopaFP6h z;eIR;4ziO4+2P>3GeM$cV$dDbV6Cu}2IgQA3IPED50M;;se*tA%zS)ifYiX`H*DCz z77!2sS~87dGE5#t5C;cj9u6sj!r1h}#9(fK@zD*#C625PW(KktG8^3tp5KL^t zh7DE>3=BtcnFdn_l1C2@kQhEpiC&nw=w`r+cSc5r9WX`Y(4=|>iyeN*V#p3f7KgGy z_ifuj*$f*tY=AAAIDjOEwa$deGcYi)A+d2WvF(<{CWaj0T(}Gai4*V_NC{b(*zgD0 z56|g8{;_3lSAN{%c~`cCk%6HNbmD-?j^CgYf{Atnvc(|7K^iw~*kBkC5CFQD8pOqk z0|EjzZ`c57Qf%0;0o*{ssTflNo_H~(u!{r)1pJ2Chg}5&LqI?Ps5Aj#uo9S`z#Im! zJR=q{@cuiH8f@VK5+?;Cn}JOqK6zw0WOu?NJ>b6hyX{Jkiy0UgN{qMvsRj+hGB7Y8 zBE4OHyE&0-0gx>~bLb6ZJ|1<*%0R7TFdx|yU;${rgV^}Yg6Siq2A@1WIgtHO3~DWd zw`arbgo=YoBSIw)Oeszp-3{n=;MB;#fYW}Ga|=F8u*n4k1i*3u*7gIkCy?3L%o+}2 zgTRlej50GrzG7SV4$AeTEu{&q$Atqc~6#`@jt_LWU*29@CpG$r2k`- zIpfLl1l@Z>HMeh9da@lfrHJmIQ9jZhHfc!Ix zQ9}Tc(;?zrXS`VULzoN$Y0MUaDgCd-fk)a9Xt}~u2=5s*7U?f|*VD zk44d7=MT{JA|NHB7)J;Y73m7WWL7ub_VX5qO$t8i$+F--0|WRnH<*1e{kzm2O8x!A^c2}%P=uqagQ*{-F++gp zNQY@f4`B2h2U7>4L25xXGXBr-zsPj^-+q|bX;0=BMh3=e7#~XS0v#6$Wy2|Kc7f~| zjc|An5yc=m(m|$z0-Aw=;g!kuKjI)h2xE&7kT^O%Q{qT>442( zWVrB?@wZLD9VPJD^I$O~0&Xs7E?{)P1u106XHqTFVNN*X#Tv-)|NlA|AHDd8iQ}V@ zBl=0&8m5520E%~w;InU}$x%Jh(E|`U0@3Ag@IRSs|HF?%Zd8~GAwcs;r-B;^8gb5x zS^M9AM$qx#koEfnbr7$Bk&*G}<7e7-0T(X(BVO02%Fz%QTpl}-#y4;q_B4?J+k=Ez@Yn&fl-KwfkE&;!~Ylm|1-T{`v2#I z#{&hjr)bW2GB09aV2nj|3uC&;_TRHm#R&51>>k&`)&dYzJgQ(c1jr5n&c<(bgCggj z@nivwyTK36`uFdjjoHqB$fvzvcyg!mW1D~f84hDeU>A79z`(HaKjZ&M@fSeGZTSf=XtbwiugE0IKytT(U6eMkh(>>*|dE|41|bXB7O;`2RiAKZb|Yn5x~O z{3z}Jf5w@}4nv z@E^2L9_xZO)$YAS!R~F~>kH`rhfV;C418S729{RZzphX@P}61{-J79Pa-1bH(Exn>Y!�P_5dsuvbXdYhPmCMH z9&@qtFhEx~|IhKfCx>$35-fOO`a%3B&)-ly=ms)pu;4QuEPELk8JuA5K@TICI6nG} zC+h_UhX48?HRR|A>7yOqa!_#N=vn~Ug)ePPCOgx^GV6Z^hHBI8f7+0p2dmBh|7Xzi zx-WYfSq{X8ZH5N18U8Z_o9y@lUpWsJ8-@fdyg*)K`NJql>SbvN{e*V|g6tT@qalDC z0wibjvmVSQ{}~xigA75hZD1Lkfq`+V=RKJmkQ!u+NURK4`}4?Zhb#MxJF6K3(|^!r zKG3?}r6xQ6#E{}wSQw&*Jt=04svUVDKvG7BB{D|Fe`dzp{(&<)D9E5^(814~1&JYJ zm_Cr$XaWU=0s{kRj$cOd3V7P?KO;kx>9#-Z5E;TGEIgV1GrAe=_`RF3j!_jOJp_o( z=&;0vo>5`7IJN<|GoCCD85kJkK!J>|4QY*%^=PLzxc3}-#=$(lfnf{MZ{m|7l~%kliV z7_y#`6{M8;78Y)x%zMU@0zaS&(X^e~vAs}&&d$M&P8iq$RIV>aw4{fylH*5nD>?*Ut zp^GXLy90eD7-B??#~0Xb-g(&JSi8no3EM8ohoZ&qU{``_s4?9h-HeAMU>wu3KD2Fyj^ z#TvM5|IfexzNZ6yKl#6Z|IdK*m~8(8Kj;y}AH{*bGh`t5)we4?naRNTKkX;q5B7iys_lrKmfG-fhZ?MFGL4}U_d~?2Pm5Xn|@;KKvsjz44C=YXk@+U@*6g6 zxW~Z2polJx%*Ul4hdXKl0s>lL`mv~)8xRnX0+Yu@Z`iN_?eI)oDj67%-M3-G2GB`E zfoQ6k0s;_s?P75gx*6aNS7>Hn<)Yb5bh?5WMu~o6^dhSP*@?~XAaP>Vfz+bmhfM$f zLl(xMNHG2n0`ZYEIfzd$jLV}q?7xpJzG1@#a;BI-XTl>JLw7bVKX2Hu0aS?L&Ez1* z;?fV2!-88#RFB07un@>?V8#FtcxR6Wz&&JJi5wTmYH`7-I`J6t2#XxZpWuRH!-fsy9;pp#eb(W#8$Eomg#o%6EN(>+B2_(# zW+(@nJD^geQlO>l73g_{g@J+b3hX3OxQUDmHyQpjsKfd6XDnr4V2H+IE$DK?yBjua zK#`(GK%*Flg|lJ9hJt{A0PxK#8#ZhpYY>w_WO|}oi%mV6UqP7)W$E&U4I9`30s=q@ z9^E*4@j>qC*sx&(%GFy83=9hb0s<02jtB?{DA=%JLq3R&jIsF_CXeQCm@qoc#=yXU z&F)PB0RjH#imAoN=58Y0i^~a585kJAN4DWoz`(!&IzC+si#%u@OelIDVfz37-wbrc zjQ<(I164azo}e$lxB*go!C!!87np<04+sc|fd-Tzh))P_*swv5kQ!3tp?0Hd=|>U( zp8hsusm~kQfLvFfbfPk-*OJ!!ArSQBdoID8r$CKvugYARs^nSpXY5 zARs^+suzCbI5u@KF&5Jue`lQWWSM@(lVzgG_CMfK*5jVcqU}nL7ykeEe2cNr!CWbe`Y}l~jb3i}dsD^9X1b3Eh5-JOb*YgYav8h6x!obK764Zwz-47XK)`vJTtGm89v1yPSj1pjKs4xxED(*37cnp} z#GwnK@%JH*9HYrmor|782>6Sj{UGxwFauk^8<(B=e#xEmYRrHf2CSBul4X7iJOAeR#7nuJ+5x{WDoynSsiRlOv6QhaI z*57Agx}f`4zkA-31)mBD6Q}0{JIu~edNc$^LxB7c0GG06JO3R883Eg}24W*&P9*jy zb2J1-LtqGo063#V-3{K!a>-abyysW^|Az zVVM^>vwPl`yYrKe7d-U_OKc#uAPiB@2s(I99$61aZWL251YqGln#rjbrX#^5@ZuSK z9))FENJd7e#FyY;dKkVka+w_Z{T-oeltpd`>{fj$@#pWq=OF!~nH&@fqnMNsz*(ci z41r~Ec;lJDH1{e6vm)T{-2TIEiBwg&_1ek zGz1t3*66S>I^)Umih+Sal!1}qfXTK$jxh1<0Yre#bw+5USL zNDPFD=|>{;GK?lvP@tjV9m{2vD5SVV0cnWC0zx1|HEzPm~*EpYyS^ z{(&9r@Xzy}EPUB5Oh1f_hQJA>;;~+)1 zqgdHKzW)z8A(G)Q^FIaiE&o903W20X@n{H;6#`Vt=rG&Qc(Qz9V1O*6#g<8t6F4IS zW247C8R!j=FcT2;8BdnQ3=9k?hler#X9zIf@n<7K)xfY$d$PP>WMB|SwjG;)k>!#7 z&hVdcmgjw$bY%H69<0d>jQ{6=#8}w=2pVi;z_!B>Bt42pLx7|Zpn67!*?!iG)$jlR z|KJSypMjy-bo-w=m>i5oN%#y5*FEpaVms#+rW{P8*Z5#T2=VmEa~-*W3l~uK)I+4m zBhPxWxcz5f0G*_XVZeXJ|9z(0{uX0Mpb4P)@tfy8S@2#0WM#;HB2-u)>l;PgC@^+=ipr$I~)XM+={XFl>;kx|**?2Jfj3*1|uA$jr4)ut)96#BWboPA( zPi+xn>~^Kc*BKZXG+=6RwOY=2uxw^vWblEiyJ52Zk0z8oN{xm9)kA z8UlkT1i*K-3?lcSI0akHc*={#3VQ*7td(&s(|?9KqaD9Nx7Q&{5M)DV4nP+rpzPE` z)A-Y5`ybF9LTJLH+|dvioFOo%YjjG2?2IRC8Uw?B(7jXyr|;nQ{AbwmpYdPca9E`W z_tPk2Gz1tJh~K_C6hjMiRqUUipF8LM z8*Tr6WGMP^)Xk$IKuib>iy9qyGYB!BCPmHv{|xb_JN|%fWh6!0sM^sG7*ruJBs2OM zPZrRI5g`I0`Gk>yVHN`e^D^V@Ke1km3DbYtgT&Sd)^VRB=8ukvG&pZ^%G85sX- zFflUl{QJ-Fg^BV1dxn1u*MIb%I}#8OfO0(*vG#y&zd^qk1Ed$*6d_1_6px0$fQ0~O zf|POoGcYjvc;1uQ zMvO71Jy{AE85sJJ)sE(RkuetCuK$exw7u@iV_koXd}b6#A0s0}r17>tD?sc~ zJQ@P@3IV!i^b7tB0)KuXE^Nn_!LileOn({V-5<(=o8I&aAYArrSAN{X@Sg$Y_!&k9 zhUFgjWTUX@Ipf8e&+z|0yxEV>@7VN@ij9T{`(JF7t6r#pW)rFzlX6YG{rfMB52J}G3}I?V>Cq6NBm{`a z^yC?joC*Ii{Ny&<&J4Px!U+i^XE)GQj7S0(@7SP?z99X14LX9Td|KL1l z8l-?2ykY$YMwwY5yBQc5oUy7Waxpxz|3K!TU)ur_JLAa$FBJF%8QE2r{r&|KAH|~~ zK+O=KNk%7BQ*Bpze20O70e%=9whVym$+KQ85&!=)VBGcyy7$O<$Db->wX|ZN@niwb z`^%%7PpApLVZ#PS_9A2OS=G4wdCHyHkco-$9LVg!a=<;vEu(n&gaFkuIuV&1vHtY` ze^t->a-d9)HAGK)vMgt0U;y7zh@_ywWc!~+BtG4lXFORzN8IutTTG3c8DL@epMhb8 z>GnU7$mWl-M?-*;5TI&Chb2@71_q3kZX2X-^RRPpfHDZ!y|`*OkmOlUme&6a3@9}< zp_&t z;fu-kKfK8HZBu%@kdc7_-2eX1@PD4yJ-K9Lc^Dh{_Cc7~V9ek!x54O3o~-=882+Q& zvqXuzk;3wl$@V{>V{>3ujnbnbKvoD)C8HxH%-1H{|A-;GWV_O1&@GI7AhDlc7`Ovo z$zlx3f#i_P0C9)ph3luiSPdBe|344*0El2@m~67`PacR#Dn^6{;~|snzwJrYKdOE- z1dv03;*5?+keD?&@~AVeruqeU4uwBVf9}H^_50snam$?;`&MDnLxMiz$#RQ<0Wr5u zZtV^W+yDRnXPWN#J7Y+Ae$$!CMJ`g@niwj?wla~3=9mQ^ObQ; zn?aS3LUFJ5il%%m04X-&Q;Qr{{~4LpO}G676$bdUV93GDU}RyDHs1W>8HR#Ufzc2k zA_NF-6UAl5884RoFgY?aJZK~yoZ&fsvh$m4|3k$L4|CbT(x7np&+s2KMaRJS|L@H+ zo-B_^brHx6ke>g4{yYWIqj)p~$O!?mGWsb`Ryl_M3=SX*VF?k$24Ny=bxn&tD#Bq0y87{=pC~3l4Q}S;^7(sWMyZ3c6_R54hPrlA?f;%x=8> zPXa8gPJ1$s?sJC)#wblx2#}P~&v-J!_K3eiK2&{!!UF*YhW`a18?j~bGw!Tr|Nb*x z0m=Vo_~$ejZc_xgWgsvmnH{E=k%2LMAlyD`=g9L zXJUR2V*lj(fpJ6ODNjxj2B!ZfLGq0M|C^3pfX0B*<$<-3K;c6ShGlkA^$}zCsG8v$ z0z_wYnOPy=O&YL-1`86{;2R?Y!?J)2su;Vlm>7P)2CHRcbTHoW_Y9alN>G&9K}%&p zL3qZKA%Hgoh|2T~3=HZZQ%rXJu>;X?3_X$= zYaiW{1vZ`b!era;Cb*ta21*E^WqhIz;s6-{(}yT5K%%2~Gz74R0Ktq7OGrwwRM zgBb&BzW-$V1)DF1Nio3mfCNmo|B(dIqZoGxz%nfZ1B2yJS5}m5vAA_1WKMdslW>9# zLf<= z6ypnl|NqrM((Ek%;ahw`e0&%dzRdsrVm*!;pWacqkre`XGx|%0ci@3GSb~BF4I_g$ zh>fku&cN`0DM$`O4@htn;|u|l9e-~#Ffe=tDL`%!f%tgwf5sCa`Lmv^)gXElkA?uY z5Wtzy!Bb=q9Vmw#!kX-i42PF>8ZV13KG)f4_NQ(Rh3E-;D z10L)A1j)nj8BZ3_=`tWLj0UksF_|G?vi%SEq#hErg9hZZK)2Qy?fMHk2cJaklq&!9 zEhe7$wE&dbHmaZe5WrfyV>1rcPsg_Wm4Sf)oJdW!{}I5ZcT|k_Az-rok2c7bf6R<` zLG&md4FQx8;B5R>hmqT%XC9-h-$d4hz6KUHBFD(U0NUDytaOw;8UmvsFi1iGBcn4g zFu;d`VQ$>6^cXzv#>m9z2opo6jko_PLKhq5(>DZQ117M-n!dJ=+BTR&041YeaAlPM zI~H@k47`%Y@$Uqh!We<)JFxmbG+~qps$vV0r zVQ@srfV%>3M#tki@a5vB+yBhOqh?fUz(e3a&Z*G>ciN}}=o$jxjDFUO)emGVBjZ2V zXgi2Iibq3WGz5l82!I#D|NsAgBgj9-+y0#f(IC8C>B)8w&GMH8GS3d;FfcHj_F}1I z1fL+qxB)6ON)5sgfGwmMgiafE(g1`2xJJir?|%=Fxa&j7mmnI38UHi1f!HS7e+Po- zQ9K#~qaiT(LjX@kAN-CVb>V0TjD`SlA%L9GM;E~m7iOcXMnho8g#gnTA6Cd^YyTL) z19B)nU}U&}%ttJ+VR(ToKFS^qfzc44V+g42dHjijwE#?ve;N8f&NbQj2fmSIyV9e{ zATg%@Oeh7x?_VsNLE?;z|4_t1VxxF81V%$(@P+`>e}@0Qn9gI&0g1WalYR`MVVIGD z!3M-e9;pZMNAYL~jE2DA4guu;`rvlysOv^UU^E2C4gv6-I@!91fW{3QHgqvCFo4$e z!>*0p#lXOj9}o}#xrA;A_+iwEqai?P2n^bczG1@#&`FjA-4_rLz=$3Q8#ZhJEz?ay z7l-oQ7#J9?Z`c4?`-4M2czF<1J(xmPw_(Euyz(Fo*wlc;v0-GhvB?u*7EeGx0Qi;& zbp7ah(d|N)2kA#w0}{iAH*DAd3VWiCv_&?PkewSgY=~iCU_jYjiL4*y7HsCgFwP!vWTq7Z1H&<7fejlr zTn-2b&_xy-0JaMQ1H)a|3&1Ox<-IuMAS_?HsnSdf~4fB=*w+W`RqBwaPTVZ#P&ttgPugArql8Q^f_3LN6t zgjWs9j1G!OXsJ)Er?GjC5;2Uv@rDf>et-(aZ+lu1J#0qwb)%!0;TA0s#R57~?qD;+2*GK%`SK zl|h@W*xZJmcrY~+76C1lC9DdI3Ty$0MHx;ZKE3By26Be*pmjm$4ZR0(k63&*UKGw8Z?l+!7EFK(ov3aoLG$02f6CPQjAY6Ob)rw z0ErRdN02g{I3OV4BB^eG8AgWP*I;^)>41QM57^ug5DicfU$Zk zARyopF@^;M1fY)&6Qga!sR;-OcrZ|&4hRT1Gf;L95_|74a5jFcV`BKvSORjB z5DmgV*?uK~XpSOdl%djp|Ni-d#s9Fa2hoE82LuG546&kDeMGo%A|5pqmn3-19!XME zU6My)!0jPX{jgIW5D=iuWU}M;4v^1S8Ck%Gz=PNUHaSN!^&lnI802=YcBFz9A9mLjjJBU>=Z3J;G3B=aaCdUOy zpzsH21O)^G>|S2v+X1#KJq8btdft;o>1<$A3o>XFkA}c#2n^m30Cm&{uj@u#HW~t> zAwZQ7z?acq1>g&?e|}+506BfT z(qoh%Q{Q3ePU9K?ht%X1<89|uV_Kcc8^g{q8 zqZ@Dg18#;hGBPa0<~8)~ZLmQ(=D$oR^X}O6j*3w?1hy+Z{tGgZiHX4iM33Up5Wp7# zC>b4>LfG1O_9A1@F`ihJopEO+0~>KT~+gU%Vkresu%$|0~_@$r7BkuVy{9i>J?0DA~v zWOO8DsB+8jJBX?6U4*PRIC{tHoyL#fdTbe z3g*AyqbYIJ>fkwc*gp1Q8CrwFQ~$w-fq34Rl^qn$8Fj>v2m!1a9qe*OhE5QD#*-O- z%#`PC1^8jp8^j)?Tzn0iV+ZT;U{(duqnNrOuwCgf>`E7UTm?d1$B&vepdo-Wqnm8| zQvq=qBg(1u@X>f423VqlC}Ut?c=B8aa;zsK<1M)0D1(|I04`iT@5y>o)6h|~hJOg) z%;*rWGvbc@2?{DOdU08 zq=W$8jBc|1_Y`PAh(c*F1*_E=7#J`PE9l-IFxTUb9Q;a@?Mja^ z?%UxPWCUMu#>l|5Z^QZxD5uSW4HzYe3ITXQ`v1QtQ5r@SjfMc05FnV*L3&^{G~@sO z=RtfJMyg?SHYhw0fQf@>)n&gy19zz)w#=*$oJ(p!@}n4g2q2BPGkD&Y1E0|kQh7Uy z73E?Jkk}|54FP;1KvYHt8NkTEm<6KIoA5vT&VtQgXJQ6l3;|LD!zSDR%whP?a2Ui# z*9Q_C#kfOYoAMLT{lKshca#><$M^q#p7UVU!mWE$W;6s)LjasfQH8McjJN-u4wE_U z#o_~F2LuHC|NsC0Vh|tR%mi_u*ks2aI|c@ZcOdR)W(S1=ahUP{fA9ezNUbKYMpzjB zWBNzzW#(Xgqr}J#0irWHD3}mUc!td&J`8)^mjmr<2jBjJHM4L3Bh0|S06upPlu$Pq zd>E`(zrwsUpfoHD|1mO|Av@%ZC(A_`-(=h0wJ`Q5JsJXpLV%=<4mRv7Bj_@4&@>u2 zV}r#Q7#KY7$->QoB~+Lkh&I{&M}~oc;W&sbBPsHGbTl3m5||h%>~?zJl|Btq0lg?w z55$KRxFGf@9t{CvLI9pQiBSwvV{+&>=t99`AU3FW2GKB#+|1mr^cY*8-HMU%e=9)&n_ASecl?i;_k&IdZ6svWX1=5;MB6*`L{{YXMBQ z|FMQycE*zh<*Fy7j4!ZV=`nno4yN9C+uwN{Kfl6mkdZ$j z1eP3v1jzjih^>8Zad}~b>~lV(FnNVMx(U()HV4FDU|@iSF_b$>jfMcpA;1_A5J0jf zY#Lw*laYzR&Uot|@TwV@I-~^609tVanv93ZA=76(S*|cJFlZyh7}HI*|DJ{54_+3` z|1cNg%beSl9&cq}VDJK|Wn}o@>Tyr52E>M8WV7&@15-arkA?uD5TIH{2U!G5prC`~ zVP{N(#6cK*+9C8HG=~2S2R!e~;@tfLGY6y(gc%ta+KjjVsRpqJ8%B34!+(ZglO2C> zUW@{TbMl(4$NJa@NgaB1DI>dF&N$>`%v^ZD-(R< zJ95(=rUsctG7BdB&t&@_$jKKlkpZC3x-;thXJSU%+4Fyv$@ahLxSX>={V_lLZ-x&r zd2D`1_U~vWhlSKAO^pzsdPWC1ico?@uGJVB7#ltA$uxlU;ljw~!PPJ_95C7T2WP7Q zu8vBKGj^<;4BY==7tO;>#nyU(OF|jQ{$*fbc;R_ZRuU=&r;z;k-(>qA_?QJ;!6;)i z1Skjrnq+j4dyo3t7ysP1LtP61mm-C6#*<|`0|Nufsuq}jVrt(VicgaNGyb0g(}QiS0=1>Uc-mz9 zZ7M@>BhqL!A87Rm%EU6r9BlZs zC(Ck128IZ1a!6u-85sW0W%$pq(PYP8jFT5n__2zz{P}0cz`&Hm@c%z}pbSYb2J|~;mp=%#eu%YmRNZI@5EBA4&*(5m5R?+DzVWu-3sA+0=WSPh;>qy; zKT3-Mm%C4UFwbXXWK4jWPD~d7rgoGb4FPgPfNmKbUmEl2c&VZ;O$C}e=smGfX~x{xd~f8943cc*!}yp`6fxtnQ2_%Q*%H z21A(G6G29nfMvgly3Q7+XOtcd0cwQ6fMs-;N3XbRy z7&Mt477J%QS-vtbFo4gsf{C%P@o*Y!`~VsRg9#Bu?@)Xk|DTZoH2Utx04_%`wJ|a< zfR4=B&cOI@rpI0RLzpV?i=6dj&HT^se;TqXQm5#U^^USfLx2_`Fz7Nm%#&w5Sla$G zGE~C&aGIG>*ksFZ(4rH#5WN|vJz3Hj85m}u+xwf7lgD!1XHY?fE;Y&@4S@j-fkB(m zkwXP}wH>lJ!+*vTCOdvxqKMIe13GN|ALIY4IE6KpTMo8kHVgAu!}J zI(qP(@niwrCxv4L9J*?9`Tsjiw*STKMv!aVsJ_t5+cP6j0`0V3=9~%c?ZBrRJD?kfnnhv=706XZ0V<}#iNFehQMeD zjE2An4uR1Moko#=)RmQoo#o#&5{CwnU2~s-;r~j;{|sx5cl-sds7IC?5H@Jn|G&Qs zf&Up9{23VjgKp3!#~oLh7#K5*w*Njtj^j^Z8pI4%m2Io{xW%w zp3s3AGNU4+Aut*Oqai>{2#i+f#Do%54XE;#o)xlzk%0lUD-J4(le+ewkulnI+wTiF zRg6Fh@N|Qua6bbBBQZyXefrD9Y+=6j=k*a7RHI%V1*0KAlMt}2QcxNGYXL?pcRY#b zv=^%ZKRQM*aWK_&>sQ+uv1qjG|a-tMVgb#{Y~ijEw(X82&RlFfcICd_^;K z`~IGP3=FG({rkH+;DG}C_9==zgl52bPbSH~42*{v7#Nk&q)@p3|1(T8-SHB36sX$ru4Xy zk%0kp`4hhGK0ZS~Gcqvj`2U|_9|OzZy`HxfZsSu-EjjQlbD!BA8U8c6FfcOsGW=&y zC)@1*3~PR}awG-Z68%iJ84L^zXFOOoGcYpv;56g^e{XCz@!?cIDlr-YqaiRF0>dN( zMqGt{+JhyIk&$5`j`;h}@c+NM$&SBdRhZk9o&+;8{9nevzyLqU9)~>$VMfNyOn?8j zyFZXWju0GrtPNt1xj1+jD*iJtl#}9rMn;AvkGrz<%TWnf@P!WN7F85vqkxBaQbCWlXKr}AT) zfBzZw5?7Hi?qL1>H_qj;{A+w>4rjR?N{?FpGcZ;Yy>G@+55yUqE2Me;o3o!lNND8UmvsFr-6Z*jDIgJeij=Ffc}8i=F>W|714+Pkln71=BFjyc&F2W9{ z0|Wl0?2HTro_A$I7cgVmM+1?QK1}i~{}`SB|7UP!U|@7&U|^7-fi(~#|1+LoVEliG zfst{W$+q7IA%e6gcc?s5{QvLoWd;TYZk)C;{@>$ySI!luJQj&Fo-C&s7#Oe&#WS8Z z+5X!MiBA`*8-gJWC8V(^RNVXs>$|0bFheG2<=pOX!(zc=@^CtRDhB3f4awAInZ%& zP!YQ$|1*Gk z#QqEn48mj^%gC_tHwR}lwXU7spmA4_{TCak!N!5rP5&8ZdES>v$0~;=2sy|1KepBQ zA5FIZ5kONu${h`X(GVC7fk7PtgSK+V6>4WZS$<A^fF!sxzeP<;D0w`NVb;|QE7ZW4iQ;9Fn* zGc5l9|Gza6e!0i+l~Kp!(C_a=sKu&eyUJs4hJOrOm>3ys-S5icJva!f0SpWb;KmpO zJGKLJx=gnJ0d3X8rFB$%Gz3ONU^D~2tWMTDY_L<^d)Cfon;pow8{cPKx!`~RN_+Y0R;p7&%qF*Fb%aK?k#i-D1G zD?X)+j11Yv+x|=gso4--w3yhn03h|F7!d*&Tv;Xlu>8M;f1x1bT9fU+LlC;?$2#N1 zvVq}0=$=`Go&OpBUpL+UR|_FB${G!U(GVC7fq@NyK~=fq*pBwWWcwdMLcz3M`SA^G zE2J5j{yTfzk=sW|Kf3%GPnJCl44`de=rRyKBjf*M<86QEL3pDiHACQpAFC+K@Bg49 zdD(H8`%RodNI-V`E6~~s97?D!e8!Unv^${_&E)eY+y4ypv-;7zG|C+ffzc2ciXkv4 zDs<4i>)(I>@1X_YbCd0VFt!|_sbScz^!P6W0|OJ9_$SYMve<^*(UhTb&v>$)Vqo}> z>tK`1Cfon$q3Rgr(IW)Tc(RrxRVqj!Q#i6FrWcwe`;x`=9qr&tKfzw{>YK;H?-o#<=dz0;dgmB1F zT^QSCGK~NK*&FZpcZlkijT$%_0;3@?8Uh5sfK}*cJXzi`FfgDDr9Vkq!-Vgc&JAj> zdD;K`z*sQF_@BwaAFBlmZ#E~sB+5U$SnLo-N4S~@R7!3hhhXCCxcdSFH{}|0p zcK$wv%d;B{HZZY&G6oH^qbT|JpV8d=z6?%%D2f>v7;qmE%5c?W`yU;2^`rcu69Q*E zS<@I8{?EkX;{Q*c>#zh|xbP2)994zRc(R~h4)f4t`yU0WS~6Ub1Eb8W5Re(j!eI6v zRz@+4O}|I%RcYWr7>2|dPZq3u85x-v?2NbmIZR70pYdX~XZZjB0J0rS{~5K7cKp7E zEH=s>4S~@R7!3hxhX7qF^s`yrnfewmC^FD1izGFKIk>wwYqQTA|pp$LT+%n1?4S~@R7!3i+ zLV#wK`x#HxC2mJz_8GG`=10_h0YmI7SJkB*7`zW~RdUtcf} z3TOJSZ?g68C2V>|#YT1r;HudFGyV@Z-S&4CEkftCH>(rlzyBz=2N7!E(!w31W{!ry zXb2475TJU6jy~M}pXr~h>DGTwa0TIZ%wG8y&wH}Sy%}6uLCe@d8{C zXFjsnD0?&nu!X=8-+#Ouf7n31XB6q5e;9eKcmMu^B1k0;dj9y!#H?k$_2+de89!>w zXb6mkzz7Zjs#WM`JeXA(7#Tr_b;E-bUk_xb!V~L%O#e}@&SL(n=6PEIW7r&SAe3>& zlX)ov17j4F3!}E1Z2#j0LK?9-DIc#ziu%rn{k zCz+5Lqw=F6Fd71*A%HIgs8YF~^!Xa?Rll1}v!+(8bm5huGk;dEptUwl{ z2K$UBb2|eAV+E2~uS~Z8LCF(Ha-+=A5Eu=C(GVaQ0u)z-XFORCB3I^&j0}qiROX;X zaL5JMBw{M_GoCD0(JS+R4F5GpD|3QzJgR`m5HQ*P+W>up>HmL*<>*7^L>h-znaTFQ zm7sm=NQ%U0VGok|qs-9|7!8489s(5g(9d|XykTHq5Jn24RFmz0<{#5vavQi$Wgm^UWd|A>O*NAYL~&>{rRda}&;&%l6kD+|+qMmM7! zzjss9zQ>+otS=ef{X#Yqy-|TIH_9Fjfzc2c4FOysKz4&37FO9NCLtr!n1~3H38h$_H$?}cPKqx#lXlAf)tc@J@3h)+_8!z2WEl}k3lcQ{xSY{HQD~h0jyw@ z7!82|3IUTHf44mmWMo0Mo{8npqqANtS;%6PvPUa*N<(l||7Zw|+z>!1pzsC58Bdl@ z1_lO8n413#|BFm^{5=EXBhx#So~-!Kzz~WgcF*&ktO^nzgL%r6S%c}<7nFlHSlDcD+*9~b{;_#ciW^w9I3 ztTGZGg9$qPgNcC=rD6y5T1G2&jG!777y%(*a_IN>e+>VOk-W-x#*=kD5`UCA8Umvs zFd70FAwX1x&cwuoeCpdTlO2D8Fic`#VA!ho(1d}Jp$AFspXWVU1tdNO^PC5(>;M0Z zJCG$nl{qqhlsy^(BP|5Xw*Nc#pW**>gunj>obqC^LhwgfqaiRF0;3^-76K1jgbc)Cfon8qnQKdZZO!u#Kg#S8q9$bp7&%?4rGT4!>O|#%qIUB|L=keFfcHT*67Hg zG0GmvAz-@wFKEIZJ{HgPpW*084v$g4kA}c#2;dI^ycPNxPnHA7+W#^9gzsHL76-A} zKN*8296?MlCbYc$l;>Zr|BQ?%4S>L%?MFACx?Y-oS<{9A%7<5XjJ| zwH;jxFhU}mB5&fX+)sIOiZC!R*uzc#&oJ9;J2SlJ02gNfAL9NVng5T0(FmE3jm^Zs z4!V~WE``3l7%nl&7!83D9s-OE%qXi(&v>zPjPM{C_5NrGjD`S~5WrcXGco*rjjY~e z$DcH0K8(FX=}8;|0|WfV7Dh&fRo?ex&co!f(da!6(Baq6<;B>PkBW_kz=#e3SL>5jjNNc>UeXb6mk z0FDs&&&UWG4TH(DpK@n5gmFgc(GVE?At2vf*)zHpfL`&0QMsS?WJc*ZgU`pr;+mam z&t(`G7`R|kj12!z2iy_`9n=mJ!k`)dF&#%1G}-=#6`4QE9u0xf5WpP*rrUlmMwVq_ zVmyb;A7zh*z-S1J>=3}H&>0yRHDN)*_@7}FjEzqJ`}5~9vY5v`IWuHFIvai1oq^$h zKDwGw{%8n{h5-H$U}R#jLsoIto5dEHKgu2rfzc2cxgmg3p`Y<&K`g>yU|=xb@dxYN z_;%$duBbuwe+nuel?S>A9c|d1!DRd2NvNtud7~jP8UlnuzpLdK;_BI3i*yK!u0n)-g9D+ z)sC`9Ltr!nP(#3I*Ef_cqo}f@ywMOC4FT$hz{7|3Swm|r0KB(;#*;M%IY7i2gyF;R z$l@Cm9>BM*LBtvNLTK#d27?cv1_bg6bpMTZ{k?}>^{D7*2#kinXb6mkz-S1Jh5$Js z0I$#)82(R2Hb8d!tKZ0c7@M7m8RY~x&wDa1Fga|rw50HD6mh2i$gAH`q((WTAut*O zqaiRF0;3@?8Uo~n08)jHFo2PPVJ3oy!9qFB3PTo6fRTX_-|TijeuE?9mVy4S~@R7!85Z5Eu;s>VyEaa%Vt3{`DyyqjmAfjY^G% zz-S1JhQMeDjE2By2nxtVp&d6{RSra<@lqa(Wn%F@!{wQ}e1V%$(Gz3ON zU^E0qLx5T#@UTU$n&p%ylM!;&{-5dJGHe!XQ+}+$_#YwjpMh~6f``ds1YI4)h!Fq( zpP?DS8~Usb8#aLU`7LK)V9=+~H!}hP0y0T4W5b3GOBom#qDaw$MQuSqK)^&S;#h?? zY}oLTfq?<u?`YJ;0+r#TxMWk&_Re`W&H^V2*7z>@P-W=)-f1p@;E-lHgquybQTKtKRCbsIKpFkxU|K-sc`O&x*s z3)+uM@b(;hW+2DGTwuxV%f$KcI?QdIxlf=wMvjFFMi2gU}` zraS)N-M|h~Ln$s?ZL^NRS^!F|fa~9|VFND%1H&h{0CgCk)rkZv_YE61fX?Iv9kfp! zTNxN8Y}l{?wn(BVARvIyYDR(;I}r{eRI!7M03B)oqH$qrR_q`c$IG0 zumRM7`-N8#>5~3P!6v8Ae46svt0o z+pu8+HOmVO^N|EVM~`By+>zAKn@O-@2RR6uKg0t90$zdm*zkr88$gZDli1|2h@oux z!Xgh!1E3Sw?qHLnKn#?=XE8DUV<5Jrh*DX5-IYhV?FEZNM6ierS)mOZHiXikGAAlL zpoxx_nX_-hh7GTYa?J2nbbP~x4Jg|@NboM|VGJZ_L{jR!VZ#PXBtEs6HlQ>_BeO|% zBeaJ??aG{FchFnIh7B9E7#J9+U76F{8^h7Qmm4;0zJ?tV;F8W0db%TXWl9f;5gYTPVDaPhL< z2LuGH#;aoJNSj=2zat#!v;|K;fNs#5fEiyU`T>636h;S zRD2Hz2x!D1N31Z&{XN7g25pfJ2ne9X9m5+oY}im95D?I{VZ#Q{9&D6DR!DFkHIy!5 zU|>MGRSl1!puD^lBoC_4xj_sfFlccI5z0okl7N5!cXC32ab|-XwblXz1O$NQ=`frT z5D;*6!-fs%4ET=;gy|!ul7gunQglE-z~c=YHW1_H&%`JKse#T{5>q$|1Ox(18zr8#ZiE4G0LJ#yma9 zYFv00E{TB_Cn7I_7By7{1O(vSi39ZqXs1dfl#P!n+pu9nd_X{e1hOI`+=8r}QuaRr zjnB6X3=Fja0Rht~wP=v&&)=|NLp~91@d^kC*iMAHA+Lmp^ad&y^3lTsRH1_s2QkBN zFBljYq=tM*4ZH_HTUeGtx1@nw0~&${4TFK$Lmm@YF-$lA5s@L#-ExY7fx$W;Am9(W z1UCML4I2n85Cv-s2nfJfO}0V82FHJO2dVMJ8u(0Ij1sWLDyY(r}JfjSO4Obq`SK!pZAjbJ%O z2F7P##>gc=jUQV09<(qGG|x&4v#Dt&Xk>vH^RQm$w_(EuLR;iuW`hcQ7@M9nXq(D! zP(cpGgbGN|`2+;r2Q}+II!#a`RKbP~8xo*QN~l3KtWLwqhZ_{mxB@C5AON&S9aIqF zQi3l2kc2tS|DZJx0RaIxr&9s~0))^lpc0=ApTDVO$8a=e8v_FaXb~JSegkc}Bt{(} zHMClnrVUC1OpFW+L@v+&hcXYdOYI?{-a6KTose$_t9(E}z+X^?rNRL-SiK0{?~YY& zFbLvXOtn2Q(*?&`0A?H&QUNi3qG7>LjP=NBer(vV0c+1|!-fsG79Sz&rxu&o!UwN? z&~$8sR}md0q3!|g)1|tt8#Zjfd5}jyK)@ZS-PCME=Hgnq4>(8#aK3)1ZYD7^7NJlo!aM%#ZnF|Vzd&Xnq2__0ig87 zxM9PFdkhQ=M4xy?D~|;P1c2OuFE4@e5hy=-GB7Y;9ahKTHc(!|I|>PEIBwXm0c0+y z$A-fg!otwPnXp166#)SOppj2P{syHx(8+k9L#~i?<7I;U4cak)SJ5DpoEQ)gKvaXD z7H$WvZQ8J5!wv=p2J{BQmw9Q^(*i9GaIv+tWZ8tO`1BPRtGiuzErSba4@=fkV#p z*M@+A08n8|$QqL5arhgw0-Gu$H6%G^1St*(2zWPG)~-Msx}Z7)gu$_}VZ(;!(DD?_ z$3-A&a;S>`jEtb;NiiyPs5BDwAGDVliI2)eZzwP@Fvbq$3Y~;rCTc(s&%-rc0p9vV zgfVZB7E2JJ9IFygg9NJtOfVoIpk~8{4WKkasImhsfC6O_m{xo==!|!=wrt=t6qj5; zKtS7u4I7B5&~X_-lsLXd1;_w8q$!X!STSfi2~>bV4>STbwy~sgW_q!h7Gk03=B;~x`90)Ab=!0K;~}PumN;lI4PrcAZ?^zlHEj#;WSiB?2303 zCxGG&MPLYUXr1S=Y)SiXiYHt^hvMUr9+e_D1OfsAa2*Omw?hvC0s;tLZ~^jPKtKR! z#Vv?Q6^!q~7OGf)#}K14^9ymT1;C?gcuEBX1Pnwkkd_rYLqI^l9x`H!*ij{r`Q%oj zw5-@c?gAa+LWb8zwG5XK09Wpe3=BlBO#WYk?D6f2k3e%i$U^9B(1|>7u``}5RJ#BI zZs_n}fVRzG^+`ZL0B8-u9ITSm6(nhho{BAHnig`@4Wd!ghCm2lJ-2%ZxSLWZ(sR)! zrEVS7Pn!_n1Xt*fpX-2bTY_70#*-QA^^>0WQ|C0kZ6vCJdp+-q) z2tNhX zO#?qb*9n3{;EX2=$|4by?SB}-5@^JBrN^Mc0kpUVEaQ1k7OOh2Bm)Bjy17rDzhMoy zAOJE0reu_+AOtpS*bu_NzyLba8MJ)`wBi+gA0_BY*qaOt3R zsMgUC7!85Z5EvmL@UTU$8Zn{upJ6rwBSR`7zcc=4^7zmA4}R0f4#mgq9`|M8`{2;^ z|7T=0Wc>dhw0{OHB_kfFvc0=2)o64rz{pMl zqrM*v0lXmq9)ky|Fxl}3<=Ck+UMyG-CHJ^5jj{seKO+O))BR1i{YKxX1loWHvT_uU zhQMeDjE2By2#kinXb4a#1mG1q)I7ul=YIy!W_74AD)pa{0i}1oP3bY{WC&D6$ULV1 z`pDcfo-ALG`J?R75Eu=C(GVC7fzc2c4FSqR0Hs1_`pbrLUg23!mSSwid)<`<-9-qO zVq{=g1?OO4m~8zEI(iLsts6|1`=k#O&ZXWkMWgg+2#kinXb6mkz-S1Jh5&IPfKs6w z?fM2fbp*az?mq(q&YS8P85vQIgxjw42z1mDPFqd3|ADWp11Vzu%k%(5kK)k~7!83D z6#_rbY`r|X7GOk0`KV_HA_Pz>caQ^qu>9f&(J*}0iv{aNy&iXE7Q@s)XhtGVulSGB z%Rl4ELe4%6h;^gnXb6mkz-S1JhQMeDjD`R_1TZReo6St1I|D%n%z_pD|Id&CW?&(H z^6_#a3vX9?jQ4mYlkI;|=HVF_80=4bGL4>=fEH0rH%KD2Q${I#XRrJqO5jj{Li=qtRD~YpXndU8KmeZ z)8NrMDm5AcqaiRF0s|ES8>Sx;A6*MDP@z9+FG(Q)KT-~xt}~u2prha5i`>wA+Q@pg zD?NrUvIB_|I%@8$7jx48|BQ1%YGD|k`7kx3^k@hShY$c=n83)yAPMu!-+%wldEb{m z1LKd?fS3=ucpF)0lsy^(!!882D?RaJU}W6%|3BkQD4sA{nGd^Y9`(>@2+%zQ z@K)#`*ZgN>RtM2g%y`C=73)!?9{1$;{%2%_U%vjIfkAM)(&L>_eOM{u?SB#&7#Q~> zNwS^sWch)_A7ze)z_1E|4Qj7>85sU=MD~>DJ=xK#>5v0tlsy^(qai?62oS8$O}G8L z#lXNY4`KTM;M3l$P6$4N<#kskiGhLP2110vZJXj_l)G7x zWcdx293@6WV3+~+KR-|wn-ZFu8fM-f_0GVBz~U+H9;0gk1}^;RZZpBk9poaD?SGOP z7#QAy*dWaK@BiL2KCD6@HahmaC#%W8zyPnD85tRxw<|tDzk3x$o5{96pe=3dkOWyq z7tkSv#wc@GgaG=Qlb?KN;cHcfg$GAHF&YA+AuymJKvabea-qrgKO!I+j{p9LSMYE# zD8uufEE@v@188v_M1=AGc7X0*KjXo=14ZE|XEX!`c?j%P zek{WN$@nj_Gk^A-Wda}6ge*A99u0xf5Wp7#ZgXyOkFEv47ed(N9=6C;EB3nGdEI z8FkBO2#kynAiIYS3m#BK4r7C8Mh0dT;8AP4$%DzDs z!^TFp2c!nQQ2`Pg#iJoGkRgDjp?byho~-Ubx@pwz(GVC7fsqjcWcScv!2_*Szry(c z8UEfm>&cuA<0I2<4`iRC4?F()&+vM?(j$~pIFR+g*dTlUGyd;`@j*1Gl0W0cf^v5& zNPHBJh5)@oV1vQ~0rblJKO;lnXk||Cm>9KhGz11q2z>ecX5qlC1)#XM4hzP!o~*n7 zGyF$BC-#xa_CNA4d31Wa(&Nhv3=BHR;^-AWvK%rSRH-BLL2MSbKY|7u89sp6qj)p~ z=otdwRiKOv4#;*Ao0gIFjIu{VU^E0qLx7SHpt!dVv)p+5Z&yYp20Iv^fq_8|Tg3~L z2lrx_8B}0=5Dn_>>`;1?3S#5Kpq~3bhX0_`ZQ#oO{9<{J&px=qQO0NpP$>j9Y}mjE zO0x{8mHGYA%A868JZj8n2#kgRNg+VB9y-j{4eK{B%FGHuJ57U8542YerUsqfuJjmm z>>SGCI${gOvz{yq{xdMdqnY{5Wcwd(G_g_cXb4a(1hy+Z-ps(j;Dc-+>+gSJE|2A3 zBa0!kK@HA-4F8PHw*NbaEI!H}4S~@R7!3hxgaFkmbeN;gc(QS9*J|tlLd6xJo1e*Ala`b+yC%@7^8SJ1gH@Ld!?TV z|6>1(zMvDdPDBcq`DZ;?i~ckI??sjejWQwgN7(GVC7 zfzc44I0UFyx#MvPmO|~S$@V`ei{p^BgXSsOi;U5R@EI709g+u`b;gqgw51NC;QYhN zC}y$gH_jnakls-|8Un~6uubXlaYhCP3uG~d|NqN8@5>>dxqu=K+5z&PfpH3o;6Fwg zlbyexqKJ)hMnhmU1V%%EN+Cd(3LWIuvmUJB{~7(&obTkAQ7`7`t>1Sa0Uw{@C z&phwRN~4LPaIrLCUz=?IL-52z6hlTiqaiRF0;3^7VhGT!LWjBQj3*2Fu;qV~?SJsD zyx*btxcxsPLj}wXDE-y*o-EOG%jgM_Ai&U!FM{%2%FIoq9)fnlcc z_CFcuW{vVkLtr!nMniyBAuu47JIphuy_kC$|1*MmMld0U{|x_&Ot=5-hYMg~Y*&1| znt_oa1YPPUA1`ab1p&}#0J;=b{sngqg+EMx?qii?V8FXtABUb%VLF7s8Bf+V3=IE+ zS(#WR^tXP0NvV}sE*JtENvI+};m0b<^85cQuwn%9-DLY8E(CX!H5vk=Aut*O^a+6h zt9A)O^LA< z+X{9D1_pwoKaV}dSYI-{!?*qC$sx>WnAL zcLoLq4j3Oq|7ZGVX}a|v)_HJ{JP2=BeXPvzm*EbGjfU@d-jhXXD4@w><(~0kPG$Jd zI18&J0|Nsi17nTx_TR0z7C60K>G3xP1_q4zb;key?L6+w9fm6;l5y6TL+byZ zKTp8*I1W9GjQ^93xBZ=mLz=e2Ue<>)G1dam)=FBNj&Cs|)b4*Qe;B0=cK-&QUjP*% zp4zVT7_?vw)F#KO^vi)xyObsr2yW-<421bUdSmgizI5y`v072%Pa`p2@(#h<{HZBSWXjwm;}w z9}tERU|~DQ6{O`qBg0#-yRsr6Ha>jDoz;wi>HkS|HUF9Z$(nBc2Ws`9OO5hJLtr!n zMnho0LSRr==&(>Zx7fq_vO#)r~GZ~Wb^^!PRd1L}=2P&0o2=i1SDb#b|77Q0{GWj#l(4VHE`9b5SC^yDH<^MDPV~_@2^bX~dMS+&m|7QAg0%gw}ev`g3FfbOGZ2ygO z5+A?z!6AFbi`Aat|Nlu03=Dciy66)FGo!J|mfzSGLl9{ML>bm&-XKy8j12!{J?_aZ zfpT$D(A$SV6Xxg#CVVm3{)ZQ*#!-pU5Eu=C(GVCUAuyyWbXb_3c3~7|WMxLbNrQob z;r}g@?SIj4a)D_>p*M&XpT<@{%0sPnof+YIGWM4ho?goTW;5>p|NBp< z>7M_fn|;7)&1bA%OpUbwJLMnC{bObTEs19Y8;nLUGBCz_+>=>^CXAJP){~|GKLZ2$ zv2hFx|Nl3c?D$)cRnw^8Xb6mkz-R~z!Vnmel{<1cp7CTk%D}*2g)9bTH=1n!(*R`? zq_!zNI?Bkvh|L_ZUPh+>&K`H<_JKK6Bu;xUXD~7{b}=w84D}x2561rtt&d;mv;|za zfN@|yB|!$h1fbCPCIchtzDHy;|1DMo)68jTRiW{`l5;9 zAut*OLnQ=;O@)pe;Ah-f92l4w_9KfS*o@I8+kc~+;favL&f2E{Nbi^Y1^y zA$%LxVMZ}BFgAMJlOcNOAEs|O(%TfDYBMta1C5lR?_R+0?*IS)7kk~8Be+!&UE^s_ zmQY3phE?d|Q2ud~?SF7it3cHdq|#=s=b*+~0D=~cDi{rc(GVC70Ww2i*jMhz;d0uG z)qwH;|MSRV$ZSR?20P=ee@H&124u!|rAGw}42*aWs|TrrVMYdqvkZ*?iahShgBl4i z;UQ0NP<$!G&h)F9;Xgwb37$Fn^9zGpz$;nM^a%;t7#N`EwLWBEV35SFk+|~=u0yO8)t z7#aUB{_&Y{2|1M=k~vgi?o@he^^fsiEW>|>CslVZ7NT682|l?Wnf?oV*pLyB3V2D%wG)u8Fv3?U|9BxpLZ2CDrCq4v4S^vP0;82X zk&$)Om6eB$<=-hr21ZT7TK-RYlD0N)^!`oUA$8V^)$jlR|H~N|7zkZB{{P?q|Ndq> z{%*!?+NjKE2#kinXb6mkz-Wa|K^i#Y&MM8o^zR^+v*>Y{`v392{|wn9rE)su$|1|d z^0%LX;eRlW06`c2|DTaD-gMjV#pn{F{Lv5?4S~@R7!3jZAuw8@;}0LQWKVms#xnl@ zKNH*CuLKPI$MBy4H2gf@bjP2)1k?_w^eH!1bw-x|F^vEJ$1^Z6%98Gw|FbxLe#_I@ z$NrOay`wrtLtr!nMnhmU1kgiZv_eM@5gPJAXQxX`Uddx*U;v#RE+k#cm5$1ehQMeDjE2By2+$=29=6C;kJjpR32NF{ z0-CgDWc{Ph_@7bXKjS||rvHqx{}~v>{xkd+V`N|yV_;wa-5(0N9R_r*+II#9h93+J z44^CCVC)wRj12ew{|Dj!cUhPi?y>#&c4u^4oHp?_YU*eRjE2By2+%SFKm|Q5%^x+J zf)F_C!EExMiOGwB;lDS-e+DyjqnH@}FfjZFiT-C`{Lctsg8TJYx8ZR@C;6dc@F0Dl zYib!7K&lxT85#clXJGij&dqSf6Do(Go-r`|-@@>pY5kM*)jLO*^CJRglrQ z&NDDDUSweSf0==q>9)z1-|vXjkD=_6CoBIChQG>;|CyB-8UO1*4^#(@8gOGMCSBk@ z!+(YnlO2E7k*;-A$7l$QhQMeDjD`TQAuw8@6B|+_sypq$8p6m3-l#4_qE?Xd{|x^b zwlXj=tYG`kxJ7UK?~fqCfyAKwcrp@K-541E1u`%&21573k?tf$hR!DfjMV|le*Yz1 z^Qex|5Eu=C(GVC70bC(4TA||#88XDrda)+_hnzOUMvC_T|Nk%e|DRzJtqzYT#Vw?& zJ>|(M!uh3xB z(X{|H4rE#xKU%p{7_w(QnC$*DGHqgDVBjaxs2_|B41MfB**kUief>nFo*|{|v?og_ z^pqWKVjcdU;eV^?_P;g6svlK18UmvsFd71*A%GeJqZK-8co4-S_9`)$VT_E7NB=M~ zrJ8U33A#HJCOl&3GhVFu4FCUkGcYi)65;8O{}~w_Ot<~MK!n;+C8HrQ8UmvsFd72T z5E!k{p#eb*bXXV5l?R{-+tQq7f*0+LOtVfq`i`BLjmtA@BcZ z_&?or``;`=>PF>9Ltr!nMnhmU1Q7pLG(W(1L*WP&@pm?{~#yFF)%Uy2c00t@c%!o+jU%nCip!0_J`O$vqk|35>#>5e~(Py{LDY}l}Yk*&xih>_u6 z90LQRABD!zQu{qdMusJ<3=ET<@5;WRrbVY+7=;;GnGZ5BFyLEU_nVP{S#$L2H)@9b zs9B>SFd70wJp@K8bfg$Ofsw%$NeG>J^#{u@OPkG1pV1}A;qO*`D)Hy@=Fa~yFnneB&-e>u5EBCKk%6J#c>A9sT=K+-Z&P`s z$MBC~IwJ$46*20usR7Mv|7ZBWm*GF--k@9*bTaBvi%R}cw6kUqoSiBFd71*AuxHmzZ8~!u=H^HkAQ}WM$hX0G0n3xuO z+>tqeDLptuHmE=5XZ!O%oAEy*Xxc!EL|1+P|DQ3*>%Pni67|8ApK@n5WMX0jE!YFy z6blzeV$3nw{wEcQKgt{pfzc2c4T0ew0uNi{sz+3<4p{*g@{ECjK>}O!GBPl%Hs1ax z44WJwu?=F6S=o6QK>M?ZzG{t;f#Kc%|NlFFer22x@JjYKAv*@M{0^mu&i@&hKzsje zNO0!^#{W#t9`~j1k)Q{z6nwoM1LFy}02+gl@t>LTwtuJ5gh#ofAut*OqaiRXLtw;I z=%+oIr!g`xW@3%S{|tvscKor!Dn~$YhvHM0|BU~(GcYi)5Ks!1{{R2~;$MGQa{?Yq z4$CuP!JcGbVA!Vg7<4x5ECvP!0hkzG`ak0=&-*fIR*xX zXC~YKNaK(j6&?+N(GVC7fngH@16!dBG6nHcycU4?xB%TJ`ilX4>I$~;+8_TIehQmy zXZ}XKi4c`LlpnAB|DS=Fp8X$2hX1J^_v98pbdEf7tHM(QCgy)D82&S;6XE;&Y(JSz zou5j*B|BV)POU70RI>PDLUHpR!fjEoGsa7}?A zg~y+N|Cmg@A4rpQ`1@l|G1iw1??4yI^B|c8V}5(`T!(k`N;p_>jMAeaFd72GDFlXP zg$_Nr1nX%ijQ<%{8SnUmwHJpgV)v-r6Z`Xz^~Qe&20>hEz~cWI*Zu526A};*0NP^) z78)gRhrkZyM`{26GtR`V0xI+Wzn|xQxy?`>QPdd^W-kUt#;w>C|7T#xFx~!#u)Wwc zkBW_kz-S1JhQJ^Vfnisn<2#`J|L?ydrhEPqJ(Rma>NXEM2gefvcc}jR&%hAjbx(Hn zV2I;$Zd`&43_k@KSeXR>{`7>l{DOt$|ac#{kkeWOC7Aut*OqaiRT zLtt1{=x01xK$W!+dPtMHl6`~01}64T#-P(u6w%Fs^EZ7z_!6-e;K9!?4B7#&WJ#Wj zhnq(N19Z=;%-jFA|Ct$_8UHglGcYh%lAxVbr7!+7{6EOZz_jlVE5mw=O}}50Y5=+F zw<|pcEqcqtr~ltSCPPw&<ilP7zKqRGMh0e;(X(c;h0LhfXb6mkz)%Z;VNtoC z^I+Bb$N2v$mcakd@snMEl%vLWC_O&&AA9c(V*G!GeV+GaK@}>5PXqFd5335}-~WmK z8UDvIFfhu}zzPbCWZdwd@&8;>*CLQ=ArV9KUw{7iArbIQ^$W3PpY>!t`=8;z5r+Ez z46{vk{K0*sAclrffzc2c4S~@R7)KO3{1|Bj-IW8?2oc%bl~iTOS@If&T*gP!-~93X7!kQe*~IR5|1@yN61~pR9 zrAGOqAut*OqhL^nz>uuaHyC_ik&zU^cH-JMlkI=Fi4F1XN{_EIFfeG~Q2*d3-&y5= z3jzOd$de>|(VJ8J_rKqu6XwW1@STC-|6@i5#(n=78TT?VGw(It`~!3v9!c&1D{jqO zScO;%02abVfGR%enV}Yp4FByI7#TqKzEa?JhM$c8|0f#n_`4jNUF3-EPFv*D}f<4S~@R7!82|4}l?9 zxu5Z7)?@g`co9A1{xkevX}bMy1iCmj{!WD_*8iCPgH~H(lm5rb^jp&9j?yb^lB9^8 zv18?A;QGImfsrAQ6nzl2j2jpj85TWBUA-Y7aByso1JCpS`JeQkks*hHfdRat9^zJ_ z$WQmotW;z*Bsvcwz)#BkPw+2lVXBgPT3jEoG6jJN%X zC*+P%`Oy#<4S~@R7%U+$q$>2YUd&1V|1-`-4?M>I3<1VF{%k}S$Hw2T^cZxuIM!9| zj12!1sd3WVSr3-P|BMXtu-T78>@UOr|5I50GIbd2{`~@nnn5Xi+JhyIk&&U5fq?;Z zZ!2+bWMtg-M1a9BVA=1##A(2;YJ|D%T8 z_EFK%5Eu=C(GVD1Auwbr^s}BU3;r`OVDy+cezNluv!Z>g@>5f$|Nl!#K_hiXB z?;U2|?I>2ZkMIA3PDZnbiQ%UiFaG=YH=9~LR{VC+N0y9r0zVo4G1{5z{EaJKh;sN2 z<;N-i|1->nDgMvM@Yd_DtO$%xEPck4fu=V8GZH zk8U0#BjZsPMwVdxt>0gw%MC~V886m+hX4O35al;U#sZUVzd`%a@hhJYs}@VdT7d0J zk3owkg+1@d5|u`UkYf?}|6P;qe^n5C>@3i*D*LC;KN%Ppm@ri^9`d{=LuAhyQ#bS?Z_qp}Go~zx z$en+G8GX!l{l1AJI)XSG3=EiLC9llGHrj$E7#d8r|7pY`PB)=59<0d>jQ{7LTPV&T z#3sA_6}Cm1=t@WVqaiRF0;3^7_YfG=75W)Z7SQQzoXDZ_pMhbM>GnVV$YR*o+Z7-A zGB7e?UA4@_z$E8>Px>)7eT2l$d$TJ3{r4Yq(j1mX0zx+<14Eth_CL)Ckx>>X1WtP} z>N7GjgSNU6T3|Pz6+5m|=ZKx|0r_VDZPZ`zapez3{;+2(&Rf3eo5%SNZ?1@+lKy~+z8aNsPqaiRdLtxNV?$|4K zMhBB^zYpSzq74QgIM_dZ#wdhAdVYRk;3ns`mD64<&5ZvUYCvY-z-LUh|1rfOH!4hG z2%Pm`srk>yfN$R7p~?0?3M3j!v2y6h0>(*1q+a<)u|q};7!85Z5Eu;sJRvZsDs-%s z`9DT;LVKQes612r|L-qo^EtX#-+11W6-5^$#6RP~tjfU124O} zLtr!n=otcorb0jC$pSjd6LtR)(|>)Ft$#1!3y>X351s!rFk#%v!N|b4(&L^CQOC+5 zn}5cWV+o3J!!l?bDAwXIP40vz-j3?^_ z28RFosLuXxVY2=2NmMaZ9YAaKVhdGXD)aWn{E6r(NPB z#bl!$zjuQw%>N7w*lyneU)guYi#eDSGbvH~O|Q9Z?2X0q)s=v+=zVIo2hC zBSR208UVU*9Zf3-bSeo=c$7OD0;3@?8UnNrfdLtEKjp@%&cqBpC>`!YMh1q3#@qiS zzy+`{wkth8#K6E{izfBO^PViBBgs&-obhA<9n!9jA_(F9dh+}YH>j|Na7W3(90F%N zSx+%A{KvM1@c)zNZ`kM&kD$9}8UFr9yENecVw3HEF)m~u%waiP-PR>_Z`bHrfZ-au zqyEAd0(9@KgRU~etk@aP5mB*&hF#Gs_K&1g?4W(dXFORz_k3bj?Ee{-nr#2WPLB!! zUxbXx5g!63+kcxeF)*U136KGdGLj;{PkAyKfjG3pCR={r`Om=UgK7t3>{$;Mg69~c z>KWyYhQMeDjD`SZAwc)uI(YsX)y>~cw*TQm6+-3hP=2)h|9?jGt!W=T@5u_Hs>IDZ z?ad^^_>T$eLGO(J{^}a<{C@?v?opY+9s*}PSwOe-uwl59k)hLM+n)*yaViU(^}Gz3ONVC01W-756cp3Ku27#M_+gBZH^7+DB~y+ip)?0?4pRVacC44*vj$%>zXp}t~0z`#C)A@{?(X{|X1=Xk`EFnOb%KfA_yCx$8Bd9lnV@Q z|NdS>ar%FT*(Te6Uq%r_;%rcR&HImu33N;=k|+a%=RH}>`BD@W2+kQ#=0^++4E6{S z2#b-0McQ=R@6iGl5*)*l1f9PQnpR<8U_ifgg$cBt1GG?*rXFWzM8C-|=Bx*^2~F)B zHGDJ#Mnho4hXBnhbm%?k$N~M)WXGR0WC3(G`=1{e$ANLbi>`~fRse*}>E=|AV@1s>^<(On9QH8RZVc5HQ*PM~s1i@glm{K>OTiTC;EeQ_jG^0J_Wo zZuJ>YmJe{wC}T7PhGz)u_+VQ-x)xv%#4XJ#_Y3|E0vHqN{}^TP`FXqIW6(XPDC6w^ znf~h&dja|xPZrSKmm+9(d^Oqrhi3jnvul()2t&YR`)>mVM#gREP6Jo)QLLbDJ-QTC z_$J%`pr?2K(_Son54({EgCkL6+RE_^6bD zkpX=&ve@g698qU}LdM-0&_@D3fv#0Uwr7+*GDE;*+i!0MhW{9gDP-RN|3k}3cJ%XX z8UHi1fi4>unXxeH>(LMx4FOt)08J|N(_SpQ&>a28Wc#0dG%+}LkIFqU21Z7dNk|3; z#zUU>Wc%QX@Gwq$GJ{s(p%1ygH`)G&=q2)aj31R6CLsVlw{;G>cR-Wu=wj638}0gg zkAZ;^OX^!WB`O z!SVAe$~nl4{~3!w7a&mCno;9MLtr!nMsNsFwL(AZ$+7?~EZ>`K|2+jw49?xA^cb{Q z2xURr|9}6r;0o|C&Ui3;G5lvtMpFV><&IKtqREePM?(M{0-)pP7#SGPfjJ05h*syx zf=*ca&+vab!YT%akMI9u?A=6`A7zh*z-S1Jh5#ucK(z|}iqlW_{|pTA$d*4zTZ3{Y zEVA4N*(>af3=D0^Vhjunle`|tT}Knc;9hWLm0)0G+=?zSy0RTTL`M1eLcnG5Eu=C;T{5;zp3s-Sqngw z%Kfw_GpLu&fgF0K+y2xd^O4!xl%LFFU|>Mm7yPsDEK#?s$jk}>U4o8m9K$~bM)`nc zzcIv76peC5LjXqzm~8*UiXp}H|0FHuyG^$Ie#h{iaVN5UXS`TWBJ)StqaiRF0;3^7 zP6$w?LT6-PM43ltX80$C%P|`??g}#g|DT8~|DTaDG#~(X&mN{0I@ievyo0!-cr*ma3W1-07%>`@AK(ASIN6Xa!_hRE?D*}0BJ`iZ?2;$z=+;Y=KpEwX zhQMeDkQxFMSLoqaiRF0;3^7QV5__t|aMXU|=}o$pSi)9j@&^!+*Rhh0#~I|NqYz z0N0F%!OrskGpe+YSzSM3Er7}PKN!QMsOm>~qai?g2$*jFlf=NlfU?c(KO@6DTFk9; z{A5Sn%K4vh2k9;z)iD|ZqaiRF0+0|OyK+D0#wEzWfOGr=h5MwdP?Mjc)&wBst zbzf#9rb<*1EJw{U{QJWqhpK9nHyQ%e3ju7$3V(e6|2y?;fS9DS?<=U`a0kMJkY~ME z(6_unBu2^65Eu=C(GVaI0%TX{|CoQFEJ|Z!{Excm2g%}X3Xed?x1ubX`}tT2)F41o zg~>eS#;T5f)GTO3z-;%A$C#=|MMgt_dLi(iky#zhG^Vp&EDO=ZDCRy%Tcd_-$p8Ng zQ<3?j?9mVy4S~@RAT|WZs?d+SGlGs>4BS z%NY~%{~Jh}K+K=`Mgc$yM)7C}P$L9PxBa}uz{r5IaP$9vhPV@ctfJH~A7%#h3^nV9b)+w|`*qc$=hnY}~#Q5rHI#P+-=QwXB* z;WM5r=h5Y#Jl7f8XTCBf>gyra0-$?uls||=z+~H>PzDADlv9*ge*b?(Z6}*-|Ko{l zCg?zTWd107Gz3ONU^E1X2?3HT_fwvnpp`u+-F35FztL`PL0J9&KjTaUkAZ>lKNH5j zA!KC#T=)m)k1|F>fDR$>o0Ah`HRc&FmPypM`v3p`CgH&DEpWIm#Ig zfzc2c4FLinKvIQ%!Jk2Zfq?Hq%;DEv{*Xb6mkz-S2I2?3HS zcLoLq&^$NXID9?z{|pSE#YJ$nKlyl3_U^zXkQg$vLhd7R!Az4Ke@BNQ!67k93}gtH z?EL){{nGXSj7$fq?k2PwUQT;5Ia1w@Q3FRqU^E1VWe5;GCwSVEc?HV#DgPOQafQeZ z<;N>g*9kM+4Y(kHa<3)2UeG={Rt5%nWbxns{)!{>N7y+XPI!{O+EZp$ z2z((ZBLmYO28MqqR~XXN0i%YGhQMeD4A~GMx`)olz=$#&XtLwaI$XZ~|DPcYS?(tv zuLd$78=IAh8ReL{{|x^hSnj0$b|GwbkBW_kz+edh=u{K(G!@eoCvLvM;=WOLjE2A{ z7`!1sRE2)llQ|pN3ycg5IOq1ZD?OTwtd@a+;US^k`DqUpX9flal+l8xX=|0x%o*j5 zh5&gXkbd#+x*@t2fRTkoAKBAC*ngfu=8v*RLtr!nMneEw2oP1d|7T#Ff~?ti`yV`q z$1vs~tNr-}^+zvJVW!WDjI*3I~oF` zAut*O7$HEgLI>Y>imL50Di^`qruYbD*qwoa;d8(%S>$^q5sIO#a~`Z12hbRA|ATH0 zRP`t|8UiCD1pYJpPeAtR8Bf+yWd107Gz3ONU^E2KLx5m~&d%~5$Fa*=8v*RLtrF^fa&(XXg9|E?;45WFzV;g z5Eu;s;zNL7h0eghV1;a)#irk{k@+z8PR0AuFgA?#ye*4zE<8*eMxXLxc7d^BwCVOg zB{23VJsJWdIRwC4=AsXUMPOMAaK!f?^1)9dIhaQMJ{kg}A%G5~Cq78Ujcm@V^6z!*IrvRTr5*${r1Y z(GVC70ays&tkBPTv1Gv%f$0BuPK;ytk2+NT^B=n(NDnRyKG&UrK^R%yWXGQvWd107 zGz3ONfPuke``=2`aQL?#l|P7iJiRlUv8)9c#4a9n+Gq$2pAdN1B3F&GhX4PcVIs0u zO?Uj+i_C|yHz>a3gt1|Cz&0Y>kR4@>hQMeDjD`Rt1h7`=yoFfe%Dmq9sl4p|PwKI_Ti2BMKM`f4C#xl#6L2#oX)Fy8(L<=}?@|Cttz^e`Fq z|7Zw|h5%*=V6D&@82?X0R%*Ka_f}*+jJ-kaHTp)`Z!kGr^nV71jc9VIX#7#`Xb6mk z0R9jlcq^RBkAQ0QSnAfq@Ol@t={{C`|T0KNcek{Qv(y z5t)yJ&4Da#vi;8-Wd107Gz3ON080q`|Btf11fXJBA3Cnhx58Gg1Rt7BwjOhe|Qv!NT)QG~JF zCpij6Ltr!naD~8s#uK=tM#V=%02Bh7v@chWt_1*v#3;sAxg&?lL1YdJn~@Ra7&(u- zGAMKFC~6rP7-VLJpd5|Gz`(d4UCk(eGz3ON0Cx!dWBON%EPK|Q#TJ=A${r1Y(GVEf zA%Ie$pYddkLJkflCPr!=!d`}KHX{r3=n-}?;-Qi z*^K}H_aKWgGBFe(^GDgEAut*O#D#!>0HY7nUp65a-e6#WGY=DI;;5?85Eu=C;SvHY zFpr$|U^aoVK{V5U29!z|Bt`)iL)L4&_0M5s{wRAi1V%%ExDZfX_WKtD0|-OZ8!=pf zFhvXo~EwBdTIJr#V2OS>KGXqmLv1g*=Iahf{?{VH`pSF z#wdF<1V%$(Gz3ONU^E1%7Xsi4oq>S?rQ!_;4ET@D3MR(6HwC`J*8)T$tNHiuKiSJxq%J<1*pfzc2c4S~@R7!85Z5FkDTV3oTbaa#VPROnt0gzdeMvwjKo&x z*YG*?v^SFss{H@EsC;_!_{|$E;cEftZTYBuqaiRF0;3@?8UmvsK(`QJlARrda@hOd z-^_cl*}g&Xr4X95vtVvPVN;Gz3ONU^E0qLtr!nND2X_ z|Nj{+k#(5u{_z-@4`Z`2{IrL$LG*uy|9C5OhX4N^K!ZZjGpc4Z1V%$(Gz3ON zU^E0qLxA)UVEX_6zX4I&m{2Qd*W1!B@abb@U_ewZhga}c(_Y#*s!6Mfq|h6tpX+4+W`Rq#D+iE>I5IFWY-3ZP>5@>q&>W)DsZD$H2f49uN?4hJZSvq&IBX@R@;u0Tkv$ zsYg@98xRlxYJi~$*Z^AhvLBxsP`m{M1h5iN zL$>sW4I6417#Kj9B0KQqr41W4{6)KZ5}SKl0s;a+?!YDo3M}>W^nieXEG*&}LK`-008KaBVMt&Ka0CPd{KOQ-F0x_6hG`5844K$v zi5G>%0eU43Dv*dbWdy3+uwg?Nv{I>}v@!<;--Znv_&_uv3{nTRcOQ|JIX)wG7#JAd zZP>8kAwES&a_LBHe9Q(sl3sY^kfjQdImqk{8#aJOAdrP9WrGUm(;GHyAUXr$vT(zO z4Ji~<<{)FAf(Q~Zs6xP}5@bio-hl{v_$Fc3iqZ`iP5H8c%S zT$v-=g`UXoBTG_|JuDgu8#Zh>%fP?@>J?EjXi$d8v6>6bFdwl>QYI(|O;m(TA+|ur zQYe39n3RHXE&#FS;ZuiCj*x%w$+K#5B}6sy&xr7(%IEDAf;g30^ZcY}l}dfq`KvNFfz5UOTC14=$r{ z*^f&cmpP<}<1-td9J(FP(FL&ZFJlqIB}CBf4I4Iq>_oXh4VPi0ilbES{}~t_kZJ%n z_2=BU1kuEAq49?tcf*DaQ492aOvb z3!$-@lu-o!{l^zzC`tz-2fD&*pqy4mCyRUNWY2K4CYD;B+OS~*C$-E%Glyfth7DKI z#74QJAut*O3=B-r)uJe2v0=l8G!y{}IRqAcP-r?tJ8Fggp8?;MY!Dp`3=E76jCd!> z|NsAoGAa0LYe^U!4Y3{7W~G zkSx2FWF5q7(AuzJ1ErJy#M_2N<%SI#%&-U#d!Y>*HlTKRhP{u6oCmRO1DQ!>Ux4-> zQ`tyV;~)hYOpJ-)9gK;e{*QO>4HFY1%Dx^#jRE|24GLMBWyXMj03ySj=HY;EB^Avr zA4C?Q-9$vtWuVnw1Qn5>z>@^EFTxy9D->_jr5=`NS4I5}N6-LW60Cqas621RKt)}@; zu(yu7QJ0aCfnEs<>?s-$Y$TXjz`(#j?A#?a=V}@Q0s=7hlx^6s0ki?zm<0C^2c_)+ z0Rf;rhj71a*suY#X%)126)uK@LF$SaVlpXcoysx>28IX*1_pT?_QHiXY}jBL5D;)0 zE<5*tBdAd(4X5ePd+6-$Vb; zz`%oCD*j^_@)bI^V8JGq6c7+FADaZN#jsW8AUgvB0!)b9w+2!^oH1zM2Hktuua%g_8~TQK?0F2;Wx}Xj5_w+kn$je$1z=!c03EYNqD6F54mvuKZk7?Uh`>gF zWbNOOIb^U0mA}bw|4`Qg>hPk}kpti%veN*_@o@M4|NkF_5107Eh|-Jy&&Ys(C<)X^ z01XO24PgBLAGxy+6&;pT%!UmcVi*`0Ko!Xr1_p-B3=9mQVZp&%a1P6ez~U)dWKz(% zw^*z~5kfz)2(+DLGKvZ;oDCZ`6a)kWOu!;VZ6Ob88%QPdT%e;&pu7ZHvjECZ+i5+- zzG1@#P=3W$pb~YSG6Ms{GYb80h~{sw-va^yMjP-H2J8^h-nU`H2GB?qXoLh5{~)|K zARqv#R>kfclnR^?M@1cQN9pT!CD4>0+z^!BI9wFUVEo7M_tSs?&AJ%W%%qHnl?W;jCTF$4qzOhi@` z5D+kB!-fq6D|7}1hJFSH26|NJ$j+k&o1DA^+ExU@=y?fL$;T0Uc>$50bJ(z9gCMe* z8#ZhJtyCsTo== zSwnsJcjYpwCt!oqkY@7`nHe_QH zAL3%5VMN?sC^b1{PWoB^Wb;53@y87tHrOHyqOtAK`1rV>!$=l@dhHuFY`}YRCYm9j zRoN)LCnR~C*A617A(aU#;tu)&*Djic%ZBm`(trwA}GF#OxFVFP$^E$STOe+J?f z>HNdr`)2%)dN|Gh|M(jN0}`|(SQ-!zfHL<$f>vUbl5#@;G3L=i4d{+mV$20KSa7KW z4~E+j2fVV(i?el~2_&_T#F=yhy_l!4^I(=Q+f8qP)I9IL*MofcG>;B)d^|ogj&m$^=zTr0SuidYlz6rIvyg z7UD7^ARqvAA1bk7kINWb;{LcKki^#`v8l@3uwer!l@nF%9;$`~1OyO!^fu_e5^60_ z#Jh8hlCa#cVS^fKh0ef0?0h2L%dDCH{R0I6+-*#ZjHnm7z-5LGBOo9^lZLKEIUI$C z_Rz!V#psrhat!DoSzlneVZ#Okbblc6Nj)F~Nh|%C0RaJv7#J9c*_%%n#{~ogfQG+m z)nmnb5qdyC08vwnBnHGaVtQWz0Rcfo9wI`b^6!pBCv4 z=Ke~Q3jIIv!%KL34v#X|qRcz}XZSzlD|AqZL04R!2eGM)^{H$gEsO)5u0)Kv*jAQf zRPs0s!nIl$hjN+<(_+4hrVhquctAja0|_S^;xmdWasdGWpm}dv^whNA$^K zV9i6A2nYxu`m&b|8#bV~NQrIS1q`Af8g|#u1_T6f4WjS`%`w|U3pdcQ#-QCGba5W^ z=v~mBUuxJVNva_M0Rdi6e}kq2hQjU_QoTqE^|ZY18nlZ7tD6G?0`M)m#HxoFLFn2G z(C#x(g95yAofv%t)F^@sL{h_ubAbnvqERLT1E??pWkwK&hsTBu8*CUD7(j``g@J(q zw672@H%u6y)x4k{1|jc&R(FGj8bSMsiLJ~*VLDp45eiw#Ah7F+n1G;Dil%GIsxEwVY0RaK78#ZhJ^}Ilhb8?LY9p#Ga9CU(a1_T6vIt3sM zR}1wwsQd^Wrez4+uP_F*aRcMhoel^H*t%iEhQkaD4Cs|NXxkvo+yh;-2^#(fVX!?L zHf#Wuub}BEP#OTGe=V>CaRf8N!xp(}v|61wt)r^2hd@9;z!3%p1`q}-*sx&(XpaH0 z$4`Ov3~~ZgQ-hAiBjn6LBn8Wn*!Y;4_#{W=s1pJK0ReBJM;$Jqj)}A}1$q!TX#X3i z3lC}?f=WATcjZB2)#SD$e*^>sP<L09wRD)U8gSad%=j1rMsGJxQT7K#Fl)mqY{SfC?rW7>jCT zKtKS|`(-w4*x)=zdKF5)1&1kCLDqqIAQC4WP~x zDfWOg7J-Q2jM0vNB*v@Hhw35t8#ZhJH3~^thykiYLFbAS6D|xa42%pX82&R5uF#qO zA7c2=0I$$FKbcxEFficW%=@45Bm?7rkQ%T<&U>>e8gBiKvNRqnF$4(co~)CD+^PGJ z4h5#AQ$a^oQES5{%$=a+*)aBSp-%(^1X$wo%wV|OJ|G}~2eg46mm6szzG1@#Md&R@ zv@o75GXnww6iEn&vjG7Cdr7ru!-fr@6TL=9Jh2BH=;%l6GB`vxY}im55D?IVLwZmO zZ`iN_bhrgIM5JiGoq^hM@*vu*&&0A z?Mh?kfJQTDYz0B%LC0(nR78tSUqlWy#{Z0X7ZfluFrp0q z{`>zQdC?TIQA2?Z8up;2JBc1rp`jyi9Z-(V$}DVxWQw(tsc%%QQ#f2H{Skd7qXR%3=E+4`N%?} zY(xm)t1KrWl+lz$^t@d_K)`frxg;PUpoUszjhZtW0t^fcWG#Lh1|diEAriyDucKbT z9s+Hk1sf<*j0{Y~4r`ElDhI85yeR${LYyxF0Ri9>__4V;ARr(gn>dxlJ_iH@d?e9K zY8-+Ox-XDKYe$ujh5*hG_!AHia2==Y@Rj%v-AX%reK_hfLLqPqbVMCSh0eh6-;|Kf zQF-cxKtMnMFA=8c5ut``C1f6ijek7_ajtPBPT8oc(GVai1lWmEHT)EfRDTb@m?Y0f zpaz{fNGq&DCw2uh%AwpF3~pmxOb0R;iXTB~Fm=(J6SQ9$%pPI{EzW;^586LXOgIDt z1c0^~lDd747^B}2>jregsBz(VKtR9+baM&tiCVK29%S**gkUx6HpI2 znj2R0*KwmPH0d5L=xPIO(`)Ky-&ub5lM_vH-L;H z#*Akmg?Ml;9yyp4rRfc34v41aow}gLp?p9<05$g5&jUG*2)vaDHQ1C;bC5CB)`8=_d5W#0dX4;%bvV5m0T{-+IF=xkSdj54g~ zc~2Ja!nZS?EGX(tw*SGa9-Ez`Vxu828UmvsFd71*Aut*O_(K3ZH_DJ2BLhPO zer=<&qaiRF0;3@?8UmvsFd71bDFnb3Iu-}%ViAJ~{b#rYVL(VC7RiB*Pn;EWSO;S* zz!@)QymRmn8%D{|5Eu=C(GVC7fzc2c4FOU@0J(ApExI76mywYXWkC)5Cl2@puvj(! zXGA@d_djDdR&}F-qaiRF0;3@?8UmvsFd716hXA}n|NsC06l7D*xU)(l^I_~CePqo*X`528`o(GVC7fzc2c4S~@RASndk6}s&9SHF>U zGBPmaAoJ1KztH%2xr~eqi;xxkVCQN;<_})>hV0r$7;6CruX9ITHW~t>Aut*OqaiRF z0t^fc@X8$;8vmd)h{D;U{?Euz4^jZb+m#;Y!Pwa7zyDc_QNxOoiUbF4r-@w>VdUT^YG$2N) z(GVC7fzc2c4S~@RphgIwROl>>tSH0cvhV&^VzUIxoH{o)b!cKJ*La=rVx`A*V`xqo z<&K8HXb6mg(GVC7f#DefC>6T?*6+w?xH6y*t)m11BLnD=Ww_8Tg~yU`4i?70fB&OU zg#Z8VL*b8ds2l=!x9wOxx)y-SfjVm3Xb6mkz-R~zi4Z`k+@UUppB2l%z`z0(!=M-$ z|D(*2|7Bvtdkccu&cEl;48dkMnvzlOXb6mkz-S1JhQMeDjD`R)A%Iz_GBP$JYdqt@ ztcuKsu|4j{?Sru)^nVWsjgS0)7g_eKCrd3df0R8M0;3@?8UmvsFd71*AwXUTU{vT& zQdgr?=!}d^D96U3S@Rj0zd_@!ATl45%@l|%_Md^F37J329u0xf5Eu=C(GVC7fzc2k zF9a|u^nkzslxx)fGyKPS!S#Pe#w28G*?+MvK<1;fO}78Oj4pQ0jf+4d0A0r@fB1*M zf%&x;M%Mxi|9Bqt;b;hqh5)TX0HbmTTg1q?4NOCc3;qHeP$mlHbysE$iU0#6&I{Gy z>i#n0@7#o?!9Cl~WM3%?K{>O;6I-ZfC1Dm>0vC$A1dLiH; zyX?#8T7aP!Z=)_A4T0em0yr!8U;q9@BYXAZhkw(M`N(WW2F5eU{2fY<5|H`mY_n~D zQ5p^)`BPpjRv>y5kA}cV2!S2SkJ6CwNbvKhA4fxAGz4fH0yryl%bovUp;-N&F&TxA z#QDMc-vx=w@SlNkJ~AIG`~QEGt!zyH84hAqH!3(90>dl>wkbcZ`2U}ACKASX6XG!Q z{it_FLtr!nXcYoDD|E1hj0_vWG?X~w!>R&hqEG=hWj~?_FfcG|P`QWq40w|re^G9A z1D#fP(uaxUyV%j~9p#UPz+evn#{UfM$Zq_}_RAibKgu2rfzc2c4FR%3;9-kgHQrkN z$+I^hC?@~?e*lG##QFc90p&WAR8zk(qmuIm~~VZX)wX*`pya+(KZx;vwJyU`t8{V0Dl1O{^mY*&1|1;u@g45JG}Q37O?Ga3S;AwX#e z5LKa@ZD$4@?EV|cGOov-VxYM}Bq0dX{hst=1_lPu!a9fm0|Uc$rAOIt4h{w*14BQu z)c^ng32$^q)-cK*4S~TN0*nma$j!LLS-8~0#s4z|!`TcB4F4G! z!j1>=;yt|vS^X$`Gz11|2%sz+{rUT&+8}l3sI%xD0tcVBo*P{YK<`i=5%v*Xxr0K3 zk%6HEM8oiDPnP8{HU`c7R|`dqkzpzd9}CB1$Def!3=GJ_{0yw$zkk4@bW~_G1cpQi zY*&7K9?hYT10L)AL=zk3j)uT!2#kgR#UVgag>Jn4PcO2Kj0_AB$b4kB=WT`C=sosr z%1=!fuRxt8x&u1GN7$^|M^%+aj3X|)SaUt zFd70wD+EZY&>>#?k8(fi884P)5OGYB_4hw1WKqWd{}Yk<*w~=Oc#I4TtC8gx85m}4 zSigZ0S!|R&8Ulko1lSpWJx6vUBLl;!fZxCWB8!c(M?+vV1V%%ES|LDkg>JI_ZzYQ5 z{~01t_(&X=$MUZk7#JQP2{CL}etZL&kBx1-{ZAOWxXi4O&*);K{Lv5?tRb*X`5|b^ zi67a09`|I;k@=(S(GVEYA+R|xaPR0^fI%CDBv^rN3 zBKV&{eS_jlArvue9H#&JXu{lQJXtHy#74QJAuuRIfbl=mRaA%lpNYyJ<&B2GXb6mk z0ChuvtP0&^+aI)wl`#sJvp^@zG5lv(gDlU^`0EWa9~;|b>)%TZ4CoiD|8KwGFTjCK z{ixVz2n?1G*slDz2i+~6_vA9r#YXv~Aut*Oqai@e5Fo2U2Ri_*mwwul`5IUp3*mWR zHWW>mahuYUYBVtnF1C~9{`~p^I{XGh?Wn+L2n>c0*sx&(Bg21&Vq~{4GW>B2IoK+ zjAuMqE<%{2OohC?fd`&xj9EppXl z*YKb;!N|zy529iC7sLNoFg7~vc~1^>=rziQ^X*EHK`ZLem7?%1HvN9h@SkBeiXa1n z-f1t^STwOw?q~=MXb5aqeB}F|fdOT~$j>hfcv`&!>fBMwM?+vV1cq4%P*kBCZ~MK8 zfq~&4Qkbxva%Zwe;-fHs@|_h%5nx~t*{1XaNke3OWo;Pyv(ef6z~(VfexR6Qz=eDjlUpLxAof zuwCi#Lv%|$@5vE!-!QtiQT}KMjE2By2#^&56j$gbJAXfAU|>KwUE{1L%PL&1@w_hw zx~&DdSI@9r=`m3o&{_U4N}`*4#*^h2y4Wax6wosSb|^mqP5+=YY?=Qtpq#iwPxp-4 zH5vk=Auv2cKs4ng2eE4bD6ZT=L1?o54@yDypMfD1-%Tt(e}Cix=|jRi+m#-p94L<@ z2WJ}W{{4c1ff4lt90mr~GoCEh;fh8Xqai@Y5ZGX_f$9JM|BH~VWMp7i=Jr4qrNN6V zKgu2rfzc2c4FOt&097hC+B3=G{H#2%wp^f2S#w8{40 z9Sr{&4#Nc?42?5h%yS^jQF1f{=obR)pNzkw+4kS#o@_Ll*eG{21V%$(Gz4fK0#vKe zjko`423?biSs{Ur`VZ z!;FlK(>E9x;I!js-&u?m>)Vwc-+}4JN1JT_BZwi-$k1`xiy7mJ0 zdSt=C_&*56nE(IF0&a;CwKfDr-zaA^1V%$(Gz6#+0yryMa?Cc}^ZzXa0|Uwl8#0nt za31>xIy07uk--+tE@kL^dPC_44`SB>Fg!_HgW(s(|BNfod9vD|nLo-M4FRf!0B8>V z9|PlYGy|V`-k0k}6N7Wlda$fKL$j0%i~02M-j z{gd$@bYnd4$x5S(!T6`WnPmPmGJr;r1kQM}yo8C5(xV|T8UmvsK+O=KdW8;h93u-0 zO5ygOfuRcD3V6?ZvH=VX3@8hY{`~#-9DIN_$Ur>U|Cq-;nR&RZ zIqSia$iTp$iY(9ZlYMk`JaT}HvPVN;Gz6#?0yL}K!7lvD$PK39#DCMX9<1STE*it* zuKdIQj2IWPi)>eX47&UUO*JOheCyBa{~4LpF=YNT&N}1CG8aR9RA4j&hzNo0st;5d zFwSyk`0jO24(HtlXY5!x|1&b6R`&n@H|gyA`iTfDN0p3*z-S1Jh5*SSK+_5xG}q0* z_#brn3fy7;8UL@qH@EI}S7tdQ1LI1#1`vah!F#*X6O?n}LBiOu>9(J@{xW0q`aw#Q z&Umt%05L}KXb2D+0vi-xax(m7#u!4!HeG~bCij2zT_3MacKoeJQ8UUJ4S~@R7!3g` zh5$_~bdWnuw*3WNk9q;bhGEw4-%$>lfr-Otk9#r^3=9m9V0;MuzhJx4qXGyUKWV<@ z-^2gE|B7HyWq!t!h2XWWSoDnwjldAtz{<+Z&iD(xVrKZs$IFT@IL>&oATNjmD>d2v zM-0puB}PMFGz3ONfJPxe^9mj0Zj4_Jr7?Sx*+wsFDnf52F7uFoM!Gh&hT!Ltr!nMniyhA@Hz8u9_}2Jje^;3_@r% zDx>TfPgcD5t$N;*$-$V@^wVA=1#Cfomj_8(!C!XMxN|9Qrf*_@CW zqw*s&1hy+ZMqcd+4g{uu|E$~}%HnL9o^ofh{?EXGGOWbN$WUsw{WnfMV1q}A(GVC7 zfzc44LI}{ULYLkC>Nf);16p_cfAASk)+l`b@VqCB;YKD#rqerAo;cvshb(8Z{SRo& z6wAIN#uKMKSx{FpA!{6EkH`?fR+;~2_#ff^Kpy?*IC%IR@%_ik#KZ(TK?yF#z`$_T zc-x;IWPwrkXb6mkz-S22Dg-bpOIle3GS_6=pY4o{3^PD%1pdGDtS7rNf{)4i`B({K zEdBq#|ND0+J$A%Yja|fK`ybHBAQ)G-F)}bDp7CV)v0?oNP){Da=26iR69QN(bH@MG zUiaisS8k$(2*)2b^yTcJ(IFi)Wux3tFd71*AwaVbplgK=@`drXKbZ^+|526^|7ZAn zXM@29j7l4%9)tsa|Ni^)KO4rWH2)bG_H9>w;t5hi3^v*RCzXkT5&e*PkXCk?St0+< zdN6^mdjs)D@sJAv{(VcfP;V^&wu+sRk)g%wu3Q_wpgH5o@(*3!WcweSX&YVbD1S5r zMnhmU1gH@Lbg$e&jyBo;7qpT57l;kRGLj;{VQg%4zkyq4fA9l2U?9lkI=382R_dU_#)GC+ogHEdQTFZC539<}g$pPHLOVBfVeje=#dxM#dt~dvfJCH8Lmd( z_WukFX4{n>!wP(&LeXT$-)&Ex>o7AgFkqZ6!oa|&2I>u-_F}0d%CJ#IGzo#Tp3DKD zcwk`okG3H4zbUzu`gY|fg^d3gFJd~F(ckl~3}|yXrW8~Jy!Dci0cBkVBLl-ilO2C1 zLzR$7-D>gkrrufr5-lH9J{kg}AutR>fH5EdblvH|`s}n9yBg#Fzvw;V_a@u_2oZAG zcBRK(85kHa7Se%w^q@unA^phmXT4am{{Lr~iY$iAW@KPiG2Z?Ybmu0r@F<%`A#lN; zLEz6XmKO{R3>Z^2U@I9JVobLESqf&8Kx|ifvYmn9zX!UOfB*lPdf%5ngD#HDKjXo& zgn^L(bXyUUFynrc?Y~Fw_eBbkQRZj}jD`UHLts!;=pb){E<6X_kqTlX;g2TU{|F%Q zu`;(SJ-*Grz@UOI`TswYj@NzZtLWnR_@HfVGP6QHFfcIi;ZXPCHz%iz<+{(Hh6D~J zqr#Mg05lFjJ0vjT6lCsy28NfW+y9`C@_?iW;_XV0-!m{UV5BKFMg~#myRw)=@kk~@ zEHq&T)O+2RZGtN#l7XXvfvo${6G28L&?$Auf}?E8Lg0)C%Vq{f z1|M9e{Ac=SX}a|v$+z)r(6}qe{)-K?IvvC4pM7VU0s;asPK-lVc*c`uEdv8X5VF{R zhW`&txBpc{78_-chQMeDjE2BKg}|Vy&_RK4+KV}i@ju$NYzz#b73x^Goq_bg@HWNA zQH+cX*oI&qd)||kgQ+8hKI6%}lz{=`?pI_ZZtx2->Z>mMjdx)mvW8JMW(b_|WC7i! zi*>OgNcI2!{}-6<_zSv-9KHi-Y_xp-Zk;RB%Z-`=LmU;glbnTWT9`zqsnOHUSw|;+# zN6n}dQV2Zu6k~nK@a`-F1A{J-Fgo*&$@V|0=z_%ZaZSxIGX7uTaaS&qkU3{OSwIWw zP=?n>d+CHiZB%|V1V%$(AVOeJR_LIBIODpK^MV*&yGhH#KPXL^cd88 z2A%VcF5BmMPqr9ckQhGbOmB`qY@h}KFMjom=_cEM6TM#^zgfdi_LMubArlkhVFm^U zd?PSlnf|iL8twW9n*Sy_5ca6t6Z`#-6?@}~k<3UGec221W*KL%KhH zeqrDP?RAG6N&@4Y8?(|sW=7DWJd7=MXvXY#BFN}7I_C}vmD8RqJ&X(tgc?)7|1;41fQFDo+p>3IDe++5VT{0W({b9~m?KXT-WKiIIV! z)8n3O1(Km8G0%H4N&aPE0-Yj8Z~-3!BV)bEw%;T#)Fa73gHZ8VFIK<*|NpPTxeX2B z=FiLw|I`h)|0C&q0fcr2h7GdM`Pfvo{TB7dTi`do`1QR7}zm) zGyG>*Z?fYL$^toT`bWivQV4ANHucWvT7aPxW20^*J_H7RNf^FLv7SG=J$MDk_bYw3UIiiKOD?VP$z{n7SRRa@)kLMlP ztytxd1kZS~>M}6=$9CR-qsjI^4M<8ynWG^v8UmvsF!(~?VT)Y#kf_yRK?gZj490`g z_e{3`A*MEE*rD{e?LPwp*5l#+GcYdkx+jCa0U2%yv5d1GEN%Z8vD<;w3`T~H41XE3 zO?LhUP1a*oGS~&LIQ?Y*!Oqpdz`#iS@W=oE6HRvfEuhqI+Y}$`GBPq?tBFBIfAG8~ zi*hJCNDv!7nA@`rr(`3i*9T?)H0;3@?8UmvsFvvn+NLA>dush?)@|l5w zfd|A!!vCK<*I@~`Z~^1KOC&X5Cg=(-_D>w3E<3grbPSCDnOr^YOJhvrgEf;toN{AT zXJY;jS_y`}$_lS3{}~uoFfuTvN zZTm~oNkG^P++eVQiT#uDGX@3*acuGo{~2>V@5@ZVDu*Try%7duX+1N;KPgJ)1kkJ* z<&K8HXb6mk08PP=tI$EAfn(D!!@qz3jm&obJr7bt2yat-T*Ju7(2PUfx1V1aL<3&Q zVmSyNhYmc#r#)Ch7#aUBW?*38z@rS86zIyZr40WV7Mtw+a{!kjT8W?aWLIWj_#5?~ zfgy^4fkA@|d;T#nFlJJ78^8{wCoBFl{13v zH)3RDGGt)*589B8vEmG;LD(gJGB7Zfm~8(|RBsu(5tyPolpm%2|IauRQwA#X|G%f_ zeL13r%0aip{AXZX57miGjh-xn92%qS(GVC7fguwDL$*Q(g)rzsZKOLmK|(P6_lY1Q zJF$DowkkX}Vq#(doqht-k3#?d&v4S~zN`g`AQd>LJXz(K{{Nqf?@UxGSVJcx82A5Y z`ddZL`K^Q;xJ~JCC?f*{Xc;|rrHudgc;1!6I3F0hVg?4twQm10m&5;O=rP&x2k*t{ zxXc_C9}R)g5Eu=CfewKoU7^DQ?~Er4Xnq`HNju|zh5+Loe>TF@5u$e}J?{9=z<}@a z?4v*X&e{?)v`fe?Z1Sf(nKhUh87u$)XNbclKgh(kGW}u~9ZHW+f-Ypoqv+nx|70|P%*3?lpw4U-~$0?M8acF>h0TT*3ICp&g$y|1&Y_dfkz^ zf=>;y+!+s+SqzK}smNkb_E(ebf6y0PLZwEj(GVC70jh?;Lr*X7(X{|n4N_c&4V%gx zA(CCAOd>*Hr_xiae+>V2651BW$iNuyaZhFu5k|t5obh5=#_*p3>rE{ze;6eV zcK?0>S3JrX4S~@R7!83T9s^Qw>2(1Me_LVy*^3Hm**#7^= za0tZ4f?qs&t|J{l#7T7MdbcY+&R}3k?6fD#VMYc9taoNoa-Ze~jk|*Ezu1nEaN62`Mh1qKAOG3f>9K+y=6`z7+ms&# zfsVI@EZ8H?M#ldP2_E-lF%AzRM)w&{)-(o&|F{mJ`TviBRm^Pr_cz3tHL7Mb1V%$( zGz5ln2#lx-9TZVloVfXZu>S-V@Yv6LXJlApyzNgsNG&;dyW%5X21dr^3=9ky`{nVP z_n&bVBh%kDkGt}q=>oh;2a_ad4><$F|GfVU4929mi;;n0^^Y$M(d1raf5wB=gMsn? zcAR!HG8UL@`#lk-{HVle2#kinXb23m5EyY4IxPOqcrgbv{Aa|yrHzrH(`4Hp;%^v( z8H_}4S9+Aqz`!^e%X#8Rs&FztF)%VL`S*`;q4#~6Q#e)8Nn(TAYhL!>KcfFL{*Ps3 zV6-CB+Fd`{enkY_P{y&PjIgN$D{=;ggC^VmI1n~zRK;irjE2By2n??fz*(WkI_mZh zp|t>1iIp>+ETA*VvvC>5_@7~w@s2-OFAl+_2b=hIUo2X!^L$DKALVk z-2V&{fAaH|1Y8jKgGUuXsk0ue;r|)`ufU`5J;%?lk~;g?i8@&hk2#}KqaiRF0;3@? zBtrmaLHCDaody7@ z8=Cn4L8gEIE8QQ+AE$=<&v>#lFfcISTQUEek%dLUc=Hd?)vMI7a@35`5Eu=C(GVD# zA#lG%p?b7dM~k~No-C&s7#K{@gps*F|1tjeG28Z+;)~W{hJucSWB>dm7h22X*nbC8 zJ*epa{}~wRNVzQw@sf5U$! zrp45(ypbJ$%8S{B=|3arq#%4l7ythKWz;g;_4_8W-cj~w2#kinXb6n-5EyP1`pmd; z+N}k^b5jg*I{MD=pCQI%$Deh`g0y09P=CzN{`xK-{ni^Q{@%-R1L7(pxQ2-)%KA0tDc*|tBRP3wfzj!5|n z{sJ7oe|?$H$iNUmFaZ8D?tJn>+l!o2fe4yCs$et(MnhmU1cp%vj8^C*M&?N$CVA$+ zOeYu^7$k_$`j+wk|6Jo8e?gnpiBLX#m7MovlKlIhc|HT5)Q+ke z4S~@R7!85Z5E!k{DM$lnJy>e~Gcq(2YXl=B!;J6DzshVjGkqpj&rnu(#*-z5fq|ii z=*!<384mpW|I^!SJM*`p?C(*xkA}c#2#kinK!m_(g-(S~zvA?h{RbQOat20*KoX4m z^PlnmBqnCoUg#dILq|N57#I{7JY5_^NHAvbDcxXTz$7DaHIaewe=P$81HOyr(H;Nd z|9{3H(;dI5vIh>`@KOF~2#kinXb6my5E!l8sT|XnJX!gFGcYXv&+y-uRHOeh{J+lh zpJ~po|9_TP?)?9XRQ>c)zafg1S^E9|AO;487)AyLKQion!SMfovdNCWpq1`qXdl%w z8UmvsFd71*A%GqNqZK-Oh)|Ia>b1&BTxt2w$N)NPmXUm;880$0{M*jJ$av^C2j?Nn zb)UbIZx-=7PkAyKG5u$3vyjduLLh9)!09Swoe z5Eu=C(GZ|Q2#i+fR0u?3jX3SaYQXUSe#dG|GywejFEv+kb!|w@E^lJX+{P{&_X}3I7p6)`00NJhGx_4e{fxyMn$VejT#Ms z(GVC7fzc2ct-aHJbtg13(AXkY>WD>d_Dw4S~@R7!3ichrnosPW2$Aje(~;|8g&ALjb(%;ol$Gf6UAZO#c~W8UFv5{m;N4$H2%a`=8;TC?f-- zBm)D3z)u3m!hQMeDjE2DQ3V~H#HyIIY0fv{~Mtw6H0;3@?GDBdrvpzE8ebm=PhQJv+R!$~f z2CcvU{^>9>FlaL{FzPTcFldo@rVf#|4O?ZP!vSypXJEL=#K3Uv9|OY;=KqXW9|NkXSq4UiGyfStnDM09_TP`lG;37b zXb6mkz-S1JhQMeD4E_*Mov}h{bS=Q(kEc-=j)uShgurNLeEHjEr9y{xf|4|NsAIMh1r8{~7;( zU|?kY$-uw>nx=+Z_WwV_FUJ22Ul|!0*#0vzaDycNGyG>|{LjDxVlyx>vM@sZ|DS<@ znUR5!?>_?rKLZ2!j6u+Of*{NUQ9HQF{|x^hF#KmYz`($`|34$+fzi(V;Eu0R*Nuk2 zXb6mkz-S1JhQMeDjE2By2oN6vqaAeO!)jFJAPWIVC;Y$deiT z%2$kxjJN;&`*)jxk?}SoBf~?c|BMg+FfcrN_Wbp;fC~bk1NjHaZB)1SfEO|ARK-GyGR$WMEWhU|;}YPO4isCJc=KjsO4uZ^FpHs6ZzVzhz`(*!ut9|CQp5!n;RLzoS#Ak6JSt0;3@? z8UmvsFd71*Aut*Oqai?a2#j{ni4Lk!g|rTV3;qHezkh%BWBkV$%fQIs#=yY9Mk6~x zrBfzb{+S;01{iIfmH@6D?C_uu~v1_p)%1_lN(QuGo~`-Fkv|29TO zrmep?IQCku`}~!F`cdf-5CW$>IYpTM|MK|H2s^WYiWk-YXFS2k^l$br4z87>>1_lg zrcp1BhQMeDjE2By2#kin;0*yE?}&|~YXJst{7}VZqn&lC1mmb7un;)v%F4se0_v3i z&w(B&29u(Me#pScu;Tx}zspT`{J%(vL8E#{ZU}%59+dj<&yk6NA%fvQV-N!a1L$;n zN(1CS10&=1|Ns8?neO1Qw)aK?_6^FQ~0A4W!oXomj`-V6*3 zEEKxy`F{q6DU8gFGfcMpen+8MquNJ9U=)moz-S1JhQMeDjE2DQ3jt@J7gvVHS^)Ll z8$|;f{-Ztifers5XZtx%R-1qS|Cci`f_pZM6nNkx1H=E7jQ{>kHs1OF3I*oSUF!yg z2Lc={EC&Do|JVQj|Gyq11ET>01A`g^1MH%5x;tsGS@@EH@&9Fp|4dgH|1(_q%fxu; z_xEqt1D>f4siX4Gd9xb-`}Z%6fq^lGfq{XGf+s!+(ZmlO2C=Q((@h*3l3c z4S~@R7!85Z5Eu=C(GVC7fngp3qaE~N9>J7)?~E6VC&Pb+Rt5$JLki6L{GZ|f5{Cc( zW)9{x>l@Tw^RoZ_ZpFyJX!HL+qa6doe~_PfDe(8GR>C20myvqQ-pqOoe;HdD7#V!Xb;o}O zh9eA&44qF?*K7?44ERs3In>s-Q~t5sf9C&A{}`F<82|mZVPIrXrM4ZT=21NaK@WV) z*9SvKn-Wh^OYa7;$E_iDB&8OP;L!zZw2F{AXYQ-H}G>QP#g185!m> zFtaopZ~pO&-odm%@ud(4)6bCq{}>|~7#Zwn+`;?C@SounBNM}+{|t;ret!Rce5f6G zM(_Bc)V`gH_oe?bFq<(kFq-{mWUygiU@&7~U|^%vTs-=}{r~@eEfX`tf*(BR4h+Pg z%4rWqeMTncMuz_k{&?JpOX>~7f5wU@>8s}rrmZBn96KsL8UmvsFd71*Aut*OqaiRF z0;3^7S_q7G&~b&=llrZzhRIrhQ|`=$%*;%a|NsAQjmx{Z#2FbF_x%0$uZ-TacaXi~ z3@QH^7*ZG*7$kAIj{f#|GJ;QkS(b%WSrE)Jf5mJAGx zj{g}LJ)s90k~1TXFYNv^F#JEn_@8n959Z%%0`4e%#iy9sa;H3)eVG^;`xqD)R7f-% zw5NR<$4~ZJoqb;iY_$N1ZXQ)W8UmvsFd71*Aut*OqaiRF0;3^-Cj>@2>v#fZs7amm zVs`)kpK%rg19<;1Db9cN|371~>5kuPNYO-z+THTc#eXyZNn&JRNMisEicn%0s@{M9 z85mA6FfeZU&%m(lci)){0Re;O06SEtj`Ao80noB~4ju-Z|BQ^D4FCTJlF`A%XFceQ zfZYuL|4;qNe{L(i268|vUw$$Ct6*SY%x7R=;Kb)Zbh-bG47*tv8Pg57{=SbcH_9Ik zfzc2c4S`WG8UmvsFd71*Auu#TV6=liG~x-TE6;eadN45ln}xQwe9purcR}4=PZ!4! zoO)pr;2Cp5csdL=egK`Wg-s1r#K6ae zb8w_F{AVa+U<4lvPN}(|3$~a2`}cpj*8{ohlo~dwe`JQh23A&PcG>&xj0_BM{~7*+ zHivUiAtZJ&GW_fExF^4t3TELo;*2}1Gy~JWb_NEX6M@9ywB@7G%Bl}SFH!E`$1H=DxQrGFCyP!h+vmnx1fXDwC7-qAu{G01`OP=nh*P**@ zlt27KV5`DY116?_SquyeQ49UU8{|rnGp7&(7QDP1uy%+oiIDY^7QpL!? zP{F{!z)DCxlKl4H|NbUe?)?7>NozuqBW6?WDbK%LER1Z5e;F7R8UFtlW@2Cz`OnB8%J82-l!1{!m=VPP z|6dpywji3?C#E4s*?R_t|6dpw7~e23FuY)7WO)7WKf`M#28QQgn(6<`zyBE?va#_# zp!F&YWJe7o8?;A*qsZ9d|9^%o21W+Z#&=3Ci9gE7@UNbhv+WxUKCsA0h!it0GBlBJ zRQ`Vk28PM}f{dlA%YOeF$nYMudo%<_Ltr!nMnhmU1V%$(Gz3ON01*PC9dtxk3X{M}4~9-@_kIxK8O#vzOh3@r={3~EGcL|6Eq;SwVw zWA6_>o@D_S1VG1AqpKKl{4+kRLjV6W7%=?%r_aQo6s-OOwibX<|33qR9&|G?E$R|{QKw6InzD=-wsB{lIo@n>W}%^|1cyl{AVa5adiu_ zRsR?n7}hc{{wwvkEB_E#f?8~(j(Z~m0|Pse=6+&eV9Yex{(Bjb>PMB0hQMeDjE2By z2#kinXb6mkz-S22Cof{jQjI<1$pX5AY$5{#0~axR|1&VWWMcduZ@lgAPGZ!P zs%DqkL#aQ1n7SAtOMuCl&HcylpJClUhJT&j_vO!!YX3m1-w?&hEcfA`#=rke)(i~) zY#10A?a4Ssf1tfFSnYq#$iQ&qKO^H&CPs!MkDh9u54dmvbiDdtbuWgyK>OS|Wba1) zXJo8l_|Kq@p@2An7mSSm>wi2_o*VG{_g~_)5U=WX6f4`u_y1ZL7#K?!7#N7n!&LfEqTIM+qR;_=G z4BiZk|GgOgGgy(ZRCv((i4uo}y}Y{vwibXAbEvHM^?!!{`xyT-?f%R1caQm&e>9nS zrly-W7;Io-`(zx<$N*k`u0e*`|NsC0zl4qXPl?MNC93ZmKjFtJ%JTdFd&Y{nDq55~M7m*A*=PuihQMeDjE2By2#kinXb6nB5E$*O4?vWk@nVT)_|Gt# zghSWXG5uvrG1~R*JvE)XLGdLg2jlPZ{|x^@OL$4Ts2S>xa}59gS9{)<+X3YdItsME zpON{uHxnb1*MElp&cv=(po2$#{Ac)opYcD_BL;^5Z$Oxlk?}2Pk2q*gIOG5SZx|W= zzF}fwd@I5r@UOIs7*YXQD7{%6?opMhZ|3p>xY!FK4~ z4&{gP|Nb#HGcqzpGcYialm{6Y816GLGNyapmDx@0us!R^n)#pM{{$jemHcO9V5onb zwx%T@FaUHA9krb^YTjrFjE2By2#kinXb6mkz-S1J_z)QFtkXCNob_Z5_|L!yx=xLo z2&?`w{AZ|slD?*knhju32c3h5q3%BeLn*1914ahMGmH%Xa^3IAogl*fbWw83o!O9y zi6Mi5fiZ#(9m3oH85oW*F)|+d|L@;XlO2C=)5Vj+&=Syof9ZFBbeNc!t^fZAZ}+fb zU|>|HtyllwVqjpJ&&a~O$awRQL2UB9qwv4F9_r7#PG!^#9BMjEp&6cV*U) zs1L953$CmZzghmTW@KQn!K)A^`JZ7Q3;Um7gN+OyU_ztxXb6mkz-S1JhQMeDjE2By z2#kgR6+&QTPgwD2FP#bjiEhM(^&1#vW`)!Ta?Y~US{^nbMUdJX$j@SmZ z*Sze1zIQV)Fs6}kdKAd2KMV|vQ+~4kZwR<4`w=8CkocrGyC(DBKj{pNjPVQ%3<5N8 z%UcG9|2x4`uq;eFO}6|79aT;PYetPECIn8ov8ppOGdTTc{O`{2pTULF3(RrY`H+Eu zamF83hPf7-e!s?{hF-#s(>Z3s*8*%)e5}jJ_Hi;=ZDzZFJSJA-sJg)u0%tr~ zbr~7{gBDwdGB7acQrTt485kM6pFGpvIFKi%?UsKo{+sD{HzOlsESmp5{`>#Wg`QU@ zfc7;oGW=doE1;r!M~)|1D-coJ zzOyp|0ziEpLdvNxf6k3r=`RywF(V^GA_=GI5pu{`Mh1rE|Cs)*G~N2|2_eO!@{-g(GVC7 zfzc2c4S~@R7!84;7y_f6bqZqboCmAxKgR#-iCmt0?Emk-ex`f=zoo!RxYnKWkLCU` zGc03ZV6cV@5@vj2Wc*+HqxanGf#{%vkC|p>ZejS(;7{181XcWIU|`(zkMZA(XQ^xV zQgcH;L7PVv4EhiNt;}F!{vX81$dJy!0KRvRQa6J(VozXU`!jK%I`5QvfExPGdNI5I z|IfIJn1dl085lP+vvG$RZ2a(p8g`GGF&YA+Aut*OqaiRF0;3@?8UiCB1V%gPWW@4m zPgZY6hW~4cxuc4akzvMT0mhtwWxxNDVHJuN&~kRRBI7bf28K2gPJ;Wx$iUe8VCNd5owXBPtlgEm1gGBTWE zU}E$fgy%jKO7_{~SqnhWXQK*6Ltr!nMnhmU1V%$(Gz3ONfTkfZ+F2(dc+Yq-2Q&O< zT+YD2z)C>dUk1kinI_x*f(~FJpp>xmPNk=^{}}#lU|?V{BCHy&V#Ck>Y{>!l#p!l1 z+7+jt>_6DK^BEWzDj3MPFdFW128LIR42+W)|FTY__J#3Cc8oGdLx2Ia-~B(s|3XFv zhA3hN-|z(4ng0y`%1pQa+mA=hu#*Cv$HDUB`+COz46a0Y?lvO}i^E_!cAp6Mjw%@q zfzc2c4S~@R7!85Z5Eu;sx`)7M2c3u@KkLC7{-5zb=uR~j0?NPsXZYt!tw#C|HmL*-ls3#EE{Z> zvXkqfQGG)*1Wvm!3Nx}Y=Q1!bF@tcqg{XRk*R-3X*=V=+{nPlSVzdwj0}wD82_@mjINj=6jGz| zqaiRF0;3@?8UmvsFd70QDFjA4=vZR=v^T30=g+Uxb@s9U zB-rXeE z%}k)%C+OF7_i_UwRkiHMnhmU1V%$($b`Vkp0MK49y?NWo%3SW z{`a5pAOiz~Fp@Af<|_sUMi-Oqzb|8xBt>k8@{?HTv^}9s?;vCUGcqtX{pdT>NbBR; zHyC_ik&zP3X88XfbaEXR$a0eKe}*l8S^njiZ~6C-Bz>cbM?+u;g}^BfW?v>ohJFSH zMrBf+{h#6g0|tiwNz~deL#qAsQh(Z$xrLE|v6_&5pyS~mKYtxGx(9|(NR7&mhQMeD zjE2By2#kinXb24F5E$*CGcYh5cV`r1Wnw+Hh^z1*ucd8 z$!H=217i*W`(V=d8U8YQdOno72@{|N4eFH3N{CeaXJn`$VmSf26F}?!>z_P-J8iUq zjUF_k{E-j>=RKJu|1dE1F)}bjkm27O3=IDhhVxYW8BgYR1_nmZ>5_zkr_p5lp9VsT zM&(CCU?4(Zb!1QW=vsh*2>DSvM?+vV1V%$(WQM?q?yR4&W94Mv`oEKbk-?Tg^#5Y` z&**QmhMZ$Fu2Xe{)uBH0|P?e54F8B*@{Q32z9D4Nu2PvNY&%kixKLca5+4kR$ zNYOVG)NWY6fl+2w$Q&Y0o%{FyKV!J*j^AsBg1<)HI2r<@Aut*OqaiRF0;3@?8UoY` zff3t5KkLcT|DS=OfIvY0XZX*UYO>=u=oC2uN^whXkiEjr&dLvJX!_t*g(5TQC*Rq^ zfC~ZtP(-Q7IqA)=$^7r{3I+xSJra$*^pD|RqS^L;=Sb8ys(dsAMq~&;I;Ekh3=H5? z7kNkvg#U|wb8=-`uKWCzG<}0x^(oK4T+ECdyZ-c&J zjQ{>58Sngmg*1Jmsz*a$Gz5@C0Mv1pofVq#pW%Nu0|Ns$vK#^SCk95w1e0yQ*Aq}T zq@>UKa!CFA^ZO_x1ET@~haczo`PFgsG)e-YG%7tB0;3@?8UmvsFd71*Au!}aV8nFL zp_d{bXJBAZ#T#q?8P-1$Vhjyf_WLhhb$BEn?B>k;>qxzVHpDhg8;E+gAca)&lF|4<2NYGh}AZ#ZWN4$0Ffa8ItxNZQUr7k z1PGIGbUfoBW`@5JhTH!=C(^1xuk4IFs~H2+|Gf+h3JFvcQ839`O3Ffvw} zZ2LWtpvqANqaiRF0#pkD&?W6Z*?!Lc&&Uu)f}uYc{{K%k+3|M?33>*#(z70{MgJMW zTLkdA^9uvhe`}Mie=p%vG%7b50;3@?8UmvsFd71*Aut*OLooz~bqD>lC#yFj!~abT z3=B+oBIiE?5#aLf5yx+;d@ZS-i4;UF4kMapHIH@lC{R^MEQMu6&7!85Z5E%Fn z_?@#+cXTblz{kU=3r0g==!L+r?5tmL`pN!-o%;v_1A_^^DEZIuf92D(wUGgV0iX^m zKBYM2wktg@VqjqC#i;-x@#;V0KS!^-^4AbT)MK6TV$Emx|G$sGzyy+o|L-#X`xh{} z-yA7OMwz1_Fo;3`w2xh8W=I(WBSRap`5Lrk7#JA-Gyead>~UXiJ^^(kOM{L`{{QFCRt5$J10pp)W%$P!GDJMnhmU1V%$(Gz3ONU^E0qLtrq6z_93`pY~wRU}R*RP9UH!u(0u18f^UV zgMd1$(i;pmFtL9&*}(Aszb{q=NP_?WGt_(Cmu*54q6!nb1{8E8vNTZ!{%6?7#>g0? zxBd4=qSTKn8V!Nb5EwEcaK?)@km3J-(CKkp#5kIffnnz3=Q>#d7cTrGM*Uz^bJmkN z;6DT7ItB&?Mm+Aj&-9ng*l5?c_juHdN{xoVXb6mkz-R~z_Yk;PaO(Q#T7cmm%fsG( zL%Oqm%JVN569e0M1_nkoJTdj3;Xgx&$&Npu1|A+|xTUr$JxOO^_)l<#hLQ3AiXXk_ zq5=ZQ-|&vj6(_yfHJShY-N(SdAc0LDi`b+m&vgoia-$rJpGJj7Ltr!nu!O)lH)f@O z%nZ927#NkYNMjLVTr18X7&%n8zkpU0NPqaho$)_|D;Ceeg+@27!-Ha!F&YA+Aut*O zqaiRF0;3@?ghF7*cF@myF}wf&&$u1mtjd1|h9eRTLQdp1M7PU7QDHbk>{2_WwVI{R|8YxG$dn&v5vO z5Tj$jvfqCPne#`TGa3S;Aut*OqaiRF0{B9}@Zj{I(X{~hLTH4@4av^>X;0=Aj0}w7 zc;e_k(|<;HqaD9N9bG)iuuE;&uz``i&}0n*J@3hY?tg;|Qp$jK z(m{<-ej?2J%*e#xZM^l*VItIyDj5xd(GVEsA#gj2mF?rZ|0@_683Kv&DkH=4CjyMI zL$O15{`jBipNHwzeIkfzc2c4S~@R7!85Z z5TG~&m|1SLkFEuvI2b_&44KaQ84s3R21bTSAV=fGjEwar+kTUEfZGOER%UkD`#TvJ z{yXC|A0l!0=YKY{fcxU_AY97HBfkH5IsUML_MYRt>>X;;dq&28?#A2xordy9snHM^ z4S~@Rz!?JIOW0=x&tPC+Ov9-NN#Z{PL!s&RKNFCIh6EF|^#9KTb>)pS<;8HUxJ{kg}Aut*OqaiRF0;3@?8Uh0u0)xJT4ryG5fR=vS;|Zv5{}}!o zn{EGl1CJV2|7jlgtto91gWi7xNPnHG-28Mb(;rff|Kck`1j^EetsKF_< zUHM5D!~g%KI2C|Si~G-T@)!RPyMPO-zwoIeR_=@)D<=cb{{syF8O#XkVPu$Wvh7bE zL3N`FMnhmU1V%%E!VrL7FL8i@fq{>JQU4kL|Bp7_@pm}^b%RR!j3@IV1_s7hJdXOr z%v%$F z6pV(zXb6mkz-S1JhQMeD&?N*0O$Qx%%=s+_1_lY7KL5|K)MUq>7@YEWB(^I(@nc~4 zzY(lt`(ER(gx3PRVPR&{b-gY90<4EZ;-n9gJo8^B(CKf&1dRIepXs0D=sjx$LTOZb zGz3ONfTkg^!N7n?M(WBshW`xyge@9ccb%Q_VO3%H`~M090|PrQzcK!2NHX5>XFe|Z zQSs3b7!85Z5Eu=C(GVC7fzc44bqEZK&iZL@CK<+mOg9-A7`SkHnDKv_$+o{_9ksq) z>G3QE28L9eW`mcv|6lBNUoH-x8dBuWda}&;&%lsQgq{YI?SC4HP&cY%Gz3ONU^E2i z5(3b{MbMp(I1lcGI^-)8<9~CbZGW#q`GcA|<@uM3iGdxoO-de@L;o}W?=#)@7vFL5 zxU`OnkA}c#2#kinXb6mkz-S1Jh5*GOFyI~ZvmVSQ{}~xUhq&Q7h4&v56Qdb9_mFKc z_`t&c>GK%|1_pgRA@z@u>A$PT9XYDpJ9N&2RqG$)|5FSM4BU9^1W8@~$MDm_Y&$cl zC&qJJIr4+>S^$s@qj)p~MnhmU1gI1OXFXVp{xkmXC1@NY!*Y{tf1(Jg8zc(Oc(U%p zx5er|Bg1afZGYSbiE~GtF&YA+Aut*OqaiRF0;3@?fSOXdU|6hkEyubfr zVAL?%{`(OgHCUx~C_GU3&%_K`+K8{y{P-sehjzd%(a%^7AyM#*C-YJU21bI7)c^k( zLrr)5UPGesqsm7^U^E0qLxAof06zGV`~L|B1_nI>mVf!r$ZTx7?I&mp69J_IFMZaN zrSCrjLm?iQTzc|c$8hu%cswCADm5AcqaiRF0;3@?8UmvsK=lw9unziZPnH5k28Mn- zK6=IRlU-G3-`7ug)L@m`ru-z5@&Es2SS1mH>pkzu1|hiQv(C7)N;5G1zr?`6AVk2F zll+2=cB;#MQ{}{X0(K2f={<6qnxkt024~!ix@t58hzx<#Ud&;P{~1>jR{p=+Wc%MT z!s-TI#TgHlTn0vlNqAgxpI?wsV?>wVcmiisYBU5!Ltr!nMnhmU1V%%E1|hJrC#)FP znsXXhhcNPtCvzA6-a6xzCxVPp_gkGQv`z&F?ajB1ae) z7|aQ%J}=H7WG=h?)o%joMx_UJ2s~K;ia-ShPZ!6KLG92{$Bl*nT|?lECuH6&5Eu=C(GVC7 zfzc2c4S~@RphgJLwX;sBqt3{{u+Vt>p9E4pwt;U6QY9>KNB><2T$Kc%5`X*?+SNAYL~jE2A{7!85Z5Eu=Cp%nsj>!5>{xHB*? zmf{Yc|BFqw|0Vf8v<*_XdDuBPKqnSS;xwC);lfYev&I1z0{-DtL!`u6PnP2U3=BO4 zlz;rkz^F&g$#(?I9hDvp0kT4XC$eqh=(zx71>LA75<=j#C-XE$2F6SRTAp(JWY;2R zI}-tOsVaTWjalg*Gvf_B1F@eOm>CsJw)}oaRf|Ur8x4Wc5Eu=C(GVC7fzc2c4FPBf z(516}#*?L!fq?n0MjJN;1 z`wa6{}r zLph#MTSCrq_ic(#wHX;1E-)}K;B$Ye=RLVxJa!W$1=@%HgZ(FHX`?hA4Zr>}F>4v` z_%k1mno+6I5Eu=C(GVE6Aply+ZnFKa90Mc68Umi|JnhM{oPe4El{Vk{^Ewmbe|(*E zF(wA~n^&BEvJa?3M=c)>fzc2c4S~@R7!85Z5EynLfUko~k!L}d>oPDffR?#qHwSc} z(DpyE*d=j`LOSRFKo^zcbUP!%|3uGwvV``JNX1- z4?n0uhfQ)+Y%~N$LttcwfaKEYM@H&e0F!NhLP3XU;!6RH3=9!xJXx-QZqLA{ZXo51 zw*9^OkKvyw0|V$xP^>PI`N7V83aiYh;AjYphQMeDjE2By2#kinXb6xQ0yNp1e%6c0 z^#6Y*+zpri42Mm2{IMg|&D)e8YBBz2x{PlY9n?|xxF@%ORI}05pYdd^U|{&)jxLVM zCvQ(Zsu`oa(GVC7fzc2cejxx_iNwIjNN8#MyT{LUvH~t#z_qk|zys!t2df7IHmE^N8~lDB#--Y6t6o!>%}tt|9^%I0`4m}+5V@CfSOV1(GVC7fzc2cnjx@# z#Sw#%x)uOWj1jsl{y*dYKGSV~i}4sSDm5AcqaiRF0;3@?8UmvsFd70xg#fdLh6Ygz ziBSYPQHPO{8Fc(Q4-WO8*%%nr?AQPPjzb=+@OH&VDvS(F*YHoJGbVf7m!-I)e#VP+ zE5m<=NURQl3NkV>M4D{>GZV@krA9+wGz3ONU^E1VaR^A=`FHlIDkCc+10&X@V2lim zmd{lg-$>p0585v^j6-77Yoj4B8UmvsFd71*AutMP9RfHvd6Q!?BLmwR1_lOU9D4sT zGB6vGdWX#hg$Du*jEv_AcFym~%)?vVGcYhD;k1j9vA|^8?}Iqy zaY(Sq-9OC0z@Ugj8g%gPM9+J&eK_TblsMzTa)?0Z{0B3`KLwL5zwZ!f+aZdkv85r;f+!f&PJLTI)YfH)_Nsu~S}(GVC7 zfzc2c4S~@R82AvNxU+uRi(QR@@&9aGUSa&tu*ziH?}@nNaf$CxezctN|9?wd@(c_N z8$IvK7T}R5N($6bXJBNo#iRHKBMXbN;r4&e@u(S<8V!Nb5Eu=C(GVB`Az*Uo_xJz* z|2p9IDx;dr>|mUAB5r-7GNU0d8UmvsFd71*Aut*Oqanb+Kv4%BblENA|Gx(q7#MKP z96Wg}#27_tK<`j`)bjs7V+0<37d`LE2H=q=N{Wci`8Sq7jEcsae>@|~z)?k`Aut*O zqaiRF0>eH8On3ah$jHE$h0~+|8I#X?G2>oghEwCPkO+T!pL29Az_5tQQBRD9z-S1J zhQMeD40H%k)LEC76j{r_z#xgk6aSh1{k4S~@R7!85p7XrrHe@|y*U|5LDXaD~*&OPPEs*X#3 zRD3i9MnhmU1V%$(Gz3ONU^E1DeERWKR?qaiRF0wXpA zjJN+uU|{%v7gqoJM!ivf2 zv0wD&6lY*$=*Jyq|7V$O`?D0cJa(C1Y=8GLFfg!Vmt|o1&&U|EWl|6{Z<+4=h^ir6S;Gz3ONU^E0qLtr!n&_jTUAqZUviLZXzleq#yfsfd=We8t{c@c z8UmvsFd71*Au!ZK;6Ky9Kn4bee>nW1C^Ivp3y0jO@Ms8(_z++^xOc|rT7VIs3`PTB zGz3ON080qq?!01A%*e{Tgn@y96^j%|=!1X|6Yd#tkP0lAor!rd0|Nsa7AdgMe+I@V z=ex3Rz#K9OY01l=rTTJsv>!Lw_9qXI{HWAu2#kinXb6mkz-S2I3<1-v|DG`XXQ;xd zfRUl(j1Q{{PWe%Z(GVC7fzc2c4S~@R7!85Z5MUtT^7PYQ%wY@+3?8^b?ce|ZajMIH z|H37YO&qe!oxum297v3jfnl}RJsFCYpP%t$&SPX?36AC zoHwg60|Vnke0mv~7(8|Mef@+_&8XaH2#kinXb6mkz-R~%69WJLGi2aV=XTbE#UGFS zsMKf(jE2By2#kinXb6mkzz7ckqB`g@l2_^&85qQIg%#s}=3-oOxWw7nITtc8FmU3M z|If&n9Pmu_3ody=;-G_i{{6#y5jz6|17oG})<1^{nKddu8UmvsFd71*Aut*O#Dsw9 zjz4=D|1+$@rS?A~!*o#ngiC%@d^7|`Ltr!n=otdX4lmw0x)y+*AwFu?Xb6xN0t7qj z7yKCn7#J98a2fmmKf^TR?LY70lE)^#UGb3z1LJ>ucg=33^a?iV*}**w3=BB;qcbuv z?lIZ^y91m1M#V-$U^E0qLtr!nMnizy5cv1!Zx#as!(SZwC1q!Y;@-oKL({16Xb6mk zz-S1JhQMeDjE2An34xV8VZ{V{>3@E+G~u}@{Ws??_DYh&i;=MxkDh<5zyGD;ktZm1 z#+}uS@jqh`9)*8@{reM*M}AalGz3ONU=)moz-S1N6#}Mv{=a2pU;u4a$EyE7!~dEK z{sJ6WB}WBELtr!nMnhmU1PF(~>CD&iqiX>OhtsHvAs+&GJL~5?nIswhGvwg%<$p$o zdQvxc?@)Y_%)r2)h09DvMus+*$MUan$rB~c^bd5rJ5HsHj0|~}JO97JsbExMGz3ON zU^E0qLtr!nC!It}peh5j=z;9i2x$k-Ba;le*soWmw_ zulhd&gAh(V{~5{xUdjH(DNjJ+qBo~Fr~!mW+5dlkSw@$<;|ZWqsnHM^4S~@R7!3jH zgaE%FBdD|f6^Ch@KiRlPXVh^7&ZzKc2#kinXb6mkz-S1J)DXbgL1*~$AGGHjm;85r z0fy}z|KPo;N z0;3@?8UmvsFd72X3IWw+zkmH_WB@ID$7~P<^w?93m4V@1Ha0o17$d_ZP$LA)CX!(L^P_-)fdQ+1AS3=W zF_eL5a`7ooCL<;W27EK+pCuTC8yOfzx1^IB#-sX1Ltr!nMnhmU1V{@3(`|o1GwL8r zn#NJpqaiRF0;3@?8UmvsFd70QG6b;BmcL?r2U_Ni^I~(B-;5JT39StqHZU?VFcjj^ zbI0qh>;^pY1f`f5nLsn?IMx0C|G!Lj`zx}S(&IFERAMv)MnhmU1V%$(Gz3ONU^E0q zLtr!nMnhmU1V%$(u!q3Ij`}}CYApcP&ieoV47g{~8U8Oe*!}wjDKWuOY@Ga`fk6BpqBNUSGm+IZ zGX5_?=A*M2|1;o~M^`_}9}R)g5Eu=C(GVC7fl)9T0;3@?8UmvsFd71*Aut*ObPs_S z4dV3}z4U(!|5vDgItyD15Rb=ImrS?)zJNy_Lu$M76Hf*P22BhJr~o4a!-Rm}zyCtH zBva?SS&i{er~kiYyyMSCk_{f!Fd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF z0(1-kl+OAFgAbq+-ePdu&$t}791fZP|I={DfQ0}5_|HbtW_OS#Wc=^{|6*i5I{QBZ zBgsnu(9Ia-kA}c#2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk0Ifp+rGqXlDH6!Q zz`%ja(!b1qN$Q|)P`Ss=z`)>#%RB}Kh7AGt#oytP$14T8Af4eqLnMl#&;NzvNBF4(ZqpP&bR;gN$hI@2HIt#_K$|Z zXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S22Ed=15b(s(U-53}ca9)`HkAYpw#jb{U8$BO?<@9e#)|1o`jZe|$6b3=E9_C|XL7uxXSu&w#tL{-1$i7cK*EiZkM#NoQbS z`1Z5!%yyi5iI8AqUZ9u*i3fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2cydePYvHxNIZ-vK+e;+@6a~zL6hSXMt$3_ec415?8P=Wu9j0d4i zvZ(+68J+NH{Lk=j13vjtxzP|94S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5EyJB z0PdhOFfy3qbl88!)1;pC#>C8sdpezgfq|LfKSdpM1_lPa=Cl4|rPO?!!7(Z^8UlkY z1gd*IQ-<|gfWa2vqwX0Efzc2c4S~@R7!85Z5Eu=C(GVC70Xl>Lq_fU|r?bxR?NasAOV5@j0`r6xVN7lqGWSA0~dHE zoe`H47#SE2;F22^9}R(_8UhFfb@HFfg!_VHPMZ9x*U5 zEC~n*I7)_ou$B!QHi$AXFcd&f$pnQvSeyjnHv6 z7#K7d7#PIK$ty&<36$oZFfcIeXJBAh8xRlxihClplCNyTh7F)HE{1`D!G(c=L6QiQ zK!bEI85kIjFfcHz2?z-ILWCZolx*0r0pteIeX7z73=AN55v32R=nn$}11K%c4hRT1 z4HX-*)P@ZkIH36zR4;+T15_XJGcYiKXxIb?t^D_%fq?;3-b@Jy2q5c3+zlHxSTZm$ zfYOx$0|Nsai4Fxdubwe5Fo3oXE({0=_(!6Cxbh7fHh@kk29f_{0s@Gx@KB8)j<;dM237_J2196DL&ku<>vjZ2SOf7+{gb6xylq#QGo8e^3JgQxep|`p=l+abIRGR!JfR&w8?M`p@v+ z7nf2d#{W7-+x}j~B|j=YFd?vE!v;{IZ((3y@CGG=fpG;^TR#Q_1b{LjR!K?(H*DD8 z%)r0^^0Ngc=FmVdsI%6~z`)QS5D@U0TIO%qupx;ZM=yaECOC>o&Nuwlat1_p+73fzIL6=%Ld zRy0`H8#ZiEVPIfrW?*0l1s|a^SRBT{0BW%DlGJ~?6sP@x^w?_>`SAgj@DYO4L4H}i8I0T@} zwKp*^Fn~tYC^nBq2Jmm#ut6vwAmAM}%-^tKgFFKR18DFIG=o45BWYmqvLsLnaa%T*gTY`75+5U?1;p&_O;oq|>)9o(>C!+iz@2J?V` z0MH5{a-0iFr{w6vriW5LVKbAeV$fO06$}gvepEG>x`u(u4NzE;I`II~LoJsmZrHGa zk<1x4a?4C?Zl}mB=xjM?5IGi`;e$vFG#CO}K|qX?Kr4+%T`4h`0ce#9Xz&D7mJ#bt zYL;8X+DQ#{ko_1FeTMso2YrKq0caT>Xv7=t0c6I1hW`&#m;DAU zT|^chWe-3IY}l}29RmXcXzAWy?wnICWI&BR=!_UB&kW|yIl7-gd1}gr4IBP$*suY# z3?E&7ls{}kz-(69T7qi3VFwml-PPUt18bR%t4I4IGVPIgmImkQeFefAEl|5m_O#lBg z;Owz8GX4kkJ`g&HU=f(|{^a^l612gN2$L|C$jDp;4TUgc%0NUI85qeP4S<+1N>UmE z8#ZiUfll!RQfk&v(GT573)*N!$@hefKmYJf*E2Bu zAMKzM2tqnYuVDbq($T@9L1YE!EakdjCyLQQDtCYm`)9Y=x7FL-85)T0P=xFuewQ9qL z4WM`&Y=?#n-T)ioE`ts_yAN@1j5>cb1n3z8@f$X5*i5X2(1HAG(9@ua)kZgUpn(Ji z1_scH#6z~7&cwjT2s+{pd!YSiAbHd7f5!j3*bM`Vl60IUSO*fp$iN6X@)t=2jrpGu zbYw4@;3$_~A+TY?2A#pW?uAxiuwlan(6PD%29aoGJI%~(pqa^|1`o9mpx5l<76t|e za*pMvb$F(4*svj-)|Sw~d=b##IA}%@w8Du7<_{Sop~v`U3>gQHI+UUifNn@HrO1#` z&7&bOGDE;;!-fqfpq*QsApkw)6?B-uVBYH)&A@+3vZt8^lpDMZdNefUCV@-{($MXq_gVmuy$lSX z8O7flHf#Xh;RF#GdE`*6qd>6)I?f+Nk7Ck70Cd07P@F*{r7ioDv>+K0s)wVWumwD5 zDK<9o!6F9AKi6>$@^09$0d!9#Xkt`$us9fpJBH#xStw;b=!iv}o$LRM|3P;+;qW_7 zVVpA}jQ=U?O#f$KYa!)5co|BO_-X9|}cqvCi&fF^gG;kA)6NzlnMNdW-?MBhD# z#X8XGO<05}76N6|!_bSaL6=8@DlgFeE1)n2UG59A7jy@i9>q3cHvn{MBgkItvZJB{ z5dxsSmg{j?47#Kpw5lT#digI7Wn>A1DrC^v3uI}=r)k564WLeXJw7#qKn}D|eJ2A0 z!;swH1`WT-8#ZicV_;yg2?z)P-D3n59uX9%%p6J{1zofZx*L(?vw?<^Cu!)`p%&ku z34@k^fPk~ah6(6u2WWn-B392JSJy?aOR@(z96%SRrw*_ih_N1Y)y@OZ5$OQ|0r1JJ z4I4IyfmUfTFyNV#CB}66r~$QG=wlOgYyl12Bp@3F>Y(EsX=M7(0P4UXt0#tyvxEMh z!VWake+HiaczWoJ3=E`??!-fr@ zrLET)7#Kh_DO&NV1q}k=lOL6%e+Zlp2nd)(z}hV6vBq@SQ%=Awc%?UN*Z{gujOhJu zcnxD<05u}C0s;aa!qjcpumRM_xWK@`K#jDlwPC}CDFFciLwP0|G#Gzz!-fr@j({C< z!wVK>M9`<^=(7*ZS^y#(Hvmd5GB7Zh1Ox*;DGsJ-}`3>)co03{i2BUKA% ziLZ7*Kmcg4oK(FuQy&lzP|d)=0Kz2Ov0=jo&>%l39h0mVj|Nbl0F^U%lnh!alG|W| z)+f|+80Zi-&^01P0RaKGHf-1cnw{U|<-jopTUa1S05(L7iz3#%d=c69Z`36;?T{f-L`WcJ@K)8A&Hw+8tl58LD@NIaD+EB77S~gCU+CcSAG&Ku%WtT6uh+e*dEoH*m3I6*J zf9Dv(k?+{P;G6IM&-fqbvUm)$un7EPVEBS(q99u!jI>APIEHRKtc1 zcyDY1H8?>_((Xd{&4B3J3=9kx0s;a^zFKgw2g<1XMnix=2uKo;Cq;T1DT;|vyJ5oy zQw9cxW@6M5sfN&L^q{?f@Fnp?nu$vpsLlkf0stMbgGTaJ{Q|0~F>GaGBCVGm@JjagcAm$e)3~s12>j2;2-+ZrVGpqagf^fv4bJ;~ zi1prZP)G9peN^%^s3ifq7XpM)j6|zZL7U2!GB7ZJPHUldYX-&MQO;-x3`7V(?;@gB z3jwsYw}+bk*sx&(XygsoDR|^q)e#U70ICUb>J118ctqvXmdrM6*iao15YUR#(ou=Q z6aoWPFR?N(FsMQ?N_e2vOQ1H`ItB)YrhtF|&_N?8Y9Sm$={E`_uLS_@eWB)P7{n}VxU6+bn+MoQ|b;-+}mu}ut7Hj20|=uS zfmRNHmOp`JMEU{(0rP|P>Ci+3+`1028IAaonSI826g^HC)1Fr9Yq_cf&;Ci zM-jxq0iCXw%)r1PjzgYQ;g$^>HmnE;2mlS2ld5-AJ$*x9u-8kV$&v^L28IY|y#z{A zkpTe#r%4T_fPjD}8#ZhRWnf@fO{!iZ)TeLQu)z{~dLC%G09XrD><9w`185))EJj75 zG$0`00Ey-e_V@;E^7Ut6VDN{=_frN2h8QyZNn)^!DyL%zP^*rkqmzf7RfD}80NslY zYM0bP%Ym&73=9d-1E;`|0lGXL_h{ok267hP1{~7R@&A{;g|LB@`aI6jx z5fBgnI-Y$f%~FF#xIh;}-rlfb1CBwe0Se5aX)Wj^J8HUIcEg4ZtN_X6QH%idU}OOG_OZ+17R5R9^q+yU*>tA=|3Cd_#J5GAQ4qJY zMrG&}0-%#)sJXP29?k||6ux1@2I+u+09qAV^zg?(+d-{+wvhcXnSp`930VLi8?+=7 zG%JNq4H5qnc?W&>-kC9Z*Msn!b13uwet}1TcbT zLPs4J6I4i=f}Mk;14f#wsju3Gfq?-u!_!5M(Hk~w0Bx-Tbs)*nj!h3}#vinLhg3|h z&N<9Y@i4{!(OPU*eQe0`hb9dlb{9<0|Nu-w9&!mK-?|@<(mn(rE$vy z1O)sJ2neuXU|>kZt!mK8Ob!SL5Tmej4%({&+A;uY4^m;kJ)tK47KLj8$PeZzHVoh-ht81z`!6!MT}5CpabPVgVFffX5<=AEq$Ot6wu0)JZc#TIyWL9 zAOJK&4VqO4%^p(AXj+?NzhT1$OlK<`~uWME*pNnvPs1_T7q;yerrodrH* zr|P z@v;z~$_*Pffc9dR<5NS19Ow=r(7`@rXdTvCKzG-;ljuWg-VsNl)ihNuMN`wsFgze2 z;AKES0BE2pjtuR?UJIxp84wU45D*Xm8jJ;%RiK&n!R~L+snKM+F(4q|OF%#XsC`sL zwjs3BxPyU#fioZ=0Cbou)olp~2-pFgp$EmcJp%&+sfW=~-AzOoNKt$voBtufL4Uy3 z0w6nN^pzCWud&FTh*{d$#7LdmP zoyP#W0F^x5qq;!L4v%iwu%Uv1fuVzn!QlhFz!!9_9yVifiDOesj2LJM=1O7|j!-pG z3=9lIrV%m{<0v2?VDW|x8NYdf`c&*0s;cIGcYiKFx+_?Hf#W$ybc=t z109+MtB+tb7hEBI8K8QJVjBYj0@^oh*Z^9R3hEeB>(&Eu-34kF_yz<7(CwhNfPjEQ z=y?Hpl_h8^jREuuJ>+%>XcYpv{-Z=6RpOfx2aGVipcN;SUSar;fq~(9KtKR!Wyp{U zKIlQKh8s3)Sj)h`5JaToze3ND0BtiNQp>#NDIjJ>|(NXSV$}@f{Y%zqc3|Kr5Ybd4aP1pvpPW$~seMW0@M>BHIkm9)9TT zAxVcof)1O7PA-7XP6;O40izo6gn;_Fd(CuT3-B=@AmAPzM~w)nfPetdHXBfT8?;@- zj)8#zv~mnIcnR}AXbTBw_4KlUfB+C1CO+h8=-uUe7#J8pm?-~12X;a2A~OaChDHWZ zvL#9Vl>iFO$Y-6189E@Wv56Y90*#10ooi5x*QlRx?#fxPzl)r=1_WEXyzyR$`uPA5(LBh?2BQtQSVrQ7HsKo3j}h7`QiV z*Z|t3N6q{3sN}+-Z47kT80b&{5Z(%1$^g0$eb9O=ARr)r!-fr@)5^{=FfdTFY#`)k z(4C4i2`L)v@&N$>pf$4~ymP~b4WMgDat6DbN8L9X0)#>U+Q#0(z`y{)gp`kHdFbfw z4F(1V5MBhjQ;~sz0dyz^Rl#e9cH~~5d1#}R`U<*!?-2>`01Ox;;f}S7+TFXVU z8KWA8R|o_I1c1s3(0bYtKV?L#nBA~p18DFHw0@daR#45{fPjG8&{Nhxdn2f3E)Ii0 zomo-P!8JJKhqAEIPJ`HVSveL6fSUE{>pi3aXnwYTzIafejlrfc8jHbOz$%?fadZs#qo*!|9_Bykr7wnrLt4;zO?&&MNq+w&lIe3X1jjh zJmbmo0uwlan(7KOG=<$lQv}&-J4O%NnO}9+k zuwet}E}nf13=E)qr$FnAKzRjp^f+jy7qotrhk=0sbVMg8kAc>ggJw*r=?0KlRM}(| z5D;*G!-fs93=9m^-arm=BeiikbOR%`%^h&_0s;bVK`%icaJP&)U|>TaARyo!0|SHd zh7B7)8xhwqFfdSauxnsFhr@R0ASmcM2vGTnL)FL?4hRT{g4R1nXjFH11_T6v4lSWe zAaB^P0d!{~Xay0~X13_!@KH+!Dg;0)OF*ZJ(b|5{-Nm3&YC&x|TH7_E&4;c>U&O$` z5I$4`0^C7oU<5BAkH+B%OB~`jh5s`!9b{zqk8?@gUj`P?jQ28}1`v?=&%khik%0l% zQZnWj3~y{?85lszpb3~XDoxE0*sx(kCIbTl(aUkEX*U(kdQYNRpsgUrBa&BpJz*K1`tF z?(ir9gMklPode(RJ{TM{>W0w}phpNmx2FvE(^rRj zy#!j>MD)pa6s8-{IYks2fuS9=5)4C-h5|!9zCm#SI{A@?K{RUg0EWO&PoD!AbR=0V zM3U;kr}!|qgZ?;uEvVs)bNL0!A12V@Lqt12;IZT<21W*)`_mbi{)1-Ii8c>iArmX> zCUh}m{(syv?a0bT*`pzV9s;fC0z~jZciTK6LfueOLe0b2ko^r>9e}f(86W^^4B0}D z#U3Chj9NPy0;3@?8Ui>&0JKybbg3~;1)~zfDg>rOx1hUlokXb9jAfw=(z0W)w*W0wIP)&*Un4!Yd{yP}~b3Th~w1MvV zpoQ_|ni&%i5O9ZF&7=B8Ltr!nMniyxA+RkVAfTRx=8qaZxIzFlaW(k2=s6^x*Z}!z%P?Q z`{oI#8I>Llfzc2c4S|6OfyRJ<0PlgYc+}1T34y5r0Rbk^LH_}9z^J98Aut*O0~!Lq z88pTG^bl(SKr20`f?NdetTQmM>;MViz(S|JSxH*{?r~QhG}{e2p&F}^jQ<&N9R!C} zJ-Q$}BNM2z4qCQ~E(PH;GEy`c05N2g9OfYaI#Hd88rQ^tmZ}8=1b|kE=rJ%b;7s>} zG(15k;xY#W1k9m_`vL+2IvE%kKqsEk!#Z4coCydBAo&VBT!xK`kA}c#2#kgRl|le? z$SzwzKtKbP%o{akaEAcserm3OfPm3i^uZlZqplkb0a}FsXloN_vJ6!2gKpb^*t4=H ztQd0KJLv4&?LY6H@npG#>)2PuzyCqKYS0}~5Z%O)pq_LI4yC%=RG#Q~-I2M1LmscN z-uB-g&v>zJXZZi$3x`6{)1Iu}rrUoH#}mGAczM8uK}VktxFhg5;0FT(1875eLqI^l zYdq@6mI??6xXi%7AhTh^2GEi1pgaR=X{nKIAZ;{)?vR_!z`)QF5D)-5J&iWD;5QX? z7u0zM1_pin%4j0{oPmLXqC+x>bQQJp!$TtN#ii^q0|NuK?(QSiEg-vbxrpi*O58!sxFI!NQ0Q%q;S&P`12yk4qa)74-v2i`I;Fo0$XdjkRjzJol9ju`_2K-U96 zgwJ@f)-nA554!LgB7jPMd6Kq<543?DRTwvKtHNU=CME{ZCL64Z82>Z2dEA$&#wv+T zaOSevuMukjP*j}tVDbOYh;M3+algs--y|&sK(SzyLvjdg*suXKr3{*?1a(m9c~LFs zlrqqLCLsKQfq?;(!A~(TFq{hr2mp1`N%jl1HEh_hL4tvS0W`P-!l3(O#Heitnt7l` zAZP$?Cj$cmXxRQF^mu19Wdq0EuwjD|0|P@6w9^i{7l?^Qt^p0s+-6{4m<=6Jp^-JH z#%|cK0n`~QW?*12f$ot&RX~^rO1q#9^m76N0uV#3g!N&l*sx&(I|Bm)Xn%tP0|SE; z8JA{*(i5lxToVuwK=L658#ZhJ*$EoF)n#B{5X5jGegTl(phf^_U)2gq*NAS|u)&>y zfgy&0fkBahfq?_R)flp%TiHf#V5mZvc=Ft{=>Fi0~n zFp!$|KQk~e++bi}=m`i2xJ$@BeDWJMYyg!>pk-*FJcv&zE;&$mfb!O$Jf?cXh7HOL z3=E)rs6pk9D6a5;iGvzpp!x__FM&=TJ;}hp09w-X6{ct~(Hk~w0M*-|I^3FpfkBc? zw}Je8je&uoJ0KtclyAv2hCFQ>Hf)e#U|;~1HK5KMs0=30AkuY#j^+o&H3);k0CbfC zs2y@TARquV2tvAvG}5tQ!v;_p49XLrflJW-J#uUUbvQtS?x1)EvB@!mEIrVBB|+sU z=z1qme1j%P$TEu#ntn4dFo5dLWdQ*J8_BhA!-fq83=9mQt8-Kt7#KkLloX?2Zde)+ z5PSYuh{RPaU4$<$B==rf7H$)90CCW0iY2@m=E5- z7^C!P2#kinXb6mkz-S1JhQMeDjE2By2#kin@C*SabZ;>-{9A}F2Iv202)P}_O45>n zUo0G;wfCUqfpBBc7@*EHnh^2a$AS#A7#J8pOAD}TVq}QE;K~YGW{h2aRCF{1MnhmU z1V%$(Gz3ONU=)moz-S1JhQMeDjE2By2n>P{!04d=_`?M1pyS$K$NKTz|8!E^9dJwZ zGXo<7=%6Yb+Wy;ZReT8AtBFG%zi_~^-+%uzGJuY##;Ncx>;KUUM{$M@krEtgn@)|c z1t2o8MwN|*z-S1JhQMeDjE2By2#kinXb6mkz-S1Jh5%(DfYDjE-u?T_e+C9nhaIcQ z{~7-mVU@rq$n>`lpENTQczHTLC3xlj|Nq&}z`y`Hv;(WU|Nj{>&Ui3`4qLz~KPosH z0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd6~_9Re5~bdZDoGyIzZqOoB{21bQ5 zUd+MR5*Zww5KwRjaVFyb8~z^`{y zc8G_7o`KWT(X{|WJQ7EpKN7@W~UBdy=-ghk=0sbRRNyrHl+dXWUspR}f*B9~B)9 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!84e41twBVZ~T`=^#h_{r9gEMB~6# zr#)CLamZm6_P8&7kl{bW0j!cBLB{`S+vT6&y0;Ca0xyPK>dpwdAQQJbCWg^zb=;vd zDl-T};LOgWuA^%K24TdEI%zZnMnhmU1V%$(Gz3ONU^E0qLtr!nXdD7KJL_gU{%&Sq zV7!9Menv(Hl8~h#ey&uS)`_I6z6uT?~0|O(2_YRduPB`U>kzo3tgiqOj#{WI1JXz)NsTq|U4S~@R zphgI2>|=2rT?;^skQ_B*Gz3ONU^E0qLtr!nMnhmU1V%$(RZa(GVC7fzc44RS4kipo1)BWMr%Y(bzB}1B1;OFV;Y8a=64? zA4Ng2|JE=tFxFd71*A@J=- zr`PCOfYEd?8UmvsFd71*Aut*OqaiRF0;3@?P$59Dvu?cY_a=t_j3;n9@IUGM$$#+u z03GfA3747w|1(V6p!iY7;we`SS^R27Wk*9`Gz3ONU^E0qLtr!n zMnhmU1V%$(Gz3ONU^E0qLx4UZK(K=jat0&Azg!TF11p~OWX{GRhgCS>g6gmT|Nkdo zm4pbgvNQZz1Ywd#8t?dfhT%U0=#DmA1~4(P{5h~;{RT!{3P#08Ltr!nMnhm|hCrp( z_eG;?0fuJWjk+Qp^*i9{&4-T^>_Z^#lKnyMmuJ85kIJFl9kI$Qjh`3Vyz?@}BP^R(T=>rSAOO z{Y;g~hk=1X3Wst*zO!1&Qg{BX$00u|JQ@O{Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0(1!hqGr=UE@5Y2EM#C{_yuBP!8{CH|7T+n$1C*X3;0y_uecQbXZSyBgZg8B zT=K+-voSEbFfcHD!=pCltS4(G9{EwJ(GVC7fzc2c4FNiaz!8<5i$~W2&@sSAtr`u1 z(GVC7fzc2c4S~@R7@Q%nvL~#V=pMV?_TL{F8UN$%*)lRjpY>w(BgLx$uVladXZW9t zM-Myu@Bb97g9o|$KNF)b9y=KRGyI=++JjLapPEs*(GVC7fzc2c4S~@R7!85Z5Eu=C z(GVC7fzc2c4S~@Rpk)XU-9ZOA!g$-?c?|yX%|LeoC-!IMnhmU1V%$(Gz3ON z0AC1P;fmilx)uOm2#v~(hQMeDjE2By2#kinXb6xH0wi_TLFWErWQk#5V8FEzUEss} z|1&`9NWnk(enc=ZFuca2r+cTuLrXmJ1f`6(|7m9U&#(oLLM}#D<_nKK#aQvE8I>9h zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S`WG8Ui#40g^lDX4}8N`Tzev&g0)185k1J zdNI3`;#$b@?+niP^!#ID+O%dH<>3oy(gb<{hfAut*OqaiRF0z*3l7y|+V$nwD%Pu6`54F4T* zX#Vt%;ish8c4mC1xM67BuJ|~MfstV))l9p?BChBA2Kp9h~v`D z$iT4Lc>A9)T=JvhqaiRF0;3@?8UjNr1okRF7Wws`;V}aP0|zdbGyVH#<^DkaI4=27 z@zD?%4S~@R7!85Z5Eu=C(GZ|@2#`FR4s#GI6H6om1H*3^AC2Z?V&K@0CPt9!d0%$g ze+Gu7cof=hS9;WmN1mXR&OY{^fByZ|V_;zTflKj!28Pfxo-9*v$&ZSUhQMeDjE2By z2n?MN*ucul{Odo%X?&e?hX0JYqn-1i6Q>w1{rCUu&C#_07{M|sFd71*Aut*OqaiS~ zLxAkgy8hPhFBusbLvVZlzuj3+mR8*I*kyk8osDH+V7QB279`48wq5aY28cy2w%qyu z6$1mK83O~ue>`Snk9OGcgwCkcXb6mkz-R~zkq}^)y|;}4)RDvC-v12$=Xu_j86BX- z5k8~BqaiRF0;3@?8UmvsFd72%3jwk_=pZK=Z~MK8fsp~{?Dl^KhN?3j%w8b1#NvQ} zfd8z&|A7vQ!#Uf{z{oItyYdrHV$H;+&Sd-V%m4oUv&ANdB$j=~lX)@{f0Q{I0;3@? z8UmvsFoZ&2yW*oh1_lN}Uv#hQMeDjE2Bq z3xVy5kBb=?84Gc_i;;oh{ZHoKR=DIx#YaP6Gz3ONU^E0qLtr!nMniz^A+WM1teE0n zI>^;OelvJ5Ffe=pv9Vw_mf!#PV-d$I)q4Xq{JXvQtx)uOW0F6qGhQMeDjD`S(AwZSRy7lhgU;h332kNln za*p;{Pu5MiByov*-jmzH!0J5?z;$#z zSRErHLl!xH0Bas4MnhmU1V%$(Gz91t0-*g-{~7)-#Ao5Ze+(Axw*ednJ8Obm>8H>^YbaBhQv0oB3{>W5KkGz3ONU^E0qLx2h)uvPh~DZ~H& zpcCWpn8xVu{Xq5{9tERPqaiRF0>eH87A~@QIJy>K*hllI2S-C-h=%}GJDtYce@|y% z_}_siOtQ~-vX@84gX_2mGm!=r3eYBU5! zLtr!nMnixeA+S^7p(WFQ#xo2I42-z!V`N}V_P8sv7?=F0_-F`>hQMeD43!XI-}Pd{ z=vsiG5?iD091Q{TLxAd?b&yj`w*84>U|=`~Vq?RM{~1@F_GYrcCWlYV>z-`le?|s; z`wc-I_M01Iudw4Yhmf4fmf!Eh8H6Ml7#MEjQOooH&!4BKy;ywks2P{NPc^^b`OXPpDK{y#&e$32;OVD>068UmvsFd71*Aut*OqaiRF0)sRJXwpFk z`Spn)qa6bS!+j7N8)p2+bo7iTt1dP`UZpe|u_Ex!l?a2BLryHSr1tD`!5UIA0q|^ zhR-;Hf{B6Q|AliNtXeqaaSDSw>|kd{->Pb|9@}1ivKg1$w*##d%=|zv`ika(oxCL5Eu=C z(GVC70m?#Po6_UT{|tB!6=nGUpE1DWz8t=TM=5i}sP54a7!85Z5Eu=C5f=jA(zhFo zt_2uz(LQh{-g(GVC7fzc44eh6$=dOV4d zfuRkb$&CM*9K7z!Y{aK#RBki`MnhmU1V%$(Gz3ONU^D~1FmC(m_+85kIj16mZKY@ zSefyv85EM*J~41`XR`_1_Ozm@Thzo7eqNcGaF`q2;= z4S~@R7!3hv2!KuqW&iWzIs*fPG*k$KVq{==&&tT4=6qN74Tj{Xz-S1JhQMeDjE2By z2#kinXb22}5TMI!IxHN_wts)~kAYQ*fq~%*Obm@?`uFeu8FKD8bAKRzoRNuHnSp`f zCz_ea+?4Igk8e3SF+zc@5w4LF#KPOZYC0+oq_rPjctmL3G8V{(t(F* zvi)}m<9`N1`vwI5GyJ`C#*=v_9-~I3MnhmU1V%$(Gz6d_uwCiV8bY1(|BU~uM?2@x z02`%7Ltr!nMnhmU1V%$(Gz11;2*iGu5+QRfz<|uABZuHwPuAW48UDK;Zy&g;r~3ZdveM6)DSCo!C!#m@2{^%|1-GeqX?+XjE=A1V%$(Gz3Os2yD=}E6Dzf?J5HU187heR}lPTV)$?EeoyWME(N3F zqaiRF0;3@?8UmvsFd71*Au!ZJV8A=*pa47T$+F--0|V$XbPyL4Gcufd@=V7v;KGG} zn3DKKwkka_WMcS#ih+TF1&_*?tW3Z4T<$2n!lQ<0sZ$=zzD$gapeumz9b^YGZ|f65 zM*o0izyE?bqj)p~MnhmU1V(xYY*&8b%JBdHP6h@BeC6lUpTAkO0v=0#8tLIR>i^LY z7!85Z5Eu=C(GVC7f#DSb1G3y5JqV4r|4Cr@|G$ZWA@SAyF9g;C{AVzek-YN$j5{mN z6MoUnhw;7d$(;ZBP82&RHCZO3{ z=KcRaXFOTc2&fyC9u0xf5Eu=CVIKlJl%A|$`2T+wfsXom&wH{mqaF2OpBzR#I2r<@ zAut*OqaiRF0wXg7R`!Gy4~i*uSn!|mV2)v6WLym6W2OK9_cqz_cN0w=H(mi31pXj&kFrKXU^E0qLtuD@ zz&54F<%|pr9Rz&$?B{$4HjO1@x-(c{8h3(Vl!;B0J7Ff-O3NkV_dNc$^Ltr!n24@ItPR2(fnh6wQ|&;8{byic z%rM#hdlra8Gh8V0W-ifd0chrmQG-WAU^E0qU9=um55C_s`D#f&6g- z>PDqULtr!nMnhmU1V%$(Gz3ONVAzDfpzf@LLiw~Ob37vh;{p&H7iRp=SZuuGH=)KJ zR_%LK?uq^W$9j%|fdT*Nbc~FffA*g74+seOkJTVD1kZS~Ok`kS$Rk4QFQ)&DK1Mr! z?$UFmTN0|P@hUPTNHe;EJ&ck;L|N9gEw0>&VuAA5?izGQd@y1UI2A&Qgr zp7H;GH{%_D&)`%s@+53~-kuv>3o!EHebmQ8I0SYmKW_g2pP`0;7ydCa{B!fTC%>0~ zI;_&Cy;x%z|NmcvB>9?&@xPu&%nT7NQAzx4F4E>Om_Y`K!n;+C8HrQ8UmvsFkm6DRpF@t z6VtyV3=9nT23r{z82%sp`B>SRoLe?F7#J|gNM7B;!0_J@n@9gMFueTF@ZZ5~``;Vb zc|BN#UsbyqfZ1lJ%(?CcaDe|XWIb@kwejjCEV3Z|7-?x8^|NYIj{oO-^ z+EFEgF$98ZTo;V41sIGmGwLQPhQJ1~$E@t!47(T@85{^0`}5y_Mmz8OGN%Zr!zz8o zn^}+HALB{9H-#`TykTber)RkR-*c>nj0%p1z-S1JhQMeDjE2By2#kinAPIpX*I5UJ z8+7IwbY+?xh>rumXJ%qFHr)FAJ`Q=j!W&fXakKwrJ;uPupodo>1H%s{hX0Q4_vAn) zxD!xHy!2@=Rs+WW|3Q1vMTpS#gW>=GU~=!1C&K(uC8HrQ8UmvsKy(NYc{m&c10%yU z&%3hOL>r8z@Qf$RVg?3=7&KvY?mUz2f0EG!NBN^6FbYOPU^E0qLtr!nMnhmwhJah^ z&Z;5VONWK#884Pe4F4H&VSKDKBg15qZGVV4r46h0t%?s#m>8K3GB7Z3VO4+-y!Mlo z!7AXU>_>zU<*ZYl%orI^C|xWcwegPV*CC@2HXy7XmY08rY4l1sHMB zPJ)NGDn2n|Vg&EwAyjrgXJ+|l;&w~^841S2l|m0N0i8<63m3#>{9|BbbTZlY8&sBK z%8rVRhQMeDjE2By2#kinXb6mkz#t5PA>COAh36@EW#$yE5JAVGh@ZXG*&51jd zo;3Vt_+JmQ6CeK1z_{cW-TpV*^u&|$%R+JkvM zBO_x1jE|LOWLR#p?N1a|IU)o%7<}Mh`}BD~BLjm4A?5%7Gt_$Bmu(@Wj#T-RK1}k= ze?hbBjCfDEgPHrE;s5o2%>O;fydc;GItv@jIITsZj3@Vp|59PWbh`! zrWK)PxhgHcLPiAul21d}#I6GDa5W)Zd{{Qzk+wpfZggZ)(hQMeDjE2By2#kin zXb6mkz|abTVbfU$1^X#CR&^%k|Delq@hyS;@sHu3wb}N6=RkVM!CRFc>N7Dgf$nF+ zXAj8efBzX7VZW)JwDx>+HhW%wWr6DC9-V`lglGJ4!GpWDfYxz_^Tofq@eyMu7hD|35>z>5e~(2&fyC z9u0xf5E$VhuwCg<0RsbL9|P!+cKl)Sn}Lzh-}A1_cKph4%RyC?`$H2|rd*mf!zZ zGcYjNlc4kFe@4a#(`~=Wp6w#Ru2H35;*GoGwz3=IFLFfcH%5^XRe!^Z!={wAC5`Tv$^J);UoLtr!n zhE)h`Q+`y%_@A+r7*8=WGBkPIm8ED^(isnyat20*4#M{R&o|lr7w>=oVKYWmjE2By z2#kinC>RZa(GVC7fx#I9Bcg*23;J_z%u4^589_@Z2`qtUSo-9JPF%o+3uIrX209g< z{o|)~42+DPF#GY)KN%Pp!#wZFY{H|4A}PoT@k|>S7#IvlFzC;J28MP4LB@&zMlCKiXFZtg{xdRx_LAc} zwH>s|{t&+)gX_SSMLTxRj3H(%050DSSMltYDb>T(Z=?Pi4S~@R7!85Z5Eu=C0S6sR zGW`o1YW01{nh8y#3F}Y&E*Roj@p! zN{@!XXb6mkz-S1JhQMeD42uvLksWkU%#pD9@gD;NW30*c-=LG(Kw8PdpgV;)K7U&L z|36~{Q3fzFZvOfCe?-75*>6OtCqvQgC|0%)AO5v7{%0%%%~z3N#9xO04E+q>80$<9 z{r*mZzEP#4AuwV>0DKk0?BEoJ|BT%X3=I6p9%p1=Jjcqw;6%y(8f5dxW$#pbF7xlt zpPdX03|hpP@t=XA&g-5mMT3?X{22uP{9--8!0=z6fSrFC8UBYDZ~qHg3PeEZs5F5P zD4TeB+UQyU0)aFtJsJX|Aut*OqaiRF0)s6CMr>yt77BRz{XZ42=KX zNw(uZ!7}YQu0u+Y889P=^2JZjm3=9k)%t6Sw{|x`HGyY?8HQD+5 zDIvAgl;5uO#E*gDKWG4wn+S9NFf#m4^tdOtgb1}bl|T;)*~q}aKyXjce@2ED(`|oh zaT+!%F&YA+Aut*OqaiRF0;3@?8Un*K1V(%Z9Tu<9`+q=7FQs8(IOtal42&)&+kaoi zAy2gM4#h`_{}~x)5pnrC$jl#%{~0no?#nI&acGIpd$TJ3{ri6o10#bI8TQ=y&&W_{ zy6w*vGW3pW84ZCU7XoKJ*_Hn@{+q<`|GyW>j%Q@t_Mh=@y6M(`Pe|5Fq6Wy|;N1xf z42(HMYW>Q<$QVfJ374RiM6!}1%lA9IQ?4AcOw4~KGB7d( zl4Hq#28Nf6{~22Og&1cJ?2*mnxPDa6NDhHBUaa;E|Npl$Ffdq>5f1&_82n_g7NG&0G@HG3U z&wUIG47r5VBFVpDVECWtc~1_s`5j4^w#-wWf4P_#IPw@6{+BT@FbGm$+xPzr{}=pc zV3=gK{qGG5%o)`>8UpYT0QE5cGyh8Z&%lt!z`!5}mmr<-o{@o}l~0gyGQIb6fY!IO z7aQj>{AcK5U|?V)(&>NyGcZ(p-IMJkQaxT}r#zW87#SG0GcqtL;8h5bywCEF#c8ys z4hf7=ax?@+Ltr!nMnhmU1V%$(Gz4&kz-R{@SI7_$KkLcj_Md?Pbg3^V0i_`6y&ON; zgLL+N{RHArigzeKwEO>`3AA}$f>^T{85qy}WBecMbyt?|9o-XttfDM`{ueX+X8^5{ zCpfTy&rjfKcSeQeGanoln1jf6C>ja1_lN$ z5T5`(|A&>)-D1=4*96p!N{@!XXb6mk02M<(=jT_R(X{|n4A4=dMnhmU1O_MsMmy^i z2K-qMW|RMnjGzC(`?u8n-p3L)=uV(8c6TOe+-O?-uGnAgE;iYr@h$K z82|q*Wnf^4C1zCu5$=1!!0>UT8UHh68t?csp9r-?DcKU(85kJD85kJ&spQ%>3=9lY8JHO-4fOqi+m#;qFfcGqC2=|a ze}@16C;a69SrKqS^%ph5&)#MPToCv}l=@LcqaiRF z0;3@?8Uh$0&|g;MGP)K3BUnZSMnhmkhQMfNoyyS;+CwfaDKd+ZfgzC?qZt_(_xxvO z2&ZP}V4Lz|4aWZrprxt$#JB-T&1EJ=hAj8HvPY1_=*|Rn#$-P*c>iZ$NN4!}--Fn@ zrATn-9R>!5ZHx>ITiJhpJ*cyf{U-@}N0rhk1VDEIGBW@6W@KRWW?*D+B66uUv99~a z0Nz*4FzIpHn%&emKZ00`v8mf&uz`vFqj3fUBls>g(gq*?Gpu1_V#sv9D+?NIz-Bzj zVrM;A{QomDfKJckAwu&nhX4PQO?LbRsV74DsFKkT7!85Z5Eu=C(GVC7fzc2c4FOys zFxo-K6*5#1Kkdnq&Ip>FBYXin$hI#G{~6*w$Dn{%1VF!1Qn459Z%12Fns` z&{vPBSntTwq{eyvW4(@8Y9pZ*Pt+^+gTR3;qHe ze}8_~VPIr1XJBMB`~RQ8jDdkcorZ%fFbDi*_|LGDfq`MZIHS;J+3l|ee4q*DFc7^} z@rfBD~4CuyCga4L+f#E*me};SinHcUf{Aaw!^q=AWe@3QzVhsF`23zM5mxm!M8JH9p z|NT>9Wc;W6kC8!%k%3W};Xeatz)6|P%g}K-16>?+z2ON4M#h!Q|NpKUsFT;x9fi){ zp#GSj;ZlhN%1%?y8m3WByK{9$1D|A&Er5yS=we_>!` z_|Nd4ffvMqV$cc$W+)r9<q#fbxd|_3Hos|JxXu7`FWU&9u{c_ivd02c_FLh&^Uy z=V44@VEA9lz`#Jxp@^WX-zNNI`qLC}N9ijyJqcZX(!%hcp^W%d4UC6anOMT~w|;*~ zO}C7iH5vk=Aut*OqaiRF0;3@?8UmvsfFlG}_JkFW_R?_#4!wk-E$2Rl{|vcA+xegI z1T!;36fKT02c2Za@zE&$KO?>b}KP!cjG& zAut*OqaiRF0;3@?8UhrAfbY~#vZHGOCCssXXiVq{=g z{qOhROw&F8L2Ipv)kd^B(57}qrvH5m{~29~)`YH*k>SGse~g{Ko+z#k`28EyIYU=5 zB=~22Ii&vm`D@6)_+O9dAA`YvMh4Jv`YHpszJ9QW8)&Nk3IhYfx&MreCm9%+PMU7} zd26sc8lU?%D8A%mXZn@G@SmZC#Cy|_js5qZk#Qj_6JwR@ZRr=t64YX!^?6r zl6W)!FaG(5DQmDVTOi&)qbf&3U^E0qLtr!nMnhmU1V%$(=!L*&2Yryn8T3@Ac?=8; z!o)fe)LH+{%9w4j={Hq3vm)ESL-DE0f5v}33=H7TDY6tP?AMG8jMIMnXPp#qU;G^@ z8i$J7b8cLM46MJE{{H)~#0c8R&G=u5k%39+Kf`}11_lO2ayBauu-`%J?H@BTFy8ym z@b4aIv;BWYhI`CRjQ9Th`*D4^o+h?k^?@qGU*;SJ1_sc65N>kg;6DSy38w!{6(0Ac z50YyJA$`zJ`7BZf4*oOzzs|_O6mGKp_hmxnkIIjRz-S1JhQMeDjE2By2#kgR4MIRV z_3wt!wE#E*cC@pOBVY!LFlb4(%zN-=*BX*$ZT~ZDVPs`VH{Sf?8Lj-XLG}teJ1c)W z0|P@f`OD-Xt^;>w82-=v@t=KVz|G;95{~5tzObiSk|1$muT@KE`@b~|B78d3oAhrMgG5q?+!0?fSf#D;y zuMz;6Kh*ICwb#7tzkkFsFfrsY{0ARRPl=cAG5%+0`td|*2`yKUobh1xVqj#P$-uxM zLxN*JF#i9aWxV6>auW26Djf}h(GVC7fzc2c4S~@R7!85Z5THW{jCRoJ5XjWB;*1Zg z5X0a96B!s7Vn{S|D+4oQfytKNcSzJnlJX5ww|Uq(Ig%OvGk}h^mLo|s7R8JV3}+b_ z|IhmIkA3lIryWZGjS7(+0vi+_2yienh5i4}m_^~r5SSaTFfuT9|9Gspl$IUx4F(2G zvQk$v{{LraWnf?sfLTri{q_I<|0Pe;*UqH&)e%J4J*s3h1V%$(Gz3ONU^E0qLtr!n zhGz)mFqp*9a4o=SXMKn!n+yI70>6JU_cJmwCXnWZ|BwGOFczC`|FfDjeWa`2uweru zdy$Fne};eMj0}ucq?>?M$0G)Y|1176{9opMPyQlS6{CWqApi{lP#WYcFtPf_^e=*e zfiaBIRUc4eLHEM#W?=l^=XqBSbWuB0m^RcECvLtUY(JV97#TrJ@L@;y6JyyA1_s8) zC(mE^1Y8jKLyU$|HKQRg8UmvsFd71*Aut*OqaiRF0s|WYqaE~t4S%ZG4sO7+|7>Pp zU;s7XnMg1c)M%f^#=uxdujS9%RG#QC{`;51z`z*Az<_dZ7zxhCRLaP}aOyt;!%Bw# z|2KKwm!s*b0ZiLRMFtvxI^wLwrY6h`|NQ>{X9#9sV9=zZo1Xvw&oGVc&%c?}zQi1t z8_#+%ng0LJ1X?*m?B(V#qrNlzXJ~vP#Ml$CZ0H`;2=mM+JsJX|Aut*OqaiRF0;3@? z8UiCN1nAg7Kja-&Lc_HHBQ55L@b6JqRvvbif9(tmjF}7!45ZAwf$md2&Get4+Gxk` z-9%VLH6=Th9$EZlU`%IZUUj_ySR|WUDl2>h({$xLtr!nMnhmU1V%$(Gz3ONU`T`j9XsnoB0PqbBR8zy zz$iT{B!rRSe=j+AfHN{M?)mrsPl@S{|8!ke4w@O|C^51B_y2za<9~)g1_lN`8b!=6 z1_p-HjQ<%9|7T`6^7GH%qX7>TKG4XnQDcb+feo_H`PkWhTQdA-aAIU&bpFr4V93D0 zK*|acB5Z*xdCli z#!=;?Aut*OqaiRF0;3@?8UmvsFd71bIRsYrgcXnW(h0|qZ|}_8!)`6WX)ks)#{YjO zFfcH95)J|k6~7r6{?BJ-_}^f-{ojC0OM`lI9ISr>{xdK{GBErHUFOb$;Y#WYJYZm8 zxWLH3c=6x=e;1h;m@oe5KYc$S0OSnnTQ*!x1dqZLnkxQd`lrYApV8ny1A{pO!+#4B zPeda(cz!Z4FzopM|NpXI4D6c+>SV%mo~$4uR3mI+X)-s2B&jMqcfTQ!Dj_Aa(@{Z7#=bF zXSmP6$Z+pJ4 zMg}Go28RF23=9m?)Ubn8GZ+~d-v47@*akYJ;MX749RZIeKapw%HPxT-V)117&rr_* z9*iK%bPk z7#JCB85kH?A+8;?B&gH>hJk_MEdvAN8;1W3Z~im<`^w0`1i4V2@!tnV2FCvkj0`{j z{b%?F@&)66hL4O)|Nk-kXZXbcl3{}Q@jnA2`~UyY4cv_XnVJ4G^8IJ{&&cqfNq~{* zzrg?h4E+BY82A|(82A|wn4EzdkTd9oKQS=;Kfu7ixc5KfzdiI`w~ot`SDb#be`n(k zXJq^j%0p_n)DaN>#=yuhnduK(r_ru&?+K_Kl^zX&(GVC7fzc2c4S~@R7!85Z5EzCb zFxpukhLK7QFP-*gvS9qr)Xwmq!GQwvZZj}4PG$PTx@a(VI5vnq=HlREH2e4Wza1kZ zgAD@%1L$aN9t!+Bs+IB(_{hM(aFUUM@%Vqnf5(3^{y7qGN9il&#!}aWQ=UvlObkrf z3=9mQYYRBYH2og~Bg0AtCdRsfUXenk`$x5nhQMeDjE2By2#kinXb6mkz-S0iIRr*K z=u{5S;bS>v<598&-P{7GYAU;trB3L@_>!+(Yqf0>zE&A0x%PJub2T1P`* zGz3ONU^E0qLtr!nMnhmU1cqq{jCR(CX~fdnTW7qP^%(v!lru0ehA}WOPXD z+Kw=v>F@tOqg{XRQE1kv_E9hz0;3@?8UmvsFd71*Aut*OBOnAuJLn@IlIh{aQ=Xh6 zObows7#J8pm>M0?j|>d|H~st1uv(H)c!%uvSHJ1u531UcpZC;~3Tpv27;IqTd21;A zo0V1YALAch76wMn|Nodc{xSXKWBA9!#>Dgw<-WTA;C<%|%pkwO_o_26F#Ka+_{#8~ z5p)L~1Bhl~VEoF+^zX-CM#c}!{}?}h|IPGqpbuOFxtZShj2$cIKdyf+%#4h&{}~wk z85kJYD0jpEy9|s>lmGqyG23iAGpM6ZxsjtLjE2By2#kinXb6mkz-S1JhQMeDjHnP8 z?W~We=%$}%LCfW(XNEX2GBV~cF#Pu=_dq%91)y*6fzz0@h$X{629N&?4DJjJ3`PtL z42)EC#V3aU3@iUL|C?vJ_1_69nmcOLXb6mkz-S1JhQMeDjE2By2#kinXb7N$03%23 z_omSvJ4%?0a_Aibps9DM89`Re%uF%=|1*F#y9?344SyK^GoE2!`hSq&ALD_ae;JNg z@BaOT2KI~^Nuv-r>BA(?^oPlwk&(fkfq}t}fq_AVMpoVb&%m&R`S1S)qnpZU6vCs% zj)uT!2#kinXb6mkz-S1JhQMeD;0%G$4m!@D8I>5w5V+#R&G&=thu?oj#!yDc)F`cw zRDa08@c$_2E-)qrrgPu_FkBez#M37QocCms{P&;9jDeBC?EilT3kF6869xtbluh@f zxTMkG_DT}h0)UR0U;mHs|MF)7O#4UoQIHbaqiRP(U^E0qLtr!nMnhmU1V%$(Gz4fB z0;8RET7~SWxikrZQ=Wghm>JmI{xdLwHof~$;Ye;$-T8)rf$=f}!@sNl85plH{$sew zz`%IhWasavq#86_)j#$WV}1Vjy%Gb{KQ(4XMz#Np|1}x@Gs5n% z`0Sa^iGT|i{?Xa~QR_xSU^E0qLtr!nMnhmU1V%$(Gz3ONfanky?VuAKRHF(9dk9?g z<`n<+|BoBv|9>6~42-S}3=E=!$jxsV7#JQiFfcs*|NsAMCI-gm|CtzHGW=(F&G7I4 zv;T~L-~VT1dH;*$=X)Y9m8Xffj=Hk)a5DZA`^)-YjNw0%2onRN$p8NgqW>BGi!(7Y zi2nc2D9ga`UxtB!L6(7mfrBQFAkR?Hy>L7KGcxS>!@;@Va^2^zmwqqz76&MYH(GVC7 zfzc2c1*0J_8UmvsFd70wJp@KO=tDgQN8L}g5CC1G&A|OnkCB1N=s&}MBSr>B5UtC= zz`#m1OGXVs4uMY$3=CKP|7W4x*vO_PyC~dUP$o(2BEB z*N%q3Xb6mkz-S1JhQMeD4BikJ?W_;pxEXcXAPxc0GI3UxziNz(j4Dk3nNO)?WU5FZPe=uQ=0xW-&&l z{{jsE83q3_{1;?o_%Fb~z$nPbz`*~Xfq|FdKLZ;BBLhDJ10y>F!+$OY1_mCq8Gg`# zY{+}XL7n9fNCAY*|IWb3@RjjDv$0W diff --git a/iso/installer.py b/iso/installer.py index 8ae9f48..1ab7c17 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -117,11 +117,8 @@ class InstallerWindow(Adw.ApplicationWindow): self.nav = Adw.NavigationView() self.set_content(self.nav) - # Check for internet before anything else - if check_internet(): - self.push_welcome() - else: - self.push_no_internet() + # Always show the landing/welcome page first + self.push_landing() # ── Navigation helpers ───────────────────────────────────────────────── @@ -197,54 +194,93 @@ class InstallerWindow(Adw.ApplicationWindow): return box - # ── No Internet Screen ───────────────────────────────────────────────── + # ── Landing / Welcome Screen ─────────────────────────────────────────── - def push_no_internet(self): + def push_landing(self): + """First screen: always shown. Welcomes the user and checks connectivity.""" outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - status = Adw.StatusPage() - status.set_title("No Internet Connection") - status.set_description( - "An active internet connection is required to install Sovran_SystemsOS.\n\n" - "Please connect an Ethernet cable or configure Wi-Fi,\n" - "then press Retry." - ) - status.set_icon_name("network-offline-symbolic") - status.set_vexpand(True) - outer.append(status) + # Hero + hero = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + hero.set_margin_top(40) + hero.set_margin_bottom(16) + hero.set_halign(Gtk.Align.CENTER) + if os.path.exists(LOGO): + try: + img = Gtk.Image.new_from_file(LOGO) + img.set_pixel_size(320) + hero.append(img) + except Exception: + pass + + title = Gtk.Label() + title.set_markup("Welcome to Sovran SystemsOS") + title.set_margin_top(8) + hero.append(title) + + sub = Gtk.Label() + sub.set_markup("Be Digitally Sovereign") + hero.append(sub) + + outer.append(hero) + + sep = Gtk.Separator() + sep.set_margin_start(40) + sep.set_margin_end(40) + sep.set_margin_top(8) + outer.append(sep) + + # Internet requirement notice + notice = Gtk.Label() + notice.set_markup( + "" + "Before installation begins, please ensure you have an active internet connection.\n" + "Sovran SystemsOS downloads packages during installation and requires internet access\n" + "to complete the process. Connect via Ethernet cable or configure Wi-Fi now." + "" + ) + notice.set_justify(Gtk.Justification.CENTER) + notice.set_wrap(True) + notice.set_margin_top(20) + notice.set_margin_start(48) + notice.set_margin_end(48) + outer.append(notice) + + # Inline offline warning banner (hidden by default) + self._offline_banner = Adw.Banner() + self._offline_banner.set_title( + "No internet connection detected. Please connect Ethernet or Wi-Fi and try again." + ) + self._offline_banner.set_revealed(False) + self._offline_banner.set_margin_top(12) + self._offline_banner.set_margin_start(40) + self._offline_banner.set_margin_end(40) + outer.append(self._offline_banner) + + outer.append(Gtk.Label(label="", vexpand=True)) + + # Check & Continue button btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) btn_box.set_halign(Gtk.Align.CENTER) btn_box.set_margin_bottom(32) - retry_btn = Gtk.Button(label="Retry") - retry_btn.add_css_class("suggested-action") - retry_btn.add_css_class("pill") - retry_btn.connect("clicked", self.on_retry_internet) - btn_box.append(retry_btn) + connect_btn = Gtk.Button(label="Check Connection & Continue →") + connect_btn.add_css_class("suggested-action") + connect_btn.add_css_class("pill") + connect_btn.connect("clicked", self._on_landing_connect) + btn_box.append(connect_btn) outer.append(btn_box) - self.push_page("No Internet", outer) + self.push_page("Sovran_SystemsOS Installer", outer) - def on_retry_internet(self, btn): + def _on_landing_connect(self, btn): if check_internet(): - # Pop the no-internet page and proceed to welcome - try: - self.nav.pop() - except Exception: - pass + self._offline_banner.set_revealed(False) self.push_welcome() else: - dlg = Adw.MessageDialog() - dlg.set_transient_for(self) - dlg.set_heading("Still Offline") - dlg.set_body( - "Could not reach the internet.\n" - "Please check your network connection and try again." - ) - dlg.add_response("ok", "OK") - dlg.present() + self._offline_banner.set_revealed(True) # ── Step 1: Welcome & Role ───────────────────────────────────────────── @@ -341,140 +377,7 @@ class InstallerWindow(Adw.ApplicationWindow): if radio.get_active(): self.role = radio.get_name() break - self.push_port_requirements() - - # ── Step 1b: Port Requirements Notice ───────────────────────────────── - - def push_port_requirements(self): - """Inform the user about required router/firewall ports before install.""" - outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - - # Detect internal IP at install time - internal_ip = "this machine's LAN IP" - try: - import subprocess as _sp - _r = _sp.run(["hostname", "-I"], capture_output=True, text=True, timeout=5) - if _r.returncode == 0: - _parts = _r.stdout.strip().split() - if _parts: - internal_ip = _parts[0] - except Exception: - pass - - # Warning banner - banner = Adw.Banner() - banner.set_title( - "⚠ Port Forwarding Setup Required — configure your router before install" - ) - banner.set_revealed(True) - banner.set_margin_top(16) - banner.set_margin_start(40) - banner.set_margin_end(40) - outer.append(banner) - - intro = Gtk.Label() - intro.set_markup( - "" - "Many Sovran_SystemsOS features require port forwarding to be configured " - "in your router's admin panel. This means telling your router to forward " - "specific ports to this machine's internal LAN IP.\n\n" - "Services like Element Video/Audio Calling and Matrix Federation " - "will not work for clients outside your LAN unless these ports are " - "forwarded to this machine." - "" - ) - intro.set_wrap(True) - intro.set_justify(Gtk.Justification.FILL) - intro.set_margin_top(14) - intro.set_margin_start(40) - intro.set_margin_end(40) - outer.append(intro) - - ip_label = Gtk.Label() - ip_label.set_markup( - f"" - f" Forward ports to this machine's internal IP: {internal_ip}" - f"" - ) - ip_label.set_margin_top(10) - ip_label.set_margin_start(40) - ip_label.set_margin_end(40) - outer.append(ip_label) - - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - sw.set_vexpand(True) - sw.set_margin_start(40) - sw.set_margin_end(40) - sw.set_margin_top(12) - sw.set_margin_bottom(8) - - ports_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) - - port_sections = [ - ( - "🌐 Web / HTTPS (all domain-based services)", - [("80", "TCP", "HTTP (redirects to HTTPS)"), - ("443", "TCP", "HTTPS")], - ), - ( - "šŸ’¬ Matrix Federation (Matrix-Synapse)", - [("8448", "TCP", "Server-to-server federation")], - ), - ( - "šŸŽ„ Element Video & Audio Calling (LiveKit / Element-call)", - [("7881", "TCP", "LiveKit WebRTC signalling"), - ("7882-7894", "UDP", "LiveKit media streams"), - ("5349", "TCP", "TURN over TLS"), - ("3478", "UDP", "TURN (STUN / relay)"), - ("30000-40000", "TCP/UDP", "TURN relay (WebRTC media)")], - ), - ( - "šŸ–„ Remote SSH (optional — only if you want WAN SSH access)", - [("22", "TCP", "SSH")], - ), - ] - - for section_title, rows in port_sections: - group = Adw.PreferencesGroup() - group.set_title(section_title) - - for port, proto, desc in rows: - row = Adw.ActionRow() - row.set_title(f"Port {port} ({proto})") - row.set_subtitle(desc) - group.add(row) - - ports_box.append(group) - - note = Gtk.Label() - note.set_markup( - "" - "ℹ In your router's admin panel (usually at 192.168.1.1), find the " - "\"Port Forwarding\" section and add a rule for each port above with " - "the destination set to this machine's internal IP. " - "These ports only need to be forwarded to this specific machine — " - "this does NOT expose your entire network.\n" - "To verify forwarding is working, test from a device on a different network " - "(e.g. a phone on mobile data) or check your router's port forwarding page." - "" - ) - note.set_wrap(True) - note.set_justify(Gtk.Justification.FILL) - note.set_margin_top(8) - ports_box.append(note) - - sw.set_child(ports_box) - outer.append(sw) - - outer.append(self.nav_row( - back_label="← Back", - back_cb=lambda b: self.nav.pop(), - next_label="I Understand →", - next_cb=lambda b: self.push_disk_detect(), - )) - - self.push_page("Network Port Requirements", outer, show_back=True) + self.push_disk_detect() # ── Step 2a: Disk Detect ────────────────────────────────────────────── @@ -773,7 +676,6 @@ class InstallerWindow(Adw.ApplicationWindow): status = Adw.StatusPage() status.set_title(title) status.set_description(subtitle) - status.set_icon_name("emblem-synchronizing-symbolic") status.set_vexpand(False) outer.append(status) @@ -830,10 +732,10 @@ class InstallerWindow(Adw.ApplicationWindow): cmd = [ "sudo", "disko", "--mode", "destroy,format,mount", f"{FLAKE}/iso/disko.nix", - "--arg", "device", f'"{boot_path}"' + "--arg", "device", boot_path ] if data_path: - cmd += ["--arg", "dataDevice", f'"{data_path}"'] + cmd += ["--arg", "dataDevice", data_path] run_stream(cmd, buf) GLib.idle_add(append_text, buf, "\n=== Generating hardware config ===\n") diff --git a/iso/plymouth-theme.nix b/iso/plymouth-theme.nix index 9b6ae18..9075659 100644 --- a/iso/plymouth-theme.nix +++ b/iso/plymouth-theme.nix @@ -10,15 +10,15 @@ pkgs.stdenv.mkDerivation { mkdir -p $out/share/plymouth/themes/sovran cp ${./assets/splash-logo.png} $out/share/plymouth/themes/sovran/logo.png - cat > $out/share/plymouth/themes/sovran/sovran.plymouth <<'EOF' + cat > $out/share/plymouth/themes/sovran/sovran.plymouth < $out/share/plymouth/themes/sovran/sovran.script <<'EOF' -- 2.53.0 From 0526945114fbe372521d1399b176405c1ccb264c Mon Sep 17 00:00:00 2001 From: Sovran_Systems <99053422+naturallaw777@users.noreply.github.com> Date: Sun, 5 Apr 2026 01:36:51 -0500 Subject: [PATCH 334/857] Update README.md --- README.md | 221 ------------------------------------------------------ 1 file changed, 221 deletions(-) diff --git a/README.md b/README.md index 549f1e3..8b13789 100644 --- a/README.md +++ b/README.md @@ -1,222 +1 @@ -

      - Sovran Systems -

      -

      Sovran_SystemsOS

      - -

      - A Fully Sovereign, Declarative NixOS Operating System
      - Take complete ownership of your digital infrastructure. -

      - -

      - NixOS - Bitcoin - License - Reproducible -

      - ---- - -## Overview - -Sovran_SystemsOS is a purpose-built, fully declarative operating system constructed entirely on [NixOS](https://nixos.org). It delivers a complete sovereign computing platform — integrating a [Bitcoin](https://bitcoin.org) financial stack, encrypted communications via [Matrix](https://matrix.org), self-hosted cloud services, and a professional web presence — all managed through a single, reproducible configuration. - -Every component of the system is defined in Nix. There are no imperative scripts, no hidden state, and no black boxes. What you declare is exactly what runs. The entire operating system can be rebuilt, replicated, or audited from source at any time. - ---- - -## The Sovran_SystemsOS Hub - - -

      - Screenshot From 2026-04-05 01-03-08 -

      - -The **Sovran_SystemsOS Hub** is the central management dashboard for the entire operating system. Accessible through a local web interface, it provides a unified view of all running infrastructure, [Bitcoin](https://bitcoin.org) services, and application status in real time. - -From the Hub, operators can: - -- Monitor the health and status of every service at a glance -- Access system administration tools including password management, backups, and tech support -- Manage Bitcoin node infrastructure ([Bitcoin Knots](https://bitcoinknots.org), [Bitcoin Core](https://bitcoincore.org), BIP-110) -- Oversee the full Bitcoin application stack ([Electrs](https://github.com/romanz/electrs), [LND](https://github.com/lightningnetwork/lnd), [Ride The Lightning](https://github.com/Ride-The-Lightning/RTL), [BTCPayServer](https://btcpayserver.org), [Zeus](https://zeusln.com), [Mempool](https://github.com/mempool/mempool)) -- Update the system with a single action -- Perform manual backups to external storage -- Access remote desktop capabilities - -The Hub eliminates the need to manage services individually through disparate interfaces. It is the operational command center for the entire Sovran_SystemsOS deployment. - ---- - -## Three Deployment Roles - -Sovran_SystemsOS is architected around three distinct deployment roles, each tailored to a specific use case. A role is selected during installation and can be changed at any time by editing a single configuration file (`custom.nix`). - -### Server + Desktop - -The complete deployment. This role activates every server service alongside a full [GNOME](https://www.gnome.org) desktop environment, delivering a workstation that simultaneously operates as a sovereign infrastructure node. - -**Includes:** [Matrix Synapse](https://github.com/element-hq/synapse) homeserver, Bitcoin ecosystem ([bitcoind](https://bitcoinknots.org), [Electrs](https://github.com/romanz/electrs), [LND](https://github.com/lightningnetwork/lnd), [RTL](https://github.com/Ride-The-Lightning/RTL), [BTCPayServer](https://btcpayserver.org)), [Vaultwarden](https://github.com/dani-garcia/vaultwarden) password manager, [WordPress](https://wordpress.org), [Nextcloud](https://nextcloud.com) file hosting, [Caddy](https://caddyserver.com) reverse proxy, [Tor](https://www.torproject.org), and the full desktop environment. - -### Desktop Only - -A clean, sovereign desktop environment without server services. Ideal for daily computing, secure communications, and Bitcoin wallet management without running full node infrastructure. - -**Includes:** [GNOME](https://www.gnome.org) desktop, Bitcoin desktop applications ([Sparrow](https://sparrowwallet.com), [Bisq](https://bisq.network), Bisq2, [Bitcoin Core](https://bitcoincore.org) GUI), [Tor](https://www.torproject.org), and all productivity tools. - -### Node (Bitcoin Only) - -A dedicated Bitcoin infrastructure node. This role strips away desktop and web services to focus entirely on running and serving the Bitcoin network. - -**Includes:** [Bitcoin Knots](https://bitcoinknots.org) with BIP-110, [Electrs](https://github.com/romanz/electrs), [LND](https://github.com/lightningnetwork/lnd), [Ride The Lightning](https://github.com/Ride-The-Lightning/RTL), [BTCPayServer](https://btcpayserver.org), [Mempool](https://github.com/mempool/mempool) block explorer, and all supporting Bitcoin infrastructure. - ---- - -## Key Benefits - -### Complete Digital Sovereignty - -Every service runs on hardware you own. Your Bitcoin keys, your communications, your files, your passwords, and your website all operate under your exclusive control. There is no reliance on third-party cloud providers, no data harvested, and no external points of failure. - -### Pure Declarative Configuration - -The entire operating system — from kernel parameters to application configurations — is defined declaratively in Nix. This guarantees: - -- **Reproducibility:** Any deployment can be identically recreated from the configuration files alone. -- **Auditability:** The complete system state is transparent and version-controlled. -- **Rollback:** Every system generation is preserved; reverting to a previous state is a single command. -- **Atomic Upgrades:** System rebuilds either succeed completely or fail without side effects. - -### Modular Service Architecture - -Services and features are organized into independently toggleable modules. Operators enable or disable capabilities through simple boolean flags in `custom.nix`: - -| Category | Service | Default | -|----------|---------|---------| -| **Services** | [Matrix Synapse](https://github.com/element-hq/synapse) | ON | -| **Services** | [Bitcoin](https://bitcoin.org) Ecosystem | ON | -| **Services** | [Vaultwarden](https://github.com/dani-garcia/vaultwarden) | ON | -| **Services** | [WordPress](https://wordpress.org) | ON | -| **Services** | [Nextcloud](https://nextcloud.com) | ON | -| **Features** | [Haven](https://github.com/bitvora/haven) (NOSTR Relay) | OFF | -| **Features** | BIP-110 | OFF | -| **Features** | [Mempool](https://github.com/mempool/mempool) Explorer | OFF | -| **Features** | [Element](https://element.io) Video Calling | OFF | -| **Features** | Remote Desktop (RDP) | OFF | -| **Features** | [Bitcoin Core](https://bitcoincore.org) GUI | OFF | - ---- - -## Security Architecture - -Sovran_SystemsOS is engineered with security as a foundational principle, not an afterthought. - -- **Declarative Firewall:** All network access is explicitly defined. Only ports required by enabled services are opened; everything else is denied by default. -- **[Fail2Ban](https://github.com/fail2ban/fail2ban) Integration:** Automated intrusion prevention monitors and blocks brute-force attacks across all exposed services. -- **SSH Hardened:** Password authentication and keyboard-interactive authentication are disabled. Access is restricted to public key authentication only. -- **[Tor](https://www.torproject.org) Built-In:** The Tor network is enabled system-wide, providing anonymized connectivity and the ability to operate hidden services for any exposed application. -- **Automated Backups:** [rsnapshot](https://rsnapshot.org) performs hourly and daily snapshots of all critical data — including home directories, system state, and Bitcoin secrets — to external storage. -- **[Vaultwarden](https://github.com/dani-garcia/vaultwarden) (Self-Hosted Bitwarden):** All credentials are managed through a locally hosted, encrypted password vault with no external dependencies. -- **NixOS Immutability:** The declarative model ensures that the running system always matches the defined configuration. Unauthorized modifications do not persist across rebuilds. -- **Nix Flake Pinning:** All dependencies — including nixpkgs, [nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), and third-party modules — are pinned to exact revisions via `flake.lock`, eliminating supply-chain ambiguity. -- **Credential Isolation:** Bitcoin secrets and service credentials are stored in dedicated, permission-restricted directories and automatically generated during provisioning. - ---- - -## Technology Stack - -| Layer | Technology | -|-------|------------| -| **Operating System** | [NixOS](https://nixos.org) (Unstable Channel) | -| **Desktop Environment** | [GNOME](https://www.gnome.org) (Wayland) | -| **Reverse Proxy** | [Caddy](https://caddyserver.com) | -| **Bitcoin Node** | [Bitcoin Knots](https://bitcoinknots.org) / [Bitcoin Core](https://bitcoincore.org) | -| **Lightning Network** | [LND](https://github.com/lightningnetwork/lnd) | -| **Lightning Management** | [Ride The Lightning](https://github.com/Ride-The-Lightning/RTL) | -| **Payment Processing** | [BTCPayServer](https://btcpayserver.org) | -| **Block Explorer** | [Mempool](https://github.com/mempool/mempool) | -| **Electrum Server** | [Electrs](https://github.com/romanz/electrs) | -| **Communications** | [Matrix Synapse](https://github.com/element-hq/synapse) + [Element](https://element.io) | -| **Video Calling** | [LiveKit](https://livekit.io) (Element Calling) | -| **File Hosting** | [Nextcloud](https://nextcloud.com) | -| **Website** | [WordPress](https://wordpress.org) | -| **Password Management** | [Vaultwarden](https://github.com/dani-garcia/vaultwarden) | -| **NOSTR Relay** | [Haven](https://github.com/bitvora/haven) | -| **DNS Management** | [Njalla](https://njal.la) Dynamic DNS | -| **Network Privacy** | [Tor](https://www.torproject.org) | -| **Intrusion Prevention** | [Fail2Ban](https://github.com/fail2ban/fail2ban) | -| **Backup** | [rsnapshot](https://rsnapshot.org) | -| **Package Management** | [Nix Flakes](https://nixos.wiki/wiki/Flakes) | - ---- - -## Repository Structure - -``` -staging_alpha/ -ā”œā”€ā”€ flake.nix # Flake entry point and dependency declarations -ā”œā”€ā”€ flake.lock # Pinned dependency revisions -ā”œā”€ā”€ configuration.nix # Core system configuration -ā”œā”€ā”€ custom.template.nix # User-facing customization template -ā”œā”€ā”€ onboarding.html # First-run onboarding interface -ā”œā”€ā”€ modules/ -│ ā”œā”€ā”€ modules.nix # Module import manifest -│ ā”œā”€ā”€ core/ -│ │ ā”œā”€ā”€ roles.nix # Role and option declarations -│ │ ā”œā”€ā”€ role-logic.nix # Role-conditional service activation -│ │ ā”œā”€ā”€ caddy.nix # Reverse proxy configuration -│ │ ā”œā”€ā”€ sovran-hub.nix # Hub dashboard -│ │ └── ... # Additional core modules -│ ā”œā”€ā”€ synapse.nix # Matrix Synapse homeserver -│ ā”œā”€ā”€ bitcoinecosystem.nix # Bitcoin infrastructure module -│ ā”œā”€ā”€ nextcloud.nix # Nextcloud file hosting -│ ā”œā”€ā”€ wordpress.nix # WordPress configuration -│ ā”œā”€ā”€ vaultwarden.nix # Password manager -│ ā”œā”€ā”€ haven.nix # NOSTR relay and Blossom -│ ā”œā”€ā”€ mempool.nix # Mempool block explorer -│ ā”œā”€ā”€ element-calling.nix # LiveKit video calling -│ └── ... # Additional service modules -ā”œā”€ā”€ iso/ -│ ā”œā”€ā”€ installer.py # Automated installation wizard -│ ā”œā”€ā”€ desktop.nix # Desktop ISO configuration -│ ā”œā”€ā”€ server.nix # Server ISO configuration -│ └── ... # ISO build assets -└── app/ - └── sovran_systemsos_web/ # Hub web application -``` - ---- - -## Getting Started - -1. **Download** the Sovran_SystemsOS ISO image. -2. **Boot** from the installation media. -3. **Select your role** — Server + Desktop, Desktop Only, or Node — during the guided installation. -4. **Customize** your deployment by editing `/etc/nixos/custom.nix` to enable or disable services and features. -5. **Rebuild** with `nixos-rebuild switch` to apply changes. - ---- - -## Acknowledgments - -Sovran_SystemsOS is built on the work of exceptional open-source contributors and projects. - -**[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin)** — The Bitcoin infrastructure layer of Sovran_SystemsOS is made possible by the nix-bitcoin project. Their rigorous, security-focused NixOS modules for [Bitcoin Core](https://bitcoincore.org), [LND](https://github.com/lightningnetwork/lnd), [Electrs](https://github.com/romanz/electrs), [BTCPayServer](https://btcpayserver.org), and related services provide the foundation upon which the entire Bitcoin ecosystem in this operating system is constructed. The nix-bitcoin team's commitment to reproducible, auditable Bitcoin infrastructure is directly aligned with the mission of Sovran_SystemsOS, and their work is deeply appreciated. - -**[Emmanuel Rosa](https://github.com/emmanuelrosa)** — The [`btc-clients-nix`](https://github.com/emmanuelrosa/btc-clients-nix) and [`bitcoin-knots-bip-110-nix`](https://github.com/emmanuelrosa/bitcoin-knots-bip-110-nix) packages, maintained by Emmanuel Rosa, bring essential Bitcoin desktop applications ([Sparrow](https://sparrowwallet.com), [Bisq](https://bisq.network), Bisq2) and the BIP-110 [Bitcoin Knots](https://bitcoinknots.org) implementation to NixOS. These ports fill a critical gap in the NixOS Bitcoin ecosystem and are integral to delivering a complete sovereign computing experience. His dedication to packaging and maintaining these tools for the Nix community is sincerely valued. - -**[NixOS](https://nixos.org)** — The purely functional Linux distribution that makes all of this possible. Without the NixOS foundation of declarative, reproducible system management, a project of this scope and reliability would not be feasible. - ---- - -## License - -Sovran_SystemsOS is released under the [GNU Affero General Public License v3.0](LICENSE). - ---- - -

      - Sovran Systems
      - Your keys. Your node. Your cloud. Your sovereignty. -

      -- 2.53.0 From 6d3dbf497ef3df861c5d68d02c7b3644f034fc8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 13:35:12 +0000 Subject: [PATCH 335/857] Initial plan -- 2.53.0 From 5b1454adf6e2eaf84299445f6ebd8359c1c06e4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 13:36:15 +0000 Subject: [PATCH 336/857] Fix three installer blockers: disko --yes-wipe-all-disks, stdin=DEVNULL, nixos-install --no-root-password Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/477c45ee-0958-4ba8-9612-a3be1bff9c6d Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/installer.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/iso/installer.py b/iso/installer.py index 1ab7c17..c8ab00e 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -38,7 +38,8 @@ def run(cmd): def run_stream(cmd, buf): log(f"$ {' '.join(cmd)}") - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, text=True) for line in proc.stdout: log(line.rstrip()) GLib.idle_add(append_text, buf, line) @@ -731,6 +732,7 @@ class InstallerWindow(Adw.ApplicationWindow): GLib.idle_add(append_text, buf, "\n=== Partitioning drives ===\n") cmd = [ "sudo", "disko", "--mode", "destroy,format,mount", + "--yes-wipe-all-disks", f"{FLAKE}/iso/disko.nix", "--arg", "device", boot_path ] @@ -848,7 +850,8 @@ class InstallerWindow(Adw.ApplicationWindow): run_stream([ "sudo", "nixos-install", "--root", "/mnt", - "--flake", "/mnt/etc/nixos#nixos" + "--flake", "/mnt/etc/nixos#nixos", + "--no-root-password" ], buf) GLib.idle_add(self.push_complete) -- 2.53.0 From 4253518ceb8d0943108ef88f1192ab4dcd6a7756 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:01:31 +0000 Subject: [PATCH 337/857] Initial plan -- 2.53.0 From 53ea704e57cb28469113c1f3995b20b8bfc89acd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:02:40 +0000 Subject: [PATCH 338/857] Fix disko.nix partition syntax and remove broken Spinner from Plymouth theme Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/487cd80b-c747-44b8-9479-d3f7f7cc3328 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/disko.nix | 9 +++------ iso/plymouth-theme.nix | 5 ----- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/iso/disko.nix b/iso/disko.nix index ad7e30f..568d1ad 100644 --- a/iso/disko.nix +++ b/iso/disko.nix @@ -12,8 +12,7 @@ ESP = { priority = 1; name = "ESP"; - start = "1M"; - end = "512M"; + size = "512M"; type = "EF00"; content = { type = "filesystem"; @@ -24,8 +23,7 @@ }; root = { name = "root"; - start = "512M"; - end = "100%"; + size = "100%"; content = { type = "filesystem"; format = "ext4"; @@ -45,8 +43,7 @@ partitions = { primary = { name = "primary"; - start = "1M"; - end = "100%"; + size = "100%"; content = { type = "filesystem"; format = "ext4"; diff --git a/iso/plymouth-theme.nix b/iso/plymouth-theme.nix index 9075659..859fc1c 100644 --- a/iso/plymouth-theme.nix +++ b/iso/plymouth-theme.nix @@ -34,11 +34,6 @@ logo = Image("logo.png"); logo_sprite = Sprite(logo); logo_sprite.SetX((Window.GetWidth() - logo.GetWidth()) / 2); logo_sprite.SetY((Window.GetHeight() - logo.GetHeight()) / 2); - -spinner = Sprite(); -spinner.SetImage(Spinner()); -spinner.SetX((Window.GetWidth() - spinner.GetImage().GetWidth()) / 2); -spinner.SetY((Window.GetHeight() + logo.GetHeight()) / 2 + 20); EOF ''; } \ No newline at end of file -- 2.53.0 From 04fd3c523b638acc7c1eeb16fb63d6ca12557d11 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Sun, 5 Apr 2026 09:16:03 -0500 Subject: [PATCH 339/857] closed unused ports --- configuration.nix | 4 +--- result | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/configuration.nix b/configuration.nix index 23b668d..dfd27a2 100644 --- a/configuration.nix +++ b/configuration.nix @@ -36,8 +36,6 @@ networking.firewall.enable = true; networking.firewall.allowedTCPPorts = [ 80 443 8448 3051 ]; networking.firewall.allowedUDPPorts = [ 80 443 8448 3051 5353 ]; - networking.firewall.allowedUDPPortRanges = [ - { from = 49152; to = 65535; } ]; # ── Avahi (mDNS) ─────────────────────────────────────────── @@ -190,4 +188,4 @@ backup /etc/nix-bitcoin-secrets/ localhost/ nix.gc = { automatic = true; dates = "weekly"; options = "--delete-older-than 7d"; }; system.stateVersion = "22.05"; -} \ No newline at end of file +} diff --git a/result b/result index aee59aa..902923f 120000 --- a/result +++ b/result @@ -1 +1 @@ -/nix/store/b084r2ravrdcyw3lwh7p5jpawfgamn20-Sovran_SystemsOS.iso \ No newline at end of file +/nix/store/pdwygxbd76b12qll8mnvirs6bym97hla-Sovran_SystemsOS.iso \ No newline at end of file -- 2.53.0 From 8dc3066f06ffc7b098bafff2e70d9c1ed96c2984 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Sun, 5 Apr 2026 09:27:38 -0500 Subject: [PATCH 340/857] syntax error --- configuration.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/configuration.nix b/configuration.nix index dfd27a2..11c9911 100644 --- a/configuration.nix +++ b/configuration.nix @@ -36,7 +36,6 @@ networking.firewall.enable = true; networking.firewall.allowedTCPPorts = [ 80 443 8448 3051 ]; networking.firewall.allowedUDPPorts = [ 80 443 8448 3051 5353 ]; - ]; # ── Avahi (mDNS) ─────────────────────────────────────────── # Advertise as sovransystemsos.local on the LAN without changing the system -- 2.53.0 From a3b34ef74b28b9259267dcc29fab2e9b5b328769 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:27:55 +0000 Subject: [PATCH 341/857] Initial plan -- 2.53.0 From 7c047a16b7bb12b63481c5bfae62e388bdc96936 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:29:09 +0000 Subject: [PATCH 342/857] Security: restrict RTL, Mempool ports to LAN-only; remove global firewall rules Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/1110322d-bc41-4d5d-9a4c-e5f7a5d2ef57 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- modules/core/caddy.nix | 4 ++-- modules/core/sovran-hub.nix | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/core/caddy.nix b/modules/core/caddy.nix index 7f4d811..b4eabec 100755 --- a/modules/core/caddy.nix +++ b/modules/core/caddy.nix @@ -156,7 +156,7 @@ EOF # ── RTL (LAN access) ──────────────────────────── cat >> /run/caddy/Caddyfile <> /run/caddy/Caddyfile < Date: Sun, 5 Apr 2026 14:40:22 +0000 Subject: [PATCH 343/857] Initial plan -- 2.53.0 From ca275c45de2ff5514df6634c3768c99e19037efb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:41:16 +0000 Subject: [PATCH 344/857] Fix disko mount failure by splitting destroy and format,mount into separate calls Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0f9fe8d2-554e-4048-9dba-5a3c3c663410 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/installer.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/iso/installer.py b/iso/installer.py index c8ab00e..3934caa 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -730,15 +730,25 @@ class InstallerWindow(Adw.ApplicationWindow): # ── Now run disko on a clean disk ── GLib.idle_add(append_text, buf, "\n=== Partitioning drives ===\n") - cmd = [ - "sudo", "disko", "--mode", "destroy,format,mount", + cmd_destroy = [ + "sudo", "disko", "--mode", "destroy", "--yes-wipe-all-disks", f"{FLAKE}/iso/disko.nix", "--arg", "device", boot_path ] if data_path: - cmd += ["--arg", "dataDevice", data_path] - run_stream(cmd, buf) + cmd_destroy += ["--arg", "dataDevice", data_path] + run_stream(cmd_destroy, buf) + + GLib.idle_add(append_text, buf, "\n=== Formatting and mounting drives ===\n") + cmd_format = [ + "sudo", "disko", "--mode", "format,mount", + f"{FLAKE}/iso/disko.nix", + "--arg", "device", boot_path + ] + if data_path: + cmd_format += ["--arg", "dataDevice", data_path] + run_stream(cmd_format, buf) GLib.idle_add(append_text, buf, "\n=== Generating hardware config ===\n") run_stream(["sudo", "nixos-generate-config", "--root", "/mnt"], buf) -- 2.53.0 From ef39040919fa5ab60a1ff6af9c9c6c12e3e58390 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:42:36 +0000 Subject: [PATCH 345/857] Initial plan -- 2.53.0 From 6584b63c36172f6969e65329f58c2449f79075e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:43:49 +0000 Subject: [PATCH 346/857] Revert commit 7c047a1: restore LAN access to Hub, RTL, and Mempool Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c92f1a7f-7c42-44f1-a86d-089383bafc94 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- modules/core/caddy.nix | 4 ++-- modules/core/sovran-hub.nix | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/core/caddy.nix b/modules/core/caddy.nix index b4eabec..7f4d811 100755 --- a/modules/core/caddy.nix +++ b/modules/core/caddy.nix @@ -156,7 +156,7 @@ EOF # ── RTL (LAN access) ──────────────────────────── cat >> /run/caddy/Caddyfile <> /run/caddy/Caddyfile < Date: Sun, 5 Apr 2026 15:03:10 +0000 Subject: [PATCH 347/857] Initial plan -- 2.53.0 From df2768c6fc7caa7482fbc45368d49582e7e8f17a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:09:02 +0000 Subject: [PATCH 348/857] feat: move sshd into its own Nix feature module, gate Tech Support behind it Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d45dc36f-0b3b-48bb-950f-700afe45dd06 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 50 +++++++++++- app/sovran_systemsos_web/static/js/support.js | 80 ++++++++++++++++++- configuration.nix | 16 ---- custom.template.nix | 1 + modules/core/roles.nix | 1 + modules/modules.nix | 1 + modules/sshd.nix | 23 ++++++ 7 files changed, 151 insertions(+), 21 deletions(-) create mode 100644 modules/sshd.nix diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index c04ffac..37244d3 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -192,6 +192,20 @@ FEATURE_REGISTRY = [ "conflicts_with": ["bip110"], "port_requirements": [], }, + { + "id": "sshd", + "name": "SSH Remote Access", + "description": "Enable SSH for remote terminal access. Required for Tech Support. Disabled by default for security — enable only when needed.", + "category": "support", + "needs_domain": False, + "domain_name": None, + "needs_ddns": False, + "extra_fields": [], + "conflicts_with": [], + "port_requirements": [ + {"port": "22", "protocol": "TCP", "description": "SSH"}, + ], + }, { "id": "btcpay-web", "name": "BTCPay Server Web Access", @@ -218,6 +232,7 @@ FEATURE_SERVICE_MAP = { "bip110": None, "bitcoin-core": None, "btcpay-web": "btcpayserver.service", + "sshd": "sshd.service", } # Port requirements for service tiles (keyed by unit name or icon) @@ -249,6 +264,8 @@ SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = { "phpfpm-nextcloud.service": _PORTS_WEB, "phpfpm-wordpress.service": _PORTS_WEB, "haven-relay.service": _PORTS_WEB, + # SSH (only open when feature is enabled) + "sshd.service": [{"port": "22", "protocol": "TCP", "description": "SSH"}], } # Maps service unit names to their domain file name in DOMAINS_DIR. @@ -285,8 +302,8 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = { # Features shown per role (None = show all) ROLE_FEATURES: dict[str, set[str] | None] = { "server_plus_desktop": None, - "desktop": {"rdp"}, - "node": {"rdp", "bip110", "bitcoin-core", "mempool", "btcpay-web"}, + "desktop": {"rdp", "sshd"}, + "node": {"rdp", "bip110", "bitcoin-core", "mempool", "btcpay-web", "sshd"}, } SERVICE_DESCRIPTIONS: dict[str, str] = { @@ -370,6 +387,12 @@ SERVICE_DESCRIPTIONS: dict[str, str] = { "Manage your system visually without being physically present. " "Sovran_SystemsOS sets up secure remote access with generated credentials — connect and go." ), + "sshd.service": ( + "Secure Shell (SSH) remote access. When enabled, authorized users can connect " + "to your machine over the network via encrypted terminal sessions. " + "Sovran_SystemsOS keeps SSH disabled by default for maximum security — " + "enable it only when you need remote access or Tech Support." + ), "root-password-setup.service": ( "Your system account credentials. These are the keys to your Sovran_SystemsOS machine — " "root access, user accounts, and SSH passphrases. Keep them safe." @@ -1026,6 +1049,15 @@ def _is_feature_enabled_in_config(feature_id: str) -> bool | None: return None +def _is_sshd_feature_enabled() -> bool: + """Check if the sshd feature is enabled via hub overrides or config.""" + overrides, _ = _read_hub_overrides() + if "sshd" in overrides: + return bool(overrides["sshd"]) + config_state = _is_feature_enabled_in_config("sshd") + return bool(config_state) if config_state is not None else False + + # ── Tech Support helpers ────────────────────────────────────────── def _is_support_active() -> bool: @@ -2091,11 +2123,13 @@ async def api_support_status(): """Check if tech support SSH access is currently enabled.""" loop = asyncio.get_event_loop() active = await loop.run_in_executor(None, _is_support_active) + sshd_enabled = await loop.run_in_executor(None, _is_sshd_feature_enabled) session = await loop.run_in_executor(None, _get_support_session_info) unlock_info = await loop.run_in_executor(None, _get_wallet_unlock_info) wallet_unlocked = bool(unlock_info) return { "active": active, + "sshd_enabled": sshd_enabled, "enabled_at": session.get("enabled_at"), "enabled_at_human": session.get("enabled_at_human"), "wallet_protected": session.get("wallet_protected", False), @@ -2109,8 +2143,18 @@ async def api_support_status(): @app.post("/api/support/enable") async def api_support_enable(): - """Add the Sovran support SSH key to allow remote tech support.""" + """Add the Sovran support SSH key to allow remote tech support. + Requires the sshd feature to be enabled first.""" loop = asyncio.get_event_loop() + + # Gate: SSH feature must be enabled before support can be activated + sshd_on = await loop.run_in_executor(None, _is_sshd_feature_enabled) + if not sshd_on: + raise HTTPException( + status_code=400, + detail="SSH must be enabled first. Please enable SSH Remote Access in the Feature Manager, then try again.", + ) + ok = await loop.run_in_executor(None, _enable_support) if not ok: raise HTTPException(status_code=500, detail="Failed to enable support access") diff --git a/app/sovran_systemsos_web/static/js/support.js b/app/sovran_systemsos_web/static/js/support.js index 0735b12..9e0dc6c 100644 --- a/app/sovran_systemsos_web/static/js/support.js +++ b/app/sovran_systemsos_web/static/js/support.js @@ -10,12 +10,84 @@ async function openSupportModal() { var status = await apiFetch("/api/support/status"); _supportStatus = status; if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(status); } + else if (!status.sshd_enabled) { renderSupportSshdOff(); } else { renderSupportInactive(); } } catch (err) { $supportBody.innerHTML = '

      Could not check support status.

      '; } } +function renderSupportSshdOff() { + stopSupportTimer(); + $supportBody.innerHTML = [ + '
      ', + '
      šŸ›Ÿ
      ', + '

      Need help from Sovran Systems?

      ', + '

      To get Tech Support, SSH must be enabled first. SSH is off by default for maximum security — it only needs to be on during a support session.

      ', + '
      ', + '
      šŸ”SSH is Off
      ', + '

      SSH (remote login) is disabled by default on your Sovran Pro. Clicking the button below will enable SSH and trigger a system rebuild. Once complete, you can then grant support access.

      ', + '

      When you end the support session, you can disable SSH again from the Feature Manager to return to the default secure state.

      ', + '
      ', + '
      Steps:
        ', + '
      1. Enable SSH (triggers a system rebuild — takes a few minutes)
      2. ', + '
      3. Grant Sovran Systems temporary support access
      4. ', + '
      5. End the session when done — SSH can be disabled again from the Feature Manager
      6. ', + '
      ', + '', + '

      This will trigger a NixOS rebuild. Your machine will remain operational during the rebuild.

      ', + '
      ', + ].join(""); + document.getElementById("btn-sshd-enable").addEventListener("click", enableSshd); +} + +async function enableSshd() { + var btn = document.getElementById("btn-sshd-enable"); + if (btn) { btn.disabled = true; btn.textContent = "Enabling SSH…"; } + try { + await apiFetch("/api/features/toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ feature: "sshd", enabled: true }), + }); + // Poll until rebuild completes and sshd_enabled is true + $supportBody.innerHTML = [ + '
      ', + '
      āš™ļø
      ', + '

      Enabling SSH…

      ', + '

      A system rebuild is in progress. This may take a few minutes. The page will update automatically when SSH is ready.

      ', + '

      Rebuilding system…

      ', + '
      ', + ].join(""); + pollForSshdReady(); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "Enable SSH"; } + alert("Failed to enable SSH. Please try again."); + } +} + +function pollForSshdReady() { + var attempts = 0; + var maxAttempts = 60; // 5 minutes (5s interval) + var interval = setInterval(async function() { + attempts++; + try { + var status = await apiFetch("/api/support/status"); + var el = document.getElementById("sshd-rebuild-status"); + if (status.sshd_enabled) { + clearInterval(interval); + _supportStatus = status; + renderSupportInactive(); + } else if (attempts >= maxAttempts) { + clearInterval(interval); + if (el) el.textContent = "Rebuild is taking longer than expected. Please close this dialog and try again."; + } else { + if (el) el.textContent = "Rebuilding system… (" + attempts * 5 + "s)"; + } + } catch (_) {} + }, 5000); +} + function renderSupportInactive() { stopSupportTimer(); var ip = _cachedExternalIp || "loading…"; @@ -24,6 +96,10 @@ function renderSupportInactive() { '
      šŸ›Ÿ
      ', '

      Need help from Sovran Systems?

      ', '

      This will temporarily grant our support team SSH access to your machine so we can help diagnose and fix issues.

      ', + '
      ', + '
      āœ…SSH is Active
      ', + '

      SSH is enabled on your machine. You can now grant Sovran Systems temporary access below.

      ', + '
      ', '
      ', '
      Your IP' + escHtml(ip) + '
      ', '
      This IP will be shared with Sovran Systems support
      ', @@ -40,7 +116,7 @@ function renderSupportInactive() { '
    10. All session events are logged for your audit
    11. ', '
    ', '', - '

    You can revoke access at any time. Wallet files are protected unless you unlock them.

    ', + '

    You can revoke access at any time. When finished, you can disable SSH from the Feature Manager to return to the default secure state.

    ', '', ].join(""); document.getElementById("btn-support-enable").addEventListener("click", enableSupport); @@ -131,7 +207,7 @@ function renderSupportRemoved(verified) { var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot to ensure it is gone."; var vclass = verified ? "verified-gone" : "verify-warning"; var vlabel = verified ? "āœ“ Removed — No access" : "⚠ Verify by rebooting"; - $supportBody.innerHTML = '
    ' + icon + '

    Support Session Ended

    ' + escHtml(msg) + '

    SSH Key Status:' + vlabel + '
    '; + $supportBody.innerHTML = '
    ' + icon + '

    Support Session Ended

    ' + escHtml(msg) + '

    SSH Key Status:' + vlabel + '
    šŸ”Disable SSH When Done

    SSH is still enabled on your machine. For maximum security, disable it from the Feature Manager when you no longer need remote access.

    '; document.getElementById("btn-support-done").addEventListener("click", closeSupportModal); } diff --git a/configuration.nix b/configuration.nix index 11c9911..bea699b 100644 --- a/configuration.nix +++ b/configuration.nix @@ -167,22 +167,6 @@ backup /etc/nix-bitcoin-secrets/ localhost/ # ── Tor ──────────────────────────────────────────────────── services.tor = { enable = true; client.enable = true; torsocks.enable = true; }; - # ── SSH ──────────────────────────────────────────────────── - services.openssh = { - enable = true; - settings = { - PasswordAuthentication = false; - KbdInteractiveAuthentication = false; - PermitRootLogin = "yes"; - }; - }; - - # ── Fail2Ban ─────────────────────────────────────────────── - services.fail2ban = { - enable = true; - ignoreIP = [ "127.0.0.0/8" "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16" "8.8.8.8" ]; - }; - # ── Garbage Collection ───────────────────────────────────── nix.gc = { automatic = true; dates = "weekly"; options = "--delete-older-than 7d"; }; diff --git a/custom.template.nix b/custom.template.nix index 652dcc5..7df86f2 100644 --- a/custom.template.nix +++ b/custom.template.nix @@ -76,6 +76,7 @@ # │ element-calling │ LiveKit server for Matrix │ # │ rdp │ GNOME Remote Desktop (RDP) │ # │ bitcoin-core │ Bitcoin Core GUI desktop app │ + # │ sshd │ SSH remote access (for support) │ # ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ļæ½ļæ½ļæ½ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ # # Example — enable element video calling: diff --git a/modules/core/roles.nix b/modules/core/roles.nix index b9fc6c8..2de80d5 100755 --- a/modules/core/roles.nix +++ b/modules/core/roles.nix @@ -48,6 +48,7 @@ element-calling = lib.mkEnableOption "Element Video and Audio Calling"; bitcoin-core = lib.mkEnableOption "Bitcoin Core"; rdp = lib.mkEnableOption "Gnome Remote Desktop"; + sshd = lib.mkEnableOption "SSH remote access"; }; # ── Web exposure (controls Caddy vhosts) ────────────────── diff --git a/modules/modules.nix b/modules/modules.nix index 16dc0a9..bf20afc 100755 --- a/modules/modules.nix +++ b/modules/modules.nix @@ -32,5 +32,6 @@ ./mempool.nix ./bitcoin-core.nix ./rdp.nix + ./sshd.nix ]; } \ No newline at end of file diff --git a/modules/sshd.nix b/modules/sshd.nix new file mode 100644 index 0000000..a4665ad --- /dev/null +++ b/modules/sshd.nix @@ -0,0 +1,23 @@ +{ config, lib, pkgs, ... }: + +lib.mkIf config.sovran_systemsOS.features.sshd { + + services.openssh = { + enable = true; + settings = { + PasswordAuthentication = false; + KbdInteractiveAuthentication = false; + PermitRootLogin = "yes"; + }; + }; + + # Only open port 22 when SSH is actually enabled + networking.firewall.allowedTCPPorts = [ 22 ]; + + # Fail2Ban protects SSH when it's active + services.fail2ban = { + enable = true; + ignoreIP = [ "127.0.0.0/8" "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16" ]; + }; + +} -- 2.53.0 From 0e0e91d1f86c9491f366d2f7ed3da7df4708819e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:17:50 +0000 Subject: [PATCH 349/857] Initial plan -- 2.53.0 From d65193d7d39785e4c3ef983eb0ddf0f7b431e7af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:17:54 +0000 Subject: [PATCH 350/857] Initial plan -- 2.53.0 From cf098454312854e912a0712067235ff1d17258e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:18:54 +0000 Subject: [PATCH 351/857] Fix disko command: use single format,mount call since disks are pre-wiped Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/80b0e65c-24c9-4448-9fdb-870891ecc30e Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/installer.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/iso/installer.py b/iso/installer.py index 3934caa..0c9488c 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -728,27 +728,18 @@ class InstallerWindow(Adw.ApplicationWindow): # Short settle so the kernel finishes re-reading time.sleep(2) - # ── Now run disko on a clean disk ── - GLib.idle_add(append_text, buf, "\n=== Partitioning drives ===\n") - cmd_destroy = [ - "sudo", "disko", "--mode", "destroy", - "--yes-wipe-all-disks", - f"{FLAKE}/iso/disko.nix", - "--arg", "device", boot_path - ] - if data_path: - cmd_destroy += ["--arg", "dataDevice", data_path] - run_stream(cmd_destroy, buf) - - GLib.idle_add(append_text, buf, "\n=== Formatting and mounting drives ===\n") - cmd_format = [ + # ── Now run disko to partition, format, and mount ── + # Disks are already wiped clean by sgdisk/wipefs above, + # so we only need disko to create partitions, format, and mount. + GLib.idle_add(append_text, buf, "\n=== Partitioning and formatting drives ===\n") + cmd = [ "sudo", "disko", "--mode", "format,mount", f"{FLAKE}/iso/disko.nix", "--arg", "device", boot_path ] if data_path: - cmd_format += ["--arg", "dataDevice", data_path] - run_stream(cmd_format, buf) + cmd += ["--arg", "dataDevice", data_path] + run_stream(cmd, buf) GLib.idle_add(append_text, buf, "\n=== Generating hardware config ===\n") run_stream(["sudo", "nixos-generate-config", "--root", "/mnt"], buf) -- 2.53.0 From 7c1dbeac273dbcafe09227498a9004ce39eb9cf1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:20:01 +0000 Subject: [PATCH 352/857] Fix disable SSH option and remove Feature Manager language Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3fc488d2-ef33-4d4f-aeb5-f2532c658aad Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 2 +- app/sovran_systemsos_web/static/js/support.js | 75 ++++++++++++++++++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 37244d3..75f5bb2 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -2152,7 +2152,7 @@ async def api_support_enable(): if not sshd_on: raise HTTPException( status_code=400, - detail="SSH must be enabled first. Please enable SSH Remote Access in the Feature Manager, then try again.", + detail="SSH must be enabled first. Please enable SSH Remote Access, then try again.", ) ok = await loop.run_in_executor(None, _enable_support) diff --git a/app/sovran_systemsos_web/static/js/support.js b/app/sovran_systemsos_web/static/js/support.js index 9e0dc6c..25dcc4d 100644 --- a/app/sovran_systemsos_web/static/js/support.js +++ b/app/sovran_systemsos_web/static/js/support.js @@ -27,12 +27,12 @@ function renderSupportSshdOff() { '
    ', '
    šŸ”SSH is Off
    ', '

    SSH (remote login) is disabled by default on your Sovran Pro. Clicking the button below will enable SSH and trigger a system rebuild. Once complete, you can then grant support access.

    ', - '

    When you end the support session, you can disable SSH again from the Feature Manager to return to the default secure state.

    ', + '

    When you end the support session, you\'ll be able to disable SSH to return to the default secure state.

    ', '
    ', '
    Steps:
      ', '
    1. Enable SSH (triggers a system rebuild — takes a few minutes)
    2. ', '
    3. Grant Sovran Systems temporary support access
    4. ', - '
    5. End the session when done — SSH can be disabled again from the Feature Manager
    6. ', + '
    7. End the session when done — you\'ll be prompted to disable SSH
    8. ', '
    ', '', '

    This will trigger a NixOS rebuild. Your machine will remain operational during the rebuild.

    ', @@ -116,7 +116,7 @@ function renderSupportInactive() { '
  1. All session events are logged for your audit
  2. ', '
', '', - '

You can revoke access at any time. When finished, you can disable SSH from the Feature Manager to return to the default secure state.

', + '

You can revoke access at any time. When you end the session, you\'ll be able to disable SSH to return to the default secure state.

', '', ].join(""); document.getElementById("btn-support-enable").addEventListener("click", enableSupport); @@ -207,8 +207,22 @@ function renderSupportRemoved(verified) { var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot to ensure it is gone."; var vclass = verified ? "verified-gone" : "verify-warning"; var vlabel = verified ? "āœ“ Removed — No access" : "⚠ Verify by rebooting"; - $supportBody.innerHTML = '
' + icon + '

Support Session Ended

' + escHtml(msg) + '

SSH Key Status:' + vlabel + '
šŸ”Disable SSH When Done

SSH is still enabled on your machine. For maximum security, disable it from the Feature Manager when you no longer need remote access.

'; + $supportBody.innerHTML = [ + '
', + '
' + icon + '
', + '

Support Session Ended

', + '

' + escHtml(msg) + '

', + '
SSH Key Status:' + vlabel + '
', + '
', + '
šŸ”Disable SSH When Done
', + '

SSH is still enabled on your machine. Click below to turn it off and return to the default secure state.

', + '', + '
', + '', + '
', + ].join(""); document.getElementById("btn-support-done").addEventListener("click", closeSupportModal); + document.getElementById("btn-sshd-disable").addEventListener("click", disableSshd); } async function enableSupport() { @@ -238,6 +252,59 @@ async function disableSupport() { } } +async function disableSshd() { + var btn = document.getElementById("btn-sshd-disable"); + if (btn) { btn.disabled = true; btn.textContent = "Disabling SSH…"; } + try { + await apiFetch("/api/features/toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ feature: "sshd", enabled: false }), + }); + $supportBody.innerHTML = [ + '
', + '
āš™ļø
', + '

Disabling SSH…

', + '

A system rebuild is in progress to turn off SSH. This may take a few minutes.

', + '

Rebuilding system…

', + '
', + ].join(""); + pollForSshdDisabled(); + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = "Disable SSH"; } + alert("Failed to disable SSH. Please try again."); + } +} + +function pollForSshdDisabled() { + var attempts = 0; + var maxAttempts = 60; // 5 minutes (5s interval) + var interval = setInterval(async function() { + attempts++; + try { + var status = await apiFetch("/api/support/status"); + var el = document.getElementById("sshd-disable-status"); + if (!status.sshd_enabled) { + clearInterval(interval); + $supportBody.innerHTML = [ + '
', + '
šŸ”
', + '

SSH is Off

', + '

SSH has been disabled. Your machine is back to its default secure state.

', + '', + '
', + ].join(""); + document.getElementById("btn-support-done").addEventListener("click", closeSupportModal); + } else if (attempts >= maxAttempts) { + clearInterval(interval); + if (el) el.textContent = "Rebuild is taking longer than expected. Please close this dialog and try again."; + } else { + if (el) el.textContent = "Rebuilding system… (" + attempts * 5 + "s)"; + } + } catch (_) {} + }, 5000); +} + async function walletUnlock() { var btn = document.getElementById("btn-wallet-unlock"); var sel = document.getElementById("wallet-unlock-duration"); -- 2.53.0 From 53a0010e4737d79f1db8ca87656746f1b4b11ca5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:38:51 +0000 Subject: [PATCH 353/857] Initial plan -- 2.53.0 From cb7b097ce00a032eaae5567265a13f2b93c65511 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:40:42 +0000 Subject: [PATCH 354/857] Drop disko: use direct sgdisk+mkfs+mount in installer, remove disko package and disko.nix Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3dbc739b-c3da-432d-b070-16217e58c76b Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/common.nix | 1 - iso/disko.nix | 59 ------------------------------------------------ iso/installer.py | 56 +++++++++++++++++++++++++++++++++------------ 3 files changed, 42 insertions(+), 74 deletions(-) delete mode 100644 iso/disko.nix diff --git a/iso/common.nix b/iso/common.nix index ce8ec62..0937431 100644 --- a/iso/common.nix +++ b/iso/common.nix @@ -55,7 +55,6 @@ in gsettings-desktop-schemas adwaita-icon-theme util-linux - disko parted dosfstools e2fsprogs diff --git a/iso/disko.nix b/iso/disko.nix deleted file mode 100644 index 568d1ad..0000000 --- a/iso/disko.nix +++ /dev/null @@ -1,59 +0,0 @@ -{ device ? "/dev/sda", dataDevice ? "", ... }: - -{ - disko.devices = { - disk = { - main = { - type = "disk"; - device = builtins.toString device; - content = { - type = "gpt"; - partitions = { - ESP = { - priority = 1; - name = "ESP"; - size = "512M"; - type = "EF00"; - content = { - type = "filesystem"; - format = "vfat"; - mountpoint = "/boot/efi"; - mountOptions = [ "umask=0077" "defaults" ]; - }; - }; - root = { - name = "root"; - size = "100%"; - content = { - type = "filesystem"; - format = "ext4"; - mountpoint = "/"; - extraArgs = [ "-L" "sovran_systemsos" ]; - }; - }; - }; - }; - }; - } // (if dataDevice != "" then { - data = { - type = "disk"; - device = builtins.toString dataDevice; - content = { - type = "gpt"; - partitions = { - primary = { - name = "primary"; - size = "100%"; - content = { - type = "filesystem"; - format = "ext4"; - mountpoint = "/run/media/Second_Drive"; - extraArgs = [ "-L" "BTCEcoandBackup" ]; - }; - }; - }; - }; - }; - } else {}); - }; -} \ No newline at end of file diff --git a/iso/installer.py b/iso/installer.py index 0c9488c..991d438 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -710,7 +710,7 @@ class InstallerWindow(Adw.ApplicationWindow): boot_path = f"/dev/{self.boot_disk}" data_path = f"/dev/{self.data_disk}" if self.data_disk else None - # ── Wipe disk(s) to clear stale GPT/MBR data before disko ── + # ── Wipe disk(s) ── GLib.idle_add(append_text, buf, "=== Wiping disk(s) ===\n") run_stream(["sudo", "sgdisk", "--zap-all", boot_path], buf) @@ -720,26 +720,54 @@ class InstallerWindow(Adw.ApplicationWindow): run_stream(["sudo", "sgdisk", "--zap-all", data_path], buf) run_stream(["sudo", "wipefs", "--all", "--force", data_path], buf) - # Inform the kernel of the wiped partition tables run_stream(["sudo", "partprobe", boot_path], buf) if data_path: run_stream(["sudo", "partprobe", data_path], buf) - # Short settle so the kernel finishes re-reading time.sleep(2) - # ── Now run disko to partition, format, and mount ── - # Disks are already wiped clean by sgdisk/wipefs above, - # so we only need disko to create partitions, format, and mount. - GLib.idle_add(append_text, buf, "\n=== Partitioning and formatting drives ===\n") - cmd = [ - "sudo", "disko", "--mode", "format,mount", - f"{FLAKE}/iso/disko.nix", - "--arg", "device", boot_path - ] + # ── Partition boot disk: 512M ESP + rest as root ── + GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk ===\n") + run_stream(["sudo", "sgdisk", + "-n", "1:1M:+512M", "-t", "1:EF00", "-c", "1:ESP", + "-n", "2:0:0", "-t", "2:8300", "-c", "2:root", + boot_path], buf) + + run_stream(["sudo", "partprobe", boot_path], buf) + time.sleep(2) + + # ── Partition data disk (if selected) ── if data_path: - cmd += ["--arg", "dataDevice", data_path] - run_stream(cmd, buf) + GLib.idle_add(append_text, buf, "\n=== Partitioning data disk ===\n") + run_stream(["sudo", "sgdisk", + "-n", "1:1M:0", "-t", "1:8300", "-c", "1:primary", + data_path], buf) + + run_stream(["sudo", "partprobe", data_path], buf) + time.sleep(2) + + # ── Format partitions ── + GLib.idle_add(append_text, buf, "\n=== Formatting partitions ===\n") + boot_p1 = f"{boot_path}p1" if "nvme" in boot_path else f"{boot_path}1" + boot_p2 = f"{boot_path}p2" if "nvme" in boot_path else f"{boot_path}2" + + run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf) + run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf) + + if data_path: + data_p1 = f"{data_path}p1" if "nvme" in data_path else f"{data_path}1" + run_stream(["sudo", "mkfs.ext4", "-F", "-L", "BTCEcoandBackup", data_p1], buf) + + # ── Mount filesystems ── + GLib.idle_add(append_text, buf, "\n=== Mounting filesystems ===\n") + run_stream(["sudo", "mount", boot_p2, "/mnt"], buf) + run_stream(["sudo", "mkdir", "-p", "/mnt/boot/efi"], buf) + run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf) + + if data_path: + data_p1 = f"{data_path}p1" if "nvme" in data_path else f"{data_path}1" + run_stream(["sudo", "mkdir", "-p", "/mnt/run/media/Second_Drive"], buf) + run_stream(["sudo", "mount", data_p1, "/mnt/run/media/Second_Drive"], buf) GLib.idle_add(append_text, buf, "\n=== Generating hardware config ===\n") run_stream(["sudo", "nixos-generate-config", "--root", "/mnt"], buf) -- 2.53.0 From a592b270afbf18a7e1f8346f43ce37bce988703e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 16:22:43 +0000 Subject: [PATCH 355/857] Initial plan -- 2.53.0 From d6cdfcf31a877ce7342f57da066ea80758b8d037 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 16:23:40 +0000 Subject: [PATCH 356/857] Add --impure flag to nixos-install command Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/7723d784-dc2c-41da-b523-451a63f335eb Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/installer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/iso/installer.py b/iso/installer.py index 991d438..e1fc844 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -880,7 +880,8 @@ class InstallerWindow(Adw.ApplicationWindow): "sudo", "nixos-install", "--root", "/mnt", "--flake", "/mnt/etc/nixos#nixos", - "--no-root-password" + "--no-root-password", + "--impure" ], buf) GLib.idle_add(self.push_complete) -- 2.53.0 From a390c4711a4c2c4ce0a33599843c0c195a8eaab9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 16:51:21 +0000 Subject: [PATCH 357/857] Initial plan -- 2.53.0 From 536eb0deb112eaac261a4e01198ef3ddc9298e77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 16:52:40 +0000 Subject: [PATCH 358/857] Add sovran-hub.desktop entry and icon to GNOME dock Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/42720669-f980-4f13-989e-0728ea9307de Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- modules/core/sovran-hub.nix | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 7635d0f..c161b50 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -230,6 +230,22 @@ let install -d $out/share/sovran-hub/icons cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true + install -d $out/share/icons/hicolor/scalable/apps + cp sovran_systemsos_web/static/logo-light.svg $out/share/icons/hicolor/scalable/apps/sovran-hub.svg + + install -d $out/share/applications + cat > $out/share/applications/sovran-hub.desktop < $out/bin/sovran-hub-web < Date: Sun, 5 Apr 2026 16:53:30 +0000 Subject: [PATCH 359/857] Initial plan -- 2.53.0 From 37ad4fd2adbe8ee4148121c4b5c929afbab132bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 16:54:29 +0000 Subject: [PATCH 360/857] fix: copy role-state.nix and custom.nix to host /etc/nixos before nixos-install Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/38396f35-c812-43e5-9bf0-f7bd611cbba7 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/installer.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/iso/installer.py b/iso/installer.py index e1fc844..3ba12c1 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -876,6 +876,14 @@ class InstallerWindow(Adw.ApplicationWindow): if not os.path.exists(f): raise RuntimeError(f"Required file missing: {f}") + # The flake.nix imports /etc/nixos/role-state.nix and /etc/nixos/custom.nix + # as absolute paths. With --impure, Nix resolves these on the live ISO host, + # not under /mnt. Copy them so they exist on the host filesystem too. + GLib.idle_add(append_text, buf, "Copying config files to host /etc/nixos for flake evaluation...\n") + run(["sudo", "mkdir", "-p", "/etc/nixos"]) + run(["sudo", "cp", "/mnt/etc/nixos/role-state.nix", "/etc/nixos/role-state.nix"]) + run(["sudo", "cp", "/mnt/etc/nixos/custom.nix", "/etc/nixos/custom.nix"]) + run_stream([ "sudo", "nixos-install", "--root", "/mnt", -- 2.53.0 From 37cc35bb1b3d2d7d7393de9d059f66db9adcc681 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:48:12 +0000 Subject: [PATCH 361/857] Initial plan -- 2.53.0 From c9a5a4dec91f58854cbecc9ead11c5fce7d484de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:48:34 +0000 Subject: [PATCH 362/857] Initial plan -- 2.53.0 From 70f3cef03a42b10f34fbe1447645ffd23864d8ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:50:18 +0000 Subject: [PATCH 363/857] Fix NixOS install: add hardware-configuration.nix to flake modules and installer Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5db1cd99-2067-4b5c-ba11-3e9aa8fde973 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- flake.nix | 1 + iso/installer.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 89222a7..c1b5fb7 100755 --- a/flake.nix +++ b/flake.nix @@ -25,6 +25,7 @@ modules = [ { nixpkgs.hostPlatform = "x86_64-linux"; } self.nixosModules.Sovran_SystemsOS + /etc/nixos/hardware-configuration.nix /etc/nixos/role-state.nix /etc/nixos/custom.nix ]; diff --git a/iso/installer.py b/iso/installer.py index 3ba12c1..a6538e0 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -814,7 +814,6 @@ class InstallerWindow(Adw.ApplicationWindow): status = Adw.StatusPage() status.set_title("Drives Ready") status.set_description("Your drives have been partitioned successfully.") - status.set_icon_name("emblem-ok-symbolic") status.set_vexpand(True) details = Adw.PreferencesGroup() @@ -883,6 +882,7 @@ class InstallerWindow(Adw.ApplicationWindow): run(["sudo", "mkdir", "-p", "/etc/nixos"]) run(["sudo", "cp", "/mnt/etc/nixos/role-state.nix", "/etc/nixos/role-state.nix"]) run(["sudo", "cp", "/mnt/etc/nixos/custom.nix", "/etc/nixos/custom.nix"]) + run(["sudo", "cp", "/mnt/etc/nixos/hardware-configuration.nix", "/etc/nixos/hardware-configuration.nix"]) run_stream([ "sudo", "nixos-install", -- 2.53.0 From 953271eeee4d9042fda507cf7937d86373fb5d55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:50:20 +0000 Subject: [PATCH 364/857] grey out node/server roles when no second internal drive detected Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/30ed5e6b-2d61-415c-ba07-aba31dbcd839 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/installer.py | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/iso/installer.py b/iso/installer.py index 3ba12c1..01f629e 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -339,6 +339,23 @@ class InstallerWindow(Adw.ApplicationWindow): "Node (Bitcoin-only)"), ] + # Detect internal (non-USB) drives to gate role availability + try: + raw = run(["lsblk", "-b", "-dno", "NAME,SIZE,TYPE,RO,TRAN", "-e", "7,11"]) + internal_disks = [] + for line in raw.splitlines(): + parts = line.split() + if len(parts) >= 4 and parts[2] == "disk" and parts[3] == "0": + tran = parts[4] if len(parts) >= 5 else "" + if tran != "usb": + internal_disks.append(parts[0]) + except Exception: + internal_disks = [] + + has_second_drive = len(internal_disks) >= 2 + + NEEDS_DATA_DRIVE = {"Server+Desktop", "Node (Bitcoin-only)"} + self._role_radios = [] radio_group = None cards_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) @@ -348,14 +365,22 @@ class InstallerWindow(Adw.ApplicationWindow): for label, desc, key in roles: card = Adw.ActionRow() card.set_title(label) - card.set_subtitle(desc) + + available = has_second_drive or key not in NEEDS_DATA_DRIVE + if not available: + card.set_subtitle(desc + "\n⚠ Requires a second internal drive (not detected)") + card.set_sensitive(False) + else: + card.set_subtitle(desc) radio = Gtk.CheckButton() radio.set_name(key) - if radio_group is None: + radio.set_sensitive(available) + + if radio_group is None and available: radio_group = radio radio.set_active(True) - else: + elif radio_group is not None: radio.set_group(radio_group) card.add_prefix(radio) -- 2.53.0 From 4765a1822443a71fdf9715167956fbf917fe2d70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:55:32 +0000 Subject: [PATCH 365/857] Initial plan -- 2.53.0 From 3c4495c066b94508d13593a7c1e159b53e3a9401 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:56:59 +0000 Subject: [PATCH 366/857] Simplify custom.template.nix and add-custom-nix.sh for hub-managed configuration Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/f90e34be-674b-4047-8096-c0db7883de1a Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- custom.template.nix | 122 ++---------------- file_fixes_and_new_services/add-custom-nix.sh | 25 ++-- 2 files changed, 24 insertions(+), 123 deletions(-) diff --git a/custom.template.nix b/custom.template.nix index 7df86f2..2c7760a 100644 --- a/custom.template.nix +++ b/custom.template.nix @@ -5,124 +5,20 @@ # # # Sovran_SystemsOS — custom.nix # # # - # This is YOUR configuration file. Edit it to customize # - # which services and features run on your machine. # + # Services, features, and roles are managed by the # + # Sovran Hub. Any changes you make through the Hub # + # will appear in the "Hub Managed" section below. # + # # + # If you want to add your own NixOS modules or # + # configuration, place them here — outside of the # + # Hub Managed section. # # # # After making changes, rebuild with: # # # - # nixos-rebuild switch # + # nixos-rebuild switch # # # ########################################################### - - # ═══════════════════════════════════════════════════════════ - # STEP 1: CHOOSE YOUR ROLE - # ═══════════════════════════════════════════════════════════ - # - # Your initial role was selected during installation. - # To CHANGE your role, uncomment exactly ONE of the lines below. - # - # Server+Desktop: Full server + desktop environment - # Desktop Only: Desktop environment, no server services - # Node (Bitcoin Only): Bitcoin ecosystem - # - # ─────────────────────────────────────────────────────────── - - # sovran_systemsOS.roles.server_plus_desktop = true; - # sovran_systemsOS.roles.desktop = true; - # sovran_systemsOS.roles.node = true; - - - # ═══════════════════════════════════════════════════════════ - # STEP 2: SERVICES (default: ON) - # ═══════════════════════════════════════════════════════════ - # - # These are all ON by default in the Server+Desktop role. - # Set any to "false" to disable it. - # - # ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” - # │ Service │ What it does │ - # ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ - # │ synapse │ Matrix Synapse homeserver │ - # │ bitcoin │ Bitcoin ecosystem (bitcoind, │ - # │ │ electrs, lnd, rtl, btcpay) │ - # │ vaultwarden │ Vaultwarden password manager │ - # │ wordpress │ WordPress website │ - # │ nextcloud │ Nextcloud file hosting │ - # ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ - # - # Example — disable WordPress and Nextcloud: - # - # sovran_systemsOS.services.wordpress = false; - # sovran_systemsOS.services.nextcloud = false; - # - # ─────────────────────────────────────────────────────────── - - # sovran_systemsOS.services.wordpress = false; - - - # ═══════════════════════════════════════════════════════════ - # STEP 3: FEATURES (default: OFF) - # ═══════════════════════════════════════════════════════════ - # - # These are OFF by default. Set to "true" to enable. - # - # ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” - # │ Feature │ What it does │ - # ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ - # │ haven │ Haven NOSTR relay & Blossom │ - # │ bip110 │ BIP-110 Bitcoin Better Money │ - # │ mempool │ Mempool.space block explorer │ - # │ element-calling │ LiveKit server for Matrix │ - # │ rdp │ GNOME Remote Desktop (RDP) │ - # │ bitcoin-core │ Bitcoin Core GUI desktop app │ - # │ sshd │ SSH remote access (for support) │ - # ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ļæ½ļæ½ļæ½ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ - # - # Example — enable element video calling: - # - # sovran_systemsOS.features.element-calling = true; - # - # ─────────────────────────────────────────────────────────── - - # sovran_systemsOS.features.element-calling = true; - - - # ═══════════════════════════════════════════════════════════ - # STEP 4: WEB EXPOSURE (default: ON) - # ═══════════════════════════════════════════════════════════ - # - # Controls whether Caddy serves this application to the web. - # (Does not stop the application itself from running). - # - # ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” - # │ Option │ Default │ - # ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ - # │ btcpayserver │ true (false in Node role) │ - # ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ - # - # Example — hide BTCPay from the web: - # - # sovran_systemsOS.web.btcpayserver = false; - # - # ─────────────────────────────────────────────────────────── - - # sovran_systemsOS.web.btcpayserver = false; - - - # ═══════════════════════════════════════════════════════════ - # STEP 5: NOSTR PUBLIC KEY (required for Haven) - # ═══════════════════════════════════════════════════════════ - # - # If you enabled Haven above, paste your npub here. - # Haven will NOT start without a valid npub. - # - # Example: - # - # sovran_systemsOS.nostr_npub = "npub1abc123..."; - # - # ─────────────────────────────────────────────────────────── - - # sovran_systemsOS.nostr_npub = ""; + # ─── Add your custom NixOS configuration below ─────────── } diff --git a/file_fixes_and_new_services/add-custom-nix.sh b/file_fixes_and_new_services/add-custom-nix.sh index 337e659..fb92a04 100755 --- a/file_fixes_and_new_services/add-custom-nix.sh +++ b/file_fixes_and_new_services/add-custom-nix.sh @@ -37,21 +37,26 @@ FILE=/var/lib/beacons/file_fixes_and_new_services/add-custom-nix/completed touch /etc/nixos/custom.nix /run/current-system/sw/bin/cat > /etc/nixos/custom.nix <<- "EOF" +{ config, lib, ... }: -{config, pkgs, lib, ...}: - -# Add custom NixOS modules here. - -let - personalization = import ./personalization.nix; - - in { + ########################################################### + # # + # Sovran_SystemsOS — custom.nix # + # # + # Services, features, and roles are managed by the # + # Sovran Hub. Any changes you make through the Hub # + # will appear in the "Hub Managed" section below. # + # # + # If you want to add your own NixOS modules or # + # configuration, place them here — outside of the # + # Hub Managed section. # + # # + ########################################################### - + # ─── Add your custom NixOS configuration below ─────────── } - EOF -- 2.53.0 From d51919ec69fdc3dfdb9e54c31ead6dec9b50358c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:17:29 +0000 Subject: [PATCH 367/857] Initial plan -- 2.53.0 From cc17c3fb420cc68a1653a7abcc852bd9ff131a36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:18:39 +0000 Subject: [PATCH 368/857] Remove PDF mention, icon, and fix reboot button color in push_complete Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3bb82d50-1a0b-4f1d-b186-1e4efde002d1 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/installer.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/iso/installer.py b/iso/installer.py index 0471826..969cc3f 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -927,7 +927,6 @@ class InstallerWindow(Adw.ApplicationWindow): status = Adw.StatusPage() status.set_title("Installation Complete!") status.set_description("Welcome to Sovran SystemsOS.") - status.set_icon_name("emblem-ok-symbolic") status.set_vexpand(True) creds_group = Adw.PreferencesGroup() @@ -949,7 +948,7 @@ class InstallerWindow(Adw.ApplicationWindow): note_row.set_title("App Passwords") note_row.set_subtitle( "After rebooting, all app passwords (Nextcloud, Bitcoin, Matrix, etc.) " - "will be saved to a secure PDF in your Documents folder." + "will be available in the Sovran Hub on your dashboard." ) creds_group.add(note_row) @@ -959,9 +958,8 @@ class InstallerWindow(Adw.ApplicationWindow): outer.append(content_box) reboot_btn = Gtk.Button(label="Reboot Now") - reboot_btn.add_css_class("suggested-action") + reboot_btn.add_css_class("success") reboot_btn.add_css_class("pill") - reboot_btn.add_css_class("destructive-action") reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"])) nav = Gtk.Box() -- 2.53.0 From 5632068ca8d17cb961c34d68bfda273010ca671b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:25:46 +0000 Subject: [PATCH 369/857] Initial plan -- 2.53.0 From f7539dc9b6c8addef891fb423b400befcb967e38 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:27:52 +0000 Subject: [PATCH 370/857] Remove redundant terminal domain setup script and update stale references Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ed7fee4d-b50e-4387-8eb6-46840b9d930f Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 2 +- modules/core/njalla.nix | 2 +- modules/core/sovran-manage-domains.nix | 461 ------------------------- modules/modules.nix | 1 - 4 files changed, 2 insertions(+), 464 deletions(-) delete mode 100644 modules/core/sovran-manage-domains.nix diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 75f5bb2..3180f92 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -2400,7 +2400,7 @@ async def api_features_toggle(req: FeatureToggleRequest): status_code=400, detail=( "Element Calling requires a Matrix domain to be configured. " - "Please run `sovran-setup-domains` first or configure the Matrix domain." + "Element Calling requires a Matrix domain to be configured. Please configure it through the Sovran Hub web interface." ), ) diff --git a/modules/core/njalla.nix b/modules/core/njalla.nix index 7677212..f7bd78f 100644 --- a/modules/core/njalla.nix +++ b/modules/core/njalla.nix @@ -23,7 +23,7 @@ IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4) ## Add DDNS entries below — one curl per line -## Run 'sudo sovran-setup-domains' to configure automatically +## Managed via Sovran Hub web interface SCRIPT chmod 700 /var/lib/njalla/njalla.sh diff --git a/modules/core/sovran-manage-domains.nix b/modules/core/sovran-manage-domains.nix deleted file mode 100644 index 9d3b15b..0000000 --- a/modules/core/sovran-manage-domains.nix +++ /dev/null @@ -1,461 +0,0 @@ -{ config, pkgs, lib, ... }: - -let - domains = config.sovran_systemsOS.domainRequirements; - - domainNamesList = lib.concatMapStringsSep " " (d: d.name) domains; - - ddnsPrompt = '' - read -p " Njal.la DDNS curl command (paste full line, or Enter to skip): " DDNS_LINE - if [ -n "$DDNS_LINE" ]; then - # Strip any leading "curl " if they pasted the whole command - DDNS_LINE="''${DDNS_LINE#curl }" - # Strip surrounding quotes - DDNS_LINE="''${DDNS_LINE%\"}" - DDNS_LINE="''${DDNS_LINE#\"}" - # Replace &auto with &a=''${IP} at the end - DDNS_LINE="''${DDNS_LINE%auto}&a=''${DOLLAR}{IP}" - # Remove any trailing double &a= if they already had &a= - DDNS_LINE=$(echo "$DDNS_LINE" | sed 's/&a=&a=/\&a=/g') - ''; - - confirmDomain = name: '' - while true; do - echo "" - printf "%b%s%b\n" "$YELLOW" " You entered:" "$NC" - printf "%b%s%b\n" "$CYAN" " Domain: $DOMAIN" "$NC" - if [ -n "''${DDNS_DISPLAY:-}" ]; then - printf "%b%s%b\n" "$CYAN" " DDNS URL: $DDNS_DISPLAY" "$NC" - fi - echo "" - read -p " Is this correct? (y/n): " CONFIRM - case "$CONFIRM" in - [yY]) - echo "$DOMAIN" > "/var/lib/domains/${name}" - printf "%b%s%b\n" "$GREEN" " Saved." "$NC" - break - ;; - [nN]) - echo " Let's try again." - REDO=true - break - ;; - *) - echo " Please enter y or n." - ;; - esac - done - ''; - - domainPrompts = lib.concatMapStringsSep "\n" (d: '' - REDO=true - while [ "$REDO" = true ]; do - REDO=false - DDNS_DISPLAY="" - echo "" - printf "%b%s%b\n" "$GREEN" "── ${d.label} ──" "$NC" - EXISTING="" - if [ -f "/var/lib/domains/${d.name}" ]; then - EXISTING=$(cat "/var/lib/domains/${d.name}") - printf "%b%s%b\n" "$CYAN" " Current: $EXISTING" "$NC" - fi - read -p " Subdomain (e.g. ${d.example}) or Enter to keep current: " DOMAIN_INPUT - DOMAIN="''${DOMAIN_INPUT:-$EXISTING}" - - if [ -n "$DOMAIN" ]; then - ${lib.optionalString d.needsDDNS '' - ${ddnsPrompt} - DDNS_DISPLAY="$DDNS_LINE" - PENDING_NJALLA="curl \"$DDNS_LINE\"" - fi - ''} - - ${confirmDomain d.name} - - if [ "$REDO" = false ] && [ -n "''${PENDING_NJALLA:-}" ]; then - NJALLA_ENTRIES="$NJALLA_ENTRIES -$PENDING_NJALLA" - PENDING_NJALLA="" - fi - else - echo " Skipped." - fi - done - '') domains; - - missingDomainPrompts = lib.concatMapStringsSep "\n" (d: '' - if [ ! -f "/var/lib/domains/${d.name}" ]; then - MISSING=true - REDO=true - while [ "$REDO" = true ]; do - REDO=false - DDNS_DISPLAY="" - echo "" - printf "%b%s%b\n" "$GREEN" "── ${d.label} (NEW) ──" "$NC" - read -p " Subdomain (e.g. ${d.example}): " DOMAIN - - if [ -n "$DOMAIN" ]; then - ${lib.optionalString d.needsDDNS '' - ${ddnsPrompt} - DDNS_DISPLAY="$DDNS_LINE" - PENDING_NJALLA="curl \"$DDNS_LINE\"" - fi - ''} - - ${confirmDomain d.name} - - if [ "$REDO" = false ] && [ -n "''${PENDING_NJALLA:-}" ]; then - NEW_NJALLA_ENTRIES="$NEW_NJALLA_ENTRIES -$PENDING_NJALLA" - PENDING_NJALLA="" - fi - else - echo " Skipped." - fi - done - fi - '') domains; - - domainSummary = lib.concatMapStringsSep "\n" (d: '' - if [ -f "/var/lib/domains/${d.name}" ]; then - printf "%b%s%b\n" "$NC" " ${d.label}: $(cat /var/lib/domains/${d.name})" "$NC" - fi - '') domains; - - setupScript = pkgs.writeShellScriptBin "sovran-setup-domains" '' - set -euo pipefail - - GREEN='\033[0;32m' - YELLOW='\033[1;33m' - CYAN='\033[0;36m' - NC='\033[0m' - DOLLAR='$' - - echo "" - printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC" - printf "%b%s%b\n" "$CYAN" " Sovran_SystemsOS — Domain & DDNS Setup" "$NC" - printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC" - echo "" - printf "%b%s%b\n" "$YELLOW" "Before running this, you need:" "$NC" - echo "" - echo " 1. Domains/subdomains purchased on https://njal.la" - echo " 2. For each subdomain, add a Dynamic record in" - echo " your Njal.la dashboard." - echo " 3. Njal.la will give you a curl command like:" - echo "" - printf "%b%s%b\n" "$CYAN" " curl \"https://njal.la/update/?h=sub.domain.com&k=abc123&auto\"" "$NC" - echo "" - echo " Have those curl commands ready." - echo "" - read -p "Press Enter to continue..." - - # ── Create directories ──────────────────────────── - mkdir -p /var/lib/domains - - NJALLA_ENTRIES="" - PENDING_NJALLA="" - - # ── SSL Email ───────────────────────────────────── - REDO=true - while [ "$REDO" = true ]; do - REDO=false - echo "" - printf "%b%s%b\n" "$GREEN" "── SSL Certificate Email ──" "$NC" - echo "Let's Encrypt needs an email for certificate notifications." - EXISTING_EMAIL="" - if [ -f "/var/lib/domains/sslemail" ]; then - EXISTING_EMAIL=$(cat /var/lib/domains/sslemail) - printf "%b%s%b\n" "$CYAN" " Current: $EXISTING_EMAIL" "$NC" - fi - read -p " Email address (or Enter to keep current): " EMAIL_INPUT - SSL_EMAIL="''${EMAIL_INPUT:-$EXISTING_EMAIL}" - if [ -n "$SSL_EMAIL" ]; then - while true; do - echo "" - printf "%b%s%b\n" "$YELLOW" " You entered:" "$NC" - printf "%b%s%b\n" "$CYAN" " Email: $SSL_EMAIL" "$NC" - echo "" - read -p " Is this correct? (y/n): " CONFIRM - case "$CONFIRM" in - [yY]) - echo "$SSL_EMAIL" > /var/lib/domains/sslemail - printf "%b%s%b\n" "$GREEN" " Saved." "$NC" - break - ;; - [nN]) - echo " Let's try again." - REDO=true - break - ;; - *) - echo " Please enter y or n." - ;; - esac - done - fi - done - - # ── All module domains ──────────────────────────── - ${domainPrompts} - - # ── Final review ────────────────────────────────── - echo "" - printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC" - printf "%b%s%b\n" "$CYAN" " Review All Entries" "$NC" - printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC" - echo "" - echo " Configured domains:" - ${domainSummary} - echo "" - echo " DDNS entries:" - if [ -n "$NJALLA_ENTRIES" ]; then - echo "$NJALLA_ENTRIES" - else - echo " (none)" - fi - echo "" - read -p " Does everything look correct? (y/n): " FINAL_CONFIRM - if [ "$FINAL_CONFIRM" != "y" ] && [ "$FINAL_CONFIRM" != "Y" ]; then - echo "" - printf "%b%s%b\n" "$YELLOW" " Setup cancelled. Run 'sudo sovran-setup-domains' to start over." "$NC" - echo "" - exit 1 - fi - - # ── Append curl entries to njalla.sh ────────────── - if [ -n "$NJALLA_ENTRIES" ]; then - echo "" - printf "%b%s%b\n" "$GREEN" "── Updating DDNS script ──" "$NC" - echo "$NJALLA_ENTRIES" >> /var/lib/njalla/njalla.sh - echo " Appended entries to /var/lib/njalla/njalla.sh" - fi - - # ── Run DDNS update now ─────────────────────────── - echo "" - read -p "Update Njal.la DNS records now? (y/n): " RUN_NOW - if [ "$RUN_NOW" = "y" ]; then - bash /var/lib/njalla/njalla.sh - echo " DNS records updated." - fi - - # ── Mark setup complete ─────────────────────────── - touch /var/lib/domains/.setup-complete - - # ── Summary ─────────────────────────────────────── - echo "" - printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC" - printf "%b%s%b\n" "$CYAN" " Setup Complete!" "$NC" - printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC" - echo "" - echo " Domain files: /var/lib/domains/" - echo " DDNS script: /var/lib/njalla/njalla.sh" - echo " DDNS cron: Every 15 minutes (already configured)" - echo "" - - # ── Port Forwarding Reminder ────────────────────── - INTERNAL_IP=$(hostname -I 2>/dev/null | awk '{print $1}') - printf "%b%s%b\n" "$YELLOW" "══════════════════════════════════════════════" "$NC" - printf "%b ⚠ Port Forwarding Reminder%b\n" "$YELLOW" "$NC" - printf "%b%s%b\n" "$YELLOW" "══════════════════════════════════════════════" "$NC" - echo "" - echo " For your services to be reachable from the internet, you must" - echo " set up PORT FORWARDING in your router's admin panel." - echo "" - if [ -n "$INTERNAL_IP" ]; then - printf " Forward these ports to this machine's internal IP: %b%s%b\n" "$CYAN" "$INTERNAL_IP" "$NC" - else - echo " Forward these ports to this machine's internal LAN IP." - fi - echo "" - echo " 80/TCP — HTTP (redirects to HTTPS)" - echo " 443/TCP — HTTPS (all domain-based services)" - echo " 8448/TCP — Matrix federation (server-to-server)" - echo "" - echo " If you enabled Element Calling, also forward:" - echo " 7881/TCP — LiveKit WebRTC signalling" - echo " 7882-7894/UDP — LiveKit media streams" - echo " 5349/TCP — TURN over TLS" - echo " 3478/UDP — TURN (STUN/relay)" - echo " 30000-40000/TCP+UDP — TURN relay" - echo "" - echo " How: Log into your router (usually 192.168.1.1), find the" - echo " \"Port Forwarding\" section, and add rules for each port above" - if [ -n "$INTERNAL_IP" ]; then - printf " with the destination set to %b%s%b.\n" "$CYAN" "$INTERNAL_IP" "$NC" - else - echo " with the destination set to this machine's IP." - fi - echo "" - echo " These ports only need to be forwarded to this specific machine —" - echo " this does NOT expose your entire network." - echo "" - printf "%b%s%b\n" "$YELLOW" "══════════════════════════════════════════════" "$NC" - echo "" - read -p "Press Enter to continue with the rebuild..." - - printf "%b%s%b\n" "$YELLOW" " Rebuilding to activate services with new domains..." "$NC" - echo "" - nixos-rebuild switch --flake /etc/nixos#nixos - ''; - - addDomainScript = pkgs.writeShellScriptBin "sovran-add-domains" '' - set -euo pipefail - - GREEN='\033[0;32m' - YELLOW='\033[1;33m' - CYAN='\033[0;36m' - NC='\033[0m' - DOLLAR='$' - - MISSING=false - NEW_NJALLA_ENTRIES="" - PENDING_NJALLA="" - - echo "" - printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC" - printf "%b%s%b\n" "$CYAN" " Sovran_SystemsOS — New Feature Domains" "$NC" - printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC" - echo "" - echo " Checking for newly enabled features that need domains..." - - mkdir -p /var/lib/domains - - ${missingDomainPrompts} - - if [ "$MISSING" = false ]; then - echo "" - printf "%b%s%b\n" "$GREEN" " All domains are already configured. Nothing to do." "$NC" - echo "" - exit 0 - fi - - # ── Final review ────────────────────────────────── - echo "" - printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC" - printf "%b%s%b\n" "$CYAN" " Review New Entries" "$NC" - printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC" - echo "" - echo " All configured domains:" - ${domainSummary} - echo "" - echo " New DDNS entries:" - if [ -n "$NEW_NJALLA_ENTRIES" ]; then - echo "$NEW_NJALLA_ENTRIES" - else - echo " (none)" - fi - echo "" - read -p " Does everything look correct? (y/n): " FINAL_CONFIRM - if [ "$FINAL_CONFIRM" != "y" ] && [ "$FINAL_CONFIRM" != "Y" ]; then - echo "" - printf "%b%s%b\n" "$YELLOW" " Setup cancelled. Run 'sudo sovran-add-domains' to start over." "$NC" - echo "" - exit 1 - fi - - # ── Append new entries to njalla.sh ─────────────── - if [ -n "$NEW_NJALLA_ENTRIES" ]; then - echo "" - printf "%b%s%b\n" "$GREEN" "── Updating DDNS script ──" "$NC" - echo "$NEW_NJALLA_ENTRIES" >> /var/lib/njalla/njalla.sh - echo " Appended new entries to /var/lib/njalla/njalla.sh" - - echo "" - read -p "Update Njal.la DNS records now? (y/n): " RUN_NOW - if [ "$RUN_NOW" = "y" ]; then - bash /var/lib/njalla/njalla.sh - echo " DNS records updated." - fi - fi - - # ── Summary ─────────────────────────────────────── - echo "" - printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC" - printf "%b%s%b\n" "$CYAN" " New Domains Added!" "$NC" - printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC" - echo "" - echo " All configured domains:" - ${domainSummary} - echo "" - - # ── Port Forwarding Reminder ────────────────────── - INTERNAL_IP=$(hostname -I 2>/dev/null | awk '{print $1}') - printf "%b%s%b\n" "$YELLOW" "══════════════════════════════════════════════" "$NC" - printf "%b ⚠ Port Forwarding Reminder%b\n" "$YELLOW" "$NC" - printf "%b%s%b\n" "$YELLOW" "══════════════════════════════════════════════" "$NC" - echo "" - echo " For your services to be reachable from the internet, you must" - echo " set up PORT FORWARDING in your router's admin panel." - echo "" - if [ -n "$INTERNAL_IP" ]; then - printf " Forward these ports to this machine's internal IP: %b%s%b\n" "$CYAN" "$INTERNAL_IP" "$NC" - else - echo " Forward these ports to this machine's internal LAN IP." - fi - echo "" - echo " 80/TCP — HTTP (redirects to HTTPS)" - echo " 443/TCP — HTTPS (all domain-based services)" - echo " 8448/TCP — Matrix federation (server-to-server)" - echo "" - echo " If you enabled Element Calling, also forward:" - echo " 7881/TCP — LiveKit WebRTC signalling" - echo " 7882-7894/UDP — LiveKit media streams" - echo " 5349/TCP — TURN over TLS" - echo " 3478/UDP — TURN (STUN/relay)" - echo " 30000-40000/TCP+UDP — TURN relay" - echo "" - echo " How: Log into your router (usually 192.168.1.1), find the" - echo " \"Port Forwarding\" section, and add rules for each port above" - if [ -n "$INTERNAL_IP" ]; then - printf " with the destination set to %b%s%b.\n" "$CYAN" "$INTERNAL_IP" "$NC" - else - echo " with the destination set to this machine's IP." - fi - echo "" - echo " These ports only need to be forwarded to this specific machine —" - echo " this does NOT expose your entire network." - echo "" - printf "%b%s%b\n" "$YELLOW" "══════════════════════════════════════════════" "$NC" - echo "" - read -p "Press Enter to continue with the rebuild..." - - printf "%b%s%b\n" "$YELLOW" " Rebuilding to activate services with new domains..." "$NC" - echo "" - nixos-rebuild switch --impure - ''; - - needsSetup = pkgs.writeShellScriptBin "sovran-domains-need-setup" '' - # First boot — no setup done at all - if [ ! -f /var/lib/domains/.setup-complete ]; then - exit 0 - fi - - # Existing machine — check for missing domain files - for NAME in ${domainNamesList}; do - if [ ! -f "/var/lib/domains/$NAME" ]; then - exit 0 - fi - done - - # Everything is configured - exit 1 - ''; - -in -{ - environment.systemPackages = [ - setupScript - addDomainScript - needsSetup - ]; - - environment.etc."xdg/autostart/sovran-setup-domains.desktop".text = '' - [Desktop Entry] - Type=Application - Name=Sovran_SystemsOS Domain Setup - Comment=Configure domains for newly enabled features - Exec=${pkgs.bash}/bin/bash -c 'if ${needsSetup}/bin/sovran-domains-need-setup; then if [ ! -f /var/lib/domains/.setup-complete ]; then ${pkgs.gnome-terminal}/bin/gnome-terminal -- sudo ${setupScript}/bin/sovran-setup-domains; else ${pkgs.gnome-terminal}/bin/gnome-terminal -- sudo ${addDomainScript}/bin/sovran-add-domains; fi; fi' - Terminal=false - X-GNOME-Autostart-enabled=true - ''; -} diff --git a/modules/modules.nix b/modules/modules.nix index bf20afc..ac31c7d 100755 --- a/modules/modules.nix +++ b/modules/modules.nix @@ -9,7 +9,6 @@ ./core/njalla.nix ./core/ssh-bootstrap.nix ./core/tech-support.nix - ./core/sovran-manage-domains.nix ./core/sovran_systemsos-desktop.nix ./core/sovran-hub.nix -- 2.53.0 From 94d94fb7a2f8d95cc472b2a5c10ac58c86eb9e20 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Mon, 6 Apr 2026 18:40:17 -0500 Subject: [PATCH 371/857] fixed ssh at first boot --- modules/core/sshd-localhost.nix | 21 +++++++++++++++++++++ modules/modules.nix | 3 ++- modules/sshd.nix | 15 ++++++--------- 3 files changed, 29 insertions(+), 10 deletions(-) create mode 100644 modules/core/sshd-localhost.nix diff --git a/modules/core/sshd-localhost.nix b/modules/core/sshd-localhost.nix new file mode 100644 index 0000000..76be80f --- /dev/null +++ b/modules/core/sshd-localhost.nix @@ -0,0 +1,21 @@ +{ config, lib, pkgs, ... }: + +{ + # ── Always-on localhost SSH ──────────────────────────────────── + # Provides "ssh root@localhost" for local root access and Hub + # operations. Binds exclusively to 127.0.0.1 — zero network exposure. + # The sshd *feature flag* in sshd.nix extends this to 0.0.0.0 and + # opens port 22 on the firewall when the user enables remote SSH. + + services.openssh = { + enable = true; + listenAddresses = lib.mkDefault [ + { addr = "127.0.0.1"; port = 22; } + ]; + settings = { + PasswordAuthentication = false; + KbdInteractiveAuthentication = false; + PermitRootLogin = "yes"; + }; + }; +} \ No newline at end of file diff --git a/modules/modules.nix b/modules/modules.nix index ac31c7d..cf9e7de 100755 --- a/modules/modules.nix +++ b/modules/modules.nix @@ -10,6 +10,7 @@ ./core/ssh-bootstrap.nix ./core/tech-support.nix ./core/sovran_systemsos-desktop.nix + ./core/sshd-localhost.nix ./core/sovran-hub.nix # ── Always on (no flag) ─────────────────────────────────── @@ -33,4 +34,4 @@ ./rdp.nix ./sshd.nix ]; -} \ No newline at end of file +} diff --git a/modules/sshd.nix b/modules/sshd.nix index a4665ad..a65ac8c 100644 --- a/modules/sshd.nix +++ b/modules/sshd.nix @@ -2,14 +2,11 @@ lib.mkIf config.sovran_systemsOS.features.sshd { - services.openssh = { - enable = true; - settings = { - PasswordAuthentication = false; - KbdInteractiveAuthentication = false; - PermitRootLogin = "yes"; - }; - }; + # Extend to listen on all interfaces for remote access + services.openssh.listenAddresses = lib.mkForce [ + { addr = "127.0.0.1"; port = 22; } + { addr = "0.0.0.0"; port = 22; } + ]; # Only open port 22 when SSH is actually enabled networking.firewall.allowedTCPPorts = [ 22 ]; @@ -20,4 +17,4 @@ lib.mkIf config.sovran_systemsOS.features.sshd { ignoreIP = [ "127.0.0.0/8" "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16" ]; }; -} +} \ No newline at end of file -- 2.53.0 From e84dd7cb916ec45aa56f75be726418327ade48de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:41:57 +0000 Subject: [PATCH 372/857] Initial plan -- 2.53.0 From b1b0e85db7ae55bffc3120caa6b39a78ed90646a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:42:58 +0000 Subject: [PATCH 373/857] =?UTF-8?q?Add=20privacy=20disclosure=20info=20box?= =?UTF-8?q?=20to=20Node=E2=86=92Server+Desktop=20upgrade=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5dadd5f8-7c8d-4aa1-be01-3dba9fc5dc1d Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/templates/index.html | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index ea904cb..44746c3 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -187,6 +187,13 @@
  • Some services require ports to be opened on your router
  • +
    +

    ā„¹ļø Good to know:

    +
      +
    • Your services will be accessible via your home internet connection. Your approximate geographic area may be visible through DNS records — this is normal for all self-hosted services
    • +
    • Your Bitcoin node remains fully private over Tor
    • +
    +

    Don't worry — the Hub will walk you through every step after the upgrade. Domain setup, port forwarding, and configuration are all guided. -- 2.53.0 From 27502c69979d916d0f4a7433151692b468aa2881 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:22:39 +0000 Subject: [PATCH 374/857] Initial plan -- 2.53.0 From 13e3b76c88e5cbef89f6b48793727dc14e907a2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:26:11 +0000 Subject: [PATCH 375/857] Add hub auto-launch: XDG autostart, API endpoints, and frontend toggle Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0b0d70c0-01d1-49d1-b9ca-8d4f8e5af64a Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 34 ++++++++++ app/sovran_systemsos_web/static/js/events.js | 2 + .../static/js/features.js | 63 +++++++++++++++++++ modules/core/sovran-hub.nix | 35 +++++++++++ 4 files changed, 134 insertions(+) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 3180f92..a0259bf 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -57,6 +57,7 @@ ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url" REBOOT_COMMAND = ["reboot"] ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete" +AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled" # ── Tech Support constants ──────────────────────────────────────── @@ -1390,6 +1391,39 @@ async def api_onboarding_complete(): return {"ok": True} +# ── Auto-launch endpoints ───────────────────────────────────────── + +@app.get("/api/autolaunch/status") +async def api_autolaunch_status(): + """Check if Hub auto-launch on login is enabled.""" + disabled = os.path.exists(AUTOLAUNCH_DISABLE_FLAG) + return {"enabled": not disabled} + + +class AutolaunchToggleRequest(BaseModel): + enabled: bool + + +@app.post("/api/autolaunch/toggle") +async def api_autolaunch_toggle(req: AutolaunchToggleRequest): + """Enable or disable Hub auto-launch on login.""" + if req.enabled: + # Remove the disable flag to enable auto-launch + try: + os.remove(AUTOLAUNCH_DISABLE_FLAG) + except FileNotFoundError: + pass + else: + # Create the disable flag to suppress auto-launch + os.makedirs(os.path.dirname(AUTOLAUNCH_DISABLE_FLAG), exist_ok=True) + try: + with open(AUTOLAUNCH_DISABLE_FLAG, "w") as f: + f.write("") + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not write flag file: {exc}") + return {"ok": True, "enabled": req.enabled} + + @app.get("/api/config") async def api_config(): cfg = load_config() diff --git a/app/sovran_systemsos_web/static/js/events.js b/app/sovran_systemsos_web/static/js/events.js index 8d5e9ea..a39ef00 100644 --- a/app/sovran_systemsos_web/static/js/events.js +++ b/app/sovran_systemsos_web/static/js/events.js @@ -105,12 +105,14 @@ async function init() { if (cfg.feature_manager) { loadFeatureManager(); } + loadAutolaunchToggle(); } catch (_) { await refreshServices(); loadNetwork(); checkUpdates(); setInterval(refreshServices, POLL_INTERVAL_SERVICES); setInterval(checkUpdates, POLL_INTERVAL_UPDATES); + loadAutolaunchToggle(); } } diff --git a/app/sovran_systemsos_web/static/js/features.js b/app/sovran_systemsos_web/static/js/features.js index 4c92cfb..0b30519 100644 --- a/app/sovran_systemsos_web/static/js/features.js +++ b/app/sovran_systemsos_web/static/js/features.js @@ -579,3 +579,66 @@ function buildFeatureCard(feat) { return card; } + +// ── Auto-launch toggle ──────────────────────────────────────────── + +async function loadAutolaunchToggle() { + try { + var data = await apiFetch("/api/autolaunch/status"); + renderAutolaunchToggle(data.enabled); + } catch (err) { + console.warn("Failed to load autolaunch status:", err); + } +} + +function renderAutolaunchToggle(enabled) { + // Remove existing section if any + var old = $sidebarFeatures.querySelector(".autolaunch-section"); + if (old) old.parentNode.removeChild(old); + + var section = document.createElement("div"); + section.className = "category-section autolaunch-section"; + + section.innerHTML = + '

    Preferences
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    Auto-launch Hub on Login
    ' + + '
    Automatically open the Sovran Hub dashboard in your browser when you log in to the desktop.
    ' + + '
    ' + + '' + + '
    ' + + '
    '; + + $sidebarFeatures.appendChild(section); + + var input = document.getElementById("autolaunch-toggle-input"); + var label = document.getElementById("autolaunch-toggle-label"); + if (!input || !label) return; + + input.addEventListener("change", async function() { + var newEnabled = input.checked; + // Revert visually until confirmed + input.checked = !newEnabled; + if (newEnabled) { label.classList.remove("active"); } else { label.classList.add("active"); } + input.disabled = true; + try { + await apiFetch("/api/autolaunch/toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: newEnabled }), + }); + input.checked = newEnabled; + if (newEnabled) { label.classList.add("active"); } else { label.classList.remove("active"); } + } catch (err) { + alert("Failed to update auto-launch setting. Please try again."); + } finally { + input.disabled = false; + } + }); +} diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index c161b50..0000a73 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -203,6 +203,30 @@ let fi ''; + # ── Hub auto-launch wrapper script ──────────────────────────────── + hub-autolaunch-script = pkgs.writeShellScript "sovran-hub-autolaunch.sh" '' + export PATH="${lib.makeBinPath [ pkgs.curl pkgs.xdg-utils ]}:$PATH" + + DISABLE_FLAG="/var/lib/sovran/hub-autolaunch-disabled" + BOOT_FLAG="/run/sovran-hub-autolaunch-done" + + # User disabled auto-launch via Hub toggle + [ -f "$DISABLE_FLAG" ] && exit 0 + + # Already launched this boot + [ -f "$BOOT_FLAG" ] && exit 0 + + touch "$BOOT_FLAG" + + # Wait for Hub server to become ready (max ~15 seconds) + for i in $(seq 1 15); do + curl -s -o /dev/null -w "" http://localhost:8937 && break + sleep 1 + done + + xdg-open http://localhost:8937 + ''; + sovran-hub-web = pkgs.python3Packages.buildPythonApplication { pname = "sovran-systemsos-hub-web"; version = "1.0.0"; @@ -313,5 +337,16 @@ in networking.firewall.allowedTCPPorts = [ 3051 8937 60847 ]; + # ── Auto-launch Hub in browser on login ─────────────────────── + environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = '' +[Desktop Entry] +Type=Application +Name=Sovran Hub Auto-Launch +Exec=${hub-autolaunch-script} +Terminal=false +X-GNOME-Autostart-enabled=true +NoDisplay=true +''; + }; } -- 2.53.0 From 5123287ef7475785eef79672cd49209cb4ca3627 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:28:55 +0000 Subject: [PATCH 376/857] Fix curl command in hub-autolaunch-script (remove unnecessary -w flag) Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0b0d70c0-01d1-49d1-b9ca-8d4f8e5af64a Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- modules/core/sovran-hub.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 0000a73..e0309c9 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -220,7 +220,7 @@ let # Wait for Hub server to become ready (max ~15 seconds) for i in $(seq 1 15); do - curl -s -o /dev/null -w "" http://localhost:8937 && break + curl -s -o /dev/null http://localhost:8937 && break sleep 1 done -- 2.53.0 From 06615a3541bc614234fe1224aac83a35573c8a36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:33:08 +0000 Subject: [PATCH 377/857] Initial plan -- 2.53.0 From a0c1628461e4994c9482228c0e0b4fd7db9b010f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:38:17 +0000 Subject: [PATCH 378/857] feat: display bitcoind version on Bitcoin node tile in Hub dashboard Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5b4f8da9-beec-45f2-b116-b5c0dcf4506d Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 76 +++++++++++++++++++ app/sovran_systemsos_web/static/css/tiles.css | 7 ++ app/sovran_systemsos_web/static/js/tiles.js | 35 ++++++++- 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index a0259bf..e43f557 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1499,6 +1499,54 @@ BITCOIN_DATADIR = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node" _btc_sync_cache: tuple[float, dict | None] = (0.0, None) _BTC_SYNC_CACHE_TTL = 5 # seconds +_btc_version_cache: tuple[float, dict | None] = (0.0, None) +_BTC_VERSION_CACHE_TTL = 60 # seconds — version doesn't change at runtime + + +def _parse_bitcoin_subversion(subversion: str) -> str: + """Parse a subversion string like '/Bitcoin Knots:27.1.0/' into 'v27.1.0'. + + Examples: + '/Bitcoin Knots:27.1.0/' → 'v27.1.0' + '/Satoshi:27.0.0/' → 'v27.0.0' + '/Bitcoin Knots:27.1.0(bip110)/' → 'v27.1.0' + Falls back to the raw subversion string if parsing fails. + """ + m = re.search(r":(\d+\.\d+(?:\.\d+)*)", subversion) + if m: + return "v" + m.group(1) + return subversion + + +def _get_bitcoin_version_info() -> dict | None: + """Call bitcoin-cli getnetworkinfo and return parsed JSON, or None on error. + + Results are cached for _BTC_VERSION_CACHE_TTL seconds since the version + does not change while the service is running. + """ + global _btc_version_cache + now = time.monotonic() + cached_at, cached_val = _btc_version_cache + if now - cached_at < _BTC_VERSION_CACHE_TTL: + return cached_val + + try: + result = subprocess.run( + ["bitcoin-cli", f"-datadir={BITCOIN_DATADIR}", "getnetworkinfo"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + _btc_version_cache = (now, None) + return None + info = json.loads(result.stdout) + _btc_version_cache = (now, info) + return info + except Exception: + _btc_version_cache = (now, None) + return None + def _get_bitcoin_sync_info() -> dict | None: """Call bitcoin-cli getblockchaininfo and return parsed JSON, or None on error. @@ -1548,6 +1596,23 @@ async def api_bitcoin_sync(): } +@app.get("/api/bitcoin/version") +async def api_bitcoin_version(): + """Return the version string of the active bitcoind implementation.""" + loop = asyncio.get_event_loop() + info = await loop.run_in_executor(None, _get_bitcoin_version_info) + if info is None: + return JSONResponse( + status_code=503, + content={"error": "bitcoin-cli unavailable or bitcoind not running"}, + ) + subversion = info.get("subversion", "") + return { + "version": _parse_bitcoin_subversion(subversion), + "subversion": subversion, + } + + @app.get("/api/services") async def api_services(): cfg = load_config() @@ -1668,6 +1733,11 @@ async def api_services(): service_data["sync_progress"] = sync_progress service_data["sync_blocks"] = sync_blocks service_data["sync_headers"] = sync_headers + if unit == "bitcoind.service": + ver_info = await loop.run_in_executor(None, _get_bitcoin_version_info) + if ver_info is not None: + subversion = ver_info.get("subversion", "") + service_data["bitcoin_version"] = _parse_bitcoin_subversion(subversion) return service_data results = await asyncio.gather(*[get_status(s) for s in services]) @@ -1940,6 +2010,12 @@ async def api_service_detail(unit: str, icon: str | None = None): service_detail["sync_progress"] = sync_progress service_detail["sync_blocks"] = sync_blocks service_detail["sync_headers"] = sync_headers + if unit == "bitcoind.service": + loop = asyncio.get_event_loop() + ver_info = await loop.run_in_executor(None, _get_bitcoin_version_info) + if ver_info is not None: + subversion = ver_info.get("subversion", "") + service_detail["bitcoin_version"] = _parse_bitcoin_subversion(subversion) return service_detail diff --git a/app/sovran_systemsos_web/static/css/tiles.css b/app/sovran_systemsos_web/static/css/tiles.css index 613fbc9..7651f02 100644 --- a/app/sovran_systemsos_web/static/css/tiles.css +++ b/app/sovran_systemsos_web/static/css/tiles.css @@ -71,6 +71,13 @@ color: var(--text-secondary); } +.tile-version { + font-size: 0.7rem; + color: var(--text-dim); + margin-top: 2px; + text-align: center; +} + .status-dot { width: 8px; height: 8px; diff --git a/app/sovran_systemsos_web/static/js/tiles.js b/app/sovran_systemsos_web/static/js/tiles.js index 436cd32..cea77b0 100644 --- a/app/sovran_systemsos_web/static/js/tiles.js +++ b/app/sovran_systemsos_web/static/js/tiles.js @@ -131,10 +131,12 @@ function buildTile(svc) { var pct = Math.round((svc.sync_progress || 0) * 100); var id = tileId(svc); var eta = _calcBtcEta(id, svc.sync_progress || 0); + var versionLabel = svc.bitcoin_version ? '
    ' + escHtml(svc.bitcoin_version) + '
    ' : ''; tile.innerHTML = '' + escHtml(svc.name) + '' + '' + '
    ' + escHtml(svc.name) + '
    ' + + versionLabel + '
    ' + '
    \u23F3 Syncing Timechain
    ' + '
    ' + @@ -150,7 +152,8 @@ function buildTile(svc) { return tile; } - tile.innerHTML = '' + escHtml(svc.name) + '
    ' + escHtml(svc.name) + '
    ' + st + '
    '; + var versionLabel = svc.bitcoin_version ? '
    ' + escHtml(svc.bitcoin_version) + '
    ' : ''; + tile.innerHTML = '' + escHtml(svc.name) + '
    ' + escHtml(svc.name) + '
    ' + versionLabel + '
    ' + st + '
    '; tile.style.cursor = "pointer"; tile.addEventListener("click", function() { @@ -204,6 +207,21 @@ function updateTiles(services) { if (fill) fill.style.width = pct + "%"; if (pctEl) pctEl.textContent = pct + "%"; if (etaEl) etaEl.textContent = etaText; + // Update or insert version label + if (svc.bitcoin_version) { + var syncVerEl = tile.querySelector(".tile-version"); + if (syncVerEl) { + syncVerEl.textContent = svc.bitcoin_version; + } else { + var syncNameEl = tile.querySelector(".tile-name"); + if (syncNameEl) { + var newSyncVerEl = document.createElement("div"); + newSyncVerEl.className = "tile-version"; + newSyncVerEl.textContent = svc.bitcoin_version; + syncNameEl.insertAdjacentElement("afterend", newSyncVerEl); + } + } + } } else { // IBD finished or not syncing — if tile had sync layout rebuild it normally if (tile.querySelector(".tile-sync-container")) { @@ -218,6 +236,21 @@ function updateTiles(services) { var text = tile.querySelector(".status-text"); if (dot) dot.className = "status-dot " + sc; if (text) text.textContent = st; + // Update or insert version label for bitcoind tiles + if (svc.bitcoin_version) { + var verEl = tile.querySelector(".tile-version"); + if (verEl) { + verEl.textContent = svc.bitcoin_version; + } else { + var nameEl = tile.querySelector(".tile-name"); + if (nameEl) { + var newVerEl = document.createElement("div"); + newVerEl.className = "tile-version"; + newVerEl.textContent = svc.bitcoin_version; + nameEl.insertAdjacentElement("afterend", newVerEl); + } + } + } } } } -- 2.53.0 From f108abd7ae4d20c836836247faa071c41a699fbf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:42:45 +0000 Subject: [PATCH 379/857] Initial plan -- 2.53.0 From 27f27b1503be074e42a485ba08cc2c1693e4cd4b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:44:43 +0000 Subject: [PATCH 380/857] feat: add wallet-autoconnect module for Sparrow and Bisq 1 Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/29aa6dce-667a-49a6-9740-68d501fed22c Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- modules/core/sovran-hub.nix | 8 ++++ modules/modules.nix | 1 + modules/wallet-autoconnect.nix | 83 ++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 modules/wallet-autoconnect.nix diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index e0309c9..a903f0d 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -59,6 +59,14 @@ let { label = "Connection URL"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; } { label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus → Scan Node Config\n3. Scan the QR code above or paste the Connection URL"; } ]; } + { name = "Sparrow Auto-Connect"; unit = "sparrow-autoconnect.service"; type = "system"; icon = "sparrow"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ + { label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; } + { label = "Status"; value = "Auto-configured on first boot"; } + ]; } + { name = "Bisq Auto-Connect"; unit = "bisq-autoconnect.service"; type = "system"; icon = "bisq"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ + { label = "Node"; value = "127.0.0.1:8333 (Bitcoin Core)"; } + { label = "Status"; value = "Auto-configured on first boot"; } + ]; } { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [ { label = "Tor Access"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; } { label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; } diff --git a/modules/modules.nix b/modules/modules.nix index cf9e7de..a8eb10f 100755 --- a/modules/modules.nix +++ b/modules/modules.nix @@ -24,6 +24,7 @@ ./nextcloud.nix ./vaultwarden.nix ./bitcoinecosystem.nix + ./wallet-autoconnect.nix # ── Features (default OFF — enable in custom.nix) ───────── ./haven.nix diff --git a/modules/wallet-autoconnect.nix b/modules/wallet-autoconnect.nix new file mode 100644 index 0000000..688e9c0 --- /dev/null +++ b/modules/wallet-autoconnect.nix @@ -0,0 +1,83 @@ +{ config, pkgs, lib, ... }: + +lib.mkIf config.sovran_systemsOS.services.bitcoin { + + # ── Sparrow Wallet Auto-Connect ───────────────────────────── + systemd.services.sparrow-autoconnect = { + description = "Auto-configure Sparrow Wallet to use local Electrs node"; + after = [ "electrs.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ pkgs.coreutils pkgs.iproute2 ]; + script = '' + CONFIG_FILE="/home/free/.sparrow/config" + + if [ -f "$CONFIG_FILE" ]; then + echo "Sparrow config already exists, skipping" + exit 0 + fi + + # Wait for Electrs to be ready (up to 30 attempts) + ATTEMPTS=0 + until ss -ltn 2>/dev/null | grep -q ':50001' || [ "$ATTEMPTS" -ge 30 ]; do + ATTEMPTS=$((ATTEMPTS + 1)) + sleep 2 + done + + mkdir -p /home/free/.sparrow + + cat > "$CONFIG_FILE" << 'EOF' +{ + "serverType": "ELECTRUM_SERVER", + "electrumServer": "tcp://127.0.0.1:50001", + "useProxy": false +} +EOF + + chown -R free:users /home/free/.sparrow + echo "Sparrow auto-configured to use local Electrs node" + ''; + }; + + # ── Bisq 1 Auto-Connect ───────────────────────────────────── + systemd.services.bisq-autoconnect = { + description = "Auto-configure Bisq to use local Bitcoin node"; + after = [ "bitcoind.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ pkgs.coreutils pkgs.iproute2 ]; + script = '' + BISQ_CONF="/home/free/.local/share/Bisq/bisq.properties" + + if [ -f "$BISQ_CONF" ]; then + echo "Bisq config already exists, skipping" + exit 0 + fi + + # Wait for bitcoind RPC to be ready (up to 30 attempts) + ATTEMPTS=0 + until ss -ltn 2>/dev/null | grep -q ':8333' || [ "$ATTEMPTS" -ge 30 ]; do + ATTEMPTS=$((ATTEMPTS + 1)) + sleep 2 + done + + mkdir -p /home/free/.local/share/Bisq + + cat > "$BISQ_CONF" << 'EOF' +btcNodes=127.0.0.1:8333 +useTorForBtc=true +useCustomBtcNodes=true +EOF + + chown -R free:users /home/free/.local/share/Bisq + echo "Bisq auto-configured to use local Bitcoin node" + ''; + }; + +} -- 2.53.0 From 8459061968616fdf500f80d43710ba55a730c7e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:50:23 +0000 Subject: [PATCH 381/857] Initial plan -- 2.53.0 From 24bf72ef692ec08a1385d852a2406e747e06726f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:55:41 +0000 Subject: [PATCH 382/857] feat: add version display for all service tiles on Hub dashboard Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/6b9b51e5-85a6-46ff-8683-120ecf3640da Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 91 ++++++++++++++++++++- app/sovran_systemsos_web/static/js/tiles.js | 22 +++-- 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index e43f557..d3f7237 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1503,6 +1503,80 @@ _btc_version_cache: tuple[float, dict | None] = (0.0, None) _BTC_VERSION_CACHE_TTL = 60 # seconds — version doesn't change at runtime +# ── Generic service version detection ──────────────────────────── + +# Map service unit names to CLI commands that print a version string. +# Only include services where a reliable --version flag exists. +_SERVICE_VERSION_COMMANDS: dict[str, list[str]] = { + "electrs.service": ["electrs", "--version"], + "lnd.service": ["lnd", "--version"], + "caddy.service": ["caddy", "version"], + "tor.service": ["tor", "--version"], + "livekit.service": ["livekit-server", "--version"], + "vaultwarden.service": ["vaultwarden", "--version"], + "btcpayserver.service": ["btcpay-server", "--version"], + "matrix-synapse.service": ["python3", "-c", "import synapse; print(synapse.__version__)"], + "gnome-remote-desktop.service": ["grdctl", "--version"], +} + +# Cache: unit → (monotonic_timestamp, version_str | None) +_svc_version_cache: dict[str, tuple[float, str | None]] = {} +_SVC_VERSION_CACHE_TTL = 300 # 5 minutes — versions only change on system update + + +def _parse_version_from_output(output: str) -> str | None: + """Extract the first semver-like version number from command output. + + Handles patterns such as: + 'electrs 0.10.5' + 'lnd version 0.18.4-beta commit=v0.18.4-beta' + 'Tor version 0.4.8.12.' + 'v2.7.6 h1:...' + Returns a string starting with 'v', e.g. 'v0.10.5', or None. + """ + m = re.search(r"v?(\d+\.\d+(?:\.\d+(?:\.\d+)?)?(?:[+-][a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*)?)", output) + if m: + ver = m.group(0) + if not ver.startswith("v"): + ver = "v" + ver + return ver + return None + + +def _get_service_version(unit: str) -> str | None: + """Return a version string for *unit*, using a CLI command when available. + + Results are cached for _SVC_VERSION_CACHE_TTL seconds so that repeated + /api/services polls don't re-exec binaries on every request. Returns + None if no version command is configured or if the command fails. + """ + now = time.monotonic() + cached = _svc_version_cache.get(unit) + if cached is not None: + cached_at, cached_val = cached + if now - cached_at < _SVC_VERSION_CACHE_TTL: + return cached_val + + version: str | None = None + cmd = _SERVICE_VERSION_COMMANDS.get(unit) + if cmd: + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=5, + ) + output_raw = result.stdout.strip() or result.stderr.strip() + output = output_raw.splitlines()[0] if output_raw else "" + version = _parse_version_from_output(output) + except Exception: + pass + + _svc_version_cache[unit] = (now, version) + return version + + def _parse_bitcoin_subversion(subversion: str) -> str: """Parse a subversion string like '/Bitcoin Knots:27.1.0/' into 'v27.1.0'. @@ -1737,7 +1811,13 @@ async def api_services(): ver_info = await loop.run_in_executor(None, _get_bitcoin_version_info) if ver_info is not None: subversion = ver_info.get("subversion", "") - service_data["bitcoin_version"] = _parse_bitcoin_subversion(subversion) + btc_ver = _parse_bitcoin_subversion(subversion) + service_data["bitcoin_version"] = btc_ver # backwards compat + service_data["version"] = btc_ver + else: + svc_ver = await loop.run_in_executor(None, _get_service_version, unit) + if svc_ver is not None: + service_data["version"] = svc_ver return service_data results = await asyncio.gather(*[get_status(s) for s in services]) @@ -2015,7 +2095,14 @@ async def api_service_detail(unit: str, icon: str | None = None): ver_info = await loop.run_in_executor(None, _get_bitcoin_version_info) if ver_info is not None: subversion = ver_info.get("subversion", "") - service_detail["bitcoin_version"] = _parse_bitcoin_subversion(subversion) + btc_ver = _parse_bitcoin_subversion(subversion) + service_detail["bitcoin_version"] = btc_ver # backwards compat + service_detail["version"] = btc_ver + else: + loop = asyncio.get_event_loop() + svc_ver = await loop.run_in_executor(None, _get_service_version, unit) + if svc_ver is not None: + service_detail["version"] = svc_ver return service_detail diff --git a/app/sovran_systemsos_web/static/js/tiles.js b/app/sovran_systemsos_web/static/js/tiles.js index cea77b0..96034ce 100644 --- a/app/sovran_systemsos_web/static/js/tiles.js +++ b/app/sovran_systemsos_web/static/js/tiles.js @@ -131,7 +131,8 @@ function buildTile(svc) { var pct = Math.round((svc.sync_progress || 0) * 100); var id = tileId(svc); var eta = _calcBtcEta(id, svc.sync_progress || 0); - var versionLabel = svc.bitcoin_version ? '
    ' + escHtml(svc.bitcoin_version) + '
    ' : ''; + var ver = svc.version || svc.bitcoin_version || ''; + var versionLabel = ver ? '
    ' + escHtml(ver) + '
    ' : ''; tile.innerHTML = '' + escHtml(svc.name) + '' + '' + @@ -152,7 +153,8 @@ function buildTile(svc) { return tile; } - var versionLabel = svc.bitcoin_version ? '
    ' + escHtml(svc.bitcoin_version) + '
    ' : ''; + var ver = svc.version || svc.bitcoin_version || ''; + var versionLabel = ver ? '
    ' + escHtml(ver) + '
    ' : ''; tile.innerHTML = '' + escHtml(svc.name) + '
    ' + escHtml(svc.name) + '
    ' + versionLabel + '
    ' + st + '
    '; tile.style.cursor = "pointer"; @@ -208,16 +210,17 @@ function updateTiles(services) { if (pctEl) pctEl.textContent = pct + "%"; if (etaEl) etaEl.textContent = etaText; // Update or insert version label - if (svc.bitcoin_version) { + var syncVer = svc.version || svc.bitcoin_version || ''; + if (syncVer) { var syncVerEl = tile.querySelector(".tile-version"); if (syncVerEl) { - syncVerEl.textContent = svc.bitcoin_version; + syncVerEl.textContent = syncVer; } else { var syncNameEl = tile.querySelector(".tile-name"); if (syncNameEl) { var newSyncVerEl = document.createElement("div"); newSyncVerEl.className = "tile-version"; - newSyncVerEl.textContent = svc.bitcoin_version; + newSyncVerEl.textContent = syncVer; syncNameEl.insertAdjacentElement("afterend", newSyncVerEl); } } @@ -236,17 +239,18 @@ function updateTiles(services) { var text = tile.querySelector(".status-text"); if (dot) dot.className = "status-dot " + sc; if (text) text.textContent = st; - // Update or insert version label for bitcoind tiles - if (svc.bitcoin_version) { + // Update or insert version label for all service tiles + var tileVer = svc.version || svc.bitcoin_version || ''; + if (tileVer) { var verEl = tile.querySelector(".tile-version"); if (verEl) { - verEl.textContent = svc.bitcoin_version; + verEl.textContent = tileVer; } else { var nameEl = tile.querySelector(".tile-name"); if (nameEl) { var newVerEl = document.createElement("div"); newVerEl.className = "tile-version"; - newVerEl.textContent = svc.bitcoin_version; + newVerEl.textContent = tileVer; nameEl.insertAdjacentElement("afterend", newVerEl); } } -- 2.53.0 From 4edcf066ca20404970ee8b2514d4fe61daef11ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:01:07 +0000 Subject: [PATCH 383/857] Initial plan -- 2.53.0 From 09a817f02d945b4180a16b8521edf4c64f07000e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:02:19 +0000 Subject: [PATCH 384/857] feat: clean up /mnt/etc/nixos after nixos-install, keep only 5 required files Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/08d1a4eb-697e-46d4-bb8e-71af6bb4316f Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/installer.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/iso/installer.py b/iso/installer.py index 969cc3f..8cdaf58 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -917,6 +917,19 @@ class InstallerWindow(Adw.ApplicationWindow): "--impure" ], buf) + # Clean up /mnt/etc/nixos — only 5 files are needed post-install. + # configuration.nix and modules/ were needed during nixos-install + # for flake evaluation, but are now baked into the Nix store via + # self.nixosModules.Sovran_SystemsOS. + GLib.idle_add(append_text, buf, "Cleaning up /mnt/etc/nixos...\n") + keep = {"flake.nix", "flake.lock", "hardware-configuration.nix", + "role-state.nix", "custom.nix"} + nixos_dir = "/mnt/etc/nixos" + for entry in os.listdir(nixos_dir): + if entry not in keep: + path = os.path.join(nixos_dir, entry) + run(["sudo", "rm", "-rf", path]) + GLib.idle_add(self.push_complete) # ── Step 6: Complete ─────────────────────────────────────────────────── -- 2.53.0 From 8a49a3d04ebed3c7b0d60e9ac3ffc87abd61a4b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:03:49 +0000 Subject: [PATCH 385/857] Initial plan -- 2.53.0 From deb66c9cb7818a91ae3dc07a7ee67c5b04b69cd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:06:13 +0000 Subject: [PATCH 386/857] Replace CLI-based version detection with Nix store path extraction via systemctl show Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d75fe7da-369a-40e9-913e-7dba45de21c3 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 82 ++++++++++++------------------ 1 file changed, 32 insertions(+), 50 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index d3f7237..1495f12 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1503,52 +1503,32 @@ _btc_version_cache: tuple[float, dict | None] = (0.0, None) _BTC_VERSION_CACHE_TTL = 60 # seconds — version doesn't change at runtime -# ── Generic service version detection ──────────────────────────── +# ── Generic service version detection (NixOS store path) ───────── -# Map service unit names to CLI commands that print a version string. -# Only include services where a reliable --version flag exists. -_SERVICE_VERSION_COMMANDS: dict[str, list[str]] = { - "electrs.service": ["electrs", "--version"], - "lnd.service": ["lnd", "--version"], - "caddy.service": ["caddy", "version"], - "tor.service": ["tor", "--version"], - "livekit.service": ["livekit-server", "--version"], - "vaultwarden.service": ["vaultwarden", "--version"], - "btcpayserver.service": ["btcpay-server", "--version"], - "matrix-synapse.service": ["python3", "-c", "import synapse; print(synapse.__version__)"], - "gnome-remote-desktop.service": ["grdctl", "--version"], -} +# Regex to extract the version from a Nix store ExecStart path. +# Pattern: /nix/store/<32-char-hash>--/... +# The name segments consist of alphabetic-starting words separated by hyphens. +# The version is the first hyphen-delimited token that starts with a digit. +_NIX_STORE_VERSION_RE = re.compile( + r"/nix/store/[a-z0-9]{32}-" # hash prefix + r"(?:[a-zA-Z][a-zA-Z0-9_]*(?:-[a-zA-Z][a-zA-Z0-9_]*)*)+" # package name + r"-(\d+\.\d+[a-zA-Z0-9._+-]*)/" # version (group 1) +) # Cache: unit → (monotonic_timestamp, version_str | None) _svc_version_cache: dict[str, tuple[float, str | None]] = {} _SVC_VERSION_CACHE_TTL = 300 # 5 minutes — versions only change on system update -def _parse_version_from_output(output: str) -> str | None: - """Extract the first semver-like version number from command output. - - Handles patterns such as: - 'electrs 0.10.5' - 'lnd version 0.18.4-beta commit=v0.18.4-beta' - 'Tor version 0.4.8.12.' - 'v2.7.6 h1:...' - Returns a string starting with 'v', e.g. 'v0.10.5', or None. - """ - m = re.search(r"v?(\d+\.\d+(?:\.\d+(?:\.\d+)?)?(?:[+-][a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*)?)", output) - if m: - ver = m.group(0) - if not ver.startswith("v"): - ver = "v" + ver - return ver - return None - - def _get_service_version(unit: str) -> str | None: - """Return a version string for *unit*, using a CLI command when available. + """Extract the version of a service from its Nix store ExecStart path. + + Runs ``systemctl show --property=ExecStart --value`` and parses + the Nix store path embedded in the output to obtain the package version. Results are cached for _SVC_VERSION_CACHE_TTL seconds so that repeated - /api/services polls don't re-exec binaries on every request. Returns - None if no version command is configured or if the command fails. + /api/services polls don't spawn extra processes on every request. + Returns None if the version cannot be determined. """ now = time.monotonic() cached = _svc_version_cache.get(unit) @@ -1558,20 +1538,22 @@ def _get_service_version(unit: str) -> str | None: return cached_val version: str | None = None - cmd = _SERVICE_VERSION_COMMANDS.get(unit) - if cmd: - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=5, - ) - output_raw = result.stdout.strip() or result.stderr.strip() - output = output_raw.splitlines()[0] if output_raw else "" - version = _parse_version_from_output(output) - except Exception: - pass + try: + result = subprocess.run( + ["systemctl", "show", unit, "--property=ExecStart", "--value"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + m = _NIX_STORE_VERSION_RE.search(result.stdout) + if m: + ver = m.group(1).rstrip(".") + # Skip Nix environment/wrapper suffixes that are not real versions + if not re.search(r"-(?:env|wrapper|wrapped|script|hook|setup|compat)$", ver): + version = ver if ver.startswith("v") else f"v{ver}" + except Exception: + pass _svc_version_cache[unit] = (now, version) return version -- 2.53.0 From 8240b9af3ccf423437cea6c500876a72e9a28383 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:08:23 +0000 Subject: [PATCH 387/857] Address review feedback: module-level wrapper suffix regex, allow digit-starting name segments Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d75fe7da-369a-40e9-913e-7dba45de21c3 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 1495f12..ea57798 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1507,12 +1507,18 @@ _BTC_VERSION_CACHE_TTL = 60 # seconds — version doesn't change at runtime # Regex to extract the version from a Nix store ExecStart path. # Pattern: /nix/store/<32-char-hash>--/... -# The name segments consist of alphabetic-starting words separated by hyphens. -# The version is the first hyphen-delimited token that starts with a digit. +# Name segments may begin with a letter or digit (e.g. 'python3', 'gtk3', +# 'lib32-foo') so each segment allows [a-zA-Z0-9] as the leading character. +# The version is identified as the first token starting with digit.digit. _NIX_STORE_VERSION_RE = re.compile( - r"/nix/store/[a-z0-9]{32}-" # hash prefix - r"(?:[a-zA-Z][a-zA-Z0-9_]*(?:-[a-zA-Z][a-zA-Z0-9_]*)*)+" # package name - r"-(\d+\.\d+[a-zA-Z0-9._+-]*)/" # version (group 1) + r"/nix/store/[a-z0-9]{32}-" # hash prefix + r"(?:[a-zA-Z0-9][a-zA-Z0-9_]*(?:-[a-zA-Z0-9][a-zA-Z0-9_]*)*)+" # package name + r"-(\d+\.\d+[a-zA-Z0-9._+-]*)/" # version (group 1) +) + +# Nix path suffixes that indicate a wrapper environment, not a real package version. +_NIX_WRAPPER_SUFFIX_RE = re.compile( + r"-(?:env|wrapper|wrapped|script|hook|setup|compat)$" ) # Cache: unit → (monotonic_timestamp, version_str | None) @@ -1550,7 +1556,7 @@ def _get_service_version(unit: str) -> str | None: if m: ver = m.group(1).rstrip(".") # Skip Nix environment/wrapper suffixes that are not real versions - if not re.search(r"-(?:env|wrapper|wrapped|script|hook|setup|compat)$", ver): + if not _NIX_WRAPPER_SUFFIX_RE.search(ver): version = ver if ver.startswith("v") else f"v{ver}" except Exception: pass -- 2.53.0 From 185ed4e3d823a5fae66deb95023ec188fe715ea1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:10:15 +0000 Subject: [PATCH 388/857] Further tighten regex: stricter version pattern, no underscores in name segments, precise trailing-dot strip Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d75fe7da-369a-40e9-913e-7dba45de21c3 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index ea57798..bb72721 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1508,12 +1508,13 @@ _BTC_VERSION_CACHE_TTL = 60 # seconds — version doesn't change at runtime # Regex to extract the version from a Nix store ExecStart path. # Pattern: /nix/store/<32-char-hash>--/... # Name segments may begin with a letter or digit (e.g. 'python3', 'gtk3', -# 'lib32-foo') so each segment allows [a-zA-Z0-9] as the leading character. +# 'lib32-foo') and consist of alphanumeric characters only (no underscores, +# since Nix store paths use hyphens as separators). # The version is identified as the first token starting with digit.digit. _NIX_STORE_VERSION_RE = re.compile( - r"/nix/store/[a-z0-9]{32}-" # hash prefix - r"(?:[a-zA-Z0-9][a-zA-Z0-9_]*(?:-[a-zA-Z0-9][a-zA-Z0-9_]*)*)+" # package name - r"-(\d+\.\d+[a-zA-Z0-9._+-]*)/" # version (group 1) + r"/nix/store/[a-z0-9]{32}-" # hash prefix + r"(?:[a-zA-Z0-9][a-zA-Z0-9]*(?:-[a-zA-Z0-9][a-zA-Z0-9]*)*)+" # package name + r"-(\d+\.\d+(?:\.\d+)*(?:[+-][a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*)?)/" # version (group 1) ) # Nix path suffixes that indicate a wrapper environment, not a real package version. @@ -1554,7 +1555,9 @@ def _get_service_version(unit: str) -> str | None: if result.returncode == 0 and result.stdout.strip(): m = _NIX_STORE_VERSION_RE.search(result.stdout) if m: - ver = m.group(1).rstrip(".") + ver = m.group(1) + # Strip a single trailing period (defensive; shouldn't appear in store paths) + ver = ver[:-1] if ver.endswith(".") else ver # Skip Nix environment/wrapper suffixes that are not real versions if not _NIX_WRAPPER_SUFFIX_RE.search(ver): version = ver if ver.startswith("v") else f"v{ver}" -- 2.53.0 From 1a48266cde9cb70630900174b7c9eeda96b0aa2d Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Mon, 6 Apr 2026 21:13:22 -0500 Subject: [PATCH 389/857] updated custom-template --- custom.template.nix | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/custom.template.nix b/custom.template.nix index 2c7760a..81b0016 100644 --- a/custom.template.nix +++ b/custom.template.nix @@ -7,7 +7,8 @@ # # # Services, features, and roles are managed by the # # Sovran Hub. Any changes you make through the Hub # - # will appear in the "Hub Managed" section below. # + # will appear in a Hub Managed section added # + # automatically below. # # # # If you want to add your own NixOS modules or # # configuration, place them here — outside of the # @@ -21,4 +22,4 @@ # ─── Add your custom NixOS configuration below ─────────── -} +} \ No newline at end of file -- 2.53.0 From 6fefed2909356d8a43cfecec090723555a86632f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:25:28 +0000 Subject: [PATCH 390/857] Initial plan -- 2.53.0 From 4978d44ba23aafbe101f0f95bdec2fcca01e1781 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:26:16 +0000 Subject: [PATCH 391/857] Add bitcoind to sovran-hub-web PATH so Bitcoin version shows on Hub tiles Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/b8aaba8d-2c51-40ca-9826-69b78060a840 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- modules/core/sovran-hub.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index a903f0d..2442871 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -322,7 +322,7 @@ in StandardError = "journal"; }; - path = [ pkgs.qrencode ]; + path = [ pkgs.qrencode ] ++ lib.optional cfg.services.bitcoin config.services.bitcoind.package; }; systemd.services.sovran-hub-update = { -- 2.53.0 From f6c9080cea06c30aa7e4fb88f0e65ee3c574e937 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:33:12 +0000 Subject: [PATCH 392/857] Initial plan -- 2.53.0 From 90ffadf2ea5933fc205f30a37e157b81776ca3be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:35:32 +0000 Subject: [PATCH 393/857] Fix Bitcoin tile version: preserve bip110 tag, only show version on enabled tile Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/05d1b130-dd46-4132-8120-2df883325c2a Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index bb72721..00a257a 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1572,14 +1572,17 @@ def _parse_bitcoin_subversion(subversion: str) -> str: """Parse a subversion string like '/Bitcoin Knots:27.1.0/' into 'v27.1.0'. Examples: - '/Bitcoin Knots:27.1.0/' → 'v27.1.0' - '/Satoshi:27.0.0/' → 'v27.0.0' - '/Bitcoin Knots:27.1.0(bip110)/' → 'v27.1.0' + '/Bitcoin Knots:27.1.0/' → 'v27.1.0' + '/Satoshi:27.0.0/' → 'v27.0.0' + '/Bitcoin Knots:27.1.0(bip110)/' → 'v27.1.0 (bip110)' Falls back to the raw subversion string if parsing fails. """ m = re.search(r":(\d+\.\d+(?:\.\d+)*)", subversion) if m: - return "v" + m.group(1) + ver = "v" + m.group(1) + if "(bip110)" in subversion.lower(): + ver += " (bip110)" + return ver return subversion @@ -1798,14 +1801,14 @@ async def api_services(): service_data["sync_progress"] = sync_progress service_data["sync_blocks"] = sync_blocks service_data["sync_headers"] = sync_headers - if unit == "bitcoind.service": + if unit == "bitcoind.service" and enabled: ver_info = await loop.run_in_executor(None, _get_bitcoin_version_info) if ver_info is not None: subversion = ver_info.get("subversion", "") btc_ver = _parse_bitcoin_subversion(subversion) service_data["bitcoin_version"] = btc_ver # backwards compat service_data["version"] = btc_ver - else: + elif unit != "bitcoind.service": svc_ver = await loop.run_in_executor(None, _get_service_version, unit) if svc_ver is not None: service_data["version"] = svc_ver @@ -2081,7 +2084,7 @@ async def api_service_detail(unit: str, icon: str | None = None): service_detail["sync_progress"] = sync_progress service_detail["sync_blocks"] = sync_blocks service_detail["sync_headers"] = sync_headers - if unit == "bitcoind.service": + if unit == "bitcoind.service" and enabled: loop = asyncio.get_event_loop() ver_info = await loop.run_in_executor(None, _get_bitcoin_version_info) if ver_info is not None: @@ -2089,7 +2092,7 @@ async def api_service_detail(unit: str, icon: str | None = None): btc_ver = _parse_bitcoin_subversion(subversion) service_detail["bitcoin_version"] = btc_ver # backwards compat service_detail["version"] = btc_ver - else: + elif unit != "bitcoind.service": loop = asyncio.get_event_loop() svc_ver = await loop.run_in_executor(None, _get_service_version, unit) if svc_ver is not None: -- 2.53.0 From 1737e93c68ec6beb2097e3d8969db3df4dbed8c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:47:51 +0000 Subject: [PATCH 394/857] Initial plan -- 2.53.0 From 28bcddb957157b4959e628cfdc7a6fe80b8ecfeb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:50:02 +0000 Subject: [PATCH 395/857] Fix BIP110 version display: detect by tile icon, not subversion string Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/23090422-e59c-4d7e-8d5e-6fd36b6cf337 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 00a257a..6b34099 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1806,6 +1806,8 @@ async def api_services(): if ver_info is not None: subversion = ver_info.get("subversion", "") btc_ver = _parse_bitcoin_subversion(subversion) + if icon == "bip110" and "(bip110)" not in btc_ver.lower(): + btc_ver += " (bip110)" service_data["bitcoin_version"] = btc_ver # backwards compat service_data["version"] = btc_ver elif unit != "bitcoind.service": @@ -2090,6 +2092,8 @@ async def api_service_detail(unit: str, icon: str | None = None): if ver_info is not None: subversion = ver_info.get("subversion", "") btc_ver = _parse_bitcoin_subversion(subversion) + if icon == "bip110" and "(bip110)" not in btc_ver.lower(): + btc_ver += " (bip110)" service_detail["bitcoin_version"] = btc_ver # backwards compat service_detail["version"] = btc_ver elif unit != "bitcoind.service": -- 2.53.0 From d60a44b4386dadb8b089a2794b30eb8b262bb83a Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Mon, 6 Apr 2026 21:54:47 -0500 Subject: [PATCH 396/857] updated hub-icon --- .../static/logo-light.svg | 114 +----------------- 1 file changed, 1 insertion(+), 113 deletions(-) diff --git a/app/sovran_systemsos_web/static/logo-light.svg b/app/sovran_systemsos_web/static/logo-light.svg index 6f6d9cb..be9dc54 100644 --- a/app/sovran_systemsos_web/static/logo-light.svg +++ b/app/sovran_systemsos_web/static/logo-light.svg @@ -1,113 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file -- 2.53.0 From 87ecccff9e996e171a989fc7cd0727a3f4616bf2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:58:50 +0000 Subject: [PATCH 397/857] Initial plan -- 2.53.0 From 2e5be9816eec74129b49be529f6bd3c152eac987 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 03:06:12 +0000 Subject: [PATCH 398/857] Add missing sparrow.svg and bisq.svg service tile icons Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d7bbfadc-d5a8-4750-81b4-685ccb993d70 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/icons/bisq.svg | 8 ++++++++ app/icons/sparrow.svg | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 app/icons/bisq.svg create mode 100644 app/icons/sparrow.svg diff --git a/app/icons/bisq.svg b/app/icons/bisq.svg new file mode 100644 index 0000000..bac7acf --- /dev/null +++ b/app/icons/bisq.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/icons/sparrow.svg b/app/icons/sparrow.svg new file mode 100644 index 0000000..caf0406 --- /dev/null +++ b/app/icons/sparrow.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + -- 2.53.0 From d0bf8785558614d268c79d51c9e12262817e323c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 04:18:20 +0000 Subject: [PATCH 399/857] Initial plan -- 2.53.0 From 44a7b2a8abfcfb128d83cffd5652677b825a5ad5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 04:22:19 +0000 Subject: [PATCH 400/857] fix: use bitcoind --version for Bitcoin tile version display (works during IBD/startup) Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/63b5dc59-a630-4c14-a6a7-99a71ee517b7 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 83 +++++++++++++++++++++++------- 1 file changed, 65 insertions(+), 18 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 6b34099..2958fbc 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1502,6 +1502,9 @@ _BTC_SYNC_CACHE_TTL = 5 # seconds _btc_version_cache: tuple[float, dict | None] = (0.0, None) _BTC_VERSION_CACHE_TTL = 60 # seconds — version doesn't change at runtime +# Cache for ``bitcoind --version`` output (available even before RPC is ready) +_btcd_version_cache: tuple[float, str | None] = (0.0, None) + # ── Generic service version detection (NixOS store path) ───────── @@ -1616,6 +1619,57 @@ def _get_bitcoin_version_info() -> dict | None: return None +def _get_bitcoind_version() -> str | None: + """Run ``bitcoind --version`` and return the raw version string, or None on error. + + Parses the first output line to extract the token after "version ". + For example: "Bitcoin Knots daemon version v29.3.knots20260210+bip110-v0.4.1" + returns "v29.3.knots20260210+bip110-v0.4.1". + + Works regardless of whether the RPC server is ready (IBD, warmup, etc.). + Results are cached for 60 seconds (_BTC_VERSION_CACHE_TTL). + """ + global _btcd_version_cache + now = time.monotonic() + cached_at, cached_val = _btcd_version_cache + if now - cached_at < _BTC_VERSION_CACHE_TTL: + return cached_val + + try: + result = subprocess.run( + ["bitcoind", "--version"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + first_line = result.stdout.splitlines()[0] + m = re.search(r"version\s+(v?\S+)", first_line, re.IGNORECASE) + if m: + ver = m.group(1) + _btcd_version_cache = (now, ver) + return ver + except Exception: + pass + + _btcd_version_cache = (now, None) + return None + + +def _format_bitcoin_version(raw_version: str, icon: str = "") -> str: + """Format a raw version string from ``bitcoind --version`` for tile display. + + Strips the ``+bip110-vX.Y.Z`` patch suffix so the base version is shown + cleanly (e.g. "v29.3.knots20260210+bip110-v0.4.1" → "v29.3.knots20260210"). + For the BIP110 tile (icon == "bip110") a " (bip110)" tag is appended. + """ + # Remove the +bip110... patch suffix that appears in BIP-110 builds + display = re.sub(r"\+bip110\S*", "", raw_version) + if icon == "bip110" and "(bip110)" not in display.lower(): + display += " (bip110)" + return display + + def _get_bitcoin_sync_info() -> dict | None: """Call bitcoin-cli getblockchaininfo and return parsed JSON, or None on error. @@ -1668,16 +1722,15 @@ async def api_bitcoin_sync(): async def api_bitcoin_version(): """Return the version string of the active bitcoind implementation.""" loop = asyncio.get_event_loop() - info = await loop.run_in_executor(None, _get_bitcoin_version_info) - if info is None: + raw_ver = await loop.run_in_executor(None, _get_bitcoind_version) + if raw_ver is None: return JSONResponse( status_code=503, - content={"error": "bitcoin-cli unavailable or bitcoind not running"}, + content={"error": "bitcoind --version failed or bitcoind not on PATH"}, ) - subversion = info.get("subversion", "") return { - "version": _parse_bitcoin_subversion(subversion), - "subversion": subversion, + "version": _format_bitcoin_version(raw_ver), + "raw_version": raw_ver, } @@ -1802,12 +1855,9 @@ async def api_services(): service_data["sync_blocks"] = sync_blocks service_data["sync_headers"] = sync_headers if unit == "bitcoind.service" and enabled: - ver_info = await loop.run_in_executor(None, _get_bitcoin_version_info) - if ver_info is not None: - subversion = ver_info.get("subversion", "") - btc_ver = _parse_bitcoin_subversion(subversion) - if icon == "bip110" and "(bip110)" not in btc_ver.lower(): - btc_ver += " (bip110)" + raw_ver = await loop.run_in_executor(None, _get_bitcoind_version) + if raw_ver is not None: + btc_ver = _format_bitcoin_version(raw_ver, icon=icon) service_data["bitcoin_version"] = btc_ver # backwards compat service_data["version"] = btc_ver elif unit != "bitcoind.service": @@ -2088,12 +2138,9 @@ async def api_service_detail(unit: str, icon: str | None = None): service_detail["sync_headers"] = sync_headers if unit == "bitcoind.service" and enabled: loop = asyncio.get_event_loop() - ver_info = await loop.run_in_executor(None, _get_bitcoin_version_info) - if ver_info is not None: - subversion = ver_info.get("subversion", "") - btc_ver = _parse_bitcoin_subversion(subversion) - if icon == "bip110" and "(bip110)" not in btc_ver.lower(): - btc_ver += " (bip110)" + raw_ver = await loop.run_in_executor(None, _get_bitcoind_version) + if raw_ver is not None: + btc_ver = _format_bitcoin_version(raw_ver, icon=icon) service_detail["bitcoin_version"] = btc_ver # backwards compat service_detail["version"] = btc_ver elif unit != "bitcoind.service": -- 2.53.0 From 9ea4fb32f4e02451210b01afa6da3e88ac64c8ec Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Mon, 6 Apr 2026 23:27:38 -0500 Subject: [PATCH 401/857] updated sparrow and bisq icon --- app/icons/bisq.svg | 15 +++-- app/icons/sparrow.svg | 131 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 123 insertions(+), 23 deletions(-) diff --git a/app/icons/bisq.svg b/app/icons/bisq.svg index bac7acf..d2d7191 100644 --- a/app/icons/bisq.svg +++ b/app/icons/bisq.svg @@ -1,8 +1,7 @@ - - - - - - - - + + + + + \ No newline at end of file diff --git a/app/icons/sparrow.svg b/app/icons/sparrow.svg index caf0406..b2522d4 100644 --- a/app/icons/sparrow.svg +++ b/app/icons/sparrow.svg @@ -1,16 +1,117 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -- 2.53.0 From 5349c2408aa79f097c152d67833966a99dad629f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 04:33:21 +0000 Subject: [PATCH 402/857] Initial plan -- 2.53.0 From 5a77a03ac03371e06773221c7fdc282d15108b23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 04:36:05 +0000 Subject: [PATCH 403/857] Initial plan -- 2.53.0 From 5ee0ef4d584b8bfa64675db445c4a13f3f132222 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 04:38:42 +0000 Subject: [PATCH 404/857] Remove non-Bitcoin version detection; only bitcoind.service tiles show versions Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/75ae6f5b-ccf1-4051-b9ae-e07c9218227d Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 2958fbc..015c5e5 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1860,10 +1860,6 @@ async def api_services(): btc_ver = _format_bitcoin_version(raw_ver, icon=icon) service_data["bitcoin_version"] = btc_ver # backwards compat service_data["version"] = btc_ver - elif unit != "bitcoind.service": - svc_ver = await loop.run_in_executor(None, _get_service_version, unit) - if svc_ver is not None: - service_data["version"] = svc_ver return service_data results = await asyncio.gather(*[get_status(s) for s in services]) @@ -2143,11 +2139,6 @@ async def api_service_detail(unit: str, icon: str | None = None): btc_ver = _format_bitcoin_version(raw_ver, icon=icon) service_detail["bitcoin_version"] = btc_ver # backwards compat service_detail["version"] = btc_ver - elif unit != "bitcoind.service": - loop = asyncio.get_event_loop() - svc_ver = await loop.run_in_executor(None, _get_service_version, unit) - if svc_ver is not None: - service_detail["version"] = svc_ver return service_detail -- 2.53.0 From 5ba1a256fec4a2a6a120cd4743923891455c30b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 04:41:37 +0000 Subject: [PATCH 405/857] fix: tighten viewBox and remove transforms on bisq.svg and sparrow.svg icons Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/bed7824c-f988-4a33-a052-4a013aa1110d Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/icons/bisq.svg | 4 ++-- app/icons/sparrow.svg | 8 +------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/app/icons/bisq.svg b/app/icons/bisq.svg index d2d7191..c983c1e 100644 --- a/app/icons/bisq.svg +++ b/app/icons/bisq.svg @@ -1,5 +1,5 @@ - - + + diff --git a/app/icons/sparrow.svg b/app/icons/sparrow.svg index b2522d4..09f1231 100644 --- a/app/icons/sparrow.svg +++ b/app/icons/sparrow.svg @@ -1,8 +1,7 @@ - - + @@ -33,10 +32,6 @@ - - - - @@ -113,5 +108,4 @@ - -- 2.53.0 From a7d40fb13863c69cb7f283fdf17cc84d3f2eb656 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 7 Apr 2026 04:26:09 -0500 Subject: [PATCH 406/857] updated branding for hub --- .../static/logo-light.svg | 122 +++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) mode change 100644 => 100755 app/sovran_systemsos_web/static/logo-light.svg diff --git a/app/sovran_systemsos_web/static/logo-light.svg b/app/sovran_systemsos_web/static/logo-light.svg old mode 100644 new mode 100755 index be9dc54..61fbd44 --- a/app/sovran_systemsos_web/static/logo-light.svg +++ b/app/sovran_systemsos_web/static/logo-light.svg @@ -1 +1,121 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -- 2.53.0 From b7c1632bb8d1b7a0cd77ec8d5e372d7924b33b7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:53:45 +0000 Subject: [PATCH 407/857] Initial plan -- 2.53.0 From b441515f89d984123059b0bc4cb826b257e0541e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:55:13 +0000 Subject: [PATCH 408/857] Fix _format_bitcoin_version to include BIP110 patch version in tile display Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/951363ee-aa21-479a-9d79-0c3b5f265bf7 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 015c5e5..5f587d7 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1661,12 +1661,24 @@ def _format_bitcoin_version(raw_version: str, icon: str = "") -> str: Strips the ``+bip110-vX.Y.Z`` patch suffix so the base version is shown cleanly (e.g. "v29.3.knots20260210+bip110-v0.4.1" → "v29.3.knots20260210"). - For the BIP110 tile (icon == "bip110") a " (bip110)" tag is appended. + For the BIP110 tile (icon == "bip110") a " (bip110 vX.Y.Z)" tag is appended + including the patch version. """ - # Remove the +bip110... patch suffix that appears in BIP-110 builds + # Extract the BIP110 patch version before stripping the suffix + bip110_ver = "" + bip_match = re.search(r"\+bip110-v(\S+)", raw_version) + if bip_match: + bip110_ver = bip_match.group(1) + + # Strip the +bip110... suffix for the base Knots version display = re.sub(r"\+bip110\S*", "", raw_version) - if icon == "bip110" and "(bip110)" not in display.lower(): - display += " (bip110)" + + # For BIP110 tile, append both the tag and the patch version + if icon == "bip110": + if bip110_ver: + display += f" (bip110 v{bip110_ver})" + elif "(bip110)" not in display.lower(): + display += " (bip110)" return display -- 2.53.0 From 09002cfe22b6ae88d4e702375023ea63cf38702a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:07:15 +0000 Subject: [PATCH 409/857] Initial plan -- 2.53.0 From a3b9608887a591f0e55c646c448e5142e3f111fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:08:30 +0000 Subject: [PATCH 410/857] fix: compact header layout - row direction, 80px logo, reduced padding Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4f63f512-d505-470b-9733-1054281a98d8 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/static/css/header.css | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/sovran_systemsos_web/static/css/header.css b/app/sovran_systemsos_web/static/css/header.css index edee489..f26d8af 100644 --- a/app/sovran_systemsos_web/static/css/header.css +++ b/app/sovran_systemsos_web/static/css/header.css @@ -3,20 +3,22 @@ .header-bar { background-color: var(--surface-color); border-bottom: 1px solid var(--border-color); - padding: 16px 24px; + padding: 8px 24px; display: flex; - flex-direction: column; + flex-direction: row; align-items: center; - gap: 8px; + justify-content: space-between; + gap: 16px; position: sticky; top: 0; z-index: 100; } .header-logo { - height: 140px; + height: 80px; width: auto; display: block; + flex-shrink: 0; } .header-bar .title { -- 2.53.0 From c7487c9763cdf27c026da29f7fb394536b77acd7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:11:52 +0000 Subject: [PATCH 411/857] Initial plan -- 2.53.0 From 8286e00eb39ac4a1616347ae7e02a38d8e457023 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:14:04 +0000 Subject: [PATCH 412/857] feat: create dedicated desktop dock icon and update nix build to use it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add app/sovran_systemsos_web/static/sovran-hub-icon.svg: a new square 256x256 app icon for the GNOME dock/dash. Uses the Sovran brand dark green (#0d3320) rounded-rectangle background, concentric arc rings in brand greens (#1C9954, #077233), and a white bold "S" letterform centered — visible at small sizes on both light and dark panels. - Update modules/core/sovran-hub.nix line 266 to copy the new icon file to the hicolor icon path instead of reusing logo-light.svg. - logo-light.svg is left untouched; it continues to serve the Hub web UI. Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e3f466ae-eee1-4ba8-b93c-00fe04c7054d Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- .../static/sovran-hub-icon.svg | 16 ++++++++++++++++ modules/core/sovran-hub.nix | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 app/sovran_systemsos_web/static/sovran-hub-icon.svg diff --git a/app/sovran_systemsos_web/static/sovran-hub-icon.svg b/app/sovran_systemsos_web/static/sovran-hub-icon.svg new file mode 100644 index 0000000..a19d719 --- /dev/null +++ b/app/sovran_systemsos_web/static/sovran-hub-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + S + diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 2442871..01db940 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -263,7 +263,7 @@ let cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true install -d $out/share/icons/hicolor/scalable/apps - cp sovran_systemsos_web/static/logo-light.svg $out/share/icons/hicolor/scalable/apps/sovran-hub.svg + cp sovran_systemsos_web/static/sovran-hub-icon.svg $out/share/icons/hicolor/scalable/apps/sovran-hub.svg install -d $out/share/applications cat > $out/share/applications/sovran-hub.desktop < Date: Tue, 7 Apr 2026 10:14:47 +0000 Subject: [PATCH 413/857] fix: add SVG title element for accessibility in sovran-hub-icon.svg Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e3f466ae-eee1-4ba8-b93c-00fe04c7054d Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/static/sovran-hub-icon.svg | 1 + 1 file changed, 1 insertion(+) diff --git a/app/sovran_systemsos_web/static/sovran-hub-icon.svg b/app/sovran_systemsos_web/static/sovran-hub-icon.svg index a19d719..60b9875 100644 --- a/app/sovran_systemsos_web/static/sovran-hub-icon.svg +++ b/app/sovran_systemsos_web/static/sovran-hub-icon.svg @@ -1,4 +1,5 @@ + Sovran Hub Application Icon -- 2.53.0 From b21d9bef87d2d2257014d87d6ef3071c3457bbb1 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 7 Apr 2026 05:20:41 -0500 Subject: [PATCH 414/857] updated branding for hub --- .../static/sovran-hub-icon.svg | 69 ++++++++++++++----- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/app/sovran_systemsos_web/static/sovran-hub-icon.svg b/app/sovran_systemsos_web/static/sovran-hub-icon.svg index 60b9875..984fcfd 100644 --- a/app/sovran_systemsos_web/static/sovran-hub-icon.svg +++ b/app/sovran_systemsos_web/static/sovran-hub-icon.svg @@ -1,17 +1,54 @@ - - Sovran Hub Application Icon - - - - - - - - - - - - - - S + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -- 2.53.0 From 85af70e2eed7a1388afdd98e65bc7176b411dd97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:32:41 +0000 Subject: [PATCH 415/857] Initial plan -- 2.53.0 From 01e3e02a62e7099661cc86b795b8d25047f4e3c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:35:35 +0000 Subject: [PATCH 416/857] Add sparrow/bisq tile descriptions, desktop launch API, and frontend launch buttons Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5a3d2f20-4635-442e-82ba-c0b7f4aeb96e Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 48 +++++++++++++++++++ app/sovran_systemsos_web/static/css/tiles.css | 15 ++++++ .../static/js/service-detail.js | 34 +++++++++++++ 3 files changed, 97 insertions(+) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 5f587d7..67c71ff 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -398,6 +398,27 @@ SERVICE_DESCRIPTIONS: dict[str, str] = { "Your system account credentials. These are the keys to your Sovran_SystemsOS machine — " "root access, user accounts, and SSH passphrases. Keep them safe." ), + "sparrow-autoconnect.service": ( + "Sparrow Wallet is a privacy-focused Bitcoin desktop wallet for sending, receiving, " + "and managing your Bitcoin. Sovran_SystemsOS automatically connects it to your local " + "Electrs server on first boot — your address lookups, balances, and transactions " + "never touch a third-party server. Full privacy, zero configuration." + ), + "bisq-autoconnect.service": ( + "Bisq is a decentralized, peer-to-peer Bitcoin exchange — buy and sell Bitcoin " + "with no KYC and no middleman. Sovran_SystemsOS automatically connects it to your " + "local Bitcoin node on first boot, routing all traffic through Tor. Your trades are " + "verified by your own node, keeping you fully sovereign." + ), +} + +SERVICE_DESKTOP_LINKS: dict[str, list[dict[str, str]]] = { + "sparrow-autoconnect.service": [ + {"label": "Open Sparrow Wallet", "desktop_file": "sparrow-desktop.desktop"}, + ], + "bisq-autoconnect.service": [ + {"label": "Open Bisq", "desktop_file": "Bisq.desktop"}, + ], } # ── App setup ──────────────────────────────────────────────────── @@ -2151,9 +2172,36 @@ async def api_service_detail(unit: str, icon: str | None = None): btc_ver = _format_bitcoin_version(raw_ver, icon=icon) service_detail["bitcoin_version"] = btc_ver # backwards compat service_detail["version"] = btc_ver + desktop_links = SERVICE_DESKTOP_LINKS.get(unit, []) + if desktop_links: + service_detail["desktop_links"] = desktop_links return service_detail +@app.post("/api/desktop/launch/{desktop_file}") +async def api_desktop_launch(desktop_file: str): + """Launch a desktop application via gtk-launch on the local GNOME session.""" + import re as _re + if not _re.match(r'^[a-zA-Z0-9_.-]+\.desktop$', desktop_file): + raise HTTPException(status_code=400, detail="Invalid desktop file name") + + try: + env = dict(os.environ) + env["DISPLAY"] = ":0" + result = subprocess.run( + ["gtk-launch", desktop_file], + capture_output=True, text=True, timeout=10, env=env, + ) + if result.returncode != 0: + raise HTTPException(status_code=500, detail=f"Failed to launch: {result.stderr.strip()}") + except FileNotFoundError: + raise HTTPException(status_code=500, detail="gtk-launch not found on this system") + except subprocess.TimeoutExpired: + raise HTTPException(status_code=500, detail="Launch command timed out") + + return {"ok": True, "launched": desktop_file} + + @app.get("/api/network") async def api_network(): loop = asyncio.get_event_loop() diff --git a/app/sovran_systemsos_web/static/css/tiles.css b/app/sovran_systemsos_web/static/css/tiles.css index 7651f02..9acd4c7 100644 --- a/app/sovran_systemsos_web/static/css/tiles.css +++ b/app/sovran_systemsos_web/static/css/tiles.css @@ -343,3 +343,18 @@ color: var(--yellow); font-weight: 600; } + + +/* ── Desktop launch buttons ──────────────────────────────────────── */ + +.svc-detail-launch-row { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.svc-detail-launch-btn { + font-size: 0.85rem; + padding: 8px 18px; + cursor: pointer; +} diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js index 6b0e928..faf869c 100644 --- a/app/sovran_systemsos_web/static/js/service-detail.js +++ b/app/sovran_systemsos_web/static/js/service-detail.js @@ -72,6 +72,21 @@ async function openServiceDetailModal(unit, name, icon) { '
    '; } + // Section: Desktop Launch (only for services with desktop apps) + if (data.desktop_links && data.desktop_links.length > 0) { + var launchBtns = ''; + data.desktop_links.forEach(function(link) { + launchBtns += ''; + }); + html += '
    ' + + '
    Open on Desktop
    ' + + '

    If you are accessing this machine locally on the GNOME desktop, click below to launch the app directly.

    ' + + '
    ' + launchBtns + '
    ' + + '
    '; + } + // Section B: Status // When a feature override is present, use the feature's enabled state so the // modal matches what the dashboard tile shows (feature toggle is authoritative). @@ -312,6 +327,25 @@ async function openServiceDetailModal(unit, name, icon) { $credsBody.innerHTML = html; _attachCopyHandlers($credsBody); + // Desktop launch button handlers + $credsBody.querySelectorAll(".svc-detail-launch-btn").forEach(function(btn) { + btn.addEventListener("click", async function() { + var desktopFile = btn.dataset.desktop; + btn.disabled = true; + btn.textContent = "Launching…"; + try { + await apiFetch("/api/desktop/launch/" + encodeURIComponent(desktopFile), { + method: "POST" + }); + btn.textContent = "āœ“ Launched!"; + setTimeout(function() { btn.textContent = "šŸ–„ļø " + btn.textContent; btn.disabled = false; }, 2000); + } catch (err) { + btn.textContent = "āŒ Failed"; + btn.disabled = false; + } + }); + }); + if (unit === "matrix-synapse.service") { var addBtn = document.getElementById("matrix-add-user-btn"); var changePwBtn = document.getElementById("matrix-change-pw-btn"); -- 2.53.0 From 2fc8b64964193891258e0182bb09137516007ae3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:36:32 +0000 Subject: [PATCH 417/857] Initial plan -- 2.53.0 From 739f6a08da6ce0f7a2768feafe3121776f68b389 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:39:59 +0000 Subject: [PATCH 418/857] Add service icon to modal header in openServiceDetailModal and openCredsModal Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3f26f03c-29fc-4d37-9d53-eebfb8a34c52 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- .../static/css/modals.css | 11 +++++++ .../static/js/service-detail.js | 32 +++++++++++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/app/sovran_systemsos_web/static/css/modals.css b/app/sovran_systemsos_web/static/css/modals.css index f98e956..371aa7b 100644 --- a/app/sovran_systemsos_web/static/css/modals.css +++ b/app/sovran_systemsos_web/static/css/modals.css @@ -147,6 +147,17 @@ button.btn-reboot:hover:not(:disabled) { font-size: 1.15rem; font-weight: 700; flex: 1; + display: flex; + align-items: center; + gap: 10px; +} + +.creds-title-icon { + width: 28px; + height: 28px; + vertical-align: middle; + border-radius: 6px; + flex-shrink: 0; } .creds-close-btn { diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js index faf869c..9ed9eef 100644 --- a/app/sovran_systemsos_web/static/js/service-detail.js +++ b/app/sovran_systemsos_web/static/js/service-detail.js @@ -55,7 +55,20 @@ function _attachCopyHandlers(container) { async function openServiceDetailModal(unit, name, icon) { if (!$credsModal) return; - if ($credsTitle) $credsTitle.textContent = name; + if ($credsTitle) { + $credsTitle.innerHTML = ''; + if (icon) { + var iconImg = document.createElement("img"); + iconImg.className = "creds-title-icon"; + iconImg.src = "/static/icons/" + escHtml(icon) + ".svg"; + iconImg.alt = name; + iconImg.onerror = function() { this.style.display = "none"; }; + $credsTitle.appendChild(iconImg); + } + var nameSpan = document.createElement("span"); + nameSpan.textContent = name; + $credsTitle.appendChild(nameSpan); + } if ($credsBody) $credsBody.innerHTML = '

    Loading…

    '; $credsModal.classList.add("open"); @@ -370,9 +383,22 @@ async function openServiceDetailModal(unit, name, icon) { // ── Credentials info modal ──────────────────────────────────────── -async function openCredsModal(unit, name) { +async function openCredsModal(unit, name, icon) { if (!$credsModal) return; - if ($credsTitle) $credsTitle.textContent = name + " — Connection Info"; + if ($credsTitle) { + $credsTitle.innerHTML = ''; + if (icon) { + var iconImg = document.createElement("img"); + iconImg.className = "creds-title-icon"; + iconImg.src = "/static/icons/" + escHtml(icon) + ".svg"; + iconImg.alt = name; + iconImg.onerror = function() { this.style.display = "none"; }; + $credsTitle.appendChild(iconImg); + } + var nameSpan = document.createElement("span"); + nameSpan.textContent = name + " — Connection Info"; + $credsTitle.appendChild(nameSpan); + } if ($credsBody) $credsBody.innerHTML = '

    Loading…

    '; $credsModal.classList.add("open"); try { -- 2.53.0 From 9dcb45a017e5df6e950bdc4eaa38a900c0b40ac7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:44:22 +0000 Subject: [PATCH 419/857] Initial plan -- 2.53.0 From 6c3bbbf72b067de6a0cfb42a62cb2016bdc3cac8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:47:03 +0000 Subject: [PATCH 420/857] Fix Hub false closed port detection: is_listening alone is sufficient; add nftables package Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/b57cc894-c639-400e-93f0-c1dc5d48870b Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 5 ++++- configuration.nix | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 67c71ff..0721387 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -685,7 +685,10 @@ def _check_port_status( for pt in ports_set ) - if is_listening and is_allowed: + # A process bound to the port is the authoritative signal; firewall + # detection (nft/iptables) is only used as a secondary hint when nothing + # is listening yet. + if is_listening: return "listening" if is_allowed: return "firewall_open" diff --git a/configuration.nix b/configuration.nix index bea699b..ea87f26 100644 --- a/configuration.nix +++ b/configuration.nix @@ -97,6 +97,7 @@ nixpkgs.config.permittedInsecurePackages = [ "jitsi-meet-1.0.8043" ]; environment.systemPackages = with pkgs; [ + nftables git wget fish htop btop gnomeExtensions.transparent-top-bar-adjustable-transparency gnomeExtensions.dash-to-dock -- 2.53.0 From 3668eb28293c4501c5d0907264a1db2a2eb49158 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:02:55 +0000 Subject: [PATCH 421/857] Initial plan -- 2.53.0 From dd8867b52f5061aa63a9ce1c3140691ee27cc277 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:06:35 +0000 Subject: [PATCH 422/857] feat: add sovran_systemsOS.caddy.extraVirtualHosts NixOS option Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e966dd20-b74e-4ec5-b4db-68aa06129162 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- custom.template.nix | 16 ++++++++++++++++ modules/core/caddy.nix | 6 ++++++ modules/core/roles.nix | 9 +++++++++ 3 files changed, 31 insertions(+) diff --git a/custom.template.nix b/custom.template.nix index 81b0016..70ee7f1 100644 --- a/custom.template.nix +++ b/custom.template.nix @@ -22,4 +22,20 @@ # ─── Add your custom NixOS configuration below ─────────── + # ─── Custom Caddy virtual hosts ────────────────────────── + # Uncomment and edit below to add your own Caddy sites: + # + # sovran_systemsOS.caddy.extraVirtualHosts = '' + # mysite.example.com { + # encode gzip zstd + # root * /var/lib/www/mysite + # php_fastcgi unix//run/phpfpm/mypool.sock + # file_server browse + # } + # + # anotherdomain.com { + # reverse_proxy localhost:9090 + # } + # ''; + } \ No newline at end of file diff --git a/modules/core/caddy.nix b/modules/core/caddy.nix index 7f4d811..15b0cba 100755 --- a/modules/core/caddy.nix +++ b/modules/core/caddy.nix @@ -2,6 +2,7 @@ let exposeBtcpay = config.sovran_systemsOS.web.btcpayserver; + extraVhosts = config.sovran_systemsOS.caddy.extraVirtualHosts; in { services.caddy = { @@ -170,6 +171,11 @@ EOF encode gzip zstd } EOF + + # ── Custom vhosts from custom.nix ────────────── + cat >> /run/caddy/Caddyfile <<'CUSTOM_VHOSTS_EOF' +${extraVhosts} +CUSTOM_VHOSTS_EOF ''; }; } diff --git a/modules/core/roles.nix b/modules/core/roles.nix index 2de80d5..95d5a2e 100755 --- a/modules/core/roles.nix +++ b/modules/core/roles.nix @@ -60,6 +60,15 @@ }; }; + # ── Caddy customisation ─────────────────────────────────── + caddy = { + extraVirtualHosts = lib.mkOption { + type = lib.types.lines; + default = ""; + description = "Additional raw Caddyfile blocks appended to the generated Caddy config. Use this in custom.nix to add custom domains and reverse proxies."; + }; + }; + # ── Domain setup registry ───────────────────────────────── domainRequirements = lib.mkOption { type = lib.types.listOf (lib.types.submodule { -- 2.53.0 From d14e25c29fff3595c3b73803d6dafbb93c18189f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:58:07 +0000 Subject: [PATCH 423/857] Initial plan -- 2.53.0 From 7e996fffa1fd582731c491ac847b49b6bc1966e3 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 7 Apr 2026 09:11:13 -0500 Subject: [PATCH 424/857] updated nextcloud.nix --- modules/nextcloud.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/nextcloud.nix b/modules/nextcloud.nix index 6604ad7..05e52d4 100755 --- a/modules/nextcloud.nix +++ b/modules/nextcloud.nix @@ -67,7 +67,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud { RemainAfterExit = true; }; - path = with pkgs; [ curl unzip php pwgen coreutils ]; + path = with pkgs; [ curl unzip php pwgen coreutils shadow ]; script = '' set -euo pipefail -- 2.53.0 From f80c8a0481125e4df163255f4bfb5a2be09ed94e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:23:59 +0000 Subject: [PATCH 425/857] Factory security: per-device SSH passphrase, factory seal, password onboarding, remove PDF generator Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4222f228-615c-4303-8286-979264c6f782 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/installer.py | 104 +++++++++- modules/core/factory-seal.nix | 115 ++++++++++++ modules/core/sovran-hub.nix | 2 +- modules/core/ssh-bootstrap.nix | 23 ++- modules/credentials-pdf.nix | 333 +-------------------------------- modules/modules.nix | 1 + 6 files changed, 244 insertions(+), 334 deletions(-) create mode 100644 modules/core/factory-seal.nix diff --git a/iso/installer.py b/iso/installer.py index 8cdaf58..5aef89d 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -930,7 +930,107 @@ class InstallerWindow(Adw.ApplicationWindow): path = os.path.join(nixos_dir, entry) run(["sudo", "rm", "-rf", path]) - GLib.idle_add(self.push_complete) + GLib.idle_add(self.push_create_password) + + # ── Step 5b: Create Password ────────────────────────────────────────── + + def push_create_password(self): + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + + status = Adw.StatusPage() + status.set_title("Create Your Password") + status.set_description( + "Choose a password for your 'free' user account. " + "This will be your login password." + ) + status.set_vexpand(True) + + form_group = Adw.PreferencesGroup() + form_group.set_margin_start(40) + form_group.set_margin_end(40) + + pw_row = Adw.PasswordEntryRow() + pw_row.set_title("Password") + form_group.add(pw_row) + + confirm_row = Adw.PasswordEntryRow() + confirm_row.set_title("Confirm Password") + form_group.add(confirm_row) + + error_lbl = Gtk.Label() + error_lbl.set_margin_start(40) + error_lbl.set_margin_end(40) + error_lbl.set_margin_top(8) + error_lbl.set_visible(False) + error_lbl.add_css_class("error") + + content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) + content_box.append(status) + content_box.append(form_group) + content_box.append(error_lbl) + outer.append(content_box) + + def on_submit(btn): + password = pw_row.get_text() + confirm = confirm_row.get_text() + + if not password: + error_lbl.set_text("Password cannot be empty.") + error_lbl.set_visible(True) + return + if len(password) < 8: + error_lbl.set_text("Password must be at least 8 characters.") + error_lbl.set_visible(True) + return + if password != confirm: + error_lbl.set_text("Passwords do not match.") + error_lbl.set_visible(True) + return + + btn.set_sensitive(False) + error_lbl.set_visible(False) + + try: + run(["sudo", "mkdir", "-p", "/mnt/var/lib/secrets"]) + proc = subprocess.run( + ["sudo", "tee", "/mnt/var/lib/secrets/free-password"], + input=password, text=True, capture_output=True + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or "Failed to write password file") + run(["sudo", "chmod", "600", "/mnt/var/lib/secrets/free-password"]) + + proc = subprocess.run( + ["sudo", "chroot", "/mnt", "chpasswd"], + input=f"free:{password}", + capture_output=True, text=True + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or "Failed to set password in chroot") + + run(["sudo", "touch", "/mnt/var/lib/sovran-customer-onboarded"]) + except Exception as e: + error_lbl.set_text(str(e)) + error_lbl.set_visible(True) + btn.set_sensitive(True) + return + + GLib.idle_add(self.push_complete) + + submit_btn = Gtk.Button(label="Set Password & Continue") + submit_btn.add_css_class("suggested-action") + submit_btn.add_css_class("pill") + submit_btn.connect("clicked", on_submit) + + nav = Gtk.Box() + nav.set_margin_bottom(24) + nav.set_margin_end(40) + nav.set_halign(Gtk.Align.END) + nav.append(submit_btn) + outer.append(nav) + + self.push_page("Create Password", outer) + return False # ── Step 6: Complete ─────────────────────────────────────────────────── @@ -954,7 +1054,7 @@ class InstallerWindow(Adw.ApplicationWindow): pass_row = Adw.ActionRow() pass_row.set_title("Password") - pass_row.set_subtitle("free") + pass_row.set_subtitle("The password you just created") creds_group.add(pass_row) note_row = Adw.ActionRow() diff --git a/modules/core/factory-seal.nix b/modules/core/factory-seal.nix new file mode 100644 index 0000000..781d010 --- /dev/null +++ b/modules/core/factory-seal.nix @@ -0,0 +1,115 @@ +{ config, pkgs, lib, ... }: + +let + sovran-factory-seal = pkgs.writeShellScriptBin "sovran-factory-seal" '' + set -euo pipefail + + if [ "$(id -u)" -ne 0 ]; then + echo "Error: must be run as root." >&2 + exit 1 + fi + + echo "" + echo "╔══════════════════════════════════════════════════════════════╗" + echo "ā•‘ ⚠ SOVRAN FACTORY SEAL — WARNING ⚠ ā•‘" + echo "╠══════════════════════════════════════════════════════════════╣" + echo "ā•‘ This command will PERMANENTLY DELETE: ā•‘" + echo "ā•‘ • All generated passwords and secrets ā•‘" + echo "ā•‘ • LND wallet data (seed words, channels, macaroons) ā•‘" + echo "ā•‘ • SSH factory login key ā•‘" + echo "ā•‘ • Application databases (Matrix, Nextcloud, WordPress) ā•‘" + echo "ā•‘ • Vaultwarden database ā•‘" + echo "ā•‘ ā•‘" + echo "ā•‘ After sealing, all credentials will be regenerated fresh ā•‘" + echo "ā•‘ when the customer boots the device for the first time. ā•‘" + echo "ā•‘ ā•‘" + echo "ā•‘ DO NOT run this on a customer's live system. ā•‘" + echo "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" + echo "" + echo -n "Type SEAL to confirm: " + read -r CONFIRM + if [ "$CONFIRM" != "SEAL" ]; then + echo "Aborted." >&2 + exit 1 + fi + + echo "" + echo "Sealing system..." + + # ── 1. Delete all generated secrets ────────────────────────────── + echo " Wiping secrets..." + [ -d /var/lib/secrets ] && find /var/lib/secrets -mindepth 1 -delete || true + rm -rf /var/lib/matrix-synapse/registration-secret + rm -rf /var/lib/matrix-synapse/db-password + rm -rf /var/lib/gnome-remote-desktop/rdp-password + rm -rf /var/lib/gnome-remote-desktop/rdp-username + rm -rf /var/lib/gnome-remote-desktop/rdp-credentials + rm -rf /var/lib/livekit/livekit_keyFile + rm -rf /etc/nix-bitcoin-secrets/* + + # ── 2. Wipe LND wallet (seed words, wallet DB, macaroons) ──────── + echo " Wiping LND wallet data..." + rm -rf /var/lib/lnd/* + + # ── 3. Wipe SSH factory key so it regenerates with new passphrase ─ + echo " Removing SSH factory key..." + rm -f /home/free/.ssh/factory_login /home/free/.ssh/factory_login.pub + if [ -f /root/.ssh/authorized_keys ]; then + sed -i '/factory_login/d' /root/.ssh/authorized_keys + fi + + # ── 4. Drop application databases ──────────────────────────────── + echo " Dropping application databases..." + sudo -u postgres psql -c "DROP DATABASE IF EXISTS \"matrix-synapse\";" 2>/dev/null || true + sudo -u postgres psql -c "DROP DATABASE IF EXISTS nextclouddb;" 2>/dev/null || true + mysql -u root -e "DROP DATABASE IF EXISTS wordpressdb;" 2>/dev/null || true + + # ── 5. Remove application config files (so init services re-run) ─ + echo " Removing application config files..." + rm -rf /var/lib/www/wordpress/wp-config.php + rm -rf /var/lib/www/nextcloud/config/config.php + + # ── 6. Wipe Vaultwarden database ────────────────────────────────── + echo " Wiping Vaultwarden data..." + rm -rf /var/lib/bitwarden_rs/* + rm -rf /var/lib/vaultwarden/* + + # ── 7. Set sealed flag and remove onboarded flag ───────────────── + echo " Setting sealed flag..." + touch /var/lib/sovran-factory-sealed + rm -f /var/lib/sovran-customer-onboarded + + echo "" + echo "System sealed. Power off now or the system will shut down in 10 seconds." + sleep 10 + poweroff + ''; + +in +{ + environment.systemPackages = [ sovran-factory-seal ]; + + # ── Legacy security check: warn existing (pre-seal) machines ─────── + systemd.services.sovran-legacy-security-check = { + description = "Check for legacy (pre-factory-seal) security status"; + wantedBy = [ "multi-user.target" ]; + after = [ "local-fs.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ pkgs.coreutils ]; + script = '' + # If already onboarded or sealed, nothing to do + [ -f /var/lib/sovran-customer-onboarded ] && exit 0 + [ -f /var/lib/sovran-factory-sealed ] && exit 0 + + # If secrets exist but no sealed/onboarded flag, this is a legacy machine + if [ -f /var/lib/secrets/root-password ]; then + mkdir -p /var/lib/sovran + echo "legacy" > /var/lib/sovran/security-status + echo "This system was deployed before the factory seal feature. Your passwords may be known to the factory. Please change your passwords through the Sovran Hub." > /var/lib/sovran/security-warning + fi + ''; + }; +} diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index 01db940..b2927fa 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -10,7 +10,7 @@ let { label = "Free Account — Username"; value = "free"; } { label = "Free Account — Password"; file = "/var/lib/secrets/free-password"; } { label = "Root Password"; file = "/var/lib/secrets/root-password"; } - { label = "SSH Local Access"; value = "ssh root@localhost / Passphrase: gosovransystems"; } + { label = "SSH Passphrase"; file = "/var/lib/secrets/ssh-passphrase"; } ]; } ] # ── Infrastructure — Caddy + Tor (NOT desktop-only) ──────── diff --git a/modules/core/ssh-bootstrap.nix b/modules/core/ssh-bootstrap.nix index 1a4e48d..e88dcd8 100644 --- a/modules/core/ssh-bootstrap.nix +++ b/modules/core/ssh-bootstrap.nix @@ -12,9 +12,29 @@ lib.mkIf userExists { "d /home/${userName}/.ssh 0700 ${userName} users -" ]; + systemd.services.ssh-passphrase-setup = { + description = "Generate per-device SSH key passphrase"; + wantedBy = [ "multi-user.target" ]; + before = [ "factory-ssh-keygen.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ pkgs.pwgen pkgs.coreutils ]; + script = '' + if [ ! -f "/var/lib/secrets/ssh-passphrase" ]; then + mkdir -p /var/lib/secrets + pwgen -s 20 1 > /var/lib/secrets/ssh-passphrase + chmod 600 /var/lib/secrets/ssh-passphrase + fi + ''; + }; + systemd.services.factory-ssh-keygen = { description = "Generate factory SSH key for ${userName} if missing"; wantedBy = [ "multi-user.target" ]; + after = [ "ssh-passphrase-setup.service" ]; + requires = [ "ssh-passphrase-setup.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; @@ -22,7 +42,8 @@ lib.mkIf userExists { path = [ pkgs.openssh pkgs.coreutils ]; script = '' if [ ! -f "${keyPath}" ]; then - ssh-keygen -q -N "gosovransystems" -t ed25519 -f "${keyPath}" + PASSPHRASE=$(cat /var/lib/secrets/ssh-passphrase) + ssh-keygen -q -N "$PASSPHRASE" -t ed25519 -f "${keyPath}" chown ${userName}:users "${keyPath}" "${keyPath}.pub" chmod 600 "${keyPath}" chmod 644 "${keyPath}.pub" diff --git a/modules/credentials-pdf.nix b/modules/credentials-pdf.nix index 09aa04b..1976ed1 100644 --- a/modules/credentials-pdf.nix +++ b/modules/credentials-pdf.nix @@ -1,8 +1,6 @@ { config, pkgs, lib, ... }: let - fonts = pkgs.liberation_ttf; - # ── Helper: change 'free' password and save it ───────────── change-free-password = pkgs.writeShellScriptBin "change-free-password" '' set -euo pipefail @@ -50,7 +48,7 @@ in echo "ā•‘ ⚠ Use 'sudo change-free-password' instead. ā•‘" echo "ā•‘ ā•‘" echo "ā•‘ 'passwd free' only updates /etc/shadow. ā•‘" - echo "ā•‘ The Hub and Magic Keys PDF will NOT be updated. ā•‘" + echo "ā•‘ The Hub credentials view will NOT be updated. ā•‘" echo "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" echo "" return 1 @@ -67,7 +65,7 @@ in echo "ā•‘ ⚠ Use 'sudo change-free-password' instead. ā•‘" echo "ā•‘ ā•‘" echo "ā•‘ 'passwd free' only updates /etc/shadow. ā•‘" - echo "ā•‘ The Hub and Magic Keys PDF will NOT be updated. ā•‘" + echo "ā•‘ The Hub credentials view will NOT be updated. ā•‘" echo "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ļæ½ļæ½ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•" echo "" return 1 @@ -116,329 +114,4 @@ in ''; }; - # ── 1c. Save Zeus/lndconnect URL for hub credentials ──────── - systemd.services.zeus-connect-setup = { - description = "Save Zeus lndconnect URL"; - wantedBy = [ "multi-user.target" ]; - after = [ "lnd.service" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - }; - path = [ pkgs.coreutils "/run/current-system/sw" ]; - script = '' - SECRET_FILE="/var/lib/secrets/zeus-connect-url" - mkdir -p /var/lib/secrets - - URL="" - if command -v lndconnect >/dev/null 2>&1; then - URL=$(lndconnect --url 2>/dev/null || true) - elif command -v lnconnect-clnrest >/dev/null 2>&1; then - URL=$(lnconnect-clnrest --url 2>/dev/null || true) - fi - - if [ -n "$URL" ]; then - echo "$URL" > "$SECRET_FILE" - chmod 600 "$SECRET_FILE" - echo "Zeus connect URL saved." - else - echo "No lndconnect URL available yet." - fi - ''; - }; - - # ── Refresh Zeus URL periodically (certs/macaroons may rotate) - systemd.timers.zeus-connect-setup = { - wantedBy = [ "timers.target" ]; - timerConfig = { - OnBootSec = "2min"; - OnUnitActiveSec = "30min"; - Unit = "zeus-connect-setup.service"; - }; - }; - - # ── 2. Timer: Check every 5 minutes ──────────────────────── - systemd.timers.generate-credentials-pdf = { - description = "Periodically check if Magic Keys PDF needs regenerating"; - wantedBy = [ "timers.target" ]; - timerConfig = { - OnBootSec = "30s"; - OnUnitActiveSec = "5min"; - Unit = "generate-credentials-pdf.service"; - }; - }; - - # ── 3. Generate the Magic Keys PDF ───────────────────────── - systemd.services.generate-credentials-pdf = { - description = "Generate Magic Keys PDF for Sovran_SystemsOS"; - serviceConfig = { - Type = "oneshot"; - }; - - path = [ - pkgs.pandoc - pkgs.typst - pkgs.coreutils - pkgs.qrencode - pkgs.gnugrep - fonts - "/run/current-system/sw" - ]; - - environment = { - TYPST_FONT_PATHS = "${fonts}/share/fonts"; - }; - - script = '' - DOC_DIR="/home/free/Documents" - OUTPUT="$DOC_DIR/Sovran_SystemsOS_Magic_Keys.pdf" - WORK_DIR="/tmp/magic_keys_build" - FILE="$WORK_DIR/magic_keys.md" - HASH_FILE="/var/lib/secrets/.magic-keys-hash" - - FENCE='```' - - # ── Collect all secret sources into a single hash ── - SECRET_SOURCES="" - for f in \ - /var/lib/secrets/root-password \ - /var/lib/secrets/free-password \ - /etc/nix-bitcoin-secrets/rtl-password \ - /var/lib/tor/onion/rtl/hostname \ - /var/lib/tor/onion/electrs/hostname \ - /var/lib/tor/onion/bitcoind/hostname \ - /var/lib/secrets/matrix-users \ - /var/lib/gnome-remote-desktop/rdp-credentials \ - /var/lib/secrets/nextcloud-admin \ - /var/lib/secrets/wordpress-admin \ - /var/lib/secrets/vaultwarden/vaultwarden.env \ - /var/lib/domains/vaultwarden \ - /var/lib/domains/btcpayserver \ - /var/lib/secrets/zeus-connect-url; do - if [ -f "$f" ]; then - SECRET_SOURCES="$SECRET_SOURCES$(cat "$f")" - fi - done - - # Add lndconnect URL to hash sources (changes if certs/macaroons rotate) - if command -v lndconnect >/dev/null 2>&1; then - SECRET_SOURCES="$SECRET_SOURCES$(lndconnect --url 2>/dev/null || true)" - elif command -v lnconnect-clnrest >/dev/null 2>&1; then - SECRET_SOURCES="$SECRET_SOURCES$(lnconnect-clnrest --url 2>/dev/null || true)" - fi - - CURRENT_HASH=$(echo -n "$SECRET_SOURCES" | sha256sum | cut -d' ' -f1) - OLD_HASH="" - if [ -f "$HASH_FILE" ]; then - OLD_HASH=$(cat "$HASH_FILE") - fi - - # ── Skip if PDF exists and nothing changed ── - if [ -f "$OUTPUT" ] && [ "$CURRENT_HASH" = "$OLD_HASH" ]; then - echo "No changes detected, skipping PDF regeneration." - exit 0 - fi - - echo "Changes detected (or PDF missing), regenerating..." - mkdir -p "$DOC_DIR" "$WORK_DIR" - - # ── Read secrets (default to placeholder if missing) ── - read_secret() { if [ -f "$1" ]; then cat "$1"; else echo "$2"; fi; } - - ROOT_PASS=$(read_secret /var/lib/secrets/root-password "Generating...") - FREE_PASS=$(read_secret /var/lib/secrets/free-password "free") - RTL_PASS=$(read_secret /etc/nix-bitcoin-secrets/rtl-password "Not found") - RTL_ONION=$(read_secret /var/lib/tor/onion/rtl/hostname "Not generated yet") - ELECTRS_ONION=$(read_secret /var/lib/tor/onion/electrs/hostname "Not generated yet") - BITCOIN_ONION=$(read_secret /var/lib/tor/onion/bitcoind/hostname "Not generated yet") - - # ── Generate Zeus QR code PNG if lndconnect URL is available ── - ZEUS_URL="" - HAS_ZEUS_QR="" - if command -v lndconnect >/dev/null 2>&1; then - ZEUS_URL=$(lndconnect --url 2>/dev/null || true) - elif command -v lnconnect-clnrest >/dev/null 2>&1; then - ZEUS_URL=$(lnconnect-clnrest --url 2>/dev/null || true) - fi - - if [ -n "$ZEUS_URL" ]; then - qrencode -o "$WORK_DIR/zeus-qr.png" -s 4 -m 1 -l H "$ZEUS_URL" 2>/dev/null && HAS_ZEUS_QR="1" - fi - - # ── Build the Markdown document ── - cat > "$FILE" << ENDOFFILE ---- -title: "Sovran SystemsOS Magic Keys" ---- - -# Your Sovran SystemsOS Magic Keys! šŸ—ļø - -Welcome to your new computer! We have built a lot of cool secret forts (services) for you. To get into your forts, you need your magic keys (passwords). - -Here are all of your keys in one place. **Keep this document safe and do not share it with strangers!** - -> **How this document works:** This PDF is automatically generated by your computer. If any of your passwords, services, or connection details change, this document will automatically update itself within a few minutes. You can always find the latest version right here in your Documents folder. If you accidentally delete it, don't worry — your computer will recreate it for you! - -## šŸ–„ļø Your Computer -These are the master keys to the actual machine. - -### 1. Main Screen Unlock (The 'free' account) -When you turn the computer on, it usually logs you in automatically. However, if the screen goes to sleep, or **if you enable Remote Desktop (RDP)**, you will need this to log in: -- **Username:** \`free\` -- **Password:** \`$FREE_PASS\` - -🚨 **VERY IMPORTANT:** You MUST write this password down and keep it safe! If you lose it, you will be locked out of your computer! - -### 2. The Big Boss (Root) -Sometimes a pop-up box might ask for an Administrator (Root) password to change a setting. We created a super-secret password just for this! -- **Root Password:** \`$ROOT_PASS\` - -### 3. The Hacker Terminal (\`ssh root@localhost\`) -Because your main account is so safe, you cannot just type normal commands to become the boss. If you open a black terminal box and want to make big changes, you must use your special factory key! - -Type this exact command into the terminal: -\`ssh root@localhost\` - -When it asks for a passphrase, type: -- **Terminal Password:** \`gosovransystems\` -ENDOFFILE - - # --- BITCOIN ECOSYSTEM --- - if [ -f "/etc/nix-bitcoin-secrets/rtl-password" ] || [ -f "/var/lib/tor/onion/rtl/hostname" ]; then - cat >> "$FILE" << BITCOIN - -## ⚔ Your Bitcoin & Lightning Node -Your computer is a real Bitcoin node! It talks to the network secretly using Tor. Here is how to connect your wallet apps to it: - -### 1. Ride The Lightning (RTL) -*This is the control panel for your Lightning Node.* -Open the **Tor Browser** and go to this website. Use this password to log in: -- **Website:** \`http://$RTL_ONION\` -- **Password:** \`$RTL_PASS\` - -### 2. Electrs (Your Private Bank Teller) -*If you use a wallet app on your phone or computer (like Sparrow or BlueWallet), tell it to connect here so nobody can spy on your money!* -- **Tor Address:** \`$ELECTRS_ONION\` -- **Port:** \`50001\` - -### 3. Bitcoin Core -*This is the heartbeat of your node. It uses this address to talk to other Bitcoiners securely.* -- **Tor Address:** \`$BITCOIN_ONION\` -BITCOIN - fi - - # --- ZEUS MOBILE WALLET QR CODE --- - if [ "$HAS_ZEUS_QR" = "1" ]; then - echo "" >> "$FILE" - echo "## šŸ“± Connect Zeus Mobile Wallet" >> "$FILE" - echo "" >> "$FILE" - echo "Take your Bitcoin Lightning node anywhere in the world! Scan this QR code with the **Zeus** app on your phone to instantly connect your mobile wallet to your Lightning node." >> "$FILE" - echo "" >> "$FILE" - echo "1. Download **Zeus** from the App Store or Google Play" >> "$FILE" - echo "2. Open Zeus and tap **\"Scan Node Config\"**" >> "$FILE" - echo "3. Point your phone's camera at this QR code:" >> "$FILE" - echo "" >> "$FILE" - echo "![Zeus Connection QR Code](zeus-qr.png){ width=200px }" >> "$FILE" - echo "" >> "$FILE" - echo "That's it! You're now mobile. Send and receive Bitcoin anywhere in the world, powered by your very own node! ⚔" >> "$FILE" - elif [ -n "$ZEUS_URL" ]; then - echo "" >> "$FILE" - echo "## šŸ“± Connect Zeus Mobile Wallet" >> "$FILE" - echo "" >> "$FILE" - echo "Take your Bitcoin Lightning node anywhere in the world! Paste this connection URL into the **Zeus** app on your phone:" >> "$FILE" - echo "" >> "$FILE" - echo "1. Download **Zeus** from the App Store or Google Play" >> "$FILE" - echo "2. Open Zeus and tap **\"Scan Node Config\"** then **\"Paste Node Config\"**" >> "$FILE" - echo "3. Paste this URL:" >> "$FILE" - echo "" >> "$FILE" - echo "$FENCE" >> "$FILE" - echo "$ZEUS_URL" >> "$FILE" - echo "$FENCE" >> "$FILE" - echo "" >> "$FILE" - echo "That's it! You're now mobile. Send and receive Bitcoin anywhere in the world, powered by your very own node! ⚔" >> "$FILE" - fi - - # --- MATRIX / ELEMENT --- - if [ -f "/var/lib/secrets/matrix-users" ]; then - echo "" >> "$FILE" - echo "## šŸ’¬ Your Private Chat (Matrix / Element)" >> "$FILE" - echo "This is your very own private messaging app! Log in using an app like Element with these details:" >> "$FILE" - echo "$FENCE" >> "$FILE" - cat /var/lib/secrets/matrix-users >> "$FILE" - echo "$FENCE" >> "$FILE" - fi - - # --- GNOME RDP --- - if [ -f "/var/lib/gnome-remote-desktop/rdp-credentials" ]; then - echo "" >> "$FILE" - echo "## šŸŒŽ Connect from Far Away (Remote Desktop)" >> "$FILE" - echo "This lets you control your computer screen from another device!" >> "$FILE" - echo "$FENCE" >> "$FILE" - cat /var/lib/gnome-remote-desktop/rdp-credentials >> "$FILE" - echo "$FENCE" >> "$FILE" - fi - - # --- NEXTCLOUD --- - if [ -f "/var/lib/secrets/nextcloud-admin" ]; then - echo "" >> "$FILE" - echo "## ā˜ļø Your Personal Cloud (Nextcloud)" >> "$FILE" - echo "This is like your own private Google Drive!" >> "$FILE" - echo "$FENCE" >> "$FILE" - cat /var/lib/secrets/nextcloud-admin >> "$FILE" - echo "$FENCE" >> "$FILE" - fi - - # --- WORDPRESS --- - if [ -f "/var/lib/secrets/wordpress-admin" ]; then - echo "" >> "$FILE" - echo "## šŸ“ Your Website (WordPress)" >> "$FILE" - echo "This is your very own website where you can write blogs or make pages." >> "$FILE" - echo "$FENCE" >> "$FILE" - cat /var/lib/secrets/wordpress-admin >> "$FILE" - echo "$FENCE" >> "$FILE" - fi - - # --- VAULTWARDEN --- - if [ -f "/var/lib/domains/vaultwarden" ]; then - DOMAIN=$(cat /var/lib/domains/vaultwarden) - VW_ADMIN_TOKEN="Not found" - if [ -f "/var/lib/secrets/vaultwarden/vaultwarden.env" ]; then - VW_ADMIN_TOKEN=$(grep -oP 'ADMIN_TOKEN=\K.*' /var/lib/secrets/vaultwarden/vaultwarden.env || echo "Not found") - fi - echo "" >> "$FILE" - echo "## šŸ” Your Password Manager (Vaultwarden)" >> "$FILE" - echo "This keeps all your other passwords safe! Go to this website to use it:" >> "$FILE" - echo "- **Website:** https://$DOMAIN" >> "$FILE" - echo "- **Admin Panel:** https://$DOMAIN/admin" >> "$FILE" - echo "- **Admin Token:** \`$VW_ADMIN_TOKEN\`" >> "$FILE" - echo "" >> "$FILE" - echo "*(Create your own account on the main page. Use the Admin Token to access the admin panel and manage your server.)*" >> "$FILE" - fi - - # --- BTCPAY SERVER --- - if [ -f "/var/lib/domains/btcpayserver" ]; then - DOMAIN=$(cat /var/lib/domains/btcpayserver) - echo "" >> "$FILE" - echo "## ₿ Your Bitcoin Store (BTCPay Server)" >> "$FILE" - echo "This lets you accept Bitcoin like a real shop!" >> "$FILE" - echo "- **Website:** https://$DOMAIN" >> "$FILE" - echo "*(You make up your own Admin Password the first time you visit!)*" >> "$FILE" - fi - - # ── Generate PDF (cd into work dir so Typst finds images) ── - cd "$WORK_DIR" - pandoc magic_keys.md -o "$OUTPUT" --pdf-engine=typst \ - -V mainfont="Liberation Sans" \ - -V monofont="Liberation Mono" - - chown free:users "$OUTPUT" - - # ── Save hash so we skip next time if nothing changed ── - mkdir -p "$(dirname "$HASH_FILE")" - echo "$CURRENT_HASH" > "$HASH_FILE" - - rm -rf "$WORK_DIR" - echo "PDF generated successfully." - ''; - }; -} \ No newline at end of file +} diff --git a/modules/modules.nix b/modules/modules.nix index a8eb10f..794bdaa 100755 --- a/modules/modules.nix +++ b/modules/modules.nix @@ -12,6 +12,7 @@ ./core/sovran_systemsos-desktop.nix ./core/sshd-localhost.nix ./core/sovran-hub.nix + ./core/factory-seal.nix # ── Always on (no flag) ─────────────────────────────────── ./php.nix -- 2.53.0 From e2bd366bb3e519e599166689c63d5840be0f2372 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 7 Apr 2026 09:27:25 -0500 Subject: [PATCH 426/857] updated nextcloud.nix --- modules/nextcloud.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/nextcloud.nix b/modules/nextcloud.nix index 05e52d4..699770a 100755 --- a/modules/nextcloud.nix +++ b/modules/nextcloud.nix @@ -67,7 +67,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud { RemainAfterExit = true; }; - path = with pkgs; [ curl unzip php pwgen coreutils shadow ]; + path = with pkgs; [ curl unzip php pwgen coreutils shadow util-linux ]; script = '' set -euo pipefail -- 2.53.0 From 9f1dd7def153760e65bf995047245cc38032be7e Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 7 Apr 2026 09:35:23 -0500 Subject: [PATCH 427/857] updated nextcloud.nix --- modules/nextcloud.nix | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/nextcloud.nix b/modules/nextcloud.nix index 699770a..e7e380f 100755 --- a/modules/nextcloud.nix +++ b/modules/nextcloud.nix @@ -109,7 +109,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud { echo "Waiting for PostgreSQL..." for i in $(seq 1 30); do - if su -s /bin/sh caddy -c "php -r \"new PDO('pgsql:host=$DB_HOST;dbname=$DB_NAME', '$DB_USER', '$DB_PASS');\"" 2>/dev/null; then + if /run/wrappers/bin/su -s /bin/sh caddy -c "php -r \"new PDO('pgsql:host=$DB_HOST;dbname=$DB_NAME', '$DB_USER', '$DB_PASS');\"" 2>/dev/null; then echo "Database ready." break fi @@ -117,7 +117,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud { done echo "Running Nextcloud installation..." - su -s /bin/sh caddy -c " + /run/wrappers/bin/su -s /bin/sh caddy -c " php $INSTALL_DIR/occ maintenance:install \ --database 'pgsql' \ --database-name '$DB_NAME' \ @@ -129,19 +129,19 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud { --data-dir '$DATA_DIR' " - su -s /bin/sh caddy -c " + /run/wrappers/bin/su -s /bin/sh caddy -c " php $INSTALL_DIR/occ config:system:set trusted_domains 0 --value='$DOMAIN' php $INSTALL_DIR/occ config:system:set overwrite.cli.url --value='https://$DOMAIN' php $INSTALL_DIR/occ config:system:set overwriteprotocol --value='https' " - su -s /bin/sh caddy -c " + /run/wrappers/bin/su -s /bin/sh caddy -c " php $INSTALL_DIR/occ config:system:set default_phone_region --value='US' php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu' php $INSTALL_DIR/occ background:cron " - su -s /bin/sh caddy -c " + /run/wrappers/bin/su -s /bin/sh caddy -c " php $INSTALL_DIR/occ app:install calendar || true php $INSTALL_DIR/occ app:install contacts || true php $INSTALL_DIR/occ app:install tasks || true @@ -187,4 +187,4 @@ CREDS sovran_systemsOS.domainRequirements = [ { name = "nextcloud"; label = "Nextcloud"; example = "cloud.yourdomain.com"; } ]; -} +} \ No newline at end of file -- 2.53.0 From 9407d500c88a718e0f6cac74c0f26af485b4b4ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:44:38 +0000 Subject: [PATCH 428/857] Initial plan -- 2.53.0 From 7a1cd8a6f6eef9d7a5987830da6485e71fc804ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:46:46 +0000 Subject: [PATCH 429/857] fix(wordpress): use /run/wrappers/bin/su to fix su: command not found in wordpress-init service Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/24a9d2b1-6b09-41ac-bb3b-418f0ea2b2d7 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- modules/wordpress.nix | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/wordpress.nix b/modules/wordpress.nix index c1023c4..9a745f9 100755 --- a/modules/wordpress.nix +++ b/modules/wordpress.nix @@ -60,7 +60,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress { RemainAfterExit = true; }; - path = with pkgs; [ curl unzip wp-cli pwgen php coreutils ]; + path = with pkgs; [ curl unzip wp-cli pwgen php coreutils shadow util-linux ]; script = '' set -euo pipefail @@ -97,7 +97,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress { echo "Generating wp-config.php..." cd "$INSTALL_DIR" - su -s /bin/sh caddy -c " + /run/wrappers/bin/su -s /bin/sh caddy -c " wp config create \ --dbname='$DB_NAME' \ --dbuser='$DB_USER' \ @@ -108,14 +108,14 @@ lib.mkIf config.sovran_systemsOS.services.wordpress { echo "Waiting for database..." for i in $(seq 1 30); do - if su -s /bin/sh caddy -c "wp db check" 2>/dev/null; then + if /run/wrappers/bin/su -s /bin/sh caddy -c "wp db check" 2>/dev/null; then break fi sleep 2 done echo "Running WordPress core install..." - su -s /bin/sh caddy -c " + /run/wrappers/bin/su -s /bin/sh caddy -c " wp core install \ --url='https://$DOMAIN' \ --title='Sovran_SystemsOS' \ @@ -125,7 +125,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress { --skip-email " - su -s /bin/sh caddy -c " + /run/wrappers/bin/su -s /bin/sh caddy -c " wp option update blogdescription 'Powered by Sovran_SystemsOS' wp option update permalink_structure '/%postname%/' wp option update default_ping_status 'closed' @@ -133,7 +133,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress { wp rewrite flush " - su -s /bin/sh caddy -c " + /run/wrappers/bin/su -s /bin/sh caddy -c " wp config set DISALLOW_FILE_EDIT true --raw wp config set WP_AUTO_UPDATE_CORE true --raw wp config set FORCE_SSL_ADMIN true --raw -- 2.53.0 From 7aed3e09e8f1a30335a3f03bcfd7a72d693d094d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:51:00 +0000 Subject: [PATCH 430/857] Initial plan -- 2.53.0 From 6c433d642d9dc1dab65c3e4253f03a7ed3c257f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:52:40 +0000 Subject: [PATCH 431/857] Add zeus-connect-setup service and timer to wallet-autoconnect.nix Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/6b3d9c59-40e1-45c1-93f9-a5ba6547567b Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- modules/wallet-autoconnect.nix | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/modules/wallet-autoconnect.nix b/modules/wallet-autoconnect.nix index 688e9c0..0993e8f 100644 --- a/modules/wallet-autoconnect.nix +++ b/modules/wallet-autoconnect.nix @@ -80,4 +80,45 @@ EOF ''; }; + # ── Zeus Connect (lndconnect URL for mobile wallet) ────────── + systemd.services.zeus-connect-setup = { + description = "Save Zeus lndconnect URL"; + wantedBy = [ "multi-user.target" ]; + after = [ "lnd.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ pkgs.coreutils "/run/current-system/sw" ]; + script = '' + SECRET_FILE="/var/lib/secrets/zeus-connect-url" + mkdir -p /var/lib/secrets + + URL="" + if command -v lndconnect >/dev/null 2>&1; then + URL=$(lndconnect --url 2>/dev/null || true) + elif command -v lnconnect-clnrest >/dev/null 2>&1; then + URL=$(lnconnect-clnrest --url 2>/dev/null || true) + fi + + if [ -n "$URL" ]; then + echo "$URL" > "$SECRET_FILE" + chmod 600 "$SECRET_FILE" + echo "Zeus connect URL saved." + else + echo "No lndconnect URL available yet." + fi + ''; + }; + + # ── Refresh Zeus URL periodically (certs/macaroons may rotate) + systemd.timers.zeus-connect-setup = { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "2min"; + OnUnitActiveSec = "30min"; + Unit = "zeus-connect-setup.service"; + }; + }; + } -- 2.53.0 From 25e8cac61300dcb887a2762c731e529cc371607d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:02:58 +0000 Subject: [PATCH 432/857] Initial plan -- 2.53.0 From 7a08bc0b2b8e8bf34db7fe97ba673ed42a08189b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:04:33 +0000 Subject: [PATCH 433/857] Remove all PDF references: rename credentials-pdf.nix and update references Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/150954c9-65a0-4d5b-b8e2-08f301f07511 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 2 +- modules/{credentials-pdf.nix => credentials.nix} | 0 modules/modules.nix | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename modules/{credentials-pdf.nix => credentials.nix} (100%) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 0721387..267e96f 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -699,7 +699,7 @@ def _check_port_status( def _generate_qr_base64(data: str) -> str | None: """Generate a QR code PNG and return it as a base64-encoded data URI. - Uses qrencode CLI (available on the system via credentials-pdf.nix).""" + Uses qrencode CLI (available on the system via credentials.nix).""" try: result = subprocess.run( ["qrencode", "-o", "-", "-t", "PNG", "-s", "6", "-m", "2", "-l", "H", data], diff --git a/modules/credentials-pdf.nix b/modules/credentials.nix similarity index 100% rename from modules/credentials-pdf.nix rename to modules/credentials.nix diff --git a/modules/modules.nix b/modules/modules.nix index 794bdaa..4d20fb0 100755 --- a/modules/modules.nix +++ b/modules/modules.nix @@ -17,7 +17,7 @@ # ── Always on (no flag) ─────────────────────────────────── ./php.nix ./Sovran_SystemsOS_File_Fixes_And_New_Services.nix - ./credentials-pdf.nix + ./credentials.nix # ── Services (default ON — disable in custom.nix) ───────── ./synapse.nix -- 2.53.0 From 93592c984d0028b3d3be5090d0949a12a0a2d42d Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 7 Apr 2026 10:05:37 -0500 Subject: [PATCH 434/857] removed erroniousfile --- onboarding.html | 139 ------------------------------------------------ result | 2 +- 2 files changed, 1 insertion(+), 140 deletions(-) delete mode 100644 onboarding.html diff --git a/onboarding.html b/onboarding.html deleted file mode 100644 index d1f1f0a..0000000 --- a/onboarding.html +++ /dev/null @@ -1,139 +0,0 @@ - - - - - - Sovran_SystemsOS — First-Boot Setup - - - - - -
    - - -
    -
    -
    - - -
    - 1 - - 2 - - 3 - - 4 -
    - - -
    - - -
    -
    - -

    Welcome to Sovran_SystemsOS!

    -

    Be Digitally Sovereign

    -
    -
    -

    - Your system is installed and ready to configure. This wizard will guide - you through the final setup steps so everything works perfectly. -

    -
    - Your Role: - Loading… -
    -

    - This setup only takes a few minutes. You can always revisit these - settings from the main Hub dashboard. -

    -
    - -
    - - - - - - - - - - -
    -
    - - - - \ No newline at end of file diff --git a/result b/result index 902923f..992f27e 120000 --- a/result +++ b/result @@ -1 +1 @@ -/nix/store/pdwygxbd76b12qll8mnvirs6bym97hla-Sovran_SystemsOS.iso \ No newline at end of file +/nix/store/x0mj2chb6a1cx6209d3dfkdxm7kwgiwz-Sovran_SystemsOS.iso \ No newline at end of file -- 2.53.0 From eba517d34d0e7a7d2db9f43c5551ffe3bb94ad67 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 7 Apr 2026 10:13:24 -0500 Subject: [PATCH 435/857] update flake --- flake.lock | 36 ++++++++++++++++++------------------ result | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/flake.lock b/flake.lock index 67f1f82..16d7cd6 100755 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1773169138, - "narHash": "sha256-6X41z8o2z8KjF4gMzLTPD41WjvCDGXTc0muPGmwcOMk=", + "lastModified": 1775155316, + "narHash": "sha256-4H8aEChZ6rra9jd8OcVHgHs3IuzKzpDt4PPtsPJrkyM=", "owner": "emmanuelrosa", "repo": "bitcoin-knots-bip-110-nix", - "rev": "b9d018b71e20ce8c1567cbc2401b6edc2c1c7793", + "rev": "663ea34f6f846f48c385a73d4581ba599bb5bbc0", "type": "github" }, "original": { @@ -24,11 +24,11 @@ "oldNixpkgs": "oldNixpkgs" }, "locked": { - "lastModified": 1774797058, - "narHash": "sha256-URUOiKNjG3s7vDkTj554+3yzQ0qqNQoQwHdc7vs63X0=", + "lastModified": 1775155241, + "narHash": "sha256-J8dGfHLXlpJgDSgbBuQtll9chM9Hsv5hhVWglSRLgIE=", "owner": "emmanuelrosa", "repo": "btc-clients-nix", - "rev": "a10dae067da04b7b170eed73efc665d27fc0e0c5", + "rev": "e76e32fef6d0b8a8e9a32318835ba09915232a5c", "type": "github" }, "original": { @@ -127,11 +127,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1772380631, - "narHash": "sha256-FhW0uxeXjefINP0vUD4yRBB52Us7fXZPk9RiPAopfiY=", + "lastModified": 1775054576, + "narHash": "sha256-iiIr1hlTMu2LLARsUYtiqlE90tqocqIMVLK2fIzB/UY=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "6d3b61b190a899042ce82a5355111976ba76d698", + "rev": "fc4b9b74d4b0bdbf3c97fef4bd34c05225172912", "type": "github" }, "original": { @@ -191,11 +191,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1772380631, - "narHash": "sha256-FhW0uxeXjefINP0vUD4yRBB52Us7fXZPk9RiPAopfiY=", + "lastModified": 1775054576, + "narHash": "sha256-iiIr1hlTMu2LLARsUYtiqlE90tqocqIMVLK2fIzB/UY=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "6d3b61b190a899042ce82a5355111976ba76d698", + "rev": "fc4b9b74d4b0bdbf3c97fef4bd34c05225172912", "type": "github" }, "original": { @@ -222,11 +222,11 @@ }, "nixpkgs_4": { "locked": { - "lastModified": 1775036866, - "narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=", + "lastModified": 1775423009, + "narHash": "sha256-vPKLpjhIVWdDrfiUM8atW6YkIggCEKdSAlJPzzhkQlw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "6201e203d09599479a3b3450ed24fa81537ebc4e", + "rev": "68d8aa3d661f0e6bd5862291b5bb263b2a6595c9", "type": "github" }, "original": { @@ -259,11 +259,11 @@ "systems": "systems_2" }, "locked": { - "lastModified": 1774802402, - "narHash": "sha256-L1UJ/zxKTyyaGGmytH6OYlgQ0HGSMhvPkvU+iz4Mkb8=", + "lastModified": 1775307257, + "narHash": "sha256-y9hEecHH4ennFwIcw1n480YCGh73DkEmizmQnyXuvgg=", "owner": "nix-community", "repo": "nixvim", - "rev": "cbd8536a05d1aae2593cb5c9ace1010c8c5845cb", + "rev": "2e008bb941f72379d5b935d5bfe70ed8b7c793ff", "type": "github" }, "original": { diff --git a/result b/result index 992f27e..0fea8cd 120000 --- a/result +++ b/result @@ -1 +1 @@ -/nix/store/x0mj2chb6a1cx6209d3dfkdxm7kwgiwz-Sovran_SystemsOS.iso \ No newline at end of file +/nix/store/2y2saivdbfj6j3r4rpwlw7k4sys89bp1-Sovran_SystemsOS.iso \ No newline at end of file -- 2.53.0 From a40ea61415dbbae7c357df3d1fede8a9429d2b59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:43:22 +0000 Subject: [PATCH 436/857] Initial plan -- 2.53.0 From 7fc04fcf20db53bcbfc4beb99d0fb47091fe8ae3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:44:31 +0000 Subject: [PATCH 437/857] Initial plan -- 2.53.0 From 7a172c03066ffcf0bd7cb728e060effdda2bbae7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:45:30 +0000 Subject: [PATCH 438/857] Fix chpasswd not found by using nixos-enter instead of bare chroot Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/1bb103de-c4a5-4701-b1b8-6aad670b97c3 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/installer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iso/installer.py b/iso/installer.py index 5aef89d..7e35402 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -1001,7 +1001,7 @@ class InstallerWindow(Adw.ApplicationWindow): run(["sudo", "chmod", "600", "/mnt/var/lib/secrets/free-password"]) proc = subprocess.run( - ["sudo", "chroot", "/mnt", "chpasswd"], + ["sudo", "nixos-enter", "--root", "/mnt", "--", "sh", "-c", "chpasswd"], input=f"free:{password}", capture_output=True, text=True ) -- 2.53.0 From 13c686a8a1dee61a52288e75f8a3bf0f8e4fa847 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:49:25 +0000 Subject: [PATCH 439/857] feat: add legacy security warning API and UI modal for pre-factory-seal machines Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/f7c8f11b-873b-403f-ac55-8b5b7cd9f1fb Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 36 ++++++++++++ .../static/css/security.css | 55 +++++++++++++++++++ app/sovran_systemsos_web/static/js/events.js | 6 ++ .../static/js/security.js | 23 ++++++++ app/sovran_systemsos_web/static/js/state.js | 5 ++ app/sovran_systemsos_web/templates/index.html | 22 ++++++++ 6 files changed, 147 insertions(+) create mode 100644 app/sovran_systemsos_web/static/css/security.css create mode 100644 app/sovran_systemsos_web/static/js/security.js diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 267e96f..2f5b355 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -59,6 +59,11 @@ REBOOT_COMMAND = ["reboot"] ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete" AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled" +# ── Legacy security check constants ────────────────────────────── + +SECURITY_STATUS_FILE = "/var/lib/sovran/security-status" +SECURITY_WARNING_FILE = "/var/lib/sovran/security-warning" + # ── Tech Support constants ──────────────────────────────────────── SUPPORT_KEY_FILE = "/root/.ssh/sovran_support_authorized" @@ -2916,6 +2921,37 @@ async def api_domains_check(req: DomainCheckRequest): return {"domains": list(check_results)} +# ── Legacy security check ───────────────────────────────────────── + +@app.get("/api/security/status") +async def api_security_status(): + """Return the legacy security status and warning message, if present. + + Reads /var/lib/sovran/security-status and /var/lib/sovran/security-warning. + Returns {"status": "legacy", "warning": ""} for legacy machines, + or {"status": "ok", "warning": ""} when the files are absent. + """ + try: + with open(SECURITY_STATUS_FILE, "r") as f: + status = f.read().strip() + except FileNotFoundError: + status = "ok" + + warning = "" + if status == "legacy": + try: + with open(SECURITY_WARNING_FILE, "r") as f: + warning = f.read().strip() + except FileNotFoundError: + warning = ( + "This machine was manufactured before the factory-seal process. " + "The default system password may be known to the factory. " + "Please change your system and application passwords immediately." + ) + + return {"status": status, "warning": warning} + + # ── Matrix user management ──────────────────────────────────────── MATRIX_USERS_FILE = "/var/lib/secrets/matrix-users" diff --git a/app/sovran_systemsos_web/static/css/security.css b/app/sovran_systemsos_web/static/css/security.css new file mode 100644 index 0000000..513fc3d --- /dev/null +++ b/app/sovran_systemsos_web/static/css/security.css @@ -0,0 +1,55 @@ +/* ── Legacy security warning modal ──────────────────────────────── */ + +.security-warning-dialog { + max-width: 520px; +} + +.security-warning-header { + background-color: #3b1212; + border-bottom-color: #7a2020; +} + +.security-warning-body { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 24px 20px; +} + +.security-warning-icon { + font-size: 2.5rem; +} + +.security-warning-message { + text-align: center; + color: var(--text-primary); + line-height: 1.6; + margin: 0; +} + +.security-warning-actions { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.security-warning-hint { + color: var(--text-secondary); + font-size: 0.85rem; + margin: 0; + text-align: center; +} + +.security-warning-links { + display: flex; + gap: 12px; + flex-wrap: wrap; + justify-content: center; +} + +.security-warning-link { + text-decoration: none; +} diff --git a/app/sovran_systemsos_web/static/js/events.js b/app/sovran_systemsos_web/static/js/events.js index a39ef00..05e713b 100644 --- a/app/sovran_systemsos_web/static/js/events.js +++ b/app/sovran_systemsos_web/static/js/events.js @@ -38,6 +38,9 @@ if ($upgradeCloseBtn) $upgradeCloseBtn.addEventListener("click", closeUpgradeMod if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal); if ($upgradeModal) $upgradeModal.addEventListener("click", function(e) { if (e.target === $upgradeModal) closeUpgradeModal(); }); +// Legacy security warning modal — dismiss closes the modal only +if ($securityWarningDismiss) $securityWarningDismiss.addEventListener("click", closeSecurityWarningModal); + // ── Upgrade modal functions ─────────────────────────────────────── function openUpgradeModal() { @@ -84,6 +87,9 @@ async function init() { // If we can't reach the endpoint, continue to normal dashboard } + // Check for legacy machine security warning + await checkLegacySecurity(); + try { var cfg = await apiFetch("/api/config"); _currentRole = cfg.role || "server_plus_desktop"; diff --git a/app/sovran_systemsos_web/static/js/security.js b/app/sovran_systemsos_web/static/js/security.js new file mode 100644 index 0000000..096c3d4 --- /dev/null +++ b/app/sovran_systemsos_web/static/js/security.js @@ -0,0 +1,23 @@ +"use strict"; + +// ── Legacy security warning ─────────────────────────────────────── + +function openSecurityWarningModal(message) { + if ($securityWarningMessage) $securityWarningMessage.textContent = message; + if ($securityWarningModal) $securityWarningModal.classList.add("open"); +} + +function closeSecurityWarningModal() { + if ($securityWarningModal) $securityWarningModal.classList.remove("open"); +} + +async function checkLegacySecurity() { + try { + var data = await apiFetch("/api/security/status"); + if (data && data.status === "legacy") { + openSecurityWarningModal(data.warning || "This machine may have a known factory password. Please change your passwords immediately."); + } + } catch (_) { + // Non-fatal — silently ignore if the endpoint is unreachable + } +} diff --git a/app/sovran_systemsos_web/static/js/state.js b/app/sovran_systemsos_web/static/js/state.js index 896c372..597213a 100644 --- a/app/sovran_systemsos_web/static/js/state.js +++ b/app/sovran_systemsos_web/static/js/state.js @@ -99,5 +99,10 @@ const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn"); const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn"); const $upgradeCloseBtn = document.getElementById("upgrade-close-btn"); +// Legacy security warning modal +const $securityWarningModal = document.getElementById("security-warning-modal"); +const $securityWarningMessage = document.getElementById("security-warning-message"); +const $securityWarningDismiss = document.getElementById("security-warning-dismiss-btn"); + // System status banner // (removed — health is now shown per-tile via the composite health field) \ No newline at end of file diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index 44746c3..2b014a6 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -14,6 +14,7 @@ + @@ -209,6 +210,26 @@
    + + +
    @@ -236,6 +257,7 @@ + \ No newline at end of file -- 2.53.0 From 11ec4b4816eeee65c05b933cad82064741a09abd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:14:42 +0000 Subject: [PATCH 440/857] Initial plan -- 2.53.0 From 1d4f1045242c51f55a97f60a880ea7506b7435d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:17:23 +0000 Subject: [PATCH 441/857] Replace security warning modal with inline banner in Preferences section Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e7946288-08c7-4081-85dd-6780f1eba17a Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- .../static/css/security.css | 71 ++++++++----------- app/sovran_systemsos_web/static/js/events.js | 3 - .../static/js/features.js | 12 ++++ .../static/js/security.js | 12 +--- app/sovran_systemsos_web/static/js/state.js | 7 +- app/sovran_systemsos_web/templates/index.html | 20 ------ 6 files changed, 46 insertions(+), 79 deletions(-) diff --git a/app/sovran_systemsos_web/static/css/security.css b/app/sovran_systemsos_web/static/css/security.css index 513fc3d..6201ccd 100644 --- a/app/sovran_systemsos_web/static/css/security.css +++ b/app/sovran_systemsos_web/static/css/security.css @@ -1,55 +1,42 @@ -/* ── Legacy security warning modal ──────────────────────────────── */ +/* ── Legacy security inline warning banner ───────────────────────── */ -.security-warning-dialog { - max-width: 520px; -} - -.security-warning-header { - background-color: #3b1212; - border-bottom-color: #7a2020; -} - -.security-warning-body { +.security-inline-banner { display: flex; flex-direction: column; - align-items: center; - gap: 16px; - padding: 24px 20px; -} - -.security-warning-icon { - font-size: 2.5rem; -} - -.security-warning-message { - text-align: center; + gap: 10px; + padding: 12px 14px; + margin-bottom: 12px; + background-color: rgba(180, 100, 0, 0.12); + border-left: 3px solid #c97a00; + border-radius: 6px; color: var(--text-primary); - line-height: 1.6; - margin: 0; } -.security-warning-actions { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; +.security-inline-icon { + font-size: 1rem; + color: #e69000; + flex-shrink: 0; } -.security-warning-hint { +.security-inline-text { + font-size: 0.82rem; + line-height: 1.5; color: var(--text-secondary); - font-size: 0.85rem; - margin: 0; - text-align: center; } -.security-warning-links { - display: flex; - gap: 12px; - flex-wrap: wrap; - justify-content: center; -} - -.security-warning-link { +.security-inline-link { + display: inline-block; + font-size: 0.82rem; + font-weight: 600; + color: #e69000; text-decoration: none; + border: 1px solid #c97a00; + border-radius: 4px; + padding: 4px 10px; + align-self: flex-start; + transition: background-color 0.15s; +} + +.security-inline-link:hover { + background-color: rgba(180, 100, 0, 0.22); } diff --git a/app/sovran_systemsos_web/static/js/events.js b/app/sovran_systemsos_web/static/js/events.js index 05e713b..efe405d 100644 --- a/app/sovran_systemsos_web/static/js/events.js +++ b/app/sovran_systemsos_web/static/js/events.js @@ -38,9 +38,6 @@ if ($upgradeCloseBtn) $upgradeCloseBtn.addEventListener("click", closeUpgradeMod if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal); if ($upgradeModal) $upgradeModal.addEventListener("click", function(e) { if (e.target === $upgradeModal) closeUpgradeModal(); }); -// Legacy security warning modal — dismiss closes the modal only -if ($securityWarningDismiss) $securityWarningDismiss.addEventListener("click", closeSecurityWarningModal); - // ── Upgrade modal functions ─────────────────────────────────────── function openUpgradeModal() { diff --git a/app/sovran_systemsos_web/static/js/features.js b/app/sovran_systemsos_web/static/js/features.js index 0b30519..eaeee3c 100644 --- a/app/sovran_systemsos_web/static/js/features.js +++ b/app/sovran_systemsos_web/static/js/features.js @@ -599,9 +599,21 @@ function renderAutolaunchToggle(enabled) { var section = document.createElement("div"); section.className = "category-section autolaunch-section"; + var securityBanner = ""; + if (_securityIsLegacy) { + var msg = _securityWarningMessage || "Your system may have factory default passwords. Please change your passwords to secure your system."; + securityBanner = + '
    ' + + '⚠' + + '' + msg + '' + + 'Change Passwords' + + '
    '; + } + section.innerHTML = '
    Preferences
    ' + '
    ' + + securityBanner + '
    ' + '
    ' + '
    ' + diff --git a/app/sovran_systemsos_web/static/js/security.js b/app/sovran_systemsos_web/static/js/security.js index 096c3d4..0d21037 100644 --- a/app/sovran_systemsos_web/static/js/security.js +++ b/app/sovran_systemsos_web/static/js/security.js @@ -2,20 +2,12 @@ // ── Legacy security warning ─────────────────────────────────────── -function openSecurityWarningModal(message) { - if ($securityWarningMessage) $securityWarningMessage.textContent = message; - if ($securityWarningModal) $securityWarningModal.classList.add("open"); -} - -function closeSecurityWarningModal() { - if ($securityWarningModal) $securityWarningModal.classList.remove("open"); -} - async function checkLegacySecurity() { try { var data = await apiFetch("/api/security/status"); if (data && data.status === "legacy") { - openSecurityWarningModal(data.warning || "This machine may have a known factory password. Please change your passwords immediately."); + _securityIsLegacy = true; + _securityWarningMessage = data.warning || "This machine may have a known factory password. Please change your passwords immediately."; } } catch (_) { // Non-fatal — silently ignore if the endpoint is unreachable diff --git a/app/sovran_systemsos_web/static/js/state.js b/app/sovran_systemsos_web/static/js/state.js index 597213a..34ae694 100644 --- a/app/sovran_systemsos_web/static/js/state.js +++ b/app/sovran_systemsos_web/static/js/state.js @@ -99,10 +99,9 @@ const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn"); const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn"); const $upgradeCloseBtn = document.getElementById("upgrade-close-btn"); -// Legacy security warning modal -const $securityWarningModal = document.getElementById("security-warning-modal"); -const $securityWarningMessage = document.getElementById("security-warning-message"); -const $securityWarningDismiss = document.getElementById("security-warning-dismiss-btn"); +// Legacy security warning state (populated by checkLegacySecurity in security.js) +var _securityIsLegacy = false; +var _securityWarningMessage = ""; // System status banner // (removed — health is now shown per-tile via the composite health field) \ No newline at end of file diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index 2b014a6..fb51b38 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -210,26 +210,6 @@
    - - -
    -- 2.53.0 From 37874ff58e0fe6a9fcde61ff62ae7524da81a3aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:26:26 +0000 Subject: [PATCH 442/857] Initial plan -- 2.53.0 From 2360b4147c19c6475ddaa738aa15da4ed924dedf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:29:08 +0000 Subject: [PATCH 443/857] fix: recover stale RUNNING status files on Hub server startup Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/22f9df39-fb39-4ffb-8c6b-c7323a894bee Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 52 ++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 2f5b355..a7fb835 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -3139,3 +3139,55 @@ async def _startup_save_ip(): loop = asyncio.get_event_loop() ip = await loop.run_in_executor(None, _get_internal_ip) _save_internal_ip(ip) + + +# ── Startup: recover stale RUNNING status files ────────────────── + +_SAFE_UNIT_RE = re.compile(r'^[a-zA-Z0-9@._\-]+\.service$') + + +def _recover_stale_status(status_file: str, log_file: str, unit_name: str): + """If status_file says RUNNING but the systemd unit is not active, reset to FAILED.""" + if not _SAFE_UNIT_RE.match(unit_name): + return + + try: + with open(status_file, "r") as f: + status = f.read().strip() + except FileNotFoundError: + return + + if status != "RUNNING": + return + + try: + result = subprocess.run( + ["systemctl", "is-active", unit_name], + capture_output=True, text=True, timeout=10, + ) + active = result.stdout.strip() == "active" + except Exception: + active = False + + if not active: + try: + with open(status_file, "w") as f: + f.write("FAILED") + except OSError: + pass + try: + with open(log_file, "a") as f: + f.write( + "\n[Hub] Process was interrupted (stale RUNNING status detected" + " on startup). Marking as failed.\n" + ) + except OSError: + pass + + +@app.on_event("startup") +async def _startup_recover_stale_status(): + """Reset stale RUNNING status files left by interrupted update/rebuild jobs.""" + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _recover_stale_status, UPDATE_STATUS, UPDATE_LOG, UPDATE_UNIT) + await loop.run_in_executor(None, _recover_stale_status, REBUILD_STATUS, REBUILD_LOG, REBUILD_UNIT) -- 2.53.0 From 06bdf999a655cf4a4aca6cfbaed63a9d39841f84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:41:02 +0000 Subject: [PATCH 444/857] Initial plan -- 2.53.0 From 3baffb2a697c65283989fc109a94e25574fc3f7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:42:51 +0000 Subject: [PATCH 445/857] Initial plan -- 2.53.0 From a8128cef8dad74d6a6999aef526f383a652c3717 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:43:50 +0000 Subject: [PATCH 446/857] Fix chpasswd: find binary in Nix store and pipe password inline Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/630a25f6-417a-47de-b163-b519252b403c Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/installer.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/iso/installer.py b/iso/installer.py index 7e35402..f27df18 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -1000,9 +1000,20 @@ class InstallerWindow(Adw.ApplicationWindow): raise RuntimeError(proc.stderr.strip() or "Failed to write password file") run(["sudo", "chmod", "600", "/mnt/var/lib/secrets/free-password"]) + # Locate chpasswd in the installed system's Nix store + chpasswd_find = subprocess.run( + ["sudo", "find", "/mnt/nix/store", "-name", "chpasswd", "-type", "f", "-path", "*/bin/chpasswd"], + capture_output=True, text=True + ) + chpasswd_paths = chpasswd_find.stdout.strip().splitlines() + if not chpasswd_paths: + raise RuntimeError("chpasswd binary not found in /mnt/nix/store") + # Use the first match; strip the /mnt prefix for chroot-relative path + chpasswd_bin = chpasswd_paths[0][len("/mnt"):] + proc = subprocess.run( - ["sudo", "nixos-enter", "--root", "/mnt", "--", "sh", "-c", "chpasswd"], - input=f"free:{password}", + ["sudo", "chroot", "/mnt", "sh", "-c", + f"echo 'free:{password}' | {chpasswd_bin}"], capture_output=True, text=True ) if proc.returncode != 0: -- 2.53.0 From ff1632dcda3c5f0f280fc52db9ea63cde5c9a299 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:44:57 +0000 Subject: [PATCH 447/857] Fix Change Passwords button: add API endpoint, system password modal, fix security banner link Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/bf43bea9-9f93-4f7b-b6fd-c76714e7f25b Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 63 +++++++++++++++++ .../static/js/features.js | 2 +- .../static/js/service-detail.js | 68 +++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index a7fb835..f626b70 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -2952,6 +2952,69 @@ async def api_security_status(): return {"status": status, "warning": warning} +# ── System password change ──────────────────────────────────────── + +FREE_PASSWORD_FILE = "/var/lib/secrets/free-password" + + +class ChangePasswordRequest(BaseModel): + new_password: str + confirm_password: str + + +@app.post("/api/change-password") +async def api_change_password(req: ChangePasswordRequest): + """Change the system 'free' user password. + + Updates /etc/shadow via chpasswd and writes the new password to + /var/lib/secrets/free-password so the Hub credentials view stays in sync. + Also clears the legacy security-status and security-warning files so the + security banner disappears after a successful change. + """ + if not req.new_password: + raise HTTPException(status_code=400, detail="New password must not be empty.") + if req.new_password != req.confirm_password: + raise HTTPException(status_code=400, detail="Passwords do not match.") + if len(req.new_password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters long.") + + # Update /etc/shadow via chpasswd + try: + result = subprocess.run( + ["chpasswd"], + input=f"free:{req.new_password}", + capture_output=True, + text=True, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() or "chpasswd failed." + raise HTTPException(status_code=500, detail=detail) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Failed to update system password: {exc}") + + # Write new password to secrets file so Hub credentials stay in sync + try: + os.makedirs(os.path.dirname(FREE_PASSWORD_FILE), exist_ok=True) + with open(FREE_PASSWORD_FILE, "w") as f: + f.write(req.new_password) + os.chmod(FREE_PASSWORD_FILE, 0o600) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Failed to write secrets file: {exc}") + + # Clear legacy security status so the warning banner is removed + for path in (SECURITY_STATUS_FILE, SECURITY_WARNING_FILE): + try: + os.remove(path) + except FileNotFoundError: + pass + except Exception: + pass # Non-fatal; don't block a successful password change + + return {"ok": True} + + # ── Matrix user management ──────────────────────────────────────── MATRIX_USERS_FILE = "/var/lib/secrets/matrix-users" diff --git a/app/sovran_systemsos_web/static/js/features.js b/app/sovran_systemsos_web/static/js/features.js index eaeee3c..831ceff 100644 --- a/app/sovran_systemsos_web/static/js/features.js +++ b/app/sovran_systemsos_web/static/js/features.js @@ -606,7 +606,7 @@ function renderAutolaunchToggle(enabled) { '
    ' + '⚠' + '' + msg + '' + - 'Change Passwords' + + 'Change Passwords' + '
    '; } diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js index 9ed9eef..9ab3592 100644 --- a/app/sovran_systemsos_web/static/js/service-detail.js +++ b/app/sovran_systemsos_web/static/js/service-detail.js @@ -280,6 +280,10 @@ async function openServiceDetailModal(unit, name, icon) { '' + '' + '
    ' : "") + + (unit === "root-password-setup.service" ? + '
    ' + + '' + + '
    ' : "") + '
    '; } else if (!data.enabled && !data.feature) { html += '
    ' + @@ -366,6 +370,11 @@ async function openServiceDetailModal(unit, name, icon) { if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name, icon); }); } + if (unit === "root-password-setup.service") { + var sysPwBtn = document.getElementById("sys-change-pw-btn"); + if (sysPwBtn) sysPwBtn.addEventListener("click", function() { openSystemChangePasswordModal(unit, name, icon); }); + } + if (data.feature) { var addonBtn = document.getElementById("svc-detail-addon-btn"); if (addonBtn) { @@ -535,4 +544,63 @@ function openMatrixChangePasswordModal(unit, name, icon) { }); } +function openSystemChangePasswordModal(unit, name, icon) { + if (!$credsBody) return; + $credsBody.innerHTML = + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '' + + '' + + '
    ' + + '
    '; + + document.getElementById("sys-chpw-back-btn").addEventListener("click", function() { + openServiceDetailModal(unit, name, icon); + }); + + document.getElementById("sys-chpw-submit-btn").addEventListener("click", async function() { + var submitBtn = document.getElementById("sys-chpw-submit-btn"); + var resultEl = document.getElementById("sys-chpw-result"); + var newPassword = document.getElementById("sys-chpw-new").value || ""; + var confirmPassword = document.getElementById("sys-chpw-confirm").value || ""; + + if (!newPassword || !confirmPassword) { + resultEl.className = "matrix-form-result error"; + resultEl.textContent = "Both password fields are required."; + return; + } + + submitBtn.disabled = true; + submitBtn.textContent = "Changing…"; + resultEl.className = "matrix-form-result"; + resultEl.textContent = ""; + + try { + await apiFetch("/api/change-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ new_password: newPassword, confirm_password: confirmPassword }) + }); + resultEl.className = "matrix-form-result success"; + resultEl.textContent = "āœ… System password changed successfully."; + submitBtn.textContent = "Change Password"; + submitBtn.disabled = false; + // Hide the legacy security banner if it's visible + if (typeof _securityIsLegacy !== "undefined" && _securityIsLegacy) { + _securityIsLegacy = false; + var banner = document.querySelector(".security-inline-banner"); + if (banner) banner.remove(); + } + } catch (err) { + resultEl.className = "matrix-form-result error"; + resultEl.textContent = "āŒ " + (err.message || "Failed to change password."); + submitBtn.textContent = "Change Password"; + submitBtn.disabled = false; + } + }); +} + function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); } -- 2.53.0 From 84124ba1b14027c9aeeb4a724ad8a06bd06bb3ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:57:23 +0000 Subject: [PATCH 448/857] Initial plan -- 2.53.0 From badab99242af4facb564565a7f74bddefa0a9412 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:01:54 +0000 Subject: [PATCH 449/857] Fix chpasswd path on NixOS, add password toggle/hints/validation in change-password form Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/de03873d-5cdb-4929-bd4a-4d306916b525 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 15 ++++- .../static/css/security.css | 65 +++++++++++++++++++ .../static/js/service-detail.js | 42 +++++++++++- 3 files changed, 119 insertions(+), 3 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index f626b70..b6b620b 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -9,6 +9,7 @@ import json import os import pwd import re +import shutil import socket import subprocess import time @@ -2978,10 +2979,22 @@ async def api_change_password(req: ChangePasswordRequest): if len(req.new_password) < 8: raise HTTPException(status_code=400, detail="Password must be at least 8 characters long.") + # Locate chpasswd binary (NixOS puts it in the Nix store, not /usr/bin) + chpasswd_bin = ( + shutil.which("chpasswd") + or ("/run/current-system/sw/bin/chpasswd" + if os.path.isfile("/run/current-system/sw/bin/chpasswd") else None) + ) + if chpasswd_bin is None: + raise HTTPException( + status_code=500, + detail="chpasswd binary not found. Cannot update system password.", + ) + # Update /etc/shadow via chpasswd try: result = subprocess.run( - ["chpasswd"], + [chpasswd_bin], input=f"free:{req.new_password}", capture_output=True, text=True, diff --git a/app/sovran_systemsos_web/static/css/security.css b/app/sovran_systemsos_web/static/css/security.css index 6201ccd..7871552 100644 --- a/app/sovran_systemsos_web/static/css/security.css +++ b/app/sovran_systemsos_web/static/css/security.css @@ -40,3 +40,68 @@ .security-inline-link:hover { background-color: rgba(180, 100, 0, 0.22); } + +/* ── System change-password form extras ──────────────────────────── */ + +.sys-chpw-header { + margin-bottom: 14px; +} + +.sys-chpw-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.sys-chpw-desc { + font-size: 0.82rem; + color: var(--text-secondary); + line-height: 1.5; +} + +.pw-input-wrap { + position: relative; + display: flex; + align-items: center; +} + +.pw-input-wrap .matrix-form-input { + padding-right: 2.4rem; + width: 100%; +} + +.pw-toggle-btn { + position: absolute; + right: 6px; + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + padding: 2px 4px; + line-height: 1; + color: var(--text-secondary); + opacity: 0.75; + transition: opacity 0.15s; +} + +.pw-toggle-btn:hover { + opacity: 1; +} + +.pw-hint { + font-size: 0.76rem; + color: var(--text-secondary); + margin-top: 4px; +} + +.pw-credentials-note { + font-size: 0.78rem; + color: #c97a00; + background-color: rgba(180, 100, 0, 0.10); + border-left: 2px solid #c97a00; + border-radius: 4px; + padding: 7px 10px; + margin-bottom: 12px; + line-height: 1.5; +} diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js index 9ab3592..d53572f 100644 --- a/app/sovran_systemsos_web/static/js/service-detail.js +++ b/app/sovran_systemsos_web/static/js/service-detail.js @@ -547,10 +547,22 @@ function openMatrixChangePasswordModal(unit, name, icon) { function openSystemChangePasswordModal(unit, name, icon) { if (!$credsBody) return; $credsBody.innerHTML = + '
    ' + + '
    šŸ”‘ Change \'free\' Account Password
    ' + + '
    This updates the system login password for the free user account on this device.
    ' + + '
    ' + '
    ' + - '
    ' + + '
    ' + + '' + + '' + + '
    ' + + '
    Password must be at least 8 characters.
    ' + '
    ' + - '
    ' + + '
    ' + + '' + + '' + + '
    ' + + '
    ⚠ After changing, your updated password will appear in the System Passwords credentials tile. Make sure to remember it.
    ' + '
    ' + '' + '' + @@ -561,6 +573,20 @@ function openSystemChangePasswordModal(unit, name, icon) { openServiceDetailModal(unit, name, icon); }); + document.getElementById("sys-chpw-new-toggle").addEventListener("click", function() { + var inp = document.getElementById("sys-chpw-new"); + var isHidden = inp.type === "password"; + inp.type = isHidden ? "text" : "password"; + this.textContent = isHidden ? "šŸ‘ā€šŸ—Ø" : "šŸ‘"; + }); + + document.getElementById("sys-chpw-confirm-toggle").addEventListener("click", function() { + var inp = document.getElementById("sys-chpw-confirm"); + var isHidden = inp.type === "password"; + inp.type = isHidden ? "text" : "password"; + this.textContent = isHidden ? "šŸ‘ā€šŸ—Ø" : "šŸ‘"; + }); + document.getElementById("sys-chpw-submit-btn").addEventListener("click", async function() { var submitBtn = document.getElementById("sys-chpw-submit-btn"); var resultEl = document.getElementById("sys-chpw-result"); @@ -573,6 +599,18 @@ function openSystemChangePasswordModal(unit, name, icon) { return; } + if (newPassword.length < 8) { + resultEl.className = "matrix-form-result error"; + resultEl.textContent = "Password must be at least 8 characters."; + return; + } + + if (newPassword !== confirmPassword) { + resultEl.className = "matrix-form-result error"; + resultEl.textContent = "Passwords do not match."; + return; + } + submitBtn.disabled = true; submitBtn.textContent = "Changing…"; resultEl.className = "matrix-form-result"; -- 2.53.0 From deae53b721c5ca0d969f1a06243e01ffa2a32809 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:13:16 +0000 Subject: [PATCH 450/857] Initial plan -- 2.53.0 From 65ce66a5412970451bc0b492c0488113957dda04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:14:32 +0000 Subject: [PATCH 451/857] Fix chpasswd: run directly from host with --root /mnt, no chroot needed Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3ff98bf4-8f62-4c81-90fd-36854e88266f Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- iso/installer.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/iso/installer.py b/iso/installer.py index f27df18..2600151 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -1000,24 +1000,27 @@ class InstallerWindow(Adw.ApplicationWindow): raise RuntimeError(proc.stderr.strip() or "Failed to write password file") run(["sudo", "chmod", "600", "/mnt/var/lib/secrets/free-password"]) - # Locate chpasswd in the installed system's Nix store + # Find chpasswd in the installed system's Nix store + # We run it directly from the host with --root /mnt so it + # modifies /mnt/etc/shadow — no chroot needed. chpasswd_find = subprocess.run( - ["sudo", "find", "/mnt/nix/store", "-name", "chpasswd", "-type", "f", "-path", "*/bin/chpasswd"], + ["sudo", "find", "/mnt/nix/store", "-maxdepth", "3", + "-name", "chpasswd", "-type", "f", "-path", "*/bin/chpasswd"], capture_output=True, text=True ) chpasswd_paths = chpasswd_find.stdout.strip().splitlines() if not chpasswd_paths: raise RuntimeError("chpasswd binary not found in /mnt/nix/store") - # Use the first match; strip the /mnt prefix for chroot-relative path - chpasswd_bin = chpasswd_paths[0][len("/mnt"):] + # Use the full host path (e.g. /mnt/nix/store/...-shadow-xxx/bin/chpasswd) + chpasswd_bin = chpasswd_paths[0] proc = subprocess.run( - ["sudo", "chroot", "/mnt", "sh", "-c", - f"echo 'free:{password}' | {chpasswd_bin}"], + ["sudo", chpasswd_bin, "--root", "/mnt"], + input=f"free:{password}", capture_output=True, text=True ) if proc.returncode != 0: - raise RuntimeError(proc.stderr.strip() or "Failed to set password in chroot") + raise RuntimeError(proc.stderr.strip() or "Failed to set password") run(["sudo", "touch", "/mnt/var/lib/sovran-customer-onboarded"]) except Exception as e: -- 2.53.0 From f2a808ed13020e73959daf534dae793ac72996e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:29:46 +0000 Subject: [PATCH 452/857] Initial plan -- 2.53.0 From d28f224ad538fe819dc7a4f71d3a37786830cb8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:36:59 +0000 Subject: [PATCH 453/857] feat: add password creation step to onboarding wizard (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /api/security/password-is-default endpoint in server.py - Add Step 2 (Create Your Password) to onboarding wizard HTML - Renumber old steps: Domains→3, Ports→4, Complete→5 - Add 5th step dot indicator - Update onboarding.js: TOTAL_STEPS=5, ROLE_SKIP_STEPS=[3,4] for desktop/node - Add loadStep2/saveStep2 for password step with smart default detection - Rename old step functions to loadStep3/saveStep3/loadStep4 - Add password form CSS styles in onboarding.css Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/74a30916-fb2d-4f1d-9763-e380b1aa5540 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 11 + .../static/css/onboarding.css | 104 ++++++++++ app/sovran_systemsos_web/static/onboarding.js | 191 +++++++++++++++--- .../templates/onboarding.html | 52 +++-- 4 files changed, 319 insertions(+), 39 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index b6b620b..165aedc 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -2953,6 +2953,17 @@ async def api_security_status(): return {"status": status, "warning": warning} +@app.get("/api/security/password-is-default") +async def api_password_is_default(): + """Check if the free account password is still the factory default.""" + try: + with open("/var/lib/secrets/free-password", "r") as f: + current = f.read().strip() + return {"is_default": current == "free"} + except FileNotFoundError: + return {"is_default": True} + + # ── System password change ──────────────────────────────────────── FREE_PASSWORD_FILE = "/var/lib/secrets/free-password" diff --git a/app/sovran_systemsos_web/static/css/onboarding.css b/app/sovran_systemsos_web/static/css/onboarding.css index 42343bb..c250f39 100644 --- a/app/sovran_systemsos_web/static/css/onboarding.css +++ b/app/sovran_systemsos_web/static/css/onboarding.css @@ -575,6 +575,110 @@ color: var(--text-secondary); } +/* ── Password step (Step 2) ─────────────────────────────────────── */ + +.onboarding-password-group { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 0; +} + +.onboarding-password-input-wrap { + display: flex; + align-items: center; + gap: 6px; +} + +.onboarding-password-input { + flex: 1; + padding: 9px 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-btn); + background-color: var(--card-color); + color: var(--text-primary); + font-size: 0.88rem; + font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif; + transition: border-color 0.15s; +} + +.onboarding-password-input:focus { + outline: none; + border-color: var(--accent-color); +} + +.onboarding-password-toggle { + padding: 6px 10px; + background-color: var(--card-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-btn); + color: var(--text-secondary); + cursor: pointer; + font-size: 1rem; + line-height: 1; + transition: background-color 0.15s, border-color 0.15s; + flex-shrink: 0; +} + +.onboarding-password-toggle:hover { + background-color: rgba(137, 180, 250, 0.12); + border-color: var(--accent-color); +} + +.onboarding-password-hint { + font-size: 0.78rem; + color: var(--text-dim); + line-height: 1.4; +} + +.onboarding-password-warning { + padding: 10px 14px; + background-color: rgba(229, 165, 10, 0.1); + border: 1px solid rgba(229, 165, 10, 0.35); + border-radius: 8px; + font-size: 0.85rem; + color: var(--yellow); + line-height: 1.5; + margin-top: 6px; +} + +.onboarding-password-success { + padding: 12px 16px; + background-color: rgba(166, 227, 161, 0.1); + border: 1px solid rgba(166, 227, 161, 0.35); + border-radius: 8px; + font-size: 0.92rem; + color: var(--green); + line-height: 1.5; +} + +.onboarding-password-optional { + margin-top: 12px; + font-size: 0.88rem; +} + +.onboarding-password-optional > summary { + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; + color: var(--accent-color); + list-style: none; + user-select: none; +} + +.onboarding-password-optional > summary::-webkit-details-marker { + display: none; +} + +.onboarding-password-optional > summary::before { + content: 'ā–¶ '; + font-size: 0.65em; +} + +.onboarding-password-optional[open] > summary::before { + content: 'ā–¼ '; +} + /* ── Reboot overlay ─────────────────────────────────────────────── */ .reboot-overlay { diff --git a/app/sovran_systemsos_web/static/onboarding.js b/app/sovran_systemsos_web/static/onboarding.js index 55a7499..4821262 100644 --- a/app/sovran_systemsos_web/static/onboarding.js +++ b/app/sovran_systemsos_web/static/onboarding.js @@ -1,21 +1,25 @@ /* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard - Drives the 4-step post-install setup flow. */ + Drives the 5-step post-install setup flow. */ "use strict"; // ── Constants ───────────────────────────────────────────────────── -const TOTAL_STEPS = 4; +const TOTAL_STEPS = 5; -// Steps to skip per role (steps 2 and 3 involve domain/port setup) +// Steps to skip per role (steps 3 and 4 involve domain/port setup) +// Step 2 (password) is NEVER skipped — all roles need it. const ROLE_SKIP_STEPS = { - "desktop": [2, 3], - "node": [2, 3], + "desktop": [3, 4], + "node": [3, 4], }; // ── Role state (loaded at init) ─────────────────────────────────── var _onboardingRole = "server_plus_desktop"; +// Password default state (loaded at step 2) +var _passwordIsDefault = true; + // Domains that may need configuration, with service unit mapping for enabled check const DOMAIN_DEFS = [ { name: "matrix", label: "Matrix (Synapse)", unit: "matrix-synapse.service", needsDdns: true }, @@ -91,6 +95,8 @@ function showStep(step) { // Lazy-load step content if (step === 2) loadStep2(); if (step === 3) loadStep3(); + if (step === 4) loadStep4(); + // Step 5 (Complete) is static — no lazy-load needed } // Return the next step number, skipping over role-excluded steps @@ -119,12 +125,135 @@ async function loadStep1() { } catch (_) {} } -// ── Step 2: Domain Configuration ───────────────────────────────── +// ── Step 2: Create Your Password ───────────────────────────────── async function loadStep2() { var body = document.getElementById("step-2-body"); if (!body) return; + var nextBtn = document.getElementById("step-2-next"); + + try { + var result = await apiFetch("/api/security/password-is-default"); + _passwordIsDefault = result.is_default !== false; + } catch (_) { + _passwordIsDefault = true; + } + + if (_passwordIsDefault) { + // Factory-sealed scenario: password must be set before continuing + if (nextBtn) nextBtn.textContent = "Set Password & Continue \u2192"; + + body.innerHTML = + '
    ' + + '' + + '
    ' + + '' + + '' + + '
    ' + + '

    Minimum 8 characters

    ' + + '
    ' + + '
    ' + + '' + + '
    ' + + '' + + '' + + '
    ' + + '
    ' + + '
    āš ļø Write this password down — it cannot be recovered.
    '; + + // Wire show/hide toggles + body.querySelectorAll(".onboarding-password-toggle").forEach(function(btn) { + btn.addEventListener("click", function() { + var inp = document.getElementById(btn.dataset.target); + if (inp) inp.type = (inp.type === "password") ? "text" : "password"; + }); + }); + + } else { + // DIY install scenario: password already set by installer + if (nextBtn) nextBtn.textContent = "Continue \u2192"; + + body.innerHTML = + '
    āœ… Your password was already set during installation.
    ' + + '
    ' + + 'Change it anyway' + + '
    ' + + '
    ' + + '' + + '
    ' + + '' + + '' + + '
    ' + + '

    Minimum 8 characters

    ' + + '
    ' + + '
    ' + + '' + + '
    ' + + '' + + '' + + '
    ' + + '
    ' + + '
    āš ļø Write this password down — it cannot be recovered.
    ' + + '
    ' + + '
    '; + + // Wire show/hide toggles + body.querySelectorAll(".onboarding-password-toggle").forEach(function(btn) { + btn.addEventListener("click", function() { + var inp = document.getElementById(btn.dataset.target); + if (inp) inp.type = (inp.type === "password") ? "text" : "password"; + }); + }); + } +} + +async function saveStep2() { + var newPw = document.getElementById("pw-new"); + var confirmPw = document.getElementById("pw-confirm"); + + // If no fields visible or both empty and password already set → skip + if (!newPw || !newPw.value.trim()) { + if (!_passwordIsDefault) return true; // already set, no change requested + setStatus("step-2-status", "⚠ Please enter a password.", "error"); + return false; + } + + var pw = newPw.value; + var cpw = confirmPw ? confirmPw.value : ""; + + if (pw.length < 8) { + setStatus("step-2-status", "⚠ Password must be at least 8 characters.", "error"); + return false; + } + if (pw !== cpw) { + setStatus("step-2-status", "⚠ Passwords do not match.", "error"); + return false; + } + + setStatus("step-2-status", "Saving password…", "info"); + try { + await apiFetch("/api/change-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ new_password: pw, confirm_password: cpw }), + }); + } catch (err) { + setStatus("step-2-status", "⚠ " + err.message, "error"); + return false; + } + + setStatus("step-2-status", "āœ“ Password saved", "ok"); + _passwordIsDefault = false; + return true; +} + +// ── Step 3: Domain Configuration ───────────────────────────────── + +async function loadStep3() { + var body = document.getElementById("step-3-body"); + if (!body) return; + try { // Fetch services, domains, and network info in parallel var results = await Promise.all([ @@ -194,8 +323,8 @@ async function loadStep2() { body.innerHTML = html; } -async function saveStep2() { - setStatus("step-2-status", "Saving domains…", "info"); +async function saveStep3() { + setStatus("step-3-status", "Saving domains…", "info"); var errors = []; // Save each domain input @@ -235,18 +364,18 @@ async function saveStep2() { } if (errors.length > 0) { - setStatus("step-2-status", "⚠ Some errors: " + errors.join("; "), "error"); + setStatus("step-3-status", "⚠ Some errors: " + errors.join("; "), "error"); return false; } - setStatus("step-2-status", "āœ“ Saved", "ok"); + setStatus("step-3-status", "āœ“ Saved", "ok"); return true; } -// ── Step 3: Port Forwarding ─────────────────────────────────────── +// ── Step 4: Port Forwarding ─────────────────────────────────────── -async function loadStep3() { - var body = document.getElementById("step-3-body"); +async function loadStep4() { + var body = document.getElementById("step-4-body"); if (!body) return; body.innerHTML = '

    Checking ports…

    '; @@ -327,10 +456,10 @@ async function loadStep3() { body.innerHTML = html; } -// ── Step 4: Complete ────────────────────────────────────────────── +// ── Step 5: Complete ────────────────────────────────────────────── async function completeOnboarding() { - var btn = document.getElementById("step-4-finish"); + var btn = document.getElementById("step-5-finish"); if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; } try { @@ -345,28 +474,40 @@ async function completeOnboarding() { // ── Event wiring ────────────────────────────────────────────────── function wireNavButtons() { - // Step 1 → next (may skip 2+3 for desktop/node) + // Step 1 → next var s1next = document.getElementById("step-1-next"); if (s1next) s1next.addEventListener("click", function() { showStep(nextStep(1)); }); - // Step 2 → 3 (save first) + // Step 2 → 3 (save password first) var s2next = document.getElementById("step-2-next"); if (s2next) s2next.addEventListener("click", async function() { s2next.disabled = true; + var origText = s2next.textContent; s2next.textContent = "Saving…"; - await saveStep2(); + var ok = await saveStep2(); s2next.disabled = false; - s2next.textContent = "Save & Continue →"; - showStep(nextStep(2)); + s2next.textContent = origText; + if (ok) showStep(nextStep(2)); }); - // Step 3 → 4 (Complete) + // Step 3 → 4 (save domains first) var s3next = document.getElementById("step-3-next"); - if (s3next) s3next.addEventListener("click", function() { showStep(nextStep(3)); }); + if (s3next) s3next.addEventListener("click", async function() { + s3next.disabled = true; + s3next.textContent = "Saving…"; + await saveStep3(); + s3next.disabled = false; + s3next.textContent = "Save & Continue →"; + showStep(nextStep(3)); + }); - // Step 4: finish - var s4finish = document.getElementById("step-4-finish"); - if (s4finish) s4finish.addEventListener("click", completeOnboarding); + // Step 4 → 5 (Complete) + var s4next = document.getElementById("step-4-next"); + if (s4next) s4next.addEventListener("click", function() { showStep(nextStep(4)); }); + + // Step 5: finish + var s5finish = document.getElementById("step-5-finish"); + if (s5finish) s5finish.addEventListener("click", completeOnboarding); // Back buttons document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) { diff --git a/app/sovran_systemsos_web/templates/onboarding.html b/app/sovran_systemsos_web/templates/onboarding.html index cd701a8..edc15d8 100644 --- a/app/sovran_systemsos_web/templates/onboarding.html +++ b/app/sovran_systemsos_web/templates/onboarding.html @@ -34,6 +34,8 @@ 3 4 + + 5
    @@ -70,8 +72,29 @@
    - + + + + - + + + +