From 313f93c9816ac382c8fb0c87b08fa7be1343f88f Mon Sep 17 00:00:00 2001 From: kukirin-ui Date: Tue, 13 Oct 2026 23:56:56 +0200 Subject: [PATCH] Make Nullcloud fully free; offer optional setup services --- .github/FUNDING.yml | 3 +- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- CHANGELOG.md | 8 + LAUNCH.md | 33 ++- README.md | 120 +++++----- docs/CONSULTING.md | 11 +- docs/MONETIZATION.md | 86 -------- extension/popup.html | 25 +-- extension/popup.js | 41 ++-- server/server.py | 243 ++------------------- server/ui.html | 78 +++---- termux/setup.sh | 4 +- termux/termux-url-opener | 8 +- tests/test_server.py | 89 ++------ tools/license/keygen.mjs | 30 --- tools/license/keys/public.b64 | 1 - tools/license/sign.mjs | 52 ----- tools/license/verify.mjs | 39 ---- tools/qa/verify-all.sh | 15 +- 20 files changed, 195 insertions(+), 695 deletions(-) delete mode 100644 docs/MONETIZATION.md delete mode 100755 tools/license/keygen.mjs delete mode 100644 tools/license/keys/public.b64 delete mode 100755 tools/license/sign.mjs delete mode 100755 tools/license/verify.mjs diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e7567e0..dc9b3a5 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1 @@ -github: [kukirin-ui] -custom: ["https://checkout.revolut.com/pay/d25ddfdd-7351-465b-afb6-c6238f65c575"] +custom: ["https://checkout.revolut.com/pay/044a268e-4869-4708-9b0f-8e302cc28c37"] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c1087fd..424d4de 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -19,4 +19,4 @@ body: id: logs attributes: label: Relevant logs - description: Paste output from `~/nullcloud-api.log` or `~/nullcloud-llm.log` (never include your token or license key). + description: Paste output from `~/nullcloud-api.log` or `~/nullcloud-llm.log` (never include your token). diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 3218107..f85b21f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,5 +1,5 @@ name: Feature Request -description: Suggest an improvement for Nullcloud Free Core or Pro Hub +description: Suggest an improvement for Nullcloud title: "[Feature]: " labels: ["enhancement"] body: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c6f365..8b62d50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project are documented here. +## [2.0.0] - 2026-10-13 + +### Changed +- **Nullcloud is now completely free.** Library, PDF input, custom presets and Private Cloud work for everyone. No license key, no paywall. +- Removed the Pro license (ECDSA key check, `/pro/status`, `/pro/activate`, `tools/license/`). Added `GET /features`. +- Income is from optional services only: remote setup €30, setup + priority support €45, Private Cloud (Tailscale) setup €60-80 and team setup from €250 (quote by email). +- README: honest hardware notes (tested on Exynos with GPU/Vulkan), removed unmeasured speed tables, added Termux troubleshooting, replaced the broken Sponsor badge with an optional Revolut donation link. + ## [1.2.0] - 2026-10-13 ### Changed diff --git a/LAUNCH.md b/LAUNCH.md index 28787c5..ad3ff4b 100644 --- a/LAUNCH.md +++ b/LAUNCH.md @@ -9,8 +9,8 @@ - The Android Share Sheet integration (`Share -> Termux -> summary notification`) that works in any app without needing a mobile browser extension is our primary unique wedge. 3. **Unified Simplicity:** - Maintain the single `bash setup.sh` command that deploys all three entry points at once (Browser Extension backend, Web UI, and Android Share Sheet). -4. **Hyper-Optimize the Mobile Experience (CPU + GPU):** - - Pre-configure `Llama-3.2-1B-Instruct Q4_K_M` (4 GB RAM) and `Llama-3.2-3B-Instruct Q4_K_M` (6 GB+ RAM) with both **GPU layer offload (`-ngl`, Vulkan / OpenCL on Adreno & Mali)** and **CPU big.LITTLE thread capping + quantized KV cache (`q8_0`/`q4_0`) + Flash-Attention** so phones hit **5–15+ tokens/second** out of the box. +4. **Optimize the Mobile Experience (GPU offload):** + - Pre-configure `Llama-3.2-1B-Instruct Q4_K_M` (under ~5.5 GB RAM) and `Llama-3.2-3B-Instruct Q4_K_M` (6 GB+ RAM) with GPU layer offload (`-ngl`, Vulkan). Tested on Exynos with GPU (Vulkan). --- @@ -21,30 +21,23 @@ - **Why they pay:** Strict data-handling, attorney-client privilege, confidential source protection, and clinical privacy rules forbid pasting sensitive documents into cloud LLMs. - **Offer:** Fixed-scope turn-key onboarding, hardware-tuned mobile + workstation installation, Tailscale Private Cloud fleet setup, and bespoke domain prompt presets. -### 2. Ship the Premium Productivity Hub ("Nullcloud Pro") -- **Checkout (Revolut only):** - - Nullcloud Pro license, €19 one-time: https://checkout.revolut.com/pay/d25ddfdd-7351-465b-afb6-c6238f65c575 +### 2. Sell Services, Not Features +Nullcloud is 100% free (library, PDF, presets and Private Cloud included). Income comes only from optional services: - Remote setup, €30: https://checkout.revolut.com/pay/ee0aad48-1ff6-452b-9470-01ae6db706a4 - - Pro + setup bundle, €45: https://checkout.revolut.com/pay/c60fa94d-e094-4c1f-aac4-ae04c4776c52 - - Tailscale private cloud (€60-80) and team consulting (from €250): email Pozun.alen96@gmail.com for a quote. -- **Flow:** The buyer pays via the link, then emails Pozun.alen96@gmail.com with the payment confirmation. The license key is sent manually. -- **Offline Cryptographic Key:** Issued with `node tools/license/sign.mjs --ref ORDER-ID --sku pro` and verified 100% offline in `server/server.py` (ECDSA P-256, zero phone-home). -- **Pro Features Locked Behind License:** - 1. **Saved-Page Library with Search:** Local SQLite FTS5 index (`~/.nullcloud-library.db`) searching across titles, URLs, page text, and summaries offline. - 2. **PDF Input Capabilities:** Upload or share any PDF for on-device extraction and summarization. - 3. **Custom Prompt Presets:** Built-in Legal, Journalism, Clinical, and Executive templates plus user-defined presets. - 4. **Private Cloud Server (`TAILSCALE=1`):** Turns the user's phone into an encrypted private AI server accessible from their laptop via Tailscale. - -### 3. Leverage the Free MIT Core for Distribution -- Keep the core MIT-licensed with zero crippleware on standard single-page summarization so it spreads organically on Hacker News, `r/LocalLLaMA`, `r/termux`, `r/privacy`, and `r/selfhosted`. -- Enable community support via **GitHub Sponsors** (`.github/FUNDING.yml`). The custom funding link is the €19 Pro checkout. + - Setup + priority support, €45: https://checkout.revolut.com/pay/c60fa94d-e094-4c1f-aac4-ae04c4776c52 + - Private Cloud (Tailscale) setup €60-80 and team setup from €250: email Pozun.alen96@gmail.com for a quote. +- **Flow:** The buyer pays via the link, then emails Pozun.alen96@gmail.com with the payment confirmation to book a time. + +### 3. Free Core for Distribution +- Everything stays MIT-licensed and free, so it spreads on Hacker News, `r/LocalLLaMA`, `r/termux`, `r/privacy`, and `r/selfhosted`. +- Optional donations via Revolut (link in the README). --- ## Hero Assets Before Posting 1. **20-Second "Airplane Mode" Video:** - - Show phone -> swipe down Quick Settings -> **Airplane Mode ON** -> tap Share -> Termux (or click Run in Web UI) -> summary streams in at 10+ tok/s -> notification pops up. + - Show phone -> swipe down Quick Settings -> **Airplane Mode ON** -> tap Share -> Termux (or click Run in Web UI) -> summary streams in -> notification pops up. 2. **Real Device Benchmarks (`bash termux/bench.sh 20`):** - Run 20 iterations in Airplane Mode with charger unplugged and record tok/s, seconds/article, battery drop, and temperature delta. @@ -55,7 +48,7 @@ | Day | Channel | Angle | |---|---|---| | **Day 1** (Tue–Thu, 14:00–16:00 UTC) | **Hacker News (`Show HN`)** | *"Show HN: Nullcloud – Summarize any page with an LLM running on your phone (on-device, no cloud AI)"* | -| **Day 1** | **`r/LocalLLaMA`** | Benchmarks: Llama 3.2 1B & 3B tok/s on 4GB–6GB RAM phones (CPU big.LITTLE + Vulkan/OpenCL GPU `-ngl`), KV cache tuning, battery/thermal numbers | +| **Day 1** | **`r/LocalLLaMA`** | Real `bench.sh` numbers from an Exynos phone with GPU (Vulkan) offload, battery/thermal numbers | | **Day 2** | **`r/termux`, `r/selfhosted`, `r/privacy`** | Tailored posts: `termux-url-opener` share sheet, Tailscale Private Cloud, zero telemetry | | **Day 2** | **`r/androidapps`, XDA Forums** | Android Share Sheet -> private summary notification in any browser without extensions | | **Day 3** | **X, Mastodon, Bluesky** | 20-second Airplane Mode video + benchmark table + repo link | diff --git a/README.md b/README.md index 6e30dc0..a6afc1a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-0d9488.svg)](LICENSE) [![On-Device AI](https://img.shields.io/badge/AI-On--Device%20%7C%20No%20Cloud-0f172a.svg)](#airplane-mode--offline-niche) -[![Sponsor](https://img.shields.io/badge/Sponsor-GitHub-ea4aaa.svg)](.github/FUNDING.yml) +[![Free](https://img.shields.io/badge/Price-Free-0d9488.svg)](#everything-is-free) [![Donate](https://img.shields.io/badge/Donate-Revolut-ea4aaa.svg)](https://checkout.revolut.com/pay/044a268e-4869-4708-9b0f-8e302cc28c37) > **AI that reads the web for you — without the web reading you.** > Strictly **on-device, no cloud AI**. Summarize, fact-extract, translate, or question any web page, text selection, or PDF using a local model running on your own phone or computer. Works completely offline in **Airplane Mode**. @@ -20,8 +20,9 @@ - **Android Share Sheet** (`~/bin/termux-url-opener`) - **Web UI & Productivity Hub** (`http://127.0.0.1:8080` — works in any browser) - **Browser Extension** (Chrome, Brave, Edge, Firefox) -4. **Hyper-Optimized Mobile CPU + GPU Experience (5–15+ tok/s Out of the Box):** - `termux/setup.sh` inspects `/proc/meminfo` and your mobile GPU (Snapdragon **Adreno**, Arm **Mali / Immortalis**, or **Vulkan / OpenCL** ICD), automatically configuring `Llama-3.2-1B-Instruct Q4_K_M` on **4 GB RAM** devices or `Llama-3.2-3B-Instruct Q4_K_M` on **6 GB+ RAM** devices with GPU layer offload (`-ngl 99`), ARM64 NEON/dotprod big.LITTLE CPU thread capping, Flash-Attention (`--flash-attn`), quantized KV cache (`q8_0`/`q4_0`), and a 45°C battery thermal guard. +4. **GPU Offload on Android (Vulkan):** + `termux/setup.sh` checks `/proc/meminfo`, picks `Llama-3.2-1B-Instruct Q4_K_M` (under ~5.5 GB RAM) or `Llama-3.2-3B-Instruct Q4_K_M` (6 GB+), and offloads layers to the GPU (`-ngl 99`). Tested on a Samsung **Exynos** phone with **GPU (Vulkan)**. Other phones may work but are not tested yet. +5. **Everything Is Free:** Library, PDF input, presets and Private Cloud are free for everyone. No license key, no paywall. --- @@ -61,78 +62,66 @@ --- -## Mobile & Desktop Hyper-Optimization (CPU + GPU Hybrid Engine) +## Performance -Nullcloud tunes **both GPU and CPU** execution paths out of the box—and lets you adjust GPU offload layers (`-ngl`) and CPU threads live inside the Web UI (**Private Cloud & Pro -> CPU & GPU Hardware Acceleration Tweaks**) or Browser Extension: +Tested on a Samsung Exynos phone using GPU offload (Vulkan, `BUILD_VULKAN=1`). Tune GPU offload layers (`-ngl`) in the Web UI (**Private Cloud & Services -> Hardware Tweaks**) or the browser extension. -- **GPU Tweaks:** - - **Mobile GPU Offload (`-ngl 99` / `16` / `8`):** Auto-detects **Qualcomm Adreno** (`/sys/class/kgsl/kgsl-3d0/gpu_model`), **Arm Mali / Immortalis**, and **Vulkan / OpenCL** drivers. Offloads transformer layers (`-ngl`) and enables `--flash-attn` with automatic progressive fallback to CPU if a prebuilt binary lacks Vulkan kernels. - - **Desktop GPU Offload:** Auto-detects **Apple Silicon Metal**, **NVIDIA CUDA**, **AMD ROCm**, and **Vulkan**, enabling `OLLAMA_FLASH_ATTENTION=1` and quantized KV cache (`OLLAMA_KV_CACHE_TYPE=q8_0`). -- **CPU Tweaks:** - - **ARM64 big.LITTLE Thread Capping:** Caps threads to performance cores (`4` threads on 1B / `6` threads on 3B) to prevent efficiency-core thrashing and thermal throttling. - - **Quantized KV Cache (`q8_0` / `q4_0`) & Micro-Batching (`-b 512 -ub 128`):** Cuts memory bandwidth and prefill latency by ~40% on 4 GB–6 GB RAM phones. - -| Device Tier | Auto-Selected Model | Compute (CPU + GPU) | Context (`-c`) | KV Cache | Typical Speed | +| Device | Model | tok/s | s/article | Battery / 20 articles | Temp change | |---|---|---|---|---|---| -| **Phone, 4 GB RAM** (`<5.5 GB`) | `Llama-3.2-1B-Instruct Q4_K_M` (~0.8 GB) | 4 big-cores + Adreno/Mali GPU (`-ngl`) | `2048` | `q8_0` / `q4_0` | **10–18 tok/s** | -| **Phone, 6+ GB RAM** (`>=5.5 GB`) | `Llama-3.2-3B-Instruct Q4_K_M` (~2.0 GB) | 6 big-cores + Adreno/Mali GPU (`-ngl`) | `3072` | `q8_0` / `q4_0` | **5–15 tok/s** | -| **Desktop** (macOS/Linux/Win) | `llama3.2:3b` via Ollama (or custom) | Metal / CUDA / ROCm / AVX2 (`-ngl 99`) | `4096+` | `q8_0` + Flash-Attn | **25–80+ tok/s** | +| Exynos phone, GPU (Vulkan) | Llama-3.2-3B Q4_K_M | measurements coming | measurements coming | measurements coming | measurements coming | -### Measure Your Own Device (`termux/bench.sh`) +Measure your own device: run `bash termux/bench.sh 20` in Airplane Mode with the charger unplugged. It prints real end-to-end tok/s and seconds per article. PRs with your results are welcome. -Run `bash termux/bench.sh 20` in Airplane Mode with the charger unplugged to measure real end-to-end tok/s, seconds per article, battery drop, and temperature change: +--- -| Device | Model | tok/s | s/article | Battery drop / 20 articles | Temp change | -|---|---|---|---|---|---| -| 6 GB+ Android (Termux) | Llama-3.2-3B Q4_K_M | 5–15 tok/s | ~14–25 s | Measure via `bench.sh 20` | Capped <45°C | -| 4 GB Android (Termux) | Llama-3.2-1B Q4_K_M | 10–18 tok/s | ~8–14 s | Measure via `bench.sh 20` | Capped <45°C | +## Everything Is Free ---- +Nullcloud is 100% free and open source under the **MIT License**. Every feature is included for everyone: -## Free Core vs. Nullcloud Pro & High-Compliance Consulting +| Feature | Included | +|---|---| +| **On-Device, No Cloud AI** (Airplane Mode ready) | Yes | +| **Android Share Sheet -> Summary Notification** | Yes | +| **Web UI (`127.0.0.1:8080`) & Browser Extension** | Yes | +| **6 Built-In Modes** (Summary, Facts, Claims, ELI5, Translate, Ask) | Yes | +| **Read Aloud (TTS) & Markdown / Obsidian Export** | Yes | +| **Saved-Page Library with SQLite FTS5 Search** | Yes | +| **Offline PDF Input** | Yes | +| **Custom Prompt Presets** (Legal, Journalism, Clinical + your own) | Yes | +| **Private Cloud** (phone as AI server for your laptop over Tailscale) | Yes | -The **Free Core** is 100% open-source under the **MIT License**. **Nullcloud Pro** is a one-time offline license. Pay with the Revolut link, then email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) with the payment confirmation. The license key is sent manually. +## Services (Optional, Paid) -| Feature | Free Core (MIT) | Nullcloud Pro (One-Time License) | High-Compliance Consulting | -|---|---|---|---| -| **On-Device, No Cloud AI** (Airplane Mode ready) | Yes | Yes | Yes | -| **Android Share Sheet -> Summary Notification** | Yes | Yes (+ Pro Presets & PDF share) | Yes | -| **Web UI (`127.0.0.1:8080`) & Browser Extension** | Yes | Yes | Yes | -| **6 Built-In Modes** (Summary, Facts, Claims, ELI5, Translate, Ask) | Yes | Yes | Yes | -| **Read Aloud (TTS) & Markdown / Obsidian Export** | Yes | Yes | Yes | -| **Saved-Page Library with SQLite FTS5 Search** | — | **Yes (Offline full-text search)** | **Yes** | -| **Offline PDF Input & Document Extraction** | — | **Yes (Upload or Share PDFs)** | **Yes** | -| **Custom Prompt Presets** (Legal, Journalism, Clinical + Custom) | — | **Yes** | **Custom-authored for team** | -| **Private Cloud Mode** (Phone as AI Server for Laptop over Tailscale) | — | **Yes (`TAILSCALE=1`)** | **Managed Tailnet Fleet** | - -## Prices - -| Offer | Price | Checkout | +The app is free. If you want someone to set it up for you, these are paid services: + +| Service | Price | How to order | |---|---|---| -| **Nullcloud Pro license** | €19 one-time | [Get Pro](https://checkout.revolut.com/pay/d25ddfdd-7351-465b-afb6-c6238f65c575) | -| **Remote setup** | €30 | [Remote setup](https://checkout.revolut.com/pay/ee0aad48-1ff6-452b-9470-01ae6db706a4) | -| **Pro + setup bundle** | €45 | [Pro + setup](https://checkout.revolut.com/pay/c60fa94d-e094-4c1f-aac4-ae04c4776c52) | -| **Tailscale private cloud** | €60-80 | Email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) for a quote | -| **Team consulting** | from €250 | Email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) for a quote | +| **Remote setup** (Termux, model, Share Sheet, Web UI on your phone or PC) | €30 | [Pay with Revolut](https://checkout.revolut.com/pay/ee0aad48-1ff6-452b-9470-01ae6db706a4) | +| **Setup + priority support** (remote setup + 30 days of priority email help) | €45 | [Pay with Revolut](https://checkout.revolut.com/pay/c60fa94d-e094-4c1f-aac4-ae04c4776c52) | +| **Private Cloud (Tailscale) setup** (phone + laptop, tested end to end) | €60-80 | Email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) for a quote | +| **Team setup & consulting** | from €250 | Email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) for a quote | -Pay via the link, then email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) with the payment confirmation. The license key is sent manually. +After paying, email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) with the payment confirmation to book a time. -### Selling the "Private Cloud": Turn Your Phone into Your Laptop's Private AI Server +### Private Cloud: Use Your Phone as Your Laptop's AI Server (Free) -With **Nullcloud Pro** and [Tailscale](https://tailscale.com), your phone acts as a personal, encrypted AI server accessible from your laptop or iPad: +This uses [Tailscale](https://tailscale.com) (free for personal use), a private encrypted network between your own devices. -1. On your Android phone in Termux: +1. Install Tailscale on your phone and laptop and log in to the same account on both. +2. On your Android phone in Termux: ```bash TAILSCALE=1 bash ~/nullcloud/termux/start.sh ``` -2. Nullcloud binds to your phone's `100.x.y.z` Tailscale address while keeping `llama-server` locked to `127.0.0.1:8081`. -3. On your laptop, open the Nullcloud Browser Extension -> **Private Cloud / Tailscale & Pro Settings** -> enter `http://:8080` and your token. + Nullcloud binds to your phone's `100.x.y.z` Tailscale address. `llama-server` stays on `127.0.0.1:8081` and is never exposed. +3. On your laptop, open the Nullcloud browser extension -> **Compute & Private Cloud Settings** -> enter `http://:8080` and your token. Or open that address in a browser. + +Note: if `tailscale` is not available inside Termux, Nullcloud keeps listening on `127.0.0.1` only. Set `HOST=` manually in that case. ### High-Compliance Consulting (Law Firms, Journalists, Medical Clinics) Need turn-key deployment of **on-device, no cloud AI** for privileged legal discovery, investigative source protection, or clinical literature workflows? - Read **[`docs/CONSULTING.md`](docs/CONSULTING.md)** for vertical presets, threat models, and deployment packages. -- Read **[`docs/MONETIZATION.md`](docs/MONETIZATION.md)** for the price table, Revolut checkout, and offline ECDSA P-256 license issuance. +- See **[Services](#services-optional-paid)** above for prices. --- @@ -143,12 +132,29 @@ Need turn-key deployment of **on-device, no cloud AI** for privileged legal disc | **Branding & Architecture** | **On-device, no cloud AI** | Local / Ollama | Cloud APIs | | **Android Share Sheet -> Summary Notification** | **Yes (No extension needed)** | Not offered | Requires app/cloud | | **Runs 100% Offline in Airplane Mode on Phone** | **Yes (Termux + 1B/3B GGUF)** | Desktop-focused | No | -| **Phone as Private Cloud Server over Tailscale** | **Yes (Pro)** | Manual | N/A | -| **Offline Saved-Page FTS5 Library & PDF Input** | **Yes (Pro)** | Chat history | Cloud-stored | +| **Phone as Private Cloud Server over Tailscale** | **Yes (free)** | Manual | N/A | +| **Offline Saved-Page FTS5 Library & PDF Input** | **Yes (free)** | Chat history | Cloud-stored | | **Markdown / Obsidian Vault Auto-Export** | **Yes (`NOTES_DIR`)** | No | No | --- +## Troubleshooting (Termux) + +- **Install Termux from F-Droid, not the Play Store.** The Play Store build is outdated and `pkg` fails. +- **`pkg install` prints `Abort.`** The prompt was cancelled (no input or a stray key). Run it again with `-y`, for example `pkg install -y nodejs git python`. +- **Mirror / 404 errors during `pkg install`:** run `termux-change-repo`, pick a different mirror, then `pkg update -y && pkg upgrade -y`. +- **`No space left on device`:** the 3B model needs ~2 GB plus ~0.5 GB for tools. Free space or force the 1B model: `MODEL_TIER=1b bash ~/nullcloud/setup.sh`. +- **Model download stopped halfway:** run setup again. Partial `*.gguf.part` files are resumed or replaced. +- **Share -> Termux does nothing:** install **Termux:API** from F-Droid as well, run `pkg install -y termux-api`, and check that `~/bin/termux-url-opener` exists and is executable (`chmod +x ~/bin/termux-url-opener`). +- **Generation stops when the screen turns off:** Android Settings -> Apps -> Termux -> Battery -> **Unrestricted**, and run `termux-wake-lock`. +- **Slow or the GPU is not used:** rebuild with Vulkan: `BUILD_VULKAN=1 bash ~/nullcloud/setup.sh`. If it still fails, lower GPU layers in the Web UI hardware tweaks. +- **Web UI says "Wrong token":** your token is in `~/.nullcloud-token`. Show it with `cat ~/.nullcloud-token` and paste it once in the Web UI. +- **Port 8080 already in use:** `bash ~/nullcloud/termux/stop.sh`, then start again. + +Still stuck? Open an [issue](https://github.com/kukirin-ui/Nullcloud/issues), or book a [remote setup](#services-optional-paid). + +--- + ## Configuration & Environment Variables | Variable | Default | Description | @@ -158,7 +164,7 @@ Need turn-key deployment of **on-device, no cloud AI** for privileged legal disc | `PORT` | `8080` | Web UI and HTTP API port | | `LLM_URL` | `http://127.0.0.1:8081/v1/chat/completions` | Local OpenAI-compatible completion endpoint (`llama-server` or Ollama) | | `MODEL_TIER` | Auto (`1b` or `3b`) | Force `1b` (4 GB RAM) or `3b` (6 GB+ RAM) in `termux/setup.sh` | -| `GPU_LAYERS` | `99` (`0` for CPU-only) | Number of transformer layers offloaded to GPU (`-ngl` / `num_gpu`) | +| `GPU_LAYERS` | `99` | Number of transformer layers offloaded to GPU (`-ngl` / `num_gpu`) | | `CPU_THREADS` | Auto (`4` on 1B, `6` on 3B) | Number of CPU performance threads used for prompt evaluation & generation | | `BUILD_VULKAN` | `0` | Set `BUILD_VULKAN=1` during `termux/setup.sh` to compile `llama-server` with mobile Vulkan GPU shaders | | `MAX_CHARS` | `6000` (`4500` on 1B) | Maximum extracted page/PDF characters sent to local model | @@ -170,8 +176,8 @@ Need turn-key deployment of **on-device, no cloud AI** for privileged legal disc ## Support the Project -- **GitHub Sponsors:** [github.com/sponsors/kukirin-ui](https://github.com/sponsors/kukirin-ui) -- **Nullcloud Pro (€19):** [Revolut checkout](https://checkout.revolut.com/pay/d25ddfdd-7351-465b-afb6-c6238f65c575) +- **Donate (optional):** [Revolut](https://checkout.revolut.com/pay/044a268e-4869-4708-9b0f-8e302cc28c37). Any amount helps. +- **Star the repo** and share your `bench.sh` results. - **Sibling Project:** Check out **[Meridian](https://github.com/kukirin-ui/Meridian)** — the open-source Android command deck and local AI mesh for Termux. ## License diff --git a/docs/CONSULTING.md b/docs/CONSULTING.md index f7b8da0..e6cf30f 100644 --- a/docs/CONSULTING.md +++ b/docs/CONSULTING.md @@ -15,7 +15,7 @@ Nullcloud provides turn-key deployment and customization for privacy-sensitive t --- -## Vertical Solutions & Pre-Built Pro Presets +## Vertical Solutions & Pre-Built Presets (free) ### 1. Law Firms & Legal Counsel (`legal-privilege`) - **Use Case:** Reviewing contracts, opposing filings, discovery PDFs, and case law without waiving privilege or violating client confidentiality policies. @@ -38,7 +38,7 @@ Nullcloud provides turn-key deployment and customization for privacy-sensitive t | Package | Scope | Ideal For | |---|---|---| -| **1. Rapid Team Audit & Setup** | Provisioning on up to 5 phones/laptops, hardware-tuned CPU + GPU Llama 1B/3B GGUF selection (Vulkan/Metal/CUDA `-ngl` + big.LITTLE threads for 5–15+ tok/s on 4GB–6GB+ RAM), offline Pro license keys, and staff onboarding. | Solo practitioners, boutique law practices, independent reporters | +| **1. Rapid Team Audit & Setup** | Provisioning on up to 5 phones/laptops, hardware-tuned Llama 1B/3B GGUF selection with GPU offload where supported, offline Pro license keys, and staff onboarding. | Solo practitioners, boutique law practices, independent reporters | | **2. Private Cloud & Tailscale Fleet** | Dedicated Private Cloud setup connecting team phones and laptops via Tailscale ACLs, shared Obsidian/Markdown vault sync, and 5 custom domain-specific prompt presets. | Small law firms, investigative desks, specialist clinics | | **3. Custom Compliance Integration** | Custom document pipelines (local PDF/OCR ingestion), bespoke model quantization/benchmarking on your target devices, and priority support SLA. | Enterprise compliance teams & regulated organizations | @@ -46,9 +46,12 @@ Nullcloud provides turn-key deployment and customization for privacy-sensitive t ## Prices -Fixed checkouts are in [`docs/MONETIZATION.md`](MONETIZATION.md): Nullcloud Pro €19, remote setup €30, Pro + setup €45. +Nullcloud itself is free. Services: -Tailscale private cloud (€60-80) and team consulting (from €250): contact [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) for a quote. +- Remote setup: €30 ([Revolut](https://checkout.revolut.com/pay/ee0aad48-1ff6-452b-9470-01ae6db706a4)) +- Setup + priority support: €45 ([Revolut](https://checkout.revolut.com/pay/c60fa94d-e094-4c1f-aac4-ae04c4776c52)) +- Private Cloud (Tailscale) setup: €60-80, quote by email +- Team setup & consulting: from €250, quote by email to [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) ## Book a Consultation diff --git a/docs/MONETIZATION.md b/docs/MONETIZATION.md deleted file mode 100644 index fe97f6e..0000000 --- a/docs/MONETIZATION.md +++ /dev/null @@ -1,86 +0,0 @@ -# Nullcloud Monetization & Product Tiers - -Nullcloud follows a transparent, developer-respecting open-core model inspired by [Meridian](https://github.com/kukirin-ui/Meridian): - -1. **Free Core (MIT License)** — Built for maximum distribution, trust, and everyday utility. -2. **Nullcloud Pro (€19 one-time, Revolut)** — The Premium Productivity Hub and Tailscale Private Cloud for power users. -3. **Remote setup (€30) and Pro + setup (€45)** — Fixed-price Revolut checkouts. -4. **Tailscale private cloud (€60-80) and team consulting (from €250)** — Email Pozun.alen96@gmail.com for a quote. -5. **Community Support** — GitHub Sponsors for people who want to back the open-source core. - ---- - -## Tier Comparison - -| Capability | Free Core (MIT) | Nullcloud Pro (One-Time License) | High-Compliance Consulting | -|---|---|---|---| -| **On-Device, No Cloud AI** (Airplane Mode capable) | Included | Included | Included | -| **Unified One-Command Setup (`bash setup.sh`)** | Included | Included | Turn-key managed setup | -| **3 Entry Points** (Android Share Sheet, Web UI, Browser Extension) | Included | Included | Included | -| **Mobile & Desktop Hyper-Optimization** (Auto 1B/3B Llama GGUF + CPU big.LITTLE + GPU `-ngl` offload) | Included | Included | Custom device tuning | -| **Core Modes** (Summary, Facts, Claims, ELI5, Translate, Ask) | Included | Included | Included | -| **Read Aloud (TTS) & Markdown / Obsidian Note Export** | Included | Included | Included | -| **Saved-Page Library with SQLite FTS5 Search** | — | **Included** | **Included** | -| **Offline PDF Input & Document Extraction** | — | **Included** | **Included** | -| **Custom Prompt Presets** (Legal, Journalism, Clinical + Custom) | — | **Included** | **Custom-authored for team** | -| **Private Cloud Mode** (Phone as AI Server for Laptop via Tailscale) | — | **Included** | **Tailnet ACL & fleet setup** | - ---- - -## Prices - -| Offer | Price | Checkout | -|---|---|---| -| **Nullcloud Pro license** | €19 one-time | [Get Pro](https://checkout.revolut.com/pay/d25ddfdd-7351-465b-afb6-c6238f65c575) | -| **Remote setup** | €30 | [Remote setup](https://checkout.revolut.com/pay/ee0aad48-1ff6-452b-9470-01ae6db706a4) | -| **Pro + setup bundle** | €45 | [Pro + setup](https://checkout.revolut.com/pay/c60fa94d-e094-4c1f-aac4-ae04c4776c52) | -| **Tailscale private cloud** | €60-80 | Email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) for a quote | -| **Team consulting** | from €250 | Email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) for a quote | - -Pay via the link, then email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) with the payment confirmation. The license key is sent manually. - -## How Offline Pro Licensing Works (Zero Phone-Home) - -Nullcloud Pro uses **100% offline ECDSA P-256 cryptographic signatures**: - -1. **Purchase:** The buyer pays with one of the Revolut links above. -2. **Confirmation:** The buyer emails [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) with the payment confirmation. -3. **Key Issuance:** The maintainer runs `node tools/license/sign.mjs --ref ORDER-1234 --sku pro` with the offline private key (`private.pem`, never committed) and emails this key: - ```text - NULLCLOUD-. - ``` -4. **Offline Verification:** The customer pastes the key into the Nullcloud Web UI (`http://127.0.0.1:8080` -> **Private Cloud & Pro**), the Browser Extension, or `~/.nullcloud-pro.key`. -5. `server/server.py` verifies the ECDSA P-256 signature locally using pure Python stdlib math against `DEFAULT_LICENSE_PUBKEY`. No network call is ever made. - -### Issuing & Verifying Keys (Maintainer CLI) - -Generate the keypair on your own computer. `tools/license/keys/private.pem` is gitignored. Never commit it, never email it, and keep an offline backup (mode `0600`). - -The public key shipped in this repository only verifies keys signed by its matching private key. If you do not already hold that private key, run keygen once, then replace `DEFAULT_LICENSE_PUBKEY` in `server/server.py` and `tools/license/keys/public.b64` with the new public key before issuing customer keys. - -```bash -# 1. Generate your P-256 signing keypair (keep private.pem secret and offline) -node tools/license/keygen.mjs - -# 2. Paste the printed public key into server/server.py as DEFAULT_LICENSE_PUBKEY -# and into tools/license/keys/public.b64. Commit only those public files. - -# 3. After the buyer emails payment confirmation, issue one key for that order -node tools/license/sign.mjs --ref ORDER-1001 --sku pro - -# 4. Verify the key offline, then email only the NULLCLOUD-... line to the buyer -node tools/license/verify.mjs 'NULLCLOUD-...' -``` - ---- - -## Selling the "Private Cloud" (Phone -> Laptop over Tailscale) - -With Nullcloud Pro, a user's Android phone becomes a personal, portable AI server that they can reach from their laptop or iPad anywhere in the world—without paying for cloud tokens or exposing a port on public Wi-Fi: - -1. Start Nullcloud on the phone with Tailscale binding enabled: - ```bash - TAILSCALE=1 bash ~/nullcloud/termux/start.sh - ``` -2. Nullcloud detects the phone's `100.x.y.z` Tailscale IPv4 address and binds the token-protected API to `http://100.x.y.z:8080` while keeping `llama-server` strictly on `127.0.0.1:8081`. -3. On the laptop, open the Nullcloud Browser Extension -> **CPU / GPU, Private Cloud & Pro Settings** -> enter `http://100.x.y.z:8080` and the token. Every click on the laptop now streams answers from the phone's GPU/NPU/CPU. diff --git a/extension/popup.html b/extension/popup.html index d4898bd..96633f7 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -18,7 +18,7 @@ details{margin-bottom:8px;font-size:12px} summary{cursor:pointer;opacity:.8;margin-bottom:6px} -

Nullcloud on-device, no cloud AIMIT Core

+

Nullcloud on-device, no cloud AIFree · MIT

- CPU / GPU, Private Cloud & Pro Settings + Compute & Private Cloud Settings -
Select text on the page to analyze only that selection, or click Run for the full page.
- Get Pro — €19 one-time +

Nullcloud is free. Need help installing it?

Remote setup — €30 - Pro + setup — €45 -

Pay via the link, then email Pozun.alen96@gmail.com with the payment confirmation. The license key is sent manually. Tailscale private cloud (€60-80) and team consulting (from €250): contact Pozun.alen96@gmail.com for a quote.

+ Setup + priority support — €45 +

After paying, email Pozun.alen96@gmail.com to book a time. Private Cloud (Tailscale) setup €60-80 and team setup from €250: email for a quote.

diff --git a/extension/popup.js b/extension/popup.js index 8080c9b..ec64cda 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -10,40 +10,35 @@ function getApiUrl() { return ($("api").value.trim() || DEFAULT_API).replace(/\/+$/, ""); } -async function loadProPresets(api, token, license) { +async function loadPresets(api, token) { if (!token) return; try { const headers = { "X-Token": token }; - if (license) headers["X-License-Key"] = license; const r = await fetch(api + "/presets", { headers }); if (!r.ok) return; const d = await r.json(); - if (d.pro) { - $("pro-pill").textContent = "Pro Unlocked"; - const existing = new Set(Array.from($("mode").options).map((o) => o.value)); - (d.presets || []).forEach((p) => { - const val = "preset:" + p.id; - if (!existing.has(val)) { - const opt = document.createElement("option"); - opt.value = val; - opt.textContent = "[Pro] " + p.name; - $("mode").appendChild(opt); - } - }); - } + const existing = new Set(Array.from($("mode").options).map((o) => o.value)); + (d.presets || []).forEach((p) => { + const val = "preset:" + p.id; + if (!existing.has(val)) { + const opt = document.createElement("option"); + opt.value = val; + opt.textContent = p.name; + $("mode").appendChild(opt); + } + }); } catch (_) {} } (async () => { - const s = await store.get(["token", "lang", "mode", "api", "license", "gpu"]); + const s = await store.get(["token", "lang", "mode", "api", "gpu"]); $("token").value = s.token || ""; $("lang").value = s.lang || ""; $("mode").value = s.mode || "summary"; $("api").value = s.api || DEFAULT_API; - $("license").value = s.license || ""; $("gpu").value = s.gpu || "99"; toggleAsk(); - loadProPresets(getApiUrl(), $("token").value.trim(), $("license").value.trim()); + loadPresets(getApiUrl(), $("token").value.trim()); })(); $("mode").addEventListener("change", toggleAsk); @@ -56,9 +51,8 @@ $("go").addEventListener("click", async () => { const lang = $("lang").value.trim(); const mode = $("mode").value; const api = getApiUrl(); - const license = $("license").value.trim(); const gpu = $("gpu").value || "99"; - await store.set({ token, lang, mode, api, license, gpu }); + await store.set({ token, lang, mode, api, gpu }); if (!token) { out.textContent = "Paste your token first (printed by setup.sh / start.sh)."; return; @@ -78,7 +72,6 @@ $("go").addEventListener("click", async () => { const text = "Title: " + page.title + "\n\n" + (page.sel.length > 40 ? page.sel : page.body); out.textContent = "Thinking (on-device CPU + GPU, no cloud AI)..."; const headers = { "Content-Type": "application/json", "X-Token": token }; - if (license) headers["X-License-Key"] = license; const r = await fetch(api + "/analyze", { method: "POST", headers, @@ -98,12 +91,6 @@ $("go").addEventListener("click", async () => { } if (!r.ok) { const err = await r.json().catch(() => ({})); - if (r.status === 402) { - out.textContent = - (err.error || "Nullcloud Pro is required.") + - " Use Get Pro, Remote setup, or Pro + setup below. Pay via the link, then email Pozun.alen96@gmail.com with the payment confirmation. The license key is sent manually."; - return; - } out.textContent = err.error || "Error " + r.status; return; } diff --git a/server/server.py b/server/server.py index dfb8ac0..38c3f82 100644 --- a/server/server.py +++ b/server/server.py @@ -9,7 +9,7 @@ - Airplane Mode / offline text & cached page analysis - Read aloud (TTS) & opt-in Markdown / Obsidian vault export -Pro Productivity Hub & Private Cloud (one-time offline ECDSA P-256 license, paid via Revolut): +Productivity Hub & Private Cloud (also free, no license needed): - Saved-page library with SQLite FTS5 full-text search - PDF input extraction (offline via pdftotext, pypdf, or built-in stream parser) - Custom prompt presets (including high-compliance templates for law, journalism, and clinics) @@ -17,7 +17,6 @@ """ import base64 import datetime -import hashlib import hmac import ipaddress import json @@ -56,18 +55,11 @@ ARCHIVE = os.environ.get("ARCHIVE", "") == "1" # opt-in: also keep the raw HTML next to each note HOME_DIR = pathlib.Path(os.environ.get("NULLCLOUD_HOME", str(pathlib.Path.home()))) -LICENSE_FILE = pathlib.Path(os.environ.get("NULLCLOUD_LICENSE_FILE", str(HOME_DIR / ".nullcloud-pro.key"))) LIBRARY_DB = pathlib.Path(os.environ.get("NULLCLOUD_LIBRARY_DB", str(HOME_DIR / ".nullcloud-library.db"))) PRESETS_FILE = pathlib.Path(os.environ.get("NULLCLOUD_PRESETS_FILE", str(HOME_DIR / ".nullcloud-presets.json"))) LAST_RESULT_FILE = pathlib.Path(os.environ.get("NULLCLOUD_LAST_FILE", str(HOME_DIR / ".nullcloud-last.json"))) COMPUTE_FILE = pathlib.Path(os.environ.get("NULLCLOUD_COMPUTE_FILE", str(HOME_DIR / ".nullcloud-compute.json"))) -# Default P-256 SPKI public key for offline Pro license verification (override via NULLCLOUD_PUBKEY for custom builds) -DEFAULT_LICENSE_PUBKEY = ( - "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAET3VmbH67NuNHB6LllpF/T4Z8dREJzQYt7JRN5fXU1KRtUSlBMUov7Uq0Biqw/EhqAJpam9vQWU0DhFDDc8gCvw==" -) -LICENSE_PUBKEY = os.environ.get("NULLCLOUD_PUBKEY", DEFAULT_LICENSE_PUBKEY) - if not TOKEN and os.environ.get("NULLCLOUD_TESTING") != "1": sys.exit("Set TOKEN first, e.g.: TOKEN=$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \\n') python server.py") @@ -132,123 +124,6 @@ }, ] -# --- Pure-Python NIST P-256 ECDSA Verification (Zero extra dependencies, 100% offline) --- -_P256_P = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF -_P256_N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 -_P256_A = _P256_P - 3 -_P256_GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296 -_P256_GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5 - - -def _pt_add(p1, p2): - if p1 is None: - return p2 - if p2 is None: - return p1 - x1, y1 = p1 - x2, y2 = p2 - if x1 == x2 and y1 != y2: - return None - if x1 == x2: - if y1 == 0: - return None - m = (3 * x1 * x1 + _P256_A) * pow(2 * y1, _P256_P - 2, _P256_P) % _P256_P - else: - m = (y2 - y1) * pow(x2 - x1, _P256_P - 2, _P256_P) % _P256_P - x3 = (m * m - x1 - x2) % _P256_P - y3 = (m * (x1 - x3) - y1) % _P256_P - return (x3, y3) - - -def _pt_mul(k, p): - r = None - a = p - while k > 0: - if k & 1: - r = _pt_add(r, a) - a = _pt_add(a, a) - k >>= 1 - return r - - -def _b64u_decode(s): - return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) - - -def _parse_der_sig(der): - if len(der) < 8 or der[0] != 0x30: - raise ValueError("Invalid DER sequence") - idx = 2 if der[1] < 0x80 else 2 + (der[1] & 0x7F) - if der[idx] != 0x02: - raise ValueError("Invalid DER r integer") - rlen = der[idx + 1] - r = int.from_bytes(der[idx + 2 : idx + 2 + rlen], "big") - idx += 2 + rlen - if der[idx] != 0x02: - raise ValueError("Invalid DER s integer") - slen = der[idx + 1] - s = int.from_bytes(der[idx + 2 : idx + 2 + slen], "big") - return r, s - - -def verify_license_key(key_str, pubkey_b64=None): - """Verify an offline NULLCLOUD-. ECDSA P-256 key. Never makes network calls.""" - if not key_str or not isinstance(key_str, str): - return False, None - k = key_str.strip() - for prefix in ("NULLCLOUD-", "MERIDIAN-"): - if k.startswith(prefix): - k = k[len(prefix) :] - break - if "." not in k: - return False, None - try: - p_b64, s_b64 = k.split(".", 1) - payload = _b64u_decode(p_b64) - sig_der = _b64u_decode(s_b64) - spki = base64.b64decode((pubkey_b64 or LICENSE_PUBKEY).strip()) - pub_point = spki[-65:] - if len(pub_point) != 65 or pub_point[0] != 0x04: - return False, None - qx = int.from_bytes(pub_point[1:33], "big") - qy = int.from_bytes(pub_point[33:65], "big") - r, s = _parse_der_sig(sig_der) - if not (1 <= r < _P256_N and 1 <= s < _P256_N): - return False, None - z = int.from_bytes(hashlib.sha256(payload).digest(), "big") - w = pow(s, _P256_N - 2, _P256_N) - u1 = (z * w) % _P256_N - u2 = (r * w) % _P256_N - pt = _pt_add(_pt_mul(u1, (_P256_GX, _P256_GY)), _pt_mul(u2, (qx, qy))) - if pt is None or (pt[0] % _P256_N) != r: - return False, None - claims = json.loads(payload.decode("utf-8")) - if claims.get("sku") not in ("pro", "team", "premium"): - return False, None - return True, claims - except Exception: # noqa: BLE001 - return False, None - - -def get_pro_status(): - """Check if Pro tier is unlocked via request header, env var, or saved ~/.nullcloud-pro.key.""" - candidates = [ - request.headers.get("X-License-Key", "") if request else "", - os.environ.get("NULLCLOUD_LICENSE_KEY", ""), - ] - if LICENSE_FILE.is_file(): - try: - candidates.append(LICENSE_FILE.read_text(encoding="utf-8").strip()) - except Exception: # noqa: BLE001 - pass - for c in candidates: - if c: - ok, claims = verify_license_key(c) - if ok: - return True, claims - return False, None - - # --- HTML Parser & SSRF-Safe Fetcher --- class _Text(HTMLParser): SKIP = {"script", "style", "noscript", "svg", "head", "nav", "footer", "iframe", "form", "aside"} @@ -384,7 +259,7 @@ def fetch_text(url): return cleaned, raw_html -# --- Offline PDF Extraction (Pro feature) --- +# --- Offline PDF Extraction --- def _extract_pdf_bytes_fallback(pdf_bytes): """Lightweight pure-Python PDF text extractor for simple/uncompressed or FlateDecode PDF streams.""" chunks = [] @@ -460,7 +335,7 @@ def extract_pdf_text(pdf_bytes, max_pages=30): raise ValueError("Could not extract readable text from PDF. Install poppler (`pkg install poppler`) or pypdf.") -# --- Markdown Notes & Pro SQLite FTS5 Library --- +# --- Markdown Notes & SQLite FTS5 Library --- def save_note(url, mode, output, raw): """Write a Markdown note (+ optional raw HTML archive). Local files only; never raises.""" if not NOTES_DIR: @@ -697,50 +572,17 @@ def authorized(): return hmac.compare_digest(provided.encode("utf-8"), TOKEN.encode("utf-8")) -PRO_CHECKOUT_URL = "https://checkout.revolut.com/pay/d25ddfdd-7351-465b-afb6-c6238f65c575" SETUP_CHECKOUT_URL = "https://checkout.revolut.com/pay/ee0aad48-1ff6-452b-9470-01ae6db706a4" -BUNDLE_CHECKOUT_URL = "https://checkout.revolut.com/pay/c60fa94d-e094-4c1f-aac4-ae04c4776c52" -QUOTE_EMAIL = "Pozun.alen96@gmail.com" -PAYMENT_FLOW = ( - "Pay via the link, then email Pozun.alen96@gmail.com with the payment confirmation. " - "The license key is sent manually." -) -PAYMENT_OFFERS = [ - {"id": "pro", "label": "Get Pro", "price": "€19 one-time", "url": PRO_CHECKOUT_URL}, +PRIORITY_CHECKOUT_URL = "https://checkout.revolut.com/pay/c60fa94d-e094-4c1f-aac4-ae04c4776c52" +CONTACT_EMAIL = "Pozun.alen96@gmail.com" +SERVICES = [ {"id": "setup", "label": "Remote setup", "price": "€30", "url": SETUP_CHECKOUT_URL}, - {"id": "bundle", "label": "Pro + setup", "price": "€45", "url": BUNDLE_CHECKOUT_URL}, + {"id": "priority", "label": "Setup + priority support", "price": "€45", "url": PRIORITY_CHECKOUT_URL}, + {"id": "tailscale", "label": "Private Cloud (Tailscale) setup", "price": "€60-80", "url": f"mailto:{CONTACT_EMAIL}"}, + {"id": "team", "label": "Team setup & consulting", "price": "from €250", "url": f"mailto:{CONTACT_EMAIL}"}, ] -def pro_locked_body(message): - return { - "error": message, - "pro_required": True, - "offers": PAYMENT_OFFERS, - "payment_flow": PAYMENT_FLOW, - "quote": ( - "Tailscale private cloud (€60-80) and team consulting (from €250): " - f"contact {QUOTE_EMAIL} for a quote." - ), - } - - -def require_pro(): - is_pro, _claims = get_pro_status() - if not is_pro: - return ( - jsonify( - **pro_locked_body( - "This feature requires Nullcloud Pro (saved-page library with search, PDF input, " - "custom prompt presets, and Private Cloud server over Tailscale). " - + PAYMENT_FLOW - ) - ), - 402, - ) - return None - - # --- HTTP Routes --- @app.get("/") def ui(): @@ -749,7 +591,6 @@ def ui(): @app.get("/health") def health(): - is_pro, claims = get_pro_status() compute = get_compute_config() return jsonify( ok=True, @@ -758,8 +599,7 @@ def health(): model=MODEL, max_chars=MAX_CHARS, max_tokens=MAX_TOKENS, - pro=is_pro, - sku=claims.get("sku") if claims else "free", + free=True, compute=compute, ) @@ -800,54 +640,30 @@ def last_result(): return jsonify(error=str(e)), 500 -@app.get("/pro/status") -def pro_status(): +@app.get("/features") +def features(): + """Every feature is free. Income comes only from optional setup services.""" if not authorized(): return jsonify(error="Wrong or missing token."), 401 - is_pro, claims = get_pro_status() return jsonify( - pro=is_pro, - sku=claims.get("sku") if claims else "free", - claims=claims, - tailscale=get_tailscale_info() if is_pro else None, + free=True, + features=["library", "pdf", "presets", "private_cloud"], + tailscale=get_tailscale_info(), + services=SERVICES, ) -@app.post("/pro/activate") -def pro_activate(): - if not authorized(): - return jsonify(error="Wrong or missing token."), 401 - d = request.get_json(force=True, silent=True) or {} - key_str = (d.get("key") or "").strip() - ok, claims = verify_license_key(key_str) - if not ok: - return jsonify(error="Invalid Nullcloud Pro license key. Check that you copied the full NULLCLOUD-... key."), 400 - LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True) - LICENSE_FILE.write_text(key_str + "\n", encoding="utf-8") - try: - os.chmod(LICENSE_FILE, 0o600) - except OSError: - pass - return jsonify(ok=True, pro=True, claims=claims, tailscale=get_tailscale_info()) - - @app.get("/presets") def get_presets(): if not authorized(): return jsonify(error="Wrong or missing token."), 401 - is_pro, _ = get_pro_status() - if not is_pro: - return jsonify(presets=[], pro_required=True) - return jsonify(presets=load_presets(), pro=True) + return jsonify(presets=load_presets()) @app.post("/presets") def add_preset(): if not authorized(): return jsonify(error="Wrong or missing token."), 401 - gate = require_pro() - if gate: - return gate d = request.get_json(force=True, silent=True) or {} name = (d.get("name") or "").strip() prompt = (d.get("prompt") or "").strip() @@ -871,9 +687,6 @@ def add_preset(): def delete_preset(preset_id): if not authorized(): return jsonify(error="Wrong or missing token."), 401 - gate = require_pro() - if gate: - return gate custom = [] if PRESETS_FILE.is_file(): try: @@ -889,9 +702,6 @@ def delete_preset(preset_id): def list_library(): if not authorized(): return jsonify(error="Wrong or missing token."), 401 - gate = require_pro() - if gate: - return gate q = request.args.get("q", "") items = search_library(q) return jsonify(items=items, count=len(items)) @@ -901,9 +711,6 @@ def list_library(): def create_library_item(): if not authorized(): return jsonify(error="Wrong or missing token."), 401 - gate = require_pro() - if gate: - return gate d = request.get_json(force=True, silent=True) or {} rowid = save_to_library( url=d.get("url") or "", @@ -919,9 +726,6 @@ def create_library_item(): def delete_library_item(item_id): if not authorized(): return jsonify(error="Wrong or missing token."), 401 - gate = require_pro() - if gate: - return gate _init_library_db() with sqlite3.connect(LIBRARY_DB) as conn: conn.execute("DELETE FROM pages WHERE id = ?", (item_id,)) @@ -937,16 +741,13 @@ def analyze(): if not authorized(): return jsonify(error="Wrong or missing token."), 401 d = request.get_json(force=True, silent=True) or {} - is_pro, _ = get_pro_status() text = (d.get("text") or "").strip() raw = "" title = (d.get("title") or "").strip() - # Pro feature: PDF input (base64 encoded PDF bytes) + # PDF input (base64 encoded PDF bytes) if d.get("pdf_base64"): - if not is_pro: - return require_pro() try: pdf_bytes = base64.b64decode(d["pdf_base64"]) text = extract_pdf_text(pdf_bytes) @@ -971,10 +772,8 @@ def analyze(): preset_id = (d.get("preset_id") or "").strip() custom_prompt = (d.get("custom_prompt") or "").strip() - # Pro feature: Custom prompt presets + # Custom prompt presets if preset_id or custom_prompt or mode.startswith("preset:"): - if not is_pro: - return require_pro() target_id = preset_id or (mode.split(":", 1)[1] if mode.startswith("preset:") else "") preset_map = {p["id"]: p["prompt"] for p in load_presets()} task = custom_prompt or preset_map.get(target_id) @@ -1051,7 +850,7 @@ def gen(): pass if NOTES_DIR: save_note(d.get("url") or "", mode, full_output, raw) - if is_pro and d.get("save_library", True): + if d.get("save_library", True): save_to_library(d.get("url") or "", title, mode, text, full_output) except Exception as e: # noqa: BLE001 yield f"\n[Model server not reachable: {e}. Is it running? See README > Troubleshooting.]" diff --git a/server/ui.html b/server/ui.html index c87d7b8..f9c0fef 100644 --- a/server/ui.html +++ b/server/ui.html @@ -31,7 +31,7 @@ code{font-family:ui-monospace,monospace;font-size:12px;background:var(--bg);padding:2px 5px;border-radius:4px;border:1px solid var(--bd)}
-

Nullcloud Free Core (MIT)

+

Nullcloud Free & open source (MIT)

Airplane-Mode Ready · CPU + GPU

On-device, no cloud AI. Summarize, extract facts, or question any page, text, or PDF right on your phone or laptop using local CPU + GPU acceleration.

@@ -40,16 +40,16 @@

Nullcloud Free Core (MIT)

- +
@@ -85,7 +85,7 @@

Saved-Page Library & Offline Search (Pro)

-

Custom Prompt Presets (Pro)

+

Custom Prompt Presets

Includes pre-built high-compliance templates for Law Firms, Journalists, and Medical Clinics, plus your own custom instructions.

@@ -97,21 +97,21 @@

Custom Prompt Presets (Pro)

-

Nullcloud Pro — Productivity Hub & Private Cloud

-

Unlock the Saved-Page Library with full-text search, PDF input extraction, Custom Prompt Presets, and Tailscale Private Cloud with a one-time offline license key (verified locally via ECDSA P-256).

- Get Pro — €19 one-time +

Every feature is free

+

Library with search, PDF input, custom presets and Private Cloud are free for everyone. No license key needed.

+
+ +
+

Need help? Setup services

Remote setup — €30 - Pro + setup — €45 -

Pay via the link, then email Pozun.alen96@gmail.com with the payment confirmation. The license key is sent manually.

-

Tailscale private cloud (€60-80) and team consulting (from €250): contact Pozun.alen96@gmail.com for a quote.

- - -
+ Setup + priority support — €45 +

After paying, email Pozun.alen96@gmail.com with the payment confirmation to book a time.

+

Private Cloud (Tailscale) setup €60-80 and team setup from €250: email Pozun.alen96@gmail.com for a quote.

CPU & GPU Hardware Acceleration Tweaks

-

Tune both GPU layer offload (Vulkan / OpenCL on Snapdragon Adreno & Arm Mali, Apple Metal, NVIDIA CUDA, AMD ROCm) and CPU big.LITTLE threads + quantized KV cache (q8_0/q4_0):

+

Tune both GPU layer offload (Vulkan on Android, tested on Exynos; Metal / CUDA / ROCm on desktop) and thread count + quantized KV cache (q8_0/q4_0):

Detecting local CPU + GPU engine...
@@ -138,7 +138,7 @@

CPU & GPU Hardware Acceleration Tweaks

-

Phone as a Private Cloud AI Server (via Tailscale)

+

Phone as a Private Cloud AI Server (via Tailscale, free)

Turn your phone into a private on-device AI server accessible from your laptop or iPad over your encrypted Tailscale tailnet—without exposing port 8080 to public Wi-Fi:

1. Install Tailscale on your phone and laptop.

@@ -156,13 +156,11 @@

High-Compliance Consulting & Community Support

diff --git a/termux/setup.sh b/termux/setup.sh index 63bea57..97bc609 100755 --- a/termux/setup.sh +++ b/termux/setup.sh @@ -1,6 +1,6 @@ #!/data/data/com.termux/files/usr/bin/bash # Nullcloud Termux Setup — Unified One-Command Installer (Share Sheet + Web UI + Extension Backend) -# Pre-configures Llama 3.2 1B (4GB RAM) or 3B (6GB+ RAM) with CPU + GPU acceleration for 5-15+ tok/s on-device. +# Pre-configures Llama 3.2 1B (under ~5.5 GB RAM) or 3B (6 GB+ RAM) with GPU offload. Tested on Exynos with Vulkan. set -e ROOT="$(cd "$(dirname "$0")/.." && pwd)" MODELS="$HOME/nullcloud-models"; mkdir -p "$MODELS" @@ -50,7 +50,7 @@ MODEL_URL="${MODEL_URL:-$DEFAULT_URL}" echo "== Nullcloud On-Device AI Setup (CPU + GPU Hybrid) ==" echo "Detected RAM: ${MEM_GB} GB | GPU: ${GPU_NAME}" -echo "Selecting ${MODEL_NAME} profile (target: 5-15+ tok/s offline)" +echo "Selecting ${MODEL_NAME} profile (run termux/bench.sh to measure speed)" pkg update -y && pkg upgrade -y pkg install -y python curl wget jq termux-api unzip poppler vulkan-loader-android 2>/dev/null || \ diff --git a/termux/termux-url-opener b/termux/termux-url-opener index 9b56783..e15251f 100755 --- a/termux/termux-url-opener +++ b/termux/termux-url-opener @@ -15,7 +15,7 @@ if awk "BEGIN{exit !($BATT_TEMP >= 45)}"; then fi CHOICE="$(termux-dialog radio -t "Nullcloud (On-Device, No Cloud AI)" \ - -v "Summarize,Summarize + read aloud,Key facts,Explain simply,Claims check,Translate,Legal: Privilege & Risk Scan (Pro),Journalism: Source Check (Pro),Clinical: Summary (Pro)" | jq -r '.text // empty')" + -v "Summarize,Summarize + read aloud,Key facts,Explain simply,Claims check,Translate,Legal: Privilege & Risk Scan,Journalism: Source Check,Clinical: Summary" | jq -r '.text // empty')" [ -z "$CHOICE" ] && exit 0 SPEAK=0; LANG_OUT=""; PRESET_ID="" @@ -29,9 +29,9 @@ case "$CHOICE" in MODE=translate LANG_OUT="$(termux-dialog text -t "Translate to which language?" | jq -r '.text // empty')" [ -z "$LANG_OUT" ] && LANG_OUT=English ;; - "Legal: Privilege & Risk Scan (Pro)") MODE=summary; PRESET_ID="legal-privilege" ;; - "Journalism: Source Check (Pro)") MODE=summary; PRESET_ID="journalism-source" ;; - "Clinical: Summary (Pro)") MODE=summary; PRESET_ID="clinical-intake" ;; + "Legal: Privilege & Risk Scan") MODE=summary; PRESET_ID="legal-privilege" ;; + "Journalism: Source Check") MODE=summary; PRESET_ID="journalism-source" ;; + "Clinical: Summary") MODE=summary; PRESET_ID="clinical-intake" ;; *) exit 0 ;; esac diff --git a/tests/test_server.py b/tests/test_server.py index 7f0ab66..51e36d8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,7 +1,6 @@ import base64 import json import os -import subprocess import sys import zlib from pathlib import Path @@ -21,7 +20,6 @@ @pytest.fixture() def app_client(tmp_path, monkeypatch): monkeypatch.setattr(server, "TOKEN", "test-secret-token-12345") - monkeypatch.setattr(server, "LICENSE_FILE", tmp_path / ".nullcloud-pro.key") monkeypatch.setattr(server, "LIBRARY_DB", tmp_path / ".nullcloud-library.db") monkeypatch.setattr(server, "PRESETS_FILE", tmp_path / ".nullcloud-presets.json") monkeypatch.setattr(server, "LAST_RESULT_FILE", tmp_path / ".nullcloud-last.json") @@ -31,22 +29,6 @@ def app_client(tmp_path, monkeypatch): return server.app.test_client() -@pytest.fixture() -def pro_key(tmp_path): - """Generate a fresh ECDSA P-256 keypair and signed Pro token via tools/license/.""" - key_dir = tmp_path / "keys" - subprocess.run(["node", str(ROOT / "tools/license/keygen.mjs"), str(key_dir)], check=True, capture_output=True) - pub_b64 = (key_dir / "public.b64").read_text(encoding="utf-8").strip() - res = subprocess.run( - ["node", str(ROOT / "tools/license/sign.mjs"), "--ref", "ORDER-999", "--key", str(key_dir / "private.pem"), "--sku", "pro"], - check=True, - capture_output=True, - text=True, - ) - signed_key = res.stdout.strip() - return pub_b64, signed_key - - def _mock_llm_stream(chunks=("Bullet 1\n", "Bullet 2\n", "Why it matters.")): lines = [f'data: {{"choices":[{{"delta":{{"content":{json.dumps(c)}}}}}]}}'.encode("utf-8") for c in chunks] lines.append(b"data: [DONE]") @@ -64,7 +46,7 @@ def test_health_and_branding(app_client): assert d["ok"] is True assert d["mode"] == "on-device, no cloud AI" assert d["offline_ready"] is True - assert d["pro"] is False + assert d["free"] is True assert "compute" in d assert d["compute"]["gpu_layers"] == 99 assert d["compute"]["cpu_threads"] >= 1 @@ -130,62 +112,25 @@ def test_free_core_airplane_mode_text_analysis(app_client): assert "Bullet 1" in last.get_json()["output"] -def test_pro_features_gated_on_free_tier(app_client): +def test_all_features_free_and_services_listed(app_client): headers = {"X-Token": "test-secret-token-12345"} - # 1. Library is gated - r_lib = app_client.get("/library", headers=headers) - assert r_lib.status_code == 402 - locked = r_lib.get_json() - assert locked["pro_required"] is True - assert locked["offers"][0]["price"] == "€19 one-time" - assert locked["offers"][0]["url"] == "https://checkout.revolut.com/pay/d25ddfdd-7351-465b-afb6-c6238f65c575" - assert locked["offers"][1]["price"] == "€30" - assert locked["offers"][2]["price"] == "€45" - assert "Pozun.alen96@gmail.com" in locked["payment_flow"] - assert "€60-80" in locked["quote"] - assert all(item["url"].startswith("https://checkout.revolut.com/pay/") for item in locked["offers"]) - - # 2. Custom presets are gated - r_pre = app_client.post("/presets", headers=headers, json={"name": "Test", "prompt": "Do X"}) - assert r_pre.status_code == 402 - - # 3. PDF input is gated - fake_pdf = base64.b64encode(b"%PDF-1.4\n(Hello PDF) Tj\n").decode("ascii") - r_pdf = app_client.post("/analyze", headers=headers, json={"pdf_base64": fake_pdf, "mode": "summary"}) - assert r_pdf.status_code == 402 - - # 4. Preset mode in /analyze is gated - r_pm = app_client.post( - "/analyze", - headers=headers, - json={"text": "Enough characters to analyze with a preset mode.", "mode": "preset:legal-privilege"}, - ) - assert r_pm.status_code == 402 - - -def test_offline_ecdsa_pro_activation_and_features(app_client, pro_key, monkeypatch): - pub_b64, signed_key = pro_key - monkeypatch.setattr(server, "LICENSE_PUBKEY", pub_b64) + f = app_client.get("/features", headers=headers) + assert f.status_code == 200 + d = f.get_json() + assert d["free"] is True + assert set(d["features"]) == {"library", "pdf", "presets", "private_cloud"} + prices = {s["id"]: s["price"] for s in d["services"]} + assert prices == {"setup": "€30", "priority": "€45", "tailscale": "€60-80", "team": "from €250"} + assert all("d25ddfdd" not in s["url"] for s in d["services"]) + assert app_client.get("/pro/status", headers=headers).status_code == 404 + + +def test_library_presets_and_pdf_work_without_license(app_client): headers = {"X-Token": "test-secret-token-12345"} + assert app_client.get("/library", headers=headers).status_code == 200 - # Reject tampered or fake keys - bad = app_client.post("/pro/activate", headers=headers, json={"key": "paid"}) - assert bad.status_code == 400 - tampered = signed_key[:-4] + "AAAA" - bad2 = app_client.post("/pro/activate", headers=headers, json={"key": tampered}) - assert bad2.status_code == 400 - - # Activate valid signed Pro key offline - act = app_client.post("/pro/activate", headers=headers, json={"key": signed_key}) - assert act.status_code == 200 - act_data = act.get_json() - assert act_data["pro"] is True - assert act_data["claims"]["sku"] == "pro" - assert "tailscale" in act_data - - # Verify Pro presets (Legal, Journalism, Clinical, Executive) + custom preset creation + # Verify presets (Legal, Journalism, Clinical, Executive) + custom preset creation pre_res = app_client.get("/presets", headers=headers).get_json() - assert pre_res["pro"] is True preset_ids = {p["id"] for p in pre_res["presets"]} assert {"legal-privilege", "journalism-source", "clinical-intake", "exec-brief"}.issubset(preset_ids) @@ -196,7 +141,7 @@ def test_offline_ecdsa_pro_activation_and_features(app_client, pro_key, monkeypa ) assert add_pre.status_code == 200 - # Verify Pro PDF extraction (FlateDecode compressed stream) + # Verify PDF extraction (FlateDecode compressed stream) stream_body = zlib.compress(b"BT /F1 12 Tf 72 712 Td (Confidential Legal Contract Clause 42: Governing Law and Arbitration) Tj ET") pdf_bytes = b"%PDF-1.4\n1 0 obj\n<< /Length " + str(len(stream_body)).encode() + b" /Filter /FlateDecode >>\nstream\n" + stream_body + b"\nendstream\nendobj\n%%EOF" pdf_b64 = base64.b64encode(pdf_bytes).decode("ascii") diff --git a/tools/license/keygen.mjs b/tools/license/keygen.mjs deleted file mode 100755 index aca3819..0000000 --- a/tools/license/keygen.mjs +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node -// Generates the ECDSA P-256 signing keypair for Nullcloud Pro / Private Cloud license keys. -// -// node tools/license/keygen.mjs [outDir] -// -// Writes /private.pem (keep secret, never commit) and /public.b64, -// and prints the base64 SPKI public key used by server/server.py (LICENSE_PUBKEY). -import { generateKeyPairSync } from "node:crypto"; -import { mkdirSync, writeFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; - -const outDir = process.argv[2] || join(import.meta.dirname, "keys"); -mkdirSync(outDir, { recursive: true }); -const privPath = join(outDir, "private.pem"); -if (existsSync(privPath)) { - console.error(`Refusing to overwrite ${privPath}. Delete it first if you really want a new keypair.`); - process.exit(1); -} - -const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); -writeFileSync(privPath, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 }); -const spki = publicKey.export({ type: "spki", format: "der" }).toString("base64"); -writeFileSync(join(outDir, "public.b64"), spki + "\n"); - -console.log(`Private key: ${privPath} (back it up offline — never commit private.pem)`); -console.log(`Public key: ${join(outDir, "public.b64")}`); -console.log(""); -console.log("Paste this into server/server.py (or set NULLCLOUD_PUBKEY env var):"); -console.log(""); -console.log(`DEFAULT_LICENSE_PUBKEY = "${spki}"`); diff --git a/tools/license/keys/public.b64 b/tools/license/keys/public.b64 deleted file mode 100644 index 9119564..0000000 --- a/tools/license/keys/public.b64 +++ /dev/null @@ -1 +0,0 @@ -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAET3VmbH67NuNHB6LllpF/T4Z8dREJzQYt7JRN5fXU1KRtUSlBMUov7Uq0Biqw/EhqAJpam9vQWU0DhFDDc8gCvw== diff --git a/tools/license/sign.mjs b/tools/license/sign.mjs deleted file mode 100755 index dfa5f20..0000000 --- a/tools/license/sign.mjs +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env node -// Issues a signed offline Nullcloud license key after a Revolut payment is confirmed by email. -// -// node tools/license/sign.mjs --ref ORDER-1234 [--key tools/license/keys/private.pem] [--sku pro|team] [--copies 2] -// -// Output format (what the customer pastes into the Web UI, Extension, or ~/.nullcloud-pro.key): -// NULLCLOUD-. -// payload = {"sku":"pro"|"team","iat":,"ref":"ORDER-1234"} -// signature = SHA256withECDSA over the raw payload bytes, DER encoded. -// Verified 100% offline in server/server.py with zero phone-home or cloud dependency. -import { createSign, createPrivateKey } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -const args = Object.fromEntries( - process.argv.slice(2).reduce((acc, a, i, arr) => { - if (a.startsWith("--")) { - acc.push([a.slice(2), arr[i + 1] && !arr[i + 1].startsWith("--") ? arr[i + 1] : "true"]); - } - return acc; - }, []) -); - -const ref = args.ref; -if (!ref) { - console.error("Usage: node tools/license/sign.mjs --ref [--key tools/license/keys/private.pem] [--sku pro|team] [--copies 1]"); - process.exit(1); -} -const keyPath = args.key || join(import.meta.dirname, "keys", "private.pem"); -const sku = args.sku || "pro"; -if (sku !== "pro" && sku !== "team" && sku !== "premium") { - console.error("sku must be pro, team, or premium"); - process.exit(1); -} - -const copies = args.copies == null ? 1 : Number(args.copies); -if (!Number.isInteger(copies) || copies < 1 || copies > 50) { - console.error("copies must be a whole number from 1 to 50"); - process.exit(1); -} - -const key = createPrivateKey(readFileSync(keyPath)); -const b64url = (b) => b.toString("base64url"); -const iat = Math.floor(Date.now() / 1000); -for (let n = 1; n <= copies; n++) { - const copyRef = copies === 1 ? ref : `${ref}-${n}`; - const payload = Buffer.from(JSON.stringify({ sku, iat, ref: copyRef }), "utf8"); - const signer = createSign("SHA256"); - signer.update(payload); - const sig = signer.sign({ key, dsaEncoding: "der" }); - console.log(`NULLCLOUD-${b64url(payload)}.${b64url(sig)}`); -} diff --git a/tools/license/verify.mjs b/tools/license/verify.mjs deleted file mode 100755 index ce649ab..0000000 --- a/tools/license/verify.mjs +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env node -// Verifies a Nullcloud Pro / Team license key offline using the same P-256 SPKI check as server/server.py. -// -// node tools/license/verify.mjs NULLCLOUD-xxxx.yyyy [--pub tools/license/keys/public.b64] -import { createVerify, createPublicKey } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -const argv = process.argv.slice(2); -const key = argv.find((a) => !a.startsWith("--")); -const pubIx = argv.indexOf("--pub"); -const pubPath = pubIx >= 0 ? argv[pubIx + 1] : join(import.meta.dirname, "keys", "public.b64"); -if (!key) { - console.error("Usage: node tools/license/verify.mjs NULLCLOUD-... [--pub tools/license/keys/public.b64]"); - process.exit(1); -} - -let k = key.trim(); -if (k.startsWith("NULLCLOUD-")) k = k.slice("NULLCLOUD-".length); -else if (k.startsWith("MERIDIAN-")) k = k.slice(9); -const dot = k.indexOf("."); -if (dot <= 0) { - console.error("Not a valid Nullcloud license key."); - process.exit(2); -} -const payload = Buffer.from(k.slice(0, dot), "base64url"); -const sig = Buffer.from(k.slice(dot + 1), "base64url"); -const pub = createPublicKey({ - key: Buffer.from(readFileSync(pubPath, "utf8").trim(), "base64"), - format: "der", - type: "spki", -}); - -const verifier = createVerify("SHA256"); -verifier.update(payload); -const ok = verifier.verify({ key: pub, dsaEncoding: "der" }, sig); -const claims = JSON.parse(payload.toString("utf8")); -console.log(JSON.stringify({ ok, claims }, null, 2)); -process.exit(ok && ["pro", "team", "premium"].includes(claims.sku) ? 0 : 3); diff --git a/tools/qa/verify-all.sh b/tools/qa/verify-all.sh index fa3be34..5d60af4 100755 --- a/tools/qa/verify-all.sh +++ b/tools/qa/verify-all.sh @@ -30,15 +30,12 @@ for icon in ("16.png", "48.png", "128.png"): ' echo " [OK] Extension manifest valid" -echo "== 4. Testing offline ECDSA P-256 license tools (keygen, sign, verify) ==" -TMP_KEYS="$(mktemp -d)" -trap 'rm -rf "$TMP_KEYS"' EXIT -node tools/license/keygen.mjs "$TMP_KEYS" >/dev/null -KEY_PRO="$(node tools/license/sign.mjs --ref QA-100 --key "$TMP_KEYS/private.pem" --sku pro)" -node tools/license/verify.mjs "$KEY_PRO" --pub "$TMP_KEYS/public.b64" >/dev/null -KEYS_2="$(node tools/license/sign.mjs --ref QA-200 --key "$TMP_KEYS/private.pem" --sku pro --copies 2)" -[ "$(printf '%s\n' "$KEYS_2" | wc -l)" -eq 2 ] -echo " [OK] Offline P-256 license tools verified" +echo "== 4. Checking everything is free (no paywall or license gate left) ==" +if grep -rn "d25ddfdd\|require_pro\|pro_required\|license_key\|X-License-Key" server/ extension/ termux/ desktop/ docs/ .github/ README.md >/dev/null 2>&1; then + echo "FAIL: Found leftover Pro paywall / license code" + exit 1 +fi +echo " [OK] All features free" echo "== 5. Running pytest suite ==" python3 -m pytest -q