From fdca639f5841b252b9bc0fd29e5fd0fdb02fca42 Mon Sep 17 00:00:00 2001 From: he Date: Tue, 28 Jul 2026 10:28:11 -0400 Subject: [PATCH] . --- .gitignore | 21 ++ .gitmodules | 3 + AGENT_Context_Project.md | 426 +++++++++++++++++++++++++++++++ DEV.md | 418 ++++++++++++++++++++++++++++++ apps/browsers/brave.sh | 18 ++ apps/development/opencode.sh | 16 ++ apps/development/vscode.sh | 18 ++ apps/install.sh | 143 +++++++++++ apps/media/obs.sh | 10 + apps/media/scrcpy.sh | 53 ++++ apps/media/vlc.sh | 10 + apps/networking/netbird.sh | 14 + apps/networking/tailscale.sh | 14 + apps/networking/zerotier.sh | 14 + apps/remote-access/termius.sh | 20 ++ apps/remote-access/vnc-viewer.sh | 10 + apps/system/docker.sh | 15 ++ apps/system/qemu.sh | 18 ++ apps/utilities/affine.sh | 35 +++ apps/utilities/btop.sh | 10 + apps/utilities/localsend.sh | 19 ++ bin/autostart.sh | 14 + bin/mp3 | 2 + bin/mp4 | 2 + bin/pos | 145 +++++++++++ bin/pos-docker-compose | 317 +++++++++++++++++++++++ bin/pos-docker-health | 109 ++++++++ bin/pos-docker-ps | 127 +++++++++ bin/pos-media-mp3 | 30 +++ bin/pos-media-mp4 | 33 +++ bin/pos-network-checkport | 44 ++++ bin/pos-network-ip | 46 ++++ bin/pos-network-scan | 270 ++++++++++++++++++++ bin/pos-ssh-load-keys | 30 +++ bin/pos-system-firewall | 284 +++++++++++++++++++++ bin/pos-vbox | 156 +++++++++++ bin/ssh-load-all | 2 + bin/vbox | 2 + bin/wr-checkport | 2 + bin/wr-compose | 2 + bin/wr-docker | 2 + bin/wr-ip | 2 + bin/wr-scan-ping | 2 + bin/wr-ufw | 2 + completions/pos.bash | 118 +++++++++ config/authorized_keys | 1 + install.sh | 144 +++++++++++ lib/common.sh | 121 +++++++++ postinstall.sh | 94 +++++++ preinstall.sh | 52 ++++ systemd/autostart.service | 13 + systemd/ssh-agent.service | 14 + 52 files changed, 3487 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 AGENT_Context_Project.md create mode 100644 DEV.md create mode 100755 apps/browsers/brave.sh create mode 100755 apps/development/opencode.sh create mode 100755 apps/development/vscode.sh create mode 100755 apps/install.sh create mode 100755 apps/media/obs.sh create mode 100644 apps/media/scrcpy.sh create mode 100755 apps/media/vlc.sh create mode 100755 apps/networking/netbird.sh create mode 100755 apps/networking/tailscale.sh create mode 100755 apps/networking/zerotier.sh create mode 100755 apps/remote-access/termius.sh create mode 100755 apps/remote-access/vnc-viewer.sh create mode 100755 apps/system/docker.sh create mode 100755 apps/system/qemu.sh create mode 100755 apps/utilities/affine.sh create mode 100755 apps/utilities/btop.sh create mode 100755 apps/utilities/localsend.sh create mode 100755 bin/autostart.sh create mode 100755 bin/mp3 create mode 100755 bin/mp4 create mode 100755 bin/pos create mode 100755 bin/pos-docker-compose create mode 100755 bin/pos-docker-health create mode 100755 bin/pos-docker-ps create mode 100755 bin/pos-media-mp3 create mode 100755 bin/pos-media-mp4 create mode 100755 bin/pos-network-checkport create mode 100755 bin/pos-network-ip create mode 100755 bin/pos-network-scan create mode 100755 bin/pos-ssh-load-keys create mode 100755 bin/pos-system-firewall create mode 100755 bin/pos-vbox create mode 100755 bin/ssh-load-all create mode 100755 bin/vbox create mode 100755 bin/wr-checkport create mode 100755 bin/wr-compose create mode 100755 bin/wr-docker create mode 100755 bin/wr-ip create mode 100755 bin/wr-scan-ping create mode 100755 bin/wr-ufw create mode 100644 completions/pos.bash create mode 100644 config/authorized_keys create mode 100755 install.sh create mode 100644 lib/common.sh create mode 100755 postinstall.sh create mode 100755 preinstall.sh create mode 100644 systemd/autostart.service create mode 100644 systemd/ssh-agent.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e12ea1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ + +# Python (if src/ or pyinstaller is ever used) +__pycache__/ +*.pyc +dist/ +build/ +*.spec +*.egg-info/ + +# Editors +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Docker Compose — user's active stacks and secrets +compose/custom/ +compose/config.env diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..f6bb6f8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "compose/scale-tail"] + path = compose/scale-tail + url = https://github.com/tailscale-dev/ScaleTail.git diff --git a/AGENT_Context_Project.md b/AGENT_Context_Project.md new file mode 100644 index 0000000..6c0f496 --- /dev/null +++ b/AGENT_Context_Project.md @@ -0,0 +1,426 @@ +# AGENT Context — myLinux Project + +> **Purpose:** Single-source context document so any AI agent can understand the project, navigate the codebase, and make correct contributions. + +--- + +## 1. Project Overview + +**myLinux** is a personal bootstrap and homelab toolkit for Debian/Ubuntu. One command turns a bare install into a fully productive machine: + +- Automated system package installation (25+ packages) +- A unified CLI (`pos`) for network, Docker, media, system, and SSH tasks +- Optional desktop application installers (15 apps) +- Docker Compose service management via ScaleTail templates (119+ self-hosted services with Tailscale sidecar) +- Systemd service management for boot-time automation + +**Repository:** `https://github.com/IFindMe/myLinux` +**Target OS:** Debian / Ubuntu (uses `apt`) +**Shell:** Bash (`#!/usr/bin/env bash`) + +--- + +## 2. Directory Structure + +``` +myLinux/ +├── install.sh # Main orchestrator — entry point +├── preinstall.sh # Phase 1: system packages via apt + yt-dlp +├── postinstall.sh # Phase 3: PATH, bash completion, systemd services +│ +├── lib/ +│ └── common.sh # Shared library (colors, logging, spinner, timer, run) +│ +├── bin/ # CLI tools — installed to /usr/local/bin/ +│ ├── pos # Main dispatcher — smart arg matching to pos-* scripts +│ ├── pos-network-ip # Show interfaces, routes, public IP +│ ├── pos-network-checkport # TCP port checker +│ ├── pos-network-scan # Parallel ping sweep of CIDR subnet +│ ├── pos-docker-ps # Enhanced docker ps (health, IPs, ports, uptime) +│ ├── pos-docker-health # Quick one-glance health dashboard +│ ├── pos-docker-compose # Docker Compose service manager (largest script, 317 lines) +│ ├── pos-media-mp3 # Audio downloader (yt-dlp → MP3) +│ ├── pos-media-mp4 # Video downloader (yt-dlp → MP4, interactive format select) +│ ├── pos-system-firewall # Interactive UFW manager (menu-driven, 284 lines) +│ ├── pos-ssh-load-keys # Load SSH keys into ssh-agent +│ ├── pos-vbox # Disposable Docker-based "VMs" +│ ├── autostart.sh # Boot-time script (via systemd) +│ ├── wr-* # Legacy wrappers → pos (backward compat) +│ ├── mp3, mp4, vbox # Legacy convenience wrappers → pos +│ └── ssh-load-all # Legacy wrapper → pos ssh load-keys +│ +├── apps/ # Optional desktop app installers (by category) +│ ├── install.sh # Interactive picker / orchestrator +│ ├── browsers/ +│ │ └── brave.sh # Brave Browser (APT repo) +│ ├── development/ +│ │ ├── opencode.sh # opencode AI agent (official script) +│ │ └── vscode.sh # VS Code (Microsoft APT repo) +│ ├── media/ +│ │ ├── obs.sh # OBS Studio (apt) +│ │ ├── scrcpy.sh # scrcpy Android mirror (GitHub release) +│ │ └── vlc.sh # VLC media player (apt) +│ ├── networking/ +│ │ ├── netbird.sh # NetBird VPN (official script) +│ │ ├── tailscale.sh # Tailscale VPN (official script) +│ │ └── zerotier.sh # ZeroTier VPN (official script) +│ ├── remote-access/ +│ │ ├── termius.sh # Termius SSH client (.deb) +│ │ └── vnc-viewer.sh # TigerVNC Viewer (apt) +│ ├── system/ +│ │ ├── docker.sh # Docker Engine (get.docker.com) +│ │ └── qemu.sh # QEMU + libvirt + KVM (apt) +│ └── utilities/ +│ ├── affine.sh # AFFiNE knowledge base (AppImage) +│ ├── btop.sh # btop resource monitor (apt) +│ └── localsend.sh # LocalSend (flatpak) +│ +├── completions/ +│ └── pos.bash # Bash tab-completion for the pos CLI +│ +├── compose/ +│ └── scale-tail/ # Git submodule → ScaleTail templates (119+ services) +│ +├── systemd/ +│ ├── autostart.service # Runs autostart.sh on boot +│ └── ssh-agent.service # System-wide SSH agent socket +│ +├── README.md # User-facing documentation +├── DEV.md # Developer guide +├── .gitignore # Excludes secrets, Python artifacts, OS files +└── .gitmodules # Submodule: compose/scale-tail → ScaleTail +``` + +--- + +## 3. Installation Flow + +``` +User runs: ./install.sh [--apps|--full|--dry-run|--skip |--steps ] +│ +├─ Phase 1: preinstall.sh (requires root) +│ └─ apt update + installs 25+ packages + yt-dlp + fail2ban +│ +├─ Phase 2: install.sh (requires root) +│ └─ Copies bin/* → /usr/local/bin/ (chmod 755) +│ └─ Copies lib/common.sh → /usr/local/bin/common.sh (chmod 644) +│ +├─ Phase 3: postinstall.sh (runs as user) +│ └─ Configures fail2ban (SSH jail: 5 retries, 1h ban) +│ └─ PATH export in ~/.bashrc +│ └─ Bash completion for pos CLI +│ └─ Copies systemd/*.service → /etc/systemd/system/, enables them +│ +├─ Phase 4: ScaleTail clone +│ └─ Shallow-clones ScaleTail templates to /usr/local/share/mylinux/scale-tail +│ +└─ [if --apps or --full]: apps/install.sh + └─ Interactive picker (or --all for non-interactive) +``` + +**After install, the repo can be deleted** — all tools live in `/usr/local/bin/` and templates in `/usr/local/share/mylinux/`. + +### install.sh Flags + +| Flag | Purpose | +|------|---------| +| `--apps` | Run interactive app picker after core install | +| `--full` | Core install + all apps (non-interactive) | +| `--dry-run` | Preview without executing | +| `--skip ` | Skip a phase (repeatable): `preinstall`, `scripts`, `postinstall`, `scalepoint`, `apps` | +| `--steps ` | Run only specific phases. Format: `1,3,4` or `1-3` | +| `--no-color` | Disable colored output | + +### pos Output Logging + +All non-interactive `pos` commands log output to `~/.local/share/mylinux/logs/`: +- Per-command files: `YYYYMMDD_HHMMSS_pos_.log` (full stdout+stderr) +- Main log: `pos.log` (command + timestamp + exit code for every invocation) +- Interactive commands (`system-firewall`, `media-mp4`) only log invocation, not output + +--- + +## 4. The `pos` CLI System + +### How It Works + +`bin/pos` is the main dispatcher. It: +1. Scans its own directory for all executable `pos-*` files +2. Extracts category-subcommand names from filenames +3. Uses variable-length argument matching to find the right script + +**Example:** `pos docker compose up jellyfin` +- Tries `pos-docker-compose-up-jellyfin` (not found) +- Tries `pos-docker-compose-up` (not found) +- Finds `pos-docker-compose` (runs with args `up jellyfin`) + +### Available Commands + +| Category | Command | Script | Description | +|----------|---------|--------|-------------| +| network | ip | `pos-network-ip` | Show interfaces, routes, public IP | +| network | checkport | `pos-network-checkport` | Check TCP port connectivity | +| network | scan | `pos-network-scan` | Parallel ping sweep of CIDR | +| docker | ps | `pos-docker-ps` | Enhanced container overview | +| docker | health | `pos-docker-health` | Quick health dashboard (exits 1 if unhealthy) | +| docker | compose | `pos-docker-compose` | Service manager (ls/up/down/restart/logs/update/config) | +| media | mp3 | `pos-media-mp3` | Download audio as MP3 | +| media | mp4 | `pos-media-mp4` | Download video with format select | +| system | firewall | `pos-system-firewall` | Interactive UFW management | +| ssh | load-keys | `pos-ssh-load-keys` | Load SSH keys into agent | +| vbox | create | `pos-vbox create` | Create disposable VM (asks "Enter now?") | +| vbox | enter | `pos-vbox enter` | Start and exec into container | +| vbox | ls | `pos-vbox ls` | List vbox-managed containers only (label-filtered) | +| vbox | start/stop/rm | `pos-vbox start/stop/rm` | Lifecycle management | + +### Legacy Wrappers + +These forward to `pos` transparently: `wr-ip`, `wr-checkport`, `wr-scan-ping`, `wr-docker`, `wr-compose`, `wr-ufw`, `mp3`, `mp4`, `vbox`, `ssh-load-all`. + +### pos vbox Details + +`pos-vbox` manages disposable Docker containers as lightweight VMs: + +- **Container labeling:** All created containers get `mylinux.vbox=true` label +- **`ls` filtering:** `docker ps --filter label=mylinux.vbox=true` — only shows vbox-managed containers +- **Post-create prompt:** After `create`, asks "Enter now? [Y/n]" using `confirm` helper +- **Working dir detection:** `enter` auto-detects bind mount path from container labels +- **Custom dirs:** `--dir ` or `--dir .` for current directory + +--- + +## 5. Shared Library — `lib/common.sh` + +Sourced by most scripts. Provides: + +| Function | Purpose | +|----------|---------| +| `log "msg"` | Green `[+]` status message | +| `warn "msg"` | Yellow `[!]` warning | +| `err "msg"` | Red `ERROR:` + exit 1 | +| `ok "msg"` | Green `OK` prefix | +| `section "title"` | Cyan-bordered section header | +| `step N T "msg"` | Numbered step header (e.g., `[1/4] Installing`) | +| `run cmd` | Executes command, respects `$DRY_RUN` | +| `spawn "msg" cmd` | Runs with animated braille spinner, elapsed time, OK/FAIL status | +| `timer_start` / `timer_stop` | Elapsed time tracking | +| `confirm "prompt" [default]` | y/N or Y/n prompt | + +**Auto-detects TTY** — disables colors when piped. + +**Source pattern:** +```bash +source "$(dirname "$0")/../lib/common.sh" +``` + +**Scripts that do NOT source common.sh** (self-contained): `bin/pos`, `pos-network-ip`, `pos-network-checkport`, `pos-network-scan`, `pos-media-mp3`, `pos-media-mp4`, `pos-ssh-load-keys`, `pos-system-firewall`. + +--- + +## 6. Docker Compose / ScaleTail + +### Architecture + +ScaleTail provides 119+ Docker Compose templates with a Tailscale sidecar pattern (`network_mode: service:tailscale`). Each service gets a `tail-xxxxx.ts.net` URL with optional automatic HTTPS. + +``` +/usr/local/share/mylinux/scale-tail/ # Templates (git repo) +└── services// + ├── compose.yaml + └── .env + +~/.config/mylinux/compose.env # Global defaults (TS_AUTHKEY, TZ, DNS_SERVER, SERVICES_BASE) + +/srv// # Active deployments (default base) + ├── compose.yaml # From template (refreshed on update) + ├── .env # User config (preserved across updates) + ├── config/ + └── data/ +``` + +### Key Commands + +| Command | Description | +|---------|-------------| +| `pos docker compose ls` | List all available ScaleTail services | +| `pos docker compose up ` | Deploy service to SERVICES_BASE | +| `pos docker compose down ` | Stop a deployed service | +| `pos docker compose restart ` | Restart a service | +| `pos docker compose logs [-f]` | View/follow logs | +| `pos docker compose update` | Pull latest templates, refresh compose.yaml (preserves .env) | +| `pos docker compose config set K=V` | Set global config value | +| `pos docker compose config show` | Display current config | + +### Global Config Keys + +- `TS_AUTHKEY` — Tailscale auth key (required) +- `TZ` — Timezone +- `DNS_SERVER` — Custom DNS +- `SERVICES_BASE` — Deployment root (default: `/srv`) + +--- + +## 7. Optional Apps (`apps/`) + +### How They Work + +- `apps/install.sh` auto-discovers all `apps//*.sh` files (excluding itself) +- Three modes: interactive (default), `--all`, or specific app names as arguments +- Interactive TUI groups apps by category with section headers +- Each app script is standalone, idempotent, sources `lib/common.sh` + +### Installation Methods + +| Method | Apps | +|--------|------| +| `apt install` | btop, obs, vlc, vnc-viewer, qemu | +| APT repo (GPG + repo) | brave, vscode | +| Official `curl \| sh` | docker, tailscale, netbird, zerotier, opencode | +| AppImage | affine | +| GitHub release binary | scrcpy | +| Flatpak | localsend | +| .deb package | termius | + +### Adding a New App + +1. Create `apps/.sh` following the template in DEV.md +2. It auto-appears in the interactive picker — no registration needed + +--- + +## 8. Systemd Services + +| Service | File | Purpose | +|---------|------|---------| +| `ssh-agent.service` | `systemd/ssh-agent.service` | System-wide SSH agent, socket at `/run/ssh-agent/socket` | +| `autostart.service` | `systemd/autostart.service` | Runs `autostart.sh` on boot | + +All `.service` files in `systemd/` are automatically copied to `/etc/systemd/system/` and enabled by `postinstall.sh`. + +--- + +## 9. Configuration Files + +### Gitignored Secrets + +- `config/rclone.conf` — rclone remote config (OAuth tokens) +- `config/authorized_keys` — SSH public keys + +### Runtime Config + +- `~/.config/mylinux/compose.env` — Docker Compose global defaults +- `~/.bashrc` — Modified by postinstall (PATH, bash completion) + +--- + +## 10. Coding Conventions + +### Script Standards + +- **Shebang:** `#!/usr/bin/env bash` +- **Strict mode:** `set -euo pipefail` +- **Help:** Every script accepts `-h`/`--help` via `case` pattern +- **Idempotency:** Check existence before creating/modifying +- **Exit codes:** 0 = success, 1 = error + +### Naming Conventions + +- `pos--` — canonical tool names +- `wr-*` — legacy wrappers +- `apps//.sh` — optional app installers +- Hyphens for word separation, lowercase always + +### Error Handling + +- `command -v &>/dev/null` to check tool availability +- `set -euo pipefail` for fail-fast +- `err()` for fatal errors, `warn()` for non-fatal +- Confirmation prompts for destructive actions + +### Security + +- Never hardcode secrets in scripts +- Use `chmod 600` for sensitive files +- Validate user input before shell commands +- Use `sudo` only where necessary + +--- + +## 11. Development Workflow + +### Adding a New App + +1. Create `apps//.sh` following the template in DEV.md +2. It auto-appears in the interactive picker — no registration needed + +### Adding a New Tool + +1. Create `bin/pos--` following conventions +2. Add system deps to `PACKAGES` array in `preinstall.sh` (if needed) +3. Add config logic to `postinstall.sh` (if needed, with `.gitignore` for secrets) +4. Update `README.md` +5. Test: `bash -n bin/your-tool && shellcheck bin/your-tool` + +### Testing + +```bash +# Syntax check all scripts +for f in bin/* apps/*/*.sh lib/common.sh install.sh preinstall.sh postinstall.sh; do + bash -n "$f" || echo "FAIL: $f" +done + +# ShellCheck linting +shellcheck bin/my-script + +# Test in Docker +docker run --rm -it -v $PWD:/repo ubuntu:22.04 bash +# inside: cd /repo && ./install.sh + +# Test apps interactively +./apps/install.sh --all +./apps/install.sh docker vscode +``` + +### Commit Conventions + +Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` + +--- + +## 12. Key File Quick Reference + +| File | Lines | Purpose | +|------|-------|---------| +| `install.sh` | ~120 | Main orchestrator — 4 phases with CLI flags | +| `preinstall.sh` | ~52 | System packages + yt-dlp + fail2ban | +| `postinstall.sh` | ~65 | fail2ban config, PATH, bash completion, systemd | +| `lib/common.sh` | 121 | Shared library | +| `bin/pos` | ~130 | CLI dispatcher with smart arg matching + logging | +| `bin/pos-docker-compose` | 317 | Largest script — full compose management | +| `bin/pos-system-firewall` | 284 | Interactive UFW manager | +| `bin/pos-docker-ps` | 127 | Enhanced container overview | +| `bin/pos-docker-health` | ~90 | Quick health dashboard | +| `bin/pos-vbox` | ~160 | Docker-based disposable VMs (label-filtered, auto-enter prompt) | +| `completions/pos.bash` | 118 | Dynamic bash completion | +| `apps/install.sh` | 99 | App picker/orchestrator | + +--- + +## 13. Common Tasks for Agents + +| Task | Where to Edit | +|------|---------------| +| Add a new CLI tool | Create `bin/pos--`, add deps in `preinstall.sh` | +| Add a new app installer | Create `apps/.sh` (auto-discovered) | +| Add a systemd service | Create `systemd/.service` (auto-installed by postinstall) | +| Modify package list | Edit `PACKAGES` array in `preinstall.sh` | +| Change PATH or bash config | Edit `postinstall.sh` | +| Modify fail2ban config | Edit jail.local section in `postinstall.sh` | +| Add bash completion | Edit `completions/pos.bash` | +| Modify Docker Compose logic | Edit `bin/pos-docker-compose` | +| Modify Docker health check | Edit `bin/pos-docker-health` | +| Modify UFW/firewall logic | Edit `bin/pos-system-firewall` | +| Modify pos logging | Edit log setup in `bin/pos` | +| Modify install phases/flags | Edit arg parsing in `install.sh` | +| Update documentation | Edit `README.md` and/or `DEV.md` | +| Add a secret config file | Add to `config/`, update `.gitignore`, add copy logic in `postinstall.sh` | diff --git a/DEV.md b/DEV.md new file mode 100644 index 0000000..8fbf444 --- /dev/null +++ b/DEV.md @@ -0,0 +1,418 @@ +# Development Guide + +How this repo works, how to add features, and what to keep in mind when editing. + +--- + +## Concepts + +### Three-Phase Installation + +The installer runs in three sequential phases: + +``` + install.sh + │ + ┌───────────┼───────────┐ + ▼ ▼ ▼ + preinstall.sh bin/* postinstall.sh + (packages) → /usr/local/bin (config + services) +``` + +| Phase | Script | Responsibility | +|-------|--------|----------------| +| Pre | `preinstall.sh` | System packages, apt repositories, global binaries (yt-dlp) | +| Install | `install.sh` | Copies everything in `bin/` to `/usr/local/bin` with `chmod 755` | +| Post | `postinstall.sh` | User config (SSH, rclone), `~/.bashrc`, systemd services | + +Each phase is independent and is only run if the corresponding file exists. + +### Script Categories + +| Directory | Purpose | Installed To | +|-----------|---------|--------------| +| `bin/` | Daily-use tools and wrappers | `/usr/local/bin/` | +| `apps//` | Optional desktop apps (by category) | run on demand | +| `lib/` | Shared library (`common.sh`) | sourced at build time | +<<<<<<< HEAD +| `config/` | Static config files + SSH authorized_keys | `~/.config//` (via postinstall) | +======= +| `config/` | Static config files (gitignored — user adds their own) | `~/.config//` (via postinstall) | +>>>>>>> bba577c (Initial commit) +| `compose/` | ScaleTail templates (dev reference only) | cloned to `/usr/local/share/mylinux/scale-tail` on install | +| `systemd/` | Systemd service unit files | `/etc/systemd/system/` (via postinstall) | + +### Key Files Added + +| File | Purpose | +|------|---------| +<<<<<<< HEAD +| `.gitignore` | Prevents secrets (rclone tokens) and build artifacts from being committed | +| `config/authorized_keys` | SSH public keys read by `postinstall.sh` (replaces hardcoded key) |ls + +======= +| `.gitignore` | Prevents secrets (rclone tokens, SSH keys) and build artifacts from being committed | +>>>>>>> bba577c (Initial commit) +| `~/.config/mylinux/compose.env` | Global Docker Compose defaults (`TS_AUTHKEY`, `TZ`, `DNS_SERVER`, `SERVICES_BASE`) — created by `wr-compose config` | + +--- + +## How to Add a New Tool + +### 1. Create the script in `bin/` + +```bash +#!/usr/bin/env bash + +set -euo pipefail + +# Use the shared library for colors and helpers (preferred) +source "$(dirname "$0")/../lib/common.sh" + +usage() { + cat < +EOF + exit 0 +} + +case "${1:-}" in + -h|--help|"") usage ;; +esac + +# --- script logic --- +``` + +**Conventions to follow:** + +- **Shebang:** `#!/usr/bin/env bash` (portable across distros) +- **Strict mode:** `set -euo pipefail` at the top +- **`--help` flag:** all tools must accept `-h` / `--help` — use the `case ... esac` pattern above +- **Shared library:** source `lib/common.sh` from any script in `bin/` or `apps/` for consistent colors, logging (`log`, `warn`, `err`, `ok`), spinners (`spawn`), and dry-run support (`run`). Use `spawn "message" command` for long-running installs. +- **Fallback (no lib):** if sourcing `common.sh` is not desired, inline: + ```bash + log() { echo "[+] $*"; } + warn() { echo "[!] $*"; } + err() { echo "ERROR: $*" >&2; exit 1; } + ``` +- **Exit codes:** `0` for success, `1` for error + +### 2. Add system dependencies (if any) + +Open `preinstall.sh` and add the package name to the `PACKAGES` array: + +```bash +PACKAGES=( + ... + your-package +) +``` + +### 3. Add runtime configuration (if any) + +If the tool needs a config file: +- Place the file in `config/` +- Add copy logic in `postinstall.sh` + +If the file contains secrets (tokens, keys): +- Add it to `.gitignore` +- Document in README how to create it manually + +<<<<<<< HEAD +### 4. Add SSH keys (if needed) + +Place public keys in `config/authorized_keys` (one per line). +`postinstall.sh` reads from this file automatically. + +======= +>>>>>>> bba577c (Initial commit) +### 5. Update README.md + +Add a section under **Tools Reference** following the existing format. + +### 6. Test + +```bash +# Syntax check +bash -n bin/your-tool + +# ShellCheck linting +shellcheck bin/your-tool + +# Run directly +./bin/your-tool --help +``` + +--- + +## How to Edit an Existing Tool + +1. **Find the script** — all tools live in `bin/` +2. **Understand the contract** — what args does it expect? What does it print? What exit codes? +3. **Make the change** — keep it idempotent if possible (running twice = same result) +4. **Update README** if usage, output, or behaviour changed +5. **Run `shellcheck`** on the modified file: + ```bash + shellcheck bin/your-tool + ``` + +--- + +## How to Add a New App + +App installers live in `apps//` and follow a simple pattern. Each is a standalone script that can be run independently. + +### Template + +```bash +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +install_myapp() { + command -v myapp &>/dev/null && { log "myapp already installed"; return 0; } + + spawn "Installing myapp" sudo apt install -y myapp +} + +install_myapp +``` + +Note: app scripts are now in `apps//`, so the source path to `common.sh` is two levels up (`../../lib/common.sh`). + +### Conventions + +- **Shebang:** `#!/usr/bin/env bash` +- **Strict mode:** `set -euo pipefail` +- **Shared library:** always source `lib/common.sh` from the app directory +- **Idempotent:** check `command -v` before installing; skip if present +- **Method:** standardize on official repos/scripts over PPAs or third-party +- **APT packages** → `sudo apt install -y ` wrapped in `spawn` +- **Official scripts** → `curl ... | sh` inside `spawn` +- **Flatpak** → `flatpak install -y flathub ` inside `spawn` +- **`.deb` files** → download to temp and `sudo apt install -y ./file.deb` inside `spawn` +- **Groups:** `usermod` commands print a re-login reminder (`log "Log out and back in for group changes to take effect"`) + +### Adding to the picker + +`apps/install.sh` auto-discovers all `apps//*.sh` files (excluding itself). Just create the script in the appropriate category subdirectory and it will appear in the interactive prompt under that category. + +Categories: `browsers`, `development`, `media`, `networking`, `remote-access`, `system`, `utilities`. + +--- + +## Best Practices + +### Idempotency + +Scripts should be safe to run multiple times: +- Check if something exists before creating it +- Use `>>` with checks (grep for existing content) instead of blindly appending +- Don't overwrite configs that the user may have customized + +### Error Handling + +```bash +# Fail fast +set -euo pipefail + +# Check for required commands +if ! command -v docker &>/dev/null; then + echo "docker not found" + exit 1 +fi + +# Check arguments +if [[ -z "${1:-}" ]]; then + echo "Usage: my-tool " + exit 1 +fi +``` + +### Portability + +This repo targets **Debian** and **Ubuntu**. Keep in mind: +- Use `apt` not `apt-get` unless you need non-interactive guarantees +- Assume `bash` is at `/usr/bin/env bash` +- Prefer POSIX-safe patterns when possible +- Check for command availability with `command -v` + +### Dry-run support + +Scripts that make changes (`install.sh`, `preinstall.sh`) support `--dry-run`: + +```bash +./install.sh --dry-run # preview without executing +``` + +Use the `run()` helper pattern: + +```bash +run() { + if [ "$DRY_RUN" -eq 1 ]; then + log "(dry-run) $*" + else + "$@" + fi +} + +run sudo apt install -y git +``` + +### Security + +- **Never hardcode secrets** in scripts (SSH keys, API tokens, passwords) — put them in `config/` files that are `.gitignore`d +- Use `chmod 600` for sensitive files (SSH keys, rclone config) +- Validate user input before using it in shell commands +- Use `sudo` only where necessary; don't run the whole script as root if only one command needs elevation + +### Naming + +- Prefix personal wrappers with `wr-` (e.g., `wr-ip`, `wr-docker`) +- Keep names lowercase, use hyphens for word separation +- Name should hint at the tool's purpose (`wr-scan-ping`, `wr-checkport`) + +--- + +## Working with Systemd + +### Adding a new service + +1. Create `systemd/.service` +2. postinstall.sh automatically copies all `*.service` files to `/etc/systemd/system/` and enables them + +Service file template: + +```ini +[Unit] +Description=My Service +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/your-script.sh +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +--- + +## Working with Config Files + +1. Place the file in `config/` +2. Add a section to `postinstall.sh`: + +```bash +if [ -f config/your-config.conf ]; then + mkdir -p "$HOME/.config/your-app" + cp config/your-config.conf "$HOME/.config/your-app/your-config.conf" + chmod 600 "$HOME/.config/your-app/your-config.conf" + echo "Installed your-config.conf" +fi +``` + +--- + +## Working with Docker Compose + +The installer clones [ScaleTail](https://github.com/tailscale-dev/ScaleTail) templates to `/usr/local/share/mylinux/scale-tail/` — a library of 119+ self-hosted services with a **Tailscale sidecar** pattern. Each service runs with `network_mode: service:tailscale`, gets a `tail-xxxxx.ts.net` URL, and optional automatic HTTPS via Tailscale Serve or Funnel. + +### Architecture (after install) + +``` +/usr/local/share/mylinux/scale-tail/ # ScaleTail templates (git repo) +└── services// + ├── compose.yaml # Service definition (Tailscale + app containers) + └── .env # Template variables (SERVICE, IMAGE_URL, TS_AUTHKEY, TZ, ...) + +~/.config/mylinux/compose.env # Global defaults — set via wr-compose config + +// # Active deployments (default: /srv/) + ├── compose.yaml # Copied from template (refreshed on wr-compose update) + ├── .env # Your real config — preserved across updates + ├── config/ # Service configuration data + └── data/ # Service persistent data +``` + +### `wr-compose` commands + +| Command | Behaviour | +|---------|-----------| +| `wr-compose up ` | Deploys service to `$SERVICES_BASE//` (default: `/srv`), creates `config/` + `data/` dirs, generates `.env` from global config (prompts for `TS_AUTHKEY` if empty), runs `docker compose up -d` | +| `wr-compose down ` | Runs `docker compose down` in the service directory | +| `wr-compose update` | `git pull` in ScaleTail templates dir, then re-copies `compose.yaml` into all deployed directories — `.env` files are left untouched | +| `wr-compose config set K=V` | Persists a value in `~/.config/mylinux/compose.env` (e.g. `TS_AUTHKEY`, `TZ`, `DNS_SERVER`, `SERVICES_BASE`) | + +### Portable `.env` design + +- **Global**: `~/.config/mylinux/compose.env` — one place for `TS_AUTHKEY`, `TZ`, `DNS_SERVER`, `SERVICES_BASE`. +- **Per-service**: `//.env` — generated from the ScaleTail template on first deploy, with empty values filled from the global config. +- **On update**: `wr-compose update` refreshes only `compose.yaml` from the templates; `.env` files are preserved. +- **Services path**: set `SERVICES_BASE` to any directory (e.g. `/srv`) via `wr-compose config set SERVICES_BASE=/srv`. Defaults to `/srv`. + +This means `wr-compose` works anywhere — no repo clone needed after install. Just set `TS_AUTHKEY` once and deploy. + +### Contributing upstream + +ScaleTail provides a [service template](https://github.com/tailscale-dev/ScaleTail/tree/main/templates/service-template). To add a service: + +1. Fork ScaleTail and add your service under `services//` +2. Submit a PR upstream +3. Changes are picked up by `wr-compose update` + +--- + +## Commit Guidelines + +- Use conventional commit prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` +- Explain *why* the change was made, not just *what* changed +- Keep commits focused — one logical change per commit + +Examples: + +``` +feat: add wr-mytool for monitoring disk usage +fix: wr-ip fails when no default route exists +docs: add example output for wr-scan-ping +``` + +--- + +## Useful Commands + +```bash +# Syntax-check a script without running it +bash -n bin/my-script +bash -n apps/utilities/myapp.sh + +# ShellCheck linting +shellcheck bin/my-script +shellcheck apps/utilities/myapp.sh + +# Quick syntax check all scripts +for f in bin/* apps/*/*.sh lib/common.sh install.sh preinstall.sh postinstall.sh; do + bash -n "$f" || echo "FAIL: $f" +done + +# Initialize submodule after clone +git submodule update --init + +# Pull latest ScaleTail services +git submodule update --remote compose/scale-tail + +# List available compose services +./bin/wr-compose ls + +# Test install in Docker +docker run --rm -it -v $PWD:/repo ubuntu:22.04 bash +# inside container: cd /repo && ./install.sh + +# Test app installation interactively +./apps/install.sh +./apps/install.sh --all # install all apps +./apps/install.sh docker vscode # install specific apps +``` diff --git a/apps/browsers/brave.sh b/apps/browsers/brave.sh new file mode 100755 index 0000000..b903966 --- /dev/null +++ b/apps/browsers/brave.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +install_brave() { + command -v brave-browser &>/dev/null && { log "brave already installed"; return 0; } + + spawn "Adding brave apt repo" bash -c " + sudo curl -fsSLo /usr/share/keyrings/brave-browser-archive-keyring.gpg \ + https://brave-browser-apt-release.s3.brave.com/brave-browser-archive-keyring.gpg + echo 'deb [arch=amd64 signed-by=/usr/share/keyrings/brave-browser-archive-keyring.gpg] https://brave-browser-apt-release.s3.brave.com/ stable main' \ + | sudo tee /etc/apt/sources.list.d/brave-browser-release.list >/dev/null + " + spawn "Installing brave-browser" sudo apt update -qq + spawn "Installing brave-browser" sudo apt install -y brave-browser +} + +install_brave diff --git a/apps/development/opencode.sh b/apps/development/opencode.sh new file mode 100755 index 0000000..1792230 --- /dev/null +++ b/apps/development/opencode.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +install_opencode() { + command -v opencode &>/dev/null && { log "opencode already installed"; return 0; } + + spawn "Installing opencode" bash -c " + curl -fsSL https://opencode.ai/install | bash + " + spawn "source bashrc" source ~/.bashrc + log "opencode installed to ~/.opencode/bin" + log "Add to PATH: export PATH=\"\$HOME/.opencode/bin:\$PATH\"" +} + +install_opencode diff --git a/apps/development/vscode.sh b/apps/development/vscode.sh new file mode 100755 index 0000000..324741c --- /dev/null +++ b/apps/development/vscode.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +install_vscode() { + command -v code &>/dev/null && { log "vscode already installed"; return 0; } + + spawn "Adding vscode apt repo" bash -c " + sudo curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \ + | sudo gpg --dearmor -o /usr/share/keyrings/packages.microsoft.gpg + echo 'deb [arch=amd64 signed-by=/usr/share/keyrings/packages.microsoft.gpg] https://packages.microsoft.com/repos/code stable main' \ + | sudo tee /etc/apt/sources.list.d/vscode.list >/dev/null + " + spawn "Updating apt" sudo apt update -qq + spawn "Installing code" sudo apt install -y code +} + +install_vscode diff --git a/apps/install.sh b/apps/install.sh new file mode 100755 index 0000000..f7a99e8 --- /dev/null +++ b/apps/install.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../lib/common.sh" + +usage() { + cat </dev/null) && { + SELECTED+=("$req") + } || { + warn "Unknown app: $req (skipping)" + } + done + +# ── Mode 2: --all ───────────────────────────────────────────── +elif [ "$ALL" -eq 1 ]; then + for cat in "${CATEGORIES[@]}"; do + for app in "${CAT_APPS[$cat]}"; do + SELECTED+=("$app") + done + done + +# ── Mode 3: interactive TUI ─────────────────────────────────── +else + section "Optional Applications" + echo "Select apps to install (y/n for each):" + echo + + for cat in "${CATEGORIES[@]}"; do + display="${CAT_NAMES[$cat]:-$cat}" + echo " $display" + for app in "${CAT_APPS[$cat]}"; do + read -rp " Install ${app}? [y/N]: " yn + if [[ "$yn" =~ ^[Yy] ]]; then + SELECTED+=("$app") + fi + done + echo + done +fi + +# ── Install selected apps ────────────────────────────────────── +[ "${#SELECTED[@]}" -eq 0 ] && { warn "No apps selected"; exit 0; } + +echo +section "Installing ${SELECTED[*]}" + +timer_start +count=1 +total=${#SELECTED[@]} +for app in "${SELECTED[@]}"; do + cat=$(find_app_category "$app") + step "$count" "$total" "$app" + bash "$APPS_DIR/$cat/$app.sh" + count=$((count + 1)) + echo +done + +echo +echo "${GREEN}════════════════════════════════════════════${RESET}" +echo "${GREEN} Apps installed ($(timer_stop))${RESET}" +echo "${GREEN}════════════════════════════════════════════${RESET}" diff --git a/apps/media/obs.sh b/apps/media/obs.sh new file mode 100755 index 0000000..90fd1ed --- /dev/null +++ b/apps/media/obs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +install_obs() { + command -v obs &>/dev/null && { log "obs-studio already installed"; return 0; } + spawn "Installing obs-studio" sudo apt install -y obs-studio +} + +install_obs diff --git a/apps/media/scrcpy.sh b/apps/media/scrcpy.sh new file mode 100644 index 0000000..730db10 --- /dev/null +++ b/apps/media/scrcpy.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +RELEASE_URL="https://api.github.com/repos/Genymobile/scrcpy/releases/latest" + +install_scrcpy() { + command -v scrcpy &>/dev/null && { log "scrcpy already installed"; return 0; } + + spawn "Fetching latest scrcpy release info" bash -c " + curl -fsSL '$RELEASE_URL' -o /tmp/scrcpy-release.json + " + + local tag asset_url + tag=$(python3 -c "import json; print(json.load(open('/tmp/scrcpy-release.json'))['tag_name'])") + asset_url=$(python3 -c " +import json +r = json.load(open('/tmp/scrcpy-release.json')) +for a in r['assets']: + if a['name'].startswith('scrcpy-linux-x86_64') and a['name'].endswith('.tar.gz'): + print(a['browser_download_url']) + break +") + version="${tag#v}" + + spawn "Downloading scrcpy $version" bash -c " + install_dir=/usr/local/lib/scrcpy-$version + curl -fsSL '$asset_url' -o /tmp/scrcpy.tar.gz + sudo rm -rf \$install_dir /usr/local/lib/scrcpy + sudo mkdir -p \$install_dir + sudo tar xzf /tmp/scrcpy.tar.gz -C \$install_dir --strip-components=1 + sudo ln -sf \$install_dir/scrcpy /usr/local/bin/scrcpy + rm -f /tmp/scrcpy.tar.gz /tmp/scrcpy-release.json + " + + spawn "Adding desktop entry" bash -c " + sudo tee /usr/share/applications/scrcpy.desktop >/dev/null <<-EOF +[Desktop Entry] +Name=scrcpy +Comment=Display and control Android devices +Exec=/usr/local/bin/scrcpy +Icon=/usr/local/lib/scrcpy-$version/scrcpy.png +Terminal=false +Type=Application +Categories=Utility; +StartupNotify=false +EOF + " + + log "scrcpy $version installed (adb included in the bundle)" +} + +install_scrcpy diff --git a/apps/media/vlc.sh b/apps/media/vlc.sh new file mode 100755 index 0000000..0190924 --- /dev/null +++ b/apps/media/vlc.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +install_vlc() { + command -v vlc &>/dev/null && { log "vlc already installed"; return 0; } + spawn "Installing vlc" sudo apt install -y vlc +} + +install_vlc diff --git a/apps/networking/netbird.sh b/apps/networking/netbird.sh new file mode 100755 index 0000000..4283f9c --- /dev/null +++ b/apps/networking/netbird.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +install_netbird() { + command -v netbird &>/dev/null && { log "netbird already installed"; return 0; } + + spawn "Installing netbird" bash -c " + curl -fsSL https://pkgs.netbird.io/install.sh | sh + " + log "Join a network: sudo netbird up --setup-key " +} + +install_netbird diff --git a/apps/networking/tailscale.sh b/apps/networking/tailscale.sh new file mode 100755 index 0000000..cfcaca9 --- /dev/null +++ b/apps/networking/tailscale.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +install_tailscale() { + command -v tailscale &>/dev/null && { log "tailscale already installed"; return 0; } + + spawn "Installing tailscale" bash -c " + curl -fsSL https://tailscale.com/install.sh | sh + " + log "Start tailscale: sudo tailscale up" +} + +install_tailscale diff --git a/apps/networking/zerotier.sh b/apps/networking/zerotier.sh new file mode 100755 index 0000000..fae674b --- /dev/null +++ b/apps/networking/zerotier.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +install_zerotier() { + command -v zerotier-one &>/dev/null && { log "zerotier already installed"; return 0; } + + spawn "Installing zerotier" bash -c " + curl -s https://install.zerotier.com | sudo bash + " + log "Join a network: sudo zerotier-cli join " +} + +install_zerotier diff --git a/apps/remote-access/termius.sh b/apps/remote-access/termius.sh new file mode 100755 index 0000000..cd69799 --- /dev/null +++ b/apps/remote-access/termius.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +install_termius() { + command -v termius &>/dev/null && { log "termius already installed"; return 0; } + + spawn "Downloading Termius .deb" bash -c " + curl -fsSL -o /tmp/termius.deb 'https://www.termius.com/download/linux/Termius.deb' + " + + spawn "Installing Termius" bash -c " + sudo dpkg -i /tmp/termius.deb || sudo apt-get install -f -y + rm -f /tmp/termius.deb + " + + log "Termius installed — launch with 'termius'" +} + +install_termius diff --git a/apps/remote-access/vnc-viewer.sh b/apps/remote-access/vnc-viewer.sh new file mode 100755 index 0000000..9e8c522 --- /dev/null +++ b/apps/remote-access/vnc-viewer.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +install_vnc_viewer() { + command -v vncviewer &>/dev/null && { log "tigervnc-viewer already installed"; return 0; } + spawn "Installing tigervnc-viewer" sudo apt install -y tigervnc-viewer +} + +install_vnc_viewer diff --git a/apps/system/docker.sh b/apps/system/docker.sh new file mode 100755 index 0000000..dd5636b --- /dev/null +++ b/apps/system/docker.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +install_docker() { + command -v docker &>/dev/null && { log "docker already installed"; return 0; } + + spawn "Installing docker engine" bash -c " + curl -fsSL https://get.docker.com | sh + " + spawn "Adding user to docker group" sudo usermod -aG docker "$USER" + warn "Log out and back in for docker group to take effect" +} + +install_docker diff --git a/apps/system/qemu.sh b/apps/system/qemu.sh new file mode 100755 index 0000000..bb63c08 --- /dev/null +++ b/apps/system/qemu.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +install_qemu() { + command -v qemu-system-x86_64 &>/dev/null && { log "qemu already installed"; return 0; } + + spawn "Installing qemu and libvirt" sudo apt install -y \ + qemu-system qemu-utils qemu-kvm \ + libvirt-daemon-system libvirt-clients \ + bridge-utils virt-manager + + spawn "Adding user to libvirt group" sudo usermod -aG libvirt "$USER" + spawn "Adding user to kvm group" sudo usermod -aG kvm "$USER" + warn "Log out and back in for libvirt/kvm groups to take effect" +} + +install_qemu diff --git a/apps/utilities/affine.sh b/apps/utilities/affine.sh new file mode 100755 index 0000000..93056bd --- /dev/null +++ b/apps/utilities/affine.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +install_affine() { + command -v affine &>/dev/null && { log "affine already installed"; return 0; } + + local appimage_url="https://github.com/toeverything/AFFiNE/releases/download/v0.26.3/affine-0.26.3-stable-linux-x64.appimage" + + local icon_url="https://raw.githubusercontent.com/toeverything/AFFiNE/master/packages/frontend/apps/electron/resources/icons/icon.png" + + spawn "Installing AFFiNE AppImage" bash -c " + mkdir -p /opt/affine + curl -fsSL '$appimage_url' -o /opt/affine/affine.AppImage + chmod +x /opt/affine/affine.AppImage + ln -sf /opt/affine/affine.AppImage /usr/local/bin/affine + " + + spawn "Adding desktop entry" bash -c " + curl -fsSL '$icon_url' -o /opt/affine/icon.png + cat > /usr/share/applications/affine.desktop <<-EOF +[Desktop Entry] +Name=AFFiNE +Comment=Next-gen knowledge base +Exec=/opt/affine/affine.AppImage +Icon=/opt/affine/icon.png +Terminal=false +Type=Application +Categories=Office;Utility; +StartupNotify=false +EOF + " +} + +install_affine diff --git a/apps/utilities/btop.sh b/apps/utilities/btop.sh new file mode 100755 index 0000000..f28a049 --- /dev/null +++ b/apps/utilities/btop.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +install_btop() { + command -v btop &>/dev/null && { log "btop already installed"; return 0; } + spawn "Installing btop" sudo apt install -y btop +} + +install_btop diff --git a/apps/utilities/localsend.sh b/apps/utilities/localsend.sh new file mode 100755 index 0000000..050a36b --- /dev/null +++ b/apps/utilities/localsend.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../../lib/common.sh" + +install_localsend() { + if flatpak list 2>/dev/null | grep -q org.localsend.localsend_app; then + log "localsend already installed" + return 0 + fi + + if ! command -v flatpak &>/dev/null; then + spawn "Installing flatpak" sudo apt install -y flatpak + fi + + spawn "Adding flathub remote" sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + spawn "Installing localsend" sudo flatpak install -y flathub org.localsend.localsend_app +} + +install_localsend diff --git a/bin/autostart.sh b/bin/autostart.sh new file mode 100755 index 0000000..79f409a --- /dev/null +++ b/bin/autostart.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +LOG="${HOME:-/root}/.autostart.log" + +echo "[$(date)] autostart running" >> "$LOG" 2>/dev/null || true + +# Check network connectivity +if ping -c 1 -W 2 8.8.8.8 &>/dev/null; then + echo "[$(date)] Network: online" >> "$LOG" 2>/dev/null || true +else + echo "[$(date)] Network: offline" >> "$LOG" 2>/dev/null || true +fi + +echo "[$(date)] autostart complete" >> "$LOG" 2>/dev/null || true diff --git a/bin/mp3 b/bin/mp3 new file mode 100755 index 0000000..f713c47 --- /dev/null +++ b/bin/mp3 @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec pos media mp3 "$@" diff --git a/bin/mp4 b/bin/mp4 new file mode 100755 index 0000000..4fb4f21 --- /dev/null +++ b/bin/mp4 @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec pos media mp4 "$@" diff --git a/bin/pos b/bin/pos new file mode 100755 index 0000000..cb65c38 --- /dev/null +++ b/bin/pos @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +set -euo pipefail + +self="$(cd "$(dirname "$0")" && pwd)" + +# ── Colors (auto-off when not a TTY) ─────────────────────────── +if [ -t 1 ]; then + BOLD=$(tput bold 2>/dev/null || true) + CYAN=$(tput setaf 6 2>/dev/null || true) + DIM=$(tput dim 2>/dev/null || true) + RESET=$(tput sgr0 2>/dev/null || true) +else + BOLD=""; CYAN=""; DIM=""; RESET="" +fi + +# ── Collect available commands ───────────────────────────────── +_pos_commands() { + local cmds=() + local f + for f in "$self"/pos-*; do + [ -x "$f" ] || continue + local name="${f##*/pos-}" + cmds+=("$name") + done + echo "${cmds[*]}" +} + +# ── Help text ────────────────────────────────────────────────── +usage() { + cat <<'EOF' +pos — Personal OS Toolkit + + A unified CLI for network, docker, media, system, + and SSH tools on Debian/Ubuntu. + +USAGE + pos [args] + +CATEGORIES + network ip | checkport | scan + docker ps | compose + media mp3 | mp4 + system firewall + ssh load-keys + vbox create | enter | stop | start | rm | ls + +EXAMPLES + pos network ip Show interfaces, routes, public IP + pos network checkport 10.0.0.1:80 Check if a TCP port is open + pos network scan 192.168.1.0/24 Fast parallel ping sweep + + pos docker ps List containers (health, IPs, ports) + pos docker compose ls List available ScaleTail services + pos docker compose up jellyfin Deploy a service with Tailscale + + pos media mp3 Download audio as MP3 + pos media mp4 Download video as MP4 + + pos system firewall Interactive UFW manager + + pos ssh load-keys Load all SSH keys into agent + + pos vbox create lab1 Create disposable Docker VM + pos vbox create lab1 --dir . Create VM using current directory + pos vbox enter lab1 Shell into a Docker VM + pos vbox ls List Docker VMs + +HELP + pos help Show help for a command + pos --help Show this help + +LEGACY WRAPPERS + wr-ip, wr-checkport, wr-scan-ping, wr-docker, + wr-compose, wr-ufw, mp3, mp4, vbox, ssh-load-all + still work and forward to pos. +EOF + exit 0 +} + +# ── Handle --help / -h ───────────────────────────────────────── +case "${1:-}" in + -h|--help|"") usage ;; +esac + +# ── pos help ───────────────────────────────────────── +if [ "${1:-}" = "help" ]; then + shift + [ $# -eq 0 ] && usage + cmd="pos-${1// /-}" + if command -v "$cmd" &>/dev/null; then + exec "$cmd" --help + fi + [ -x "$self/$cmd" ] && exec "$self/$cmd" --help + echo "pos: unknown command '$1'" >&2 + echo "Run 'pos --help' to see available commands." >&2 + exit 1 +fi + +# ── Dispatch ─────────────────────────────────────────────────── +args=("$@") +n=${#args[@]} + +# ── Logging setup ────────────────────────────────────────────── +LOG_DIR="$HOME/.local/share/mylinux/logs" +mkdir -p "$LOG_DIR" 2>/dev/null || true +CMD_SAFE=$(echo "${args[*]}" | tr ' /' '__') +LOG_FILE="$LOG_DIR/$(date +%Y%m%d_%H%M%S)_pos_${CMD_SAFE}.log" +MAIN_LOG="$LOG_DIR/pos.log" +log_cmd() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $* → exit $2" >> "$MAIN_LOG"; } + +# Commands that read from stdin interactively — only log invocation +INTERACTIVE_CMDS="system-firewall media-mp4" + +for ((i=n-1; i>=0; i--)); do + cmd="pos" + for ((j=0; j<=i; j++)); do + cmd="${cmd}-${args[$j]}" + done + + resolved="" + if command -v "$cmd" &>/dev/null; then + resolved="$cmd" + elif [ -x "$self/$cmd" ]; then + resolved="$self/$cmd" + fi + + [ -z "$resolved" ] && continue + + sub="${cmd#pos-}" + if [[ " $INTERACTIVE_CMDS " == *" $sub "* ]]; then + # Interactive: log invocation only, then exec normally + log_cmd "pos $*" "" 0 + exec "$resolved" "${args[@]:i+1}" + else + # Non-interactive: capture full output + "$resolved" "${args[@]:i+1}" 2>&1 | tee "$LOG_FILE" + rc=${PIPESTATUS[0]} + log_cmd "pos $*" "$LOG_FILE" "$rc" + exit "$rc" + fi +done + +echo "pos: unknown command '${args[0]}'" >&2 +echo "Run 'pos --help' to see available commands." >&2 +exit 1 diff --git a/bin/pos-docker-compose b/bin/pos-docker-compose new file mode 100755 index 0000000..5f00859 --- /dev/null +++ b/bin/pos-docker-compose @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" + +SCALE_DIR="/usr/local/share/mylinux/scale-tail/services" +CONFIG_ENV="${HOME}/.config/mylinux/compose.env" + +usage() { + cat < Deploy a service + pos docker compose down Stop a service + pos docker compose restart Restart a service + pos docker compose logs [-f] View service logs + pos docker compose update Pull latest ScaleTail + refresh compose files + pos docker compose config Show global config + pos docker compose config set KEY=VALUE Set a global config value + pos docker compose config edit Open global config in editor + -h, --help Show this help + +Examples: + pos docker compose up jellyfin + pos docker compose down actual-budget + pos docker compose logs home-assistant -f + pos docker compose config set TS_AUTHKEY=tskey-auth-xxxxx +EOF + exit 0 +} + +check_deps() { + command -v docker &>/dev/null || err "docker not found — run 'install.sh --apps' and install Docker first" +} + +check_templates() { + [ -d "$SCALE_DIR" ] || err "ScaleTail templates not found at $SCALE_DIR — run 'install.sh' to set them up" +} + +check_service() { + local svc="$1" + [ -d "$SCALE_DIR/$svc" ] || err "Unknown service '$svc' — run 'pos docker compose ls' to see available services" +} + +load_global_config() { + mkdir -p "$(dirname "$CONFIG_ENV")" + [ -f "$CONFIG_ENV" ] && source "$CONFIG_ENV" + SERVICES_BASE="${SERVICES_BASE:-/srv}" +} + +service_dir() { + local svc="$1" + echo "$SERVICES_BASE/$svc" +} + +############################################################################### +# Commands +############################################################################### + +cmd_ls() { + check_templates + local names=() + for svc in "$SCALE_DIR"/*/; do + names+=("$(basename "$svc")") + done + IFS=$'\n' names=($(sort <<<"${names[*]}")); unset IFS + + echo "${CYAN}Available ScaleTail services:${RESET}" + echo "${BLUE}────────────────────────────────────────${RESET}" + local name + for name in "${names[@]}"; do + printf " ${GREEN}%s${RESET}\n" "$name" + done + echo "${BLUE}────────────────────────────────────────${RESET}" + echo " ${#names[@]} services total" +} + +cmd_installed() { + load_global_config + local count=0 + + echo "${CYAN}Deployed services:${RESET}" + echo "${BLUE}────────────────────────────────────────${RESET}" + + [ -d "$SERVICES_BASE" ] || { echo " (none — $SERVICES_BASE does not exist)"; echo "${BLUE}────────────────────────────────────────${RESET}"; echo " 0 services deployed"; return; } + + for svc in "$SERVICES_BASE"/*/; do + [ -d "$svc" ] || continue + local name + name=$(basename "$svc") + local status="" + if [ -f "$svc/compose.yaml" ] || [ -f "$svc/compose.yml" ]; then + status=$(docker compose ls --format json 2>/dev/null | python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + if not isinstance(data, list): + data = [data] + for e in data: + if e.get('Name') == '$name': + print(e.get('Status', 'unknown')) + break +except: pass +" 2>/dev/null || echo "unknown") + fi + printf " ${GREEN}%-28s${RESET} %s\n" "$name" "${status:-unknown}" + count=$((count + 1)) + done + echo "${BLUE}────────────────────────────────────────${RESET}" + echo " $count services deployed" +} + +cmd_up() { + local svc="$1" + [ -z "$svc" ] && usage + check_deps + check_templates + check_service "$svc" + load_global_config + + local target + target=$(service_dir "$svc") + + if [ ! -d "$target" ]; then + log "Creating $svc at $target" + mkdir -p "$target/config" "$target/data" + if [ -f "$SCALE_DIR/$svc/compose.yaml" ]; then + cp "$SCALE_DIR/$svc/compose.yaml" "$target/" + fi + if [ -f "$SCALE_DIR/$svc/compose.yml" ]; then + cp "$SCALE_DIR/$svc/compose.yml" "$target/" + fi + else + mkdir -p "$target/config" "$target/data" + fi + + local env_file="$target/.env" + if [ ! -f "$env_file" ]; then + if [ -f "$SCALE_DIR/$svc/.env" ]; then + cp "$SCALE_DIR/$svc/.env" "$env_file" + else + cat > "$env_file" <<-EOF +SERVICE=$svc +IMAGE_URL= +SERVICEPORT= +DNS_SERVER=${DNS_SERVER:-9.9.9.9} +TS_AUTHKEY=${TS_AUTHKEY:-} +TZ=${TZ:-Europe/Amsterdam} +EOF + fi + + if [ -n "${TS_AUTHKEY:-}" ]; then + sed -i "s|^TS_AUTHKEY=.*|TS_AUTHKEY=$TS_AUTHKEY|" "$env_file" 2>/dev/null || true + fi + if [ -n "${TZ:-}" ]; then + sed -i "s|^TZ=.*|TZ=$TZ|" "$env_file" 2>/dev/null || true + fi + if [ -n "${DNS_SERVER:-}" ]; then + sed -i "s|^DNS_SERVER=.*|DNS_SERVER=$DNS_SERVER|" "$env_file" 2>/dev/null || true + fi + + if grep -q "^TS_AUTHKEY=$" "$env_file" 2>/dev/null; then + warn "TS_AUTHKEY is not set" + local key + read -rp " Enter your Tailscale auth key (or press Enter to skip): " key + if [ -n "$key" ]; then + sed -i "s|^TS_AUTHKEY=.*|TS_AUTHKEY=$key|" "$env_file" + fi + fi + log ".env created at $env_file — edit it if needed before continuing" + fi + + log "Starting $svc..." + (cd "$target" && docker compose up -d) + ok "$svc is running" +} + +cmd_down() { + local svc="$1" + [ -z "$svc" ] && usage + check_deps + load_global_config + local target + target=$(service_dir "$svc") + [ -d "$target" ] || err "$svc is not deployed at $target" + log "Stopping $svc..." + (cd "$target" && docker compose down) + ok "$svc stopped" +} + +cmd_restart() { + local svc="$1" + [ -z "$svc" ] && usage + check_deps + load_global_config + local target + target=$(service_dir "$svc") + [ -d "$target" ] || err "$svc is not deployed" + log "Restarting $svc..." + (cd "$target" && docker compose restart) + ok "$svc restarted" +} + +cmd_logs() { + local svc="$1" + shift 2>/dev/null || true + [ -z "$svc" ] && usage + check_deps + load_global_config + local target + target=$(service_dir "$svc") + [ -d "$target" ] || err "$svc is not deployed" + (cd "$target" && exec docker compose logs "$@") +} + +cmd_update() { + check_templates + + log "Updating ScaleTail templates..." + sudo git -C "/usr/local/share/mylinux/scale-tail" pull + ok "Templates updated" + + load_global_config + local refreshed=0 + + [ -d "$SERVICES_BASE" ] || { ok "$refreshed compose files refreshed (.env preserved)"; return; } + + for svc in "$SERVICES_BASE"/*/; do + [ -d "$svc" ] || continue + local name + name=$(basename "$svc") + if [ -f "$SCALE_DIR/$name/compose.yaml" ]; then + cp "$SCALE_DIR/$name/compose.yaml" "$svc/compose.yaml" + refreshed=$((refreshed + 1)) + fi + if [ -f "$SCALE_DIR/$name/compose.yml" ]; then + cp "$SCALE_DIR/$name/compose.yml" "$svc/compose.yml" + refreshed=$((refreshed + 1)) + fi + done + + ok "$refreshed compose files refreshed (.env preserved)" +} + +cmd_config() { + local action="${1:-show}" + shift 2>/dev/null || true + + case "$action" in + show) + load_global_config + if [ -f "$CONFIG_ENV" ]; then + echo "${CYAN}Global compose config:${RESET}" + echo "${BLUE}────────────────────────────────────────${RESET}" + cat "$CONFIG_ENV" + echo "${BLUE}────────────────────────────────────────${RESET}" + echo "SERVICES_BASE=$SERVICES_BASE" + else + warn "No config file found at $CONFIG_ENV" + echo "Default SERVICES_BASE=/srv" + fi + ;; + set) + local pair="${1:-}" + [ -z "$pair" ] && usage + local key="${pair%%=*}" + local val="${pair#*=}" + mkdir -p "$(dirname "$CONFIG_ENV")" + if [ -f "$CONFIG_ENV" ] && grep -q "^${key}=" "$CONFIG_ENV" 2>/dev/null; then + sed -i "s|^${key}=.*|${key}=${val}|" "$CONFIG_ENV" + else + echo "${key}=${val}" >> "$CONFIG_ENV" + fi + ok "Set ${key}=${val}" + ;; + edit) + mkdir -p "$(dirname "$CONFIG_ENV")" + if [ ! -f "$CONFIG_ENV" ]; then + cat > "$CONFIG_ENV" <<-EOF +TS_AUTHKEY= +TZ=Europe/Amsterdam +DNS_SERVER=9.9.9.9 +SERVICES_BASE=/srv +EOF + fi + "${EDITOR:-nano}" "$CONFIG_ENV" + ;; + *) + usage + ;; + esac +} + +############################################################################### +# CLI dispatch +############################################################################### + +[ $# -eq 0 ] && usage + +case "${1:-}" in + -h|--help) usage ;; +esac + +cmd="${1:-}" +shift + +case "$cmd" in + ls) cmd_ls ;; + installed) cmd_installed ;; + up) cmd_up "${1:-}" ;; + down) cmd_down "${1:-}" ;; + restart) cmd_restart "${1:-}" ;; + logs) cmd_logs "$@" ;; + update) cmd_update ;; + config) cmd_config "$@" ;; + *) usage ;; +esac diff --git a/bin/pos-docker-health b/bin/pos-docker-health new file mode 100755 index 0000000..82658a3 --- /dev/null +++ b/bin/pos-docker-health @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" + +usage() { + cat </dev/null; then + echo "docker not found" + exit 1 +fi + +if ! container_ids=$(docker ps -a -q 2>/dev/null) || [[ -z "$container_ids" ]]; then + echo "No containers." + exit 0 +fi + +healthy=0 +unhealthy=0 +no_check=0 +other=0 + +echo "── Docker Health ─────────────────────────────────" + +for cid in $container_ids; do + info=$(docker inspect "$cid" 2>/dev/null) || continue + + name=$(echo "$info" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['Name'].lstrip('/'))" 2>/dev/null) + health=$(echo "$info" | python3 -c " +import sys,json +d=json.load(sys.stdin)[0] +h=d.get('State',{}).get('Health',{}) +print(h.get('Status','') if h else '') +" 2>/dev/null || true) + started_at=$(echo "$info" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['State']['StartedAt'])" 2>/dev/null) + status=$(echo "$info" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['State']['Status'])" 2>/dev/null) + + # Calculate uptime + uptime="-" + if start_ts=$(date -d "$started_at" +%s 2>/dev/null); then + now_ts=$(date +%s) + diff=$((now_ts - start_ts)) + days=$((diff / 86400)) + hours=$(((diff % 86400) / 3600)) + mins=$(((diff % 3600) / 60)) + if [ "$days" -gt 0 ]; then + uptime="${days}d ${hours}h" + elif [ "$hours" -gt 0 ]; then + uptime="${hours}h ${mins}m" + else + uptime="${mins}m" + fi + fi + + # Determine health display + if [ -n "$health" ]; then + case "$health" in + healthy) + health_display="${GREEN}healthy${RESET}" + healthy=$((healthy + 1)) + ;; + unhealthy) + health_display="${RED}unhealthy${RESET}" + unhealthy=$((unhealthy + 1)) + ;; + starting) + health_display="${YELLOW}starting${RESET}" + other=$((other + 1)) + ;; + *) + health_display="${YELLOW}${health}${RESET}" + other=$((other + 1)) + ;; + esac + elif [ "$status" != "running" ]; then + health_display="${RED}${status}${RESET}" + other=$((other + 1)) + else + health_display="${YELLOW}no healthcheck${RESET}" + no_check=$((no_check + 1)) + fi + + printf " %-24s %b%-20s${RESET} %s\n" "$name" "" "$health_display" "$uptime" +done + +total=$((healthy + unhealthy + no_check + other)) +echo "──────────────────────────────────────────────────" +echo " Total: $total ${GREEN}healthy: $healthy${RESET} ${RED}unhealthy: $unhealthy${RESET} no check: $no_check" + +if [ "$unhealthy" -gt 0 ]; then + echo + echo "${RED}Unhealthy containers detected${RESET}" + exit 1 +fi diff --git a/bin/pos-docker-ps b/bin/pos-docker-ps new file mode 100755 index 0000000..320f4f7 --- /dev/null +++ b/bin/pos-docker-ps @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" + +usage() { + cat </dev/null; then + echo "docker not found" + exit 1 +fi + +if ! container_ids=$(docker ps -q 2>/dev/null) || [[ -z "$container_ids" ]]; then + echo "No running containers." + exit 0 +fi + +healthy_count=0 +unhealthy_count=0 +running_count=0 +total_count=0 + +printf "%-28s %-35s %-22s %-10s %-35s %-20s %s\n" \ + "NAME" "IMAGE" "STATUS(HEALTH)" "UPTIME" "IPS" "PORTS" "CONTAINER ID" + +for cid in $container_ids; do + total_count=$((total_count + 1)) + + info=$(docker inspect "$cid" 2>/dev/null) || continue + + name=$(echo "$info" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['Name'].lstrip('/'))" 2>/dev/null) + image=$(echo "$info" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['Config']['Image'])" 2>/dev/null) + status=$(echo "$info" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['State']['Status'])" 2>/dev/null) + health=$(echo "$info" | python3 -c " +import sys,json +d=json.load(sys.stdin)[0] +h=d.get('State',{}).get('Health',{}) +print(h.get('Status','') if h else '') +" 2>/dev/null || true) + started_at=$(echo "$info" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['State']['StartedAt'])" 2>/dev/null) + ip_data=$(echo "$info" | python3 -c " +import sys,json +d=json.load(sys.stdin)[0] +nets=d.get('NetworkSettings',{}).get('Networks',{}) +out=' '.join(f'{k}:{v.get(\"IPAddress\",\"\")}' for k,v in nets.items() if v.get('IPAddress')) +print(out or '-') +" 2>/dev/null) + port_data=$(echo "$info" | python3 -c " +import sys,json +d=json.load(sys.stdin)[0] +ports=d.get('NetworkSettings',{}).get('Ports',{}) or {} +parts=[] +for container_port, bindings in ports.items(): + if bindings: + for b in bindings: + hp=b.get('HostPort','') + hi=b.get('HostIp','') + if hi and hp: + parts.append(f'{hi}:{hp}->{container_port}') + elif hp: + parts.append(f'{hp}->{container_port}') + else: + parts.append(container_port) + else: + parts.append(container_port) +print(', '.join(parts) if parts else '-') +" 2>/dev/null) + + [ "${#image}" -gt 35 ] && image="${image:0:32}..." + + if [ "$status" = "running" ]; then + running_count=$((running_count + 1)) + fi + + if [ -n "$health" ]; then + case "$health" in + healthy) healthy_count=$((healthy_count + 1)); status_display="${GREEN}${status} (${health})${RESET}" ;; + unhealthy) unhealthy_count=$((unhealthy_count + 1)); status_display="${RED}${status} (${health})${RESET}" ;; + *) status_display="${YELLOW}${status} (${health})${RESET}" ;; + esac + else + status_display="${YELLOW}${status}${RESET}" + fi + + uptime="-" + if start_ts=$(date -d "$started_at" +%s 2>/dev/null); then + now_ts=$(date +%s) + diff=$((now_ts - start_ts)) + days=$((diff / 86400)) + hours=$(((diff % 86400) / 3600)) + mins=$(((diff % 3600) / 60)) + if [ "$days" -gt 0 ]; then + uptime="${days}d ${hours}h" + elif [ "$hours" -gt 0 ]; then + uptime="${hours}h ${mins}m" + else + uptime="${mins}m" + fi + fi + + printf "%-28s %-35s %-22b %-10s %-35s %-20s %s\n" \ + "$name" "$image" "$status_display" "$uptime" "$ip_data" "$port_data" "$cid" +done + +echo +printf '%*s\n' 120 '' | tr ' ' '-' + +echo "Containers : $total_count" +echo "Healthy : $healthy_count" +echo "Unhealthy : $unhealthy_count" +echo "Running : $running_count" + +if [ "$unhealthy_count" -gt 0 ]; then + echo + echo "${RED}Warning: unhealthy containers detected${RESET}" +fi diff --git a/bin/pos-media-mp3 b/bin/pos-media-mp3 new file mode 100755 index 0000000..8a9de83 --- /dev/null +++ b/bin/pos-media-mp3 @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat < + +Download audio from a URL and convert to MP3 via yt-dlp. + +Examples: + pos media mp3 https://youtube.com/watch?v=dQw4w9WgXcQ +EOF + exit 0 +} + +case "${1:-}" in + -h|--help|"") usage ;; +esac + +url="$1" + +yt-dlp \ + -x \ + --audio-format mp3 \ + --audio-quality 0 \ + --embed-thumbnail \ + --convert-thumbnails jpg \ + --add-metadata \ + -o "$HOME/Music/%(title)s.%(ext)s" \ + "$url" diff --git a/bin/pos-media-mp4 b/bin/pos-media-mp4 new file mode 100755 index 0000000..8ce842f --- /dev/null +++ b/bin/pos-media-mp4 @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat < + +Download video from a URL via yt-dlp with interactive format selection. + +Examples: + pos media mp4 https://youtube.com/watch?v=dQw4w9WgXcQ +EOF + exit 0 +} + +case "${1:-}" in + -h|--help|"") usage ;; +esac + +url="$1" + +yt-dlp -F "$url" + +echo +read -rp "Enter format ID: " format + +yt-dlp \ + -f "$format" \ + --merge-output-format mp4 \ + --embed-thumbnail \ + --add-metadata \ + -o "$HOME/Videos/%(title)s.%(ext)s" \ + "$url" diff --git a/bin/pos-network-checkport b/bin/pos-network-checkport new file mode 100755 index 0000000..d437b00 --- /dev/null +++ b/bin/pos-network-checkport @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat < + +Check if a TCP port is open on a remote host. + +Examples: + pos network checkport 192.168.1.1:80 + pos network checkport 10.0.0.5:443 +EOF + exit 0 +} + +case "${1:-}" in + -h|--help|"") usage ;; +esac + +target="$1" + +if [[ "$target" != *:* ]]; then + echo "ERROR: Expected format, got '$target'" + echo "Usage: pos network checkport " + exit 1 +fi + +ip="${target%:*}" +port="${target#*:}" + +if [[ -z "$ip" || -z "$port" ]]; then + echo "ERROR: Invalid target '$target'" + exit 1 +fi + +echo "Checking $ip:$port ..." +echo + +if timeout 2 bash -c "cat < /dev/null > /dev/tcp/$ip/$port" 2>/dev/null; then + echo "OPEN ✔ $ip:$port" +else + echo "CLOSED ✖ $ip:$port" +fi diff --git a/bin/pos-network-ip b/bin/pos-network-ip new file mode 100755 index 0000000..a8bb242 --- /dev/null +++ b/bin/pos-network-ip @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat </dev/null || echo "Unavailable" +echo diff --git a/bin/pos-network-scan b/bin/pos-network-scan new file mode 100755 index 0000000..a9fb3ce --- /dev/null +++ b/bin/pos-network-scan @@ -0,0 +1,270 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat < [--full] [--retries N] + +Two-phase network scan using nmap. + +Phase 1: Fast host discovery (finds alive hosts) +Phase 2: Full metadata scan on alive hosts only (--full only) + +Options: + --full Detailed scan: OS, ports, services, NSE scripts (slower) + --retries N Retries per host in discovery (default: 1) + +Examples: + pos network scan 192.168.1.0/24 + pos network scan 10.0.0.0/28 --full + pos network scan 172.1.1.104 + pos network scan 192.168.1.0/24 --retries 3 +EOF + exit 0 +} + +case "${1:-}" in + -h|--help|"") usage ;; +esac + +net="" +full=0 +retries=1 + +while [[ $# -gt 0 ]]; do + case "$1" in + --full) full=1; shift ;; + --retries) + if [[ -z "${2:-}" || "$2" == --* ]]; then + echo "ERROR: --retries requires a number" + exit 1 + fi + retries="$2"; shift 2 ;; + *) net="$1"; shift ;; + esac +done + +if [[ -z "$net" ]]; then + echo "ERROR: Missing CIDR (e.g. 192.168.1.0/24)" + exit 1 +fi + +# ── Input validation ─────────────────────────────────────────── +# Bare IP → /32 +if [[ "$net" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then + net="${net}/32" +# Valid CIDR +elif [[ "$net" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then + : # ok +else + echo "ERROR: Invalid target '$net'" + echo "Expected: IP (172.1.1.104) or CIDR (192.168.1.0/24)" + exit 1 +fi + +if ! command -v nmap &>/dev/null; then + echo "ERROR: nmap is required. Install with: sudo apt install nmap" + exit 1 +fi + +# ── Estimate host count ──────────────────────────────────────── +cidr_bits="${net##*/}" +if [[ "$cidr_bits" -ge 24 ]]; then + host_estimate=$(( 1 << (32 - cidr_bits) )) +elif [[ "$cidr_bits" -ge 16 ]]; then + host_estimate="$(( 1 << (32 - cidr_bits) ))+" +else + host_estimate="many" +fi + +is_root=0 +[[ $EUID -eq 0 ]] && is_root=1 + +can_sudo=0 +if [[ "$is_root" -eq 1 ]]; then + can_sudo=1 +elif sudo -n nmap -V &>/dev/null; then + can_sudo=1 +elif [[ "$full" -eq 1 && -t 0 ]]; then + can_sudo=1 +fi + +tmpfile=$(mktemp /tmp/scan-XXXXXX.txt) +trap 'rm -f "$tmpfile"' EXIT + +# ── Phase 1: Fast host discovery ─────────────────────────────── +nmap_cmd="nmap" +[[ "$can_sudo" -eq 1 ]] && nmap_cmd="sudo nmap" + +echo "Discovering hosts in $net (~$host_estimate) ..." +echo + +$nmap_cmd -sn -T5 -n \ + --min-rate 1000 \ + --min-parallelism 1024 \ + --min-hostgroup 1024 \ + --max-retries "$retries" \ + --host-timeout 5s \ + "$net" 2>/dev/null | awk '/^Nmap scan report for/ { + ip = $(NF); + gsub(/[()]/, "", ip); + print ip; +}' > "$tmpfile" + +host_count=$(wc -l < "$tmpfile") + +if [[ "$host_count" -eq 0 ]]; then + echo "No hosts found." + exit 0 +fi + +echo "Found $host_count host(s)." +echo + +if [[ "$full" -eq 0 ]]; then + cat "$tmpfile" + echo + echo "Done." + exit 0 +fi + +# ── Phase 2: Full metadata scan ──────────────────────────────── +echo "Scanning $host_count host(s) (full) ..." +echo + +nmap_args="-sV --version-intensity 9 -sC -T4 -n" +nmap_args="$nmap_args --min-parallelism 256 --max-parallelism 512" +nmap_args="$nmap_args --min-hostgroup 256 --max-hostgroup 512" +nmap_args="$nmap_args --min-rate 1000 --max-retries 1" +nmap_args="$nmap_args --host-timeout 60s --max-rtt-timeout 200ms" +nmap_args="$nmap_args --script ssh-hostkey,ssl-cert,http-title,http-server-header,smb-os-discovery,nbstat,rpcinfo" +[[ "$can_sudo" -eq 1 ]] && nmap_args="$nmap_args -O --osscan-guess" + +# shellcheck disable=SC2086 +$nmap_cmd $nmap_args -iL "$tmpfile" 2>/dev/null | awk ' +BEGIN { ip_count = 0; has_os = 0 } + +/^Nmap scan report for/ { + ip = $(NF); + gsub(/[()]/, "", ip); + hostname = ""; + if ($(NF) ~ /^\(/) { + hostname = $(NF-1); + } else if (NF > 4) { + if (ip != $(NF-1) && $(NF-1) !~ /^(for|[0-9])/) { + hostname = $(NF-1); + } + } + if (ip_count > 0) printf "\n"; + ip_count++; + has_os = 0; + if (hostname != "" && hostname != ip) + printf "\033[1;36m%s\033[0m (%s)\n", ip, hostname; + else + printf "\033[1;36m%s\033[0m\n", ip; +} + +/^MAC Address/ { + vendor = $0; + sub(/.*\(/, "", vendor); + sub(/\).*/, "", vendor); + printf " \033[2m%-10s\033[0m %s %s\n", "MAC:", $3, vendor; +} + +/^Aggressive OS guesses:/ { + has_os = 1; + line = $0; + sub(/.*guesses: /, "", line); + gsub(/\s*\(.*/, "", line); + printf " \033[2m%-10s\033[0m %s\n", "OS:", line; +} + +/^OS details:/ { + has_os = 1; + sub(/.*OS details: /, ""); + printf " \033[2m%-10s\033[0m %s\n", "OS:", $0; +} + +/^Running:/ { + sub(/.*Running: /, ""); + printf " \033[2m%-10s\033[0m %s\n", "OS:", $0; +} + +/^Service Info:/ { + line = $0; + sub(/.*Service Info:/, "", line); + gsub(/^ +/, "", line); + if (has_os == 0) + printf " \033[2m%-10s\033[0m %s\n", "Info:", line; +} + +/^\| ssh-hostkey:/ { + line = $0; + sub(/.*ssh-hostkey:/, "", line); + gsub(/^ +/, "", line); + if (line ~ /SHA256/) { + match(line, /SHA256:[A-Za-z0-9+\/=]+/); + key = substr(line, RSTART, RLENGTH); + printf " \033[2m%-10s\033[0m %s\n", "SSH:", key; + } +} + +/^\| http-title:/ { + line = $0; + sub(/.*http-title:/, "", line); + gsub(/^ +/, "", line); + gsub(/\s*\[.*$/, "", line); + if (line != "" && line !~ /^No/) + printf " \033[2m%-10s\033[0m %s\n", "HTTP Title:", line; +} + +/^\| http-server-header:/ { + line = $0; + sub(/.*http-server-header:/, "", line); + gsub(/^ +/, "", line); + printf " \033[2m%-10s\033[0m %s\n", "HTTP Server:", line; +} + +/^\|_?NetBIOS name:/ { + line = $0; + sub(/.*NetBIOS name:/, "", line); + sub(/,.*$/, "", line); + gsub(/^ +/, "", line); + printf " \033[2m%-10s\033[0m %s\n", "NetBIOS:", line; +} + +/^\|_?SMB OS:/ { + line = $0; + sub(/.*SMB OS:/, "", line); + gsub(/^ +/, "", line); + printf " \033[2m%-10s\033[0m %s\n", "SMB:", line; +} + +/^\|_?Domain:/ { + line = $0; + sub(/.*Domain:/, "", line); + gsub(/^ +/, "", line); + if (line != "" && line !~ /^WORKGROUP/) + printf " \033[2m%-10s\033[0m %s\n", "Domain:", line; +} + +/^\| [0-9]+\/tcp/ { + line = $0; + gsub(/^ *\| */, "", line); + printf " \033[2m%-10s\033[0m %s\n", "RPC:", line; +} + +/^[0-9]+\/tcp[[:space:]]+open/ { + port = $1; service = $3; + version = ""; + for (i = 4; i <= NF; i++) version = version " " $i; + gsub(/^ +/, "", version); + if (version != "") + printf " \033[2m%-10s\033[0m %s — %s\n", port, service, version; + else + printf " \033[2m%-10s\033[0m %s\n", port, service; +} +' + +echo +echo "Done." diff --git a/bin/pos-ssh-load-keys b/bin/pos-ssh-load-keys new file mode 100755 index 0000000..b301da6 --- /dev/null +++ b/bin/pos-ssh-load-keys @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat </dev/null || continue + ssh-add "$key" 2>/dev/null +done + +ssh-add -l diff --git a/bin/pos-system-firewall b/bin/pos-system-firewall new file mode 100755 index 0000000..92c095d --- /dev/null +++ b/bin/pos-system-firewall @@ -0,0 +1,284 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +if [[ $EUID -ne 0 ]]; then + echo "ERROR: Please run as root (sudo)." + echo "Usage: sudo pos system firewall" + exit 1 +fi + +HISTORY=() +DRY_RUN=0 + +if [[ "${1:-}" == "--dry-run" ]]; then + DRY_RUN=1 +fi + +log() { echo "[+] $*"; } +warn() { echo "[!] $*"; } +err() { echo "ERROR: $*" >&2; exit 1; } + +run_cmd() { + local -a cmd=("$@") + printf "\n>>> %s\n" "${cmd[*]}" + read -rp "Execute this command? [y/N]: " confirm + if [[ "$confirm" =~ ^[Yy]$ ]]; then + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "(dry-run) skipping execution" + else + "${cmd[@]}" + fi + HISTORY+=("${cmd[*]}") + else + echo "Cancelled." + fi +} + +build_ufw_cmd() { + local action="$1" + local direction="$2" + local proto="$3" + local from="$4" + local to="$5" + local port="$6" + local onif="$7" + local logmode="$8" + local comment="$9" + local insert_pos="${10:-}" + local suffix="${11:-}" + + local -a cmd=(ufw) + + if [[ -n "$insert_pos" ]]; then + if [[ "$insert_pos" == "prepend" ]]; then + cmd+=(prepend) + else + cmd+=(insert "$insert_pos") + fi + fi + + cmd+=("$action") + + [[ -n "$direction" ]] && cmd+=("$direction") + [[ -n "$onif" ]] && cmd+=(on "$onif") + [[ -n "$proto" ]] && cmd+=(proto "$proto") + [[ -n "$from" ]] && cmd+=(from "$from") + cmd+=(to "$to") + [[ -n "$port" ]] && cmd+=(port "$port") + [[ -n "$logmode" ]] && cmd+=("$logmode") + if [[ -n "$comment" ]]; then + local safe="${comment// /_}" + cmd+=(comment "$safe") + fi + [[ "$suffix" == "v6" ]] && cmd+=(v6) + + run_cmd "${cmd[@]}" +} + +prompt_ipver() { + local ver + read -rp "IP version (4 / 6 / both): " ver + echo "$ver" +} + +apply_for_versions() { + local action="$1" direction="$2" proto="$3" from="$4" to="$5" + local port="$6" onif="$7" logmode="$8" comment="$9" + local insert_pos="${10:-}" + local ipver + ipver=$(prompt_ipver) + + case "$ipver" in + 4) build_ufw_cmd "$action" "$direction" "$proto" "$from" "$to" "$port" "$onif" "$logmode" "$comment" "$insert_pos" "" ;; + 6) build_ufw_cmd "$action" "$direction" "$proto" "$from" "$to" "$port" "$onif" "$logmode" "$comment" "$insert_pos" "v6" ;; + both) + build_ufw_cmd "$action" "$direction" "$proto" "$from" "$to" "$port" "$onif" "$logmode" "$comment" "$insert_pos" "" + build_ufw_cmd "$action" "$direction" "$proto" "$from" "$to" "$port" "$onif" "$logmode" "$comment" "$insert_pos" "v6" + ;; + *) echo "Invalid choice. Choose 4, 6 or both." ;; + esac +} + +add_rule() { + echo + echo "Choose rule type:" + echo "1) Port/service (eg: port 8080 or 'ssh')" + echo "2) IP-based (from X to Y)" + echo "3) Directional port rule (in/out to any port ...)" + read -rp "Choice: " rtype + + case "$rtype" in + 1) + read -rp "Action (allow/deny/reject/limit) [allow]: " action + action=${action:-allow} + read -rp "Enter port number or service name (eg 'ssh' or '8080'): " port_or_svc + + if [[ "$port_or_svc" =~ ^[0-9]+$ ]]; then + read -rp "Protocol (tcp/udp/any) [tcp]: " proto + proto=${proto:-tcp} + [[ "$proto" == "any" ]] && proto="" + read -rp "Interface (leave empty for any): " onif + read -rp "Log? (none/log/log-all) [none]: " logmode + [[ "$logmode" == "none" ]] && logmode="" + read -rp "Comment (optional): " comment + + apply_for_versions "$action" "" "$proto" "" "any" "$port_or_svc" "$onif" "$logmode" "$comment" + else + read -rp "IP version (4 / 6 / both) [4]: " ipver + ipver=${ipver:-4} + case "$ipver" in + 4) run_cmd ufw "$action" "$port_or_svc" ;; + 6) run_cmd ufw "$action" "$port_or_svc" v6 ;; + both) run_cmd ufw "$action" "$port_or_svc" + run_cmd ufw "$action" "$port_or_svc" v6 ;; + *) echo "invalid ipver" ;; + esac + fi + ;; + + 2) + read -rp "Action (allow/deny/reject) [deny]: " action + action=${action:-deny} + read -rp "From address/CIDR (eg 192.168.1.5 or 10.0.0.0/24): " from + read -rp "To address (leave empty for 'any') [any]: " to + to=${to:-any} + read -rp "Direction (in/out) [in]: " direction + direction=${direction:-in} + read -rp "Port (leave empty if not applicable): " port + read -rp "Protocol (tcp/udp/any) [any]: " proto + [[ "$proto" == "any" ]] && proto="" + read -rp "Interface (leave empty for any): " onif + read -rp "Log? (none/log/log-all) [none]: " logmode + [[ "$logmode" == "none" ]] && logmode="" + read -rp "Comment (optional): " comment + + apply_for_versions "$action" "$direction" "$proto" "$from" "$to" "$port" "$onif" "$logmode" "$comment" + ;; + + 3) + read -rp "Action (allow/deny/reject/limit) [allow]: " action + action=${action:-allow} + read -rp "Direction (in/out) [in]: " direction + direction=${direction:-in} + read -rp "Port number: " port + read -rp "Protocol (tcp/udp/any) [tcp]: " proto + [[ "$proto" == "any" ]] && proto="" + read -rp "On interface (leave empty for any): " onif + read -rp "From address (optional): " from + from=${from:-} + read -rp "To address [any]: " to + to=${to:-any} + read -rp "Log? (none/log/log-all) [none]: " logmode + [[ "$logmode" == "none" ]] && logmode="" + read -rp "Comment (optional): " comment + read -rp "Insert position (number/prepend/empty): " insert_pos + + apply_for_versions "$action" "$direction" "$proto" "$from" "$to" "$port" "$onif" "$logmode" "$comment" "$insert_pos" + ;; + + *) echo "Unknown choice." ;; + esac +} + +delete_rule() { + echo + echo "Delete rule by:" + echo "1) rule number (use 'ufw status numbered' to see numbers)" + echo "2) rule text (eg: 'allow 22/tcp')" + read -rp "Choice: " dch + + case "$dch" in + 1) + ufw status numbered + read -rp "Number to delete: " num + run_cmd ufw delete "$num" + ;; + 2) + read -rp "Exact rule text to delete (eg: deny 80/tcp): " ruletext + run_cmd ufw delete $ruletext + ;; + *) echo "Unknown choice." ;; + esac +} + +show_status() { + echo + echo "1) Simple status" + echo "2) Verbose status" + echo "3) Numbered status (useful for delete)" + read -rp "Choice: " sc + case "$sc" in + 1) run_cmd ufw status ;; + 2) run_cmd ufw status verbose ;; + 3) run_cmd ufw status numbered ;; + *) echo "Unknown choice." ;; + esac +} + +while true; do + cat <<'MENU' + +============================== + UFW POWER — human friendly +============================== +1) Add rule (port/service/ip/directional) +2) Delete rule (by number or text) +3) Show status (simple / verbose / numbered) +4) Enable UFW +5) Disable UFW +6) Reset UFW (delete all rules) +7) Set default policy (incoming/outgoing) +8) Show executed commands history (so far) +0) Exit +------------------------------ +MENU + read -rp "Choose: " opt + + case "$opt" in + 1) add_rule ;; + 2) delete_rule ;; + 3) show_status ;; + 4) run_cmd ufw enable ;; + 5) run_cmd ufw disable ;; + 6) + echo "WARNING: ufw reset will disable and remove all rules." + read -rp "Type 'RESET' to confirm: " c + [[ "$c" == "RESET" ]] && run_cmd ufw reset || echo "Reset aborted." + ;; + 7) + read -rp "Default incoming policy (allow/deny/reject) [deny]: " defin + defin=${defin:-deny} + read -rp "Default outgoing policy (allow/deny/reject) [allow]: " defout + defout=${defout:-allow} + run_cmd ufw default "$defin" incoming + run_cmd ufw default "$defout" outgoing + ;; + 8) + echo + echo "Executed commands so far:" + echo + if [[ "${#HISTORY[@]}" -eq 0 ]]; then + echo "(none yet)" + else + for c in "${HISTORY[@]}"; do echo " - $c"; done + fi + ;; + 0) + echo + echo "Final executed commands summary:" + if [[ "${#HISTORY[@]}" -eq 0 ]]; then + echo "(no commands executed)" + else + for c in "${HISTORY[@]}"; do echo " - $c"; done + fi + echo "Goodbye — firewall remains watchful." + exit 0 + ;; + *) echo "Unknown option." ;; + esac + + echo + read -rp "Press Enter to continue..." + clear +done diff --git a/bin/pos-vbox b/bin/pos-vbox new file mode 100755 index 0000000..2920964 --- /dev/null +++ b/bin/pos-vbox @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" + +usage() { + cat < [image] [--dir ] + pos vbox enter + pos vbox stop + pos vbox start + pos vbox rm + pos vbox ls + +Manage disposable Docker containers as lightweight VMs. + +Each container gets a bind-mounted host directory so files persist +on the host even after the container is removed. + +Options: + --dir Use custom directory instead of default ~/ + Use "." for current directory + +Examples: + pos vbox create lab1 + pos vbox create lab1 --dir . + pos vbox create lab1 --dir /mnt/data/lab1 + pos vbox create kali kalilinux/kali-rolling + pos vbox enter lab1 + pos vbox stop lab1 + pos vbox start lab1 + pos vbox rm lab1 + pos vbox ls +EOF + exit 0 +} + +case "${1:-}" in + -h|--help) usage ;; +esac + +cmd="${1:-}" +[ -z "$cmd" ] && usage + +container_exists() { + docker container inspect "$1" &>/dev/null +} + +container_running() { + [[ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null)" == "true" ]] +} + +case "$cmd" in + create) + name="${2:-}" + [ -z "$name" ] && usage + + # Parse remaining args: [image] [--dir ] + image="ubuntu:22.04" + custom_dir="" + shift 2 || true + while [ $# -gt 0 ]; do + case "$1" in + --dir) + [ -z "${2:-}" ] && { echo "Missing value for --dir"; exit 1; } + custom_dir="$2" + shift 2 + ;; + *) + image="$1" + shift + ;; + esac + done + + if container_exists "$name"; then + echo "[!] Container already exists: $name" + exit 0 + fi + + if [ -n "$custom_dir" ]; then + lab_dir="$(cd "$custom_dir" 2>/dev/null && pwd)" || { echo "[!] Directory not found: $custom_dir"; exit 1; } + else + lab_dir="$HOME/$name" + fi + mkdir -p "$lab_dir" + echo "[+] Lab directory: $lab_dir" + + echo "[+] Pulling image: $image" + docker pull "$image" + + echo "[+] Creating: $name" + docker create \ + -it \ + --name "$name" \ + --label mylinux.vbox=true \ + -v "$lab_dir:$lab_dir" \ + -w "$lab_dir" \ + "$image" \ + bash >/dev/null + echo "[+] Done" + + if confirm "Enter now?"; then + docker start "$name" >/dev/null + exec docker exec -it -w "$lab_dir" "$name" bash + fi + ;; + + enter) + name="${2:-}" + [ -z "$name" ] && usage + + if ! container_exists "$name"; then + echo "[!] Container not found: $name" + exit 1 + fi + + if ! container_running "$name"; then + docker start "$name" >/dev/null + fi + + # Detect working dir from container mounts + lab_dir=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination .Destination}}{{.Source}}{{end}}{{end}}' "$name" 2>/dev/null | head -1) + if [ -n "$lab_dir" ] && [ -d "$lab_dir" ]; then + exec docker exec -it -w "$lab_dir" "$name" bash + else + exec docker exec -it "$name" bash + fi + ;; + + start) + name="${2:-}" + [ -z "$name" ] && usage + docker start "$name" + ;; + + stop) + name="${2:-}" + [ -z "$name" ] && usage + docker stop "$name" + ;; + + rm) + name="${2:-}" + [ -z "$name" ] && usage + docker rm -f "$name" + ;; + + ls) + docker ps -a --filter label=mylinux.vbox=true --format "table {{.Names}}\t{{.Image}}\t{{.Status}}" + ;; + + *) + usage + ;; +esac diff --git a/bin/ssh-load-all b/bin/ssh-load-all new file mode 100755 index 0000000..909129e --- /dev/null +++ b/bin/ssh-load-all @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec pos ssh load-keys "$@" diff --git a/bin/vbox b/bin/vbox new file mode 100755 index 0000000..528955b --- /dev/null +++ b/bin/vbox @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec pos vbox "$@" diff --git a/bin/wr-checkport b/bin/wr-checkport new file mode 100755 index 0000000..211597f --- /dev/null +++ b/bin/wr-checkport @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec pos network checkport "$@" diff --git a/bin/wr-compose b/bin/wr-compose new file mode 100755 index 0000000..b304965 --- /dev/null +++ b/bin/wr-compose @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec pos docker compose "$@" diff --git a/bin/wr-docker b/bin/wr-docker new file mode 100755 index 0000000..53474cd --- /dev/null +++ b/bin/wr-docker @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec pos docker ps "$@" diff --git a/bin/wr-ip b/bin/wr-ip new file mode 100755 index 0000000..8e7fc53 --- /dev/null +++ b/bin/wr-ip @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec pos network ip "$@" diff --git a/bin/wr-scan-ping b/bin/wr-scan-ping new file mode 100755 index 0000000..f6b6390 --- /dev/null +++ b/bin/wr-scan-ping @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec pos network scan "$@" diff --git a/bin/wr-ufw b/bin/wr-ufw new file mode 100755 index 0000000..d13511d --- /dev/null +++ b/bin/wr-ufw @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec pos system firewall "$@" diff --git a/completions/pos.bash b/completions/pos.bash new file mode 100644 index 0000000..ccd8656 --- /dev/null +++ b/completions/pos.bash @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Bash completion for pos — dynamically discovers pos-* subcommands +# Install: source this file in ~/.bashrc or place in /etc/bash_completion.d/ + +_pos() { + local cur prev words cword + + # Manual init if bash-completion package is not loaded + if declare -F _init_completion &>/dev/null; then + _init_completion || return + else + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + words=("${COMP_WORDS[@]}") + cword=$COMP_CWORD + fi + + local pos_bin="${COMP_WORDS[0]}" + local pos_dir + pos_dir="$(dirname "$(command -v "$pos_bin" 2>/dev/null || echo "$pos_bin")")" + + # ── Collect all pos-* subcommands ────────────────────────── + local all_cmds=() + local f + for f in "$pos_dir"/pos-*; do + [ -x "$f" ] || continue + all_cmds+=("${f##*/pos-}") + done + + # ── Build category→subcommand map ────────────────────────── + local -A cat_cmds + for cmd in "${all_cmds[@]}"; do + local cat="${cmd%%-*}" + local sub="${cmd#*-}" + if [ "$cat" != "$cmd" ]; then + cat_cmds["$cat"]+="${sub} " + fi + done + + # ── Helpers ──────────────────────────────────────────────── + _pos_complete_categories() { + COMPREPLY=($(compgen -W "${!cat_cmds[*]}" -- "$cur")) + } + + _pos_complete_subcats() { + local cat="${words[1]}" + COMPREPLY=($(compgen -W "${cat_cmds[$cat]:-}" -- "$cur")) + } + + _pos_complete_compose_services() { + local scale_dir="/usr/local/share/mylinux/scale-tail/services" + if [ -d "$scale_dir" ]; then + local svcs=() + for d in "$scale_dir"/*/; do + [ -d "$d" ] && svcs+=("$(basename "$d")") + done + COMPREPLY=($(compgen -W "${svcs[*]}" -- "$cur")) + fi + } + + _pos_complete_compose_cmds() { + COMPREPLY=($(compgen -W "ls installed up down restart logs update config" -- "$cur")) + } + + _pos_complete_vbox_cmds() { + COMPREPLY=($(compgen -W "create enter stop start rm ls" -- "$cur")) + } + + _pos_complete_docker_vbox_names() { + local names + names=$(docker ps -a --filter label=mylinux.vbox=true --format '{{.Names}}' 2>/dev/null) + COMPREPLY=($(compgen -W "$names" -- "$cur")) + } + + # ── Dispatch ─────────────────────────────────────────────── + case "${#words[@]}" in + 2) + _pos_complete_categories + ;; + 3) + _pos_complete_subcats + ;; + 4) + case "${words[1]}-${words[2]}" in + docker-compose) + case "${words[3]}" in + up|down|restart|logs) + _pos_complete_compose_services + ;; + *) + _pos_complete_compose_cmds + ;; + esac + ;; + vbox-*) + _pos_complete_vbox_cmds + ;; + esac + ;; + 5) + case "${words[1]}-${words[2]}" in + docker-compose) + case "${words[3]}" in + up|down|restart|logs) + _pos_complete_compose_services + ;; + esac + ;; + vbox-create|vbox-enter|vbox-stop|vbox-start|vbox-rm) + _pos_complete_docker_vbox_names + ;; + esac + ;; + esac +} + +complete -F _pos pos diff --git a/config/authorized_keys b/config/authorized_keys new file mode 100644 index 0000000..99dbbc6 --- /dev/null +++ b/config/authorized_keys @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK8u4mvN1oWUGjWZCjUT0742u6AJEIHfi+PAQgZuNnPv diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..4502da8 --- /dev/null +++ b/install.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── Parse --no-color BEFORE sourcing common.sh ────────────────── +NO_COLOR=0 +for arg in "$@"; do + [ "$arg" = "--no-color" ] && NO_COLOR=1 +done +if [ "$NO_COLOR" -eq 1 ]; then + export TERM=dumb + unset CYAN GREEN YELLOW RED BLUE BOLD RESET +fi + +source "$(dirname "$0")/lib/common.sh" + +DRY_RUN=0 +RUN_APPS=0 +SKIP_PHASES="" +STEPS_SPEC="" + +usage() { + cat < Skip a phase (repeatable): + preinstall, scripts, postinstall, scalepoint, apps + --steps Run only specific phases. Format: 1,3,4 or 1-3 + (1=preinstall, 2=scripts, 3=postinstall, 4=scalepoint) + --no-color Disable colored output + -h, --help Show this help message +EOF + exit 0 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --apps) RUN_APPS=1; shift ;; + --full) RUN_APPS=2; shift ;; + --dry-run) DRY_RUN=1; shift ;; + --skip) + [ -z "${2:-}" ] && err "Missing value for --skip" + SKIP_PHASES="${SKIP_PHASES:+$SKIP_PHASES,}$2" + shift 2 + ;; + --steps) + [ -z "${2:-}" ] && err "Missing value for --steps" + STEPS_SPEC="$2" + shift 2 + ;; + --no-color) shift ;; + -h|--help) usage ;; + *) err "Unknown option: $1" ;; + esac +done + +# ── Phase runner ──────────────────────────────────────────────── +# Phase names → numbers: preinstall=1 scripts=2 postinstall=3 scalepoint=4 +should_run() { + local phase_num="$1" + local phase_name="$2" + + # --skip takes precedence + if [[ ",$SKIP_PHASES," == *",$phase_name,"* ]]; then + return 1 + fi + + # --steps restricts to listed phases only + if [ -n "$STEPS_SPEC" ]; then + if [[ ",$STEPS_SPEC," != *",$phase_num,"* ]]; then + return 1 + fi + fi + + return 0 +} + +section "myLinux Bootstrap" +timer_start + +# ── Phase 1: preinstall ──────────────────────────────────────── +if should_run 1 preinstall; then + step 1 4 "Installing system packages" + if [ -f preinstall.sh ]; then + spawn "apt update" sudo apt update + bash preinstall.sh + fi +fi + +# ── Phase 2: install wrappers ────────────────────────────────── +if should_run 2 scripts; then + step 2 4 "Installing wrapper scripts" + run sudo mkdir -p /usr/local/bin + count=0 + for f in bin/*; do + [ -f "$f" ] || continue + run sudo install -m 755 "$f" /usr/local/bin/ + count=$((count + 1)) + done + run sudo install -m 644 lib/common.sh /usr/local/bin/common.sh + ok "$count scripts + lib -> /usr/local/bin" +fi + +# ── Phase 3: postinstall ─────────────────────────────────────── +if should_run 3 postinstall; then + step 3 4 "Post-install configuration" + if [ -f postinstall.sh ]; then + bash postinstall.sh + fi +fi + +# ── Phase 4: ScaleTail templates ──────────────────────────────── +if should_run 4 scalepoint; then + step 4 4 "Cloning ScaleTail templates" + scale_dest="/usr/local/share/mylinux/scale-tail" + if [ ! -d "$scale_dest" ]; then + spawn "Cloning ScaleTail" sudo git clone --depth 1 \ + https://github.com/tailscale-dev/ScaleTail.git "$scale_dest" + else + log "ScaleTail already cloned" + fi +fi + +echo +echo "${GREEN}════════════════════════════════════════════${RESET}" +echo "${GREEN} Bootstrap complete ($(timer_stop))${RESET}" +echo "${GREEN}════════════════════════════════════════════${RESET}" + +# ── Optional apps ────────────────────────────────────────────── +if [ "$RUN_APPS" -eq 1 ]; then + echo + bash apps/install.sh +elif [ "$RUN_APPS" -eq 2 ]; then + echo + bash apps/install.sh --all +elif should_run 5 apps && [ -f apps/install.sh ]; then + # --skip apps disables app phase even if --apps/--full is not used + true +fi diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..0dc149e --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,121 @@ +# ── Colors (auto-off when not a TTY) ─────────────────────────── +if [ -t 1 ]; then + CYAN=$(tput setaf 6) + GREEN=$(tput setaf 2) + YELLOW=$(tput setaf 3) + RED=$(tput setaf 1) + BLUE=$(tput setaf 4) + BOLD=$(tput bold) + RESET=$(tput sgr0) +else + CYAN=""; GREEN=""; YELLOW=""; RED=""; BLUE=""; BOLD=""; RESET="" +fi + +# ── Core helpers ─────────────────────────────────────────────── +log() { echo "${GREEN}[+]${RESET} $*"; } +warn() { echo "${YELLOW}[!]${RESET} $*"; } +err() { echo "${RED}ERROR:${RESET} $*" >&2; exit 1; } +ok() { echo "${GREEN} OK${RESET} $*"; } + +# ── Section header ───────────────────────────────────────────── +section() { + local title="$*" + echo + echo "${CYAN}════════════════════════════════════════════${RESET}" + echo "${CYAN} ${title}${RESET}" + echo "${CYAN}════════════════════════════════════════════${RESET}" +} + +# ── Step header (numbered) ───────────────────────────────────── +step() { + local current="$1" total="$2" msg="$3" + echo + echo "${BOLD} [${current}/${total}] ${msg}${RESET}" + echo "${BLUE} ─────────────────────────────────────────${RESET}" +} + +# ── Dry-run aware executor ───────────────────────────────────── +run() { + if [ "${DRY_RUN:-0}" -eq 1 ]; then + log "(dry-run) $*" + else + "$@" + fi +} + +# ── Internal: nanoseconds → formatted time string ───────────── +_nano_now() { date +%s%N; } +_elapsed() { + local start="$1" end + end=$(_nano_now) + local ms=$(( (end - start) / 1000000 )) + if [ "$ms" -ge 1000 ]; then + awk "BEGIN { printf \"%.1fs\", $ms / 1000 }" + elif [ "$ms" -ge 1 ]; then + echo "${ms}ms" + else + echo "0ms" + fi +} + +# ── Timer ────────────────────────────────────────────────────── +TIMER_START=0 +timer_start() { TIMER_START=$(_nano_now); } +timer_stop() { _elapsed "$TIMER_START"; } + +# ── Timed command runner ─────────────────────────────────────── +# Shows a spinner while the command runs in background, +# then prints result + elapsed time. +spawn() { + local msg="$1" + shift + local start + start=$(_nano_now) + + # Run in background, capture output + local out err rc + out=$(mktemp) + err=$(mktemp) + "$@" >"$out" 2>"$err" & + local pid=$! + + # Spinner + local spin=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + local i=0 + while kill -0 "$pid" 2>/dev/null; do + printf "\r${CYAN} %s${RESET} %s" "${spin[$i]}" "$msg" + i=$(( (i + 1) % ${#spin[@]} )) + sleep 0.1 + done + rc=0; wait "$pid" || rc=$? + local elapsed + elapsed=$(_elapsed "$start") + + if [ "$rc" -eq 0 ]; then + printf "\r${GREEN} OK${RESET} %s (${elapsed})\n" "$msg" + else + printf "\r${RED} FAIL${RESET} %s (${elapsed})\n" "$msg" + # Show captured stderr on failure + if [ -s "$err" ]; then + sed 's/^/ /' "$err" + fi + rm -f "$out" "$err" + exit "$rc" + fi + rm -f "$out" "$err" +} + +# ── Confirmation prompt ──────────────────────────────────────── +confirm() { + local prompt="$1" default="${2:-y}" yn + if [ "$default" = "y" ]; then + read -rp "${prompt} [Y/n]: " yn + [[ -z "$yn" || "$yn" =~ ^[Yy] ]] + else + read -rp "${prompt} [y/N]: " yn + [[ "$yn" =~ ^[Yy] ]] + fi +} + +# ── Source guard ─────────────────────────────────────────────── +return 0 2>/dev/null || true diff --git a/postinstall.sh b/postinstall.sh new file mode 100755 index 0000000..d9284df --- /dev/null +++ b/postinstall.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/lib/common.sh" + +log "Running post-install..." + +# ── rclone config ────────────────────────────────────────────── +# Place your rclone.conf in config/ (gitignored) and this will install it. +if [ -f config/rclone.conf ]; then + mkdir -p "$HOME/.config/rclone" + cp config/rclone.conf "$HOME/.config/rclone/rclone.conf" + chmod 600 "$HOME/.config/rclone/rclone.conf" + log "Installed rclone.conf" +else + warn "fail2ban not found, skipping" +fi + +# ── Ensure all bin dirs are in PATH ──────────────────────────── +PATH_LINE='export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$HOME/.local/bin:$PATH"' +BASHRC="$HOME/.bashrc" + +if grep -qsF "$PATH_LINE" "$BASHRC" 2>/dev/null; then + log "PATH already configured" +else + echo "$PATH_LINE" >> "$BASHRC" + log "Added PATH to ~/.bashrc" +fi + +# ── Bash completion for pos ───────────────────────────────────── +COMPLETION_LINE='source /usr/local/share/bash-completion/completions/pos.bash 2>/dev/null || true' + +if grep -qsF "pos.bash" "$BASHRC" 2>/dev/null; then + log "pos completion already configured" +else + echo "$COMPLETION_LINE" >> "$BASHRC" + log "Added pos completion to ~/.bashrc" +fi + +if [ -f completions/pos.bash ]; then + run sudo mkdir -p /usr/local/share/bash-completion/completions + run sudo install -m 644 completions/pos.bash \ + /usr/local/share/bash-completion/completions/pos.bash + log "Installed pos completion" +else + warn "completions/pos.bash not found, skipping" +fi + +<<<<<<< HEAD +# ── SSH authorized keys ──────────────────────────────────────── +SSH_DIR="$HOME/.ssh" +AUTH_FILE="$SSH_DIR/authorized_keys" +KEY_FILE="config/authorized_keys" + +if [ -f "$KEY_FILE" ]; then + mkdir -p "$SSH_DIR" + chmod 700 "$SSH_DIR" + touch "$AUTH_FILE" + chmod 600 "$AUTH_FILE" + + added=0 + while IFS= read -r key; do + [[ -z "$key" || "$key" == \#* ]] && continue + if grep -qsF "$key" "$AUTH_FILE" 2>/dev/null; then + log "SSH key already present" + else + echo "$key" >> "$AUTH_FILE" + added=$((added + 1)) + fi + done < "$KEY_FILE" + if [ "$added" -gt 0 ]; then + log "Installed $added SSH key(s)" + fi +else + warn "config/authorized_keys not found, skipping SSH setup" +fi + +======= +>>>>>>> bba577c (Initial commit) +# ── systemd services ─────────────────────────────────────────── +if [ -d systemd ] && [ -n "$(ls -A systemd/*.service 2>/dev/null)" ]; then + run sudo cp systemd/*.service /etc/systemd/system/ + run sudo systemctl daemon-reload + + for svc in systemd/*.service; do + svc_name=$(basename "$svc") + run sudo systemctl enable --now "$svc_name" 2>/dev/null || \ + run sudo systemctl enable "$svc_name" + done + log "Systemd services installed and enabled" +else + warn "No systemd services found, skipping" +fi + +log "Post-install completed." diff --git a/preinstall.sh b/preinstall.sh new file mode 100755 index 0000000..612663e --- /dev/null +++ b/preinstall.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/lib/common.sh" + +DRY_RUN=0 + +usage() { + cat </dev/null; then + run "$cmd" --version + fi +done + +log "Pre-install completed." diff --git a/systemd/autostart.service b/systemd/autostart.service new file mode 100644 index 0000000..97f095e --- /dev/null +++ b/systemd/autostart.service @@ -0,0 +1,13 @@ +[Unit] +Description=My Linux Autostart Script +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/autostart.sh +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/systemd/ssh-agent.service b/systemd/ssh-agent.service new file mode 100644 index 0000000..1dd6f48 --- /dev/null +++ b/systemd/ssh-agent.service @@ -0,0 +1,14 @@ +[Unit] +Description=SSH Authentication Agent +After=network.target + +[Service] +Type=simple +ExecStartPre=mkdir -p /run/ssh-agent +ExecStart=/usr/bin/ssh-agent -D -a /run/ssh-agent/socket +ExecStartPost=/bin/sh -c 'chmod 666 /run/ssh-agent/socket' +ExecStopPost=/bin/sh -c 'rm -f /run/ssh-agent/socket' +Restart=on-failure + +[Install] +WantedBy=multi-user.target