This commit is contained in:
he
2026-07-28 10:28:11 -04:00
parent 0c30e04533
commit fdca639f58
52 changed files with 3487 additions and 0 deletions
+21
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
[submodule "compose/scale-tail"]
path = compose/scale-tail
url = https://github.com/tailscale-dev/ScaleTail.git
+426
View File
@@ -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 <phase>|--steps <spec>]
├─ 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 <phase>` | Skip a phase (repeatable): `preinstall`, `scripts`, `postinstall`, `scalepoint`, `apps` |
| `--steps <spec>` | 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_<cmd>.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 <path>` 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/<name>/
├── compose.yaml
└── .env
~/.config/mylinux/compose.env # Global defaults (TS_AUTHKEY, TZ, DNS_SERVER, SERVICES_BASE)
/srv/<service>/ # 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 <svc>` | Deploy service to SERVICES_BASE |
| `pos docker compose down <svc>` | Stop a deployed service |
| `pos docker compose restart <svc>` | Restart a service |
| `pos docker compose logs <svc> [-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/<category>/*.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/<name>.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-<category>-<command>` — canonical tool names
- `wr-*` — legacy wrappers
- `apps/<category>/<name>.sh` — optional app installers
- Hyphens for word separation, lowercase always
### Error Handling
- `command -v <tool> &>/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/<category>/<name>.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-<category>-<command>` 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-<cat>-<cmd>`, add deps in `preinstall.sh` |
| Add a new app installer | Create `apps/<name>.sh` (auto-discovered) |
| Add a systemd service | Create `systemd/<name>.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` |
+418
View File
@@ -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/<category>/` | 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/<app>/` (via postinstall) |
=======
| `config/` | Static config files (gitignored — user adds their own) | `~/.config/<app>/` (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
Usage: my-tool <argument>
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/<category>/` 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/<category>/`, 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 <pkg>` wrapped in `spawn`
- **Official scripts** → `curl ... | sh` inside `spawn`
- **Flatpak** → `flatpak install -y flathub <app-id>` 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/<category>/*.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 <argument>"
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/<name>.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/<name>/
├── 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
<SERVICES_BASE>/<name>/ # Active deployments (default: /srv/<name>)
├── 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 <service>` | Deploys service to `$SERVICES_BASE/<service>/` (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 <service>` | 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**: `<SERVICES_BASE>/<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/<name>/`
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
```
+18
View File
@@ -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
+16
View File
@@ -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
+18
View File
@@ -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
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/../lib/common.sh"
usage() {
cat <<EOF
Usage: bash apps/install.sh [OPTIONS] [app ...]
Install optional desktop applications.
Options:
--all Install all available apps without prompting
-h, --help Show this help message
Examples:
bash apps/install.sh # interactive selection
bash apps/install.sh --all # install everything
bash apps/install.sh brave vscode # install specific apps
EOF
exit 0
}
ALL=0
POSITIONAL=()
while [[ $# -gt 0 ]]; do
case "$1" in
--all) ALL=1; shift ;;
-h|--help) usage ;;
-*) err "Unknown option: $1" ;;
*) POSITIONAL+=("$1"); shift ;;
esac
done
APPS_DIR="$(dirname "$0")"
# ── Category display names ─────────────────────────────────────
declare -A CAT_NAMES=(
[browsers]="Browsers"
[development]="Development"
[media]="Media"
[networking]="Networking / VPN"
[remote-access]="Remote Access"
[system]="System / Virtualization"
[utilities]="Utilities"
)
# ── Discover categories and apps ───────────────────────────────
CATEGORIES=()
declare -A CAT_APPS
for cat_dir in "$APPS_DIR"/*/; do
[ -d "$cat_dir" ] || continue
cat_name=$(basename "$cat_dir")
# Skip non-app directories
[[ "$cat_name" == "install" ]] && continue
# Check if directory has any .sh files
has_apps=0
for f in "$cat_dir"*.sh; do
[ -f "$f" ] && has_apps=1 && break
done
[ "$has_apps" -eq 0 ] && continue
CATEGORIES+=("$cat_name")
CAT_APPS["$cat_name"]=()
for f in "$cat_dir"*.sh; do
[ -f "$f" ] || continue
app_name=$(basename "$f" .sh)
CAT_APPS["$cat_name"]+=("$app_name")
done
done
# ── Helper: find which category an app belongs to ──────────────
find_app_category() {
local app="$1"
for cat in "${CATEGORIES[@]}"; do
for a in "${CAT_APPS[$cat]}"; do
[ "$a" == "$app" ] && { echo "$cat"; return 0; }
done
done
return 1
}
SELECTED=()
# ── Mode 1: specific apps requested on command line ────────────
if [ "${#POSITIONAL[@]}" -gt 0 ]; then
for req in "${POSITIONAL[@]}"; do
cat=$(find_app_category "$req" 2>/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}"
+10
View File
@@ -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
+53
View File
@@ -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
+10
View File
@@ -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
+14
View File
@@ -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 <key>"
}
install_netbird
+14
View File
@@ -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
+14
View File
@@ -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 <network-id>"
}
install_zerotier
+20
View File
@@ -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
+10
View File
@@ -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
+15
View File
@@ -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
+18
View File
@@ -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
+35
View File
@@ -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
+10
View File
@@ -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
+19
View File
@@ -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
+14
View File
@@ -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
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec pos media mp3 "$@"
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec pos media mp4 "$@"
Executable
+145
View File
@@ -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 <category> <command> [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 <url> Download audio as MP3
pos media mp4 <url> 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 <command> 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 <command> ─────────────────────────────────────────
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
+317
View File
@@ -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 <<EOF
Usage:
pos docker compose ls List available ScaleTail services
pos docker compose installed List deployed services
pos docker compose up <service> Deploy a service
pos docker compose down <service> Stop a service
pos docker compose restart <service> Restart a service
pos docker compose logs <service> [-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
+109
View File
@@ -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 <<EOF
Usage: pos docker health
Quick one-glance health dashboard for all Docker containers.
Shows health status, uptime, and exits with code 1 if any container is unhealthy.
Exit codes:
0 All containers healthy (or no healthcheck configured)
1 One or more containers unhealthy
EOF
exit 0
}
case "${1:-}" in
-h|--help) usage ;;
esac
if ! command -v docker &>/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
+127
View File
@@ -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 <<EOF
Usage: pos docker ps
Display enhanced Docker container overview with health status,
uptime, IPs, and port mappings.
EOF
exit 0
}
case "${1:-}" in
-h|--help) usage ;;
esac
if ! command -v docker &>/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
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
Usage: pos media mp3 <url>
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"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
Usage: pos media mp4 <url>
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"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
Usage: pos network checkport <ip:port>
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 <ip:port> format, got '$target'"
echo "Usage: pos network checkport <ip:port>"
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
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
Usage: pos network ip
Display network interfaces, default route, and public IP.
EOF
exit 0
}
case "${1:-}" in
-h|--help) usage ;;
esac
SEP() { printf '\u2550%.0s' $(seq 1 47); echo; }
SEP
echo "Network Interfaces"
SEP
ip -o -4 addr show | while read -r _ ifname _ ipaddr _; do
printf "%-20s %s\n" "$ifname" "${ipaddr%%/*}"
done
echo
SEP
echo "Default Route"
SEP
gateway=$(ip route | awk '/default/ {print $3; exit}')
iface=$(ip route | awk '/default/ {print $5; exit}')
printf "%-20s %s\n" "Interface" "${iface:-N/A}"
printf "%-20s %s\n" "Gateway" "${gateway:-N/A}"
echo
SEP
echo "Public IP"
SEP
curl -4 -s --max-time 5 https://ifconfig.me 2>/dev/null || echo "Unavailable"
echo
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
Usage: pos network scan <cidr> [--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."
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
Usage: pos ssh load-keys
Load all SSH private keys into the ssh-agent.
Requires ssh-agent.service running (socket at /run/ssh-agent/socket).
EOF
exit 0
}
case "${1:-}" in
-h|--help) usage ;;
esac
export SSH_AUTH_SOCK="${SSH_AUTH_SOCK:-/run/ssh-agent/socket}"
for key in ~/.ssh/id_*; do
[ -f "$key" ] || continue
case "$key" in
*.pub|known_hosts|authorized_keys|config) continue ;;
esac
ssh-keygen -y -f "$key" &>/dev/null || continue
ssh-add "$key" 2>/dev/null
done
ssh-add -l
+284
View File
@@ -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
Executable
+156
View File
@@ -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 <<EOF
Usage:
pos vbox create <name> [image] [--dir <path>]
pos vbox enter <name>
pos vbox stop <name>
pos vbox start <name>
pos vbox rm <name>
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 <path> Use custom directory instead of default ~/<name>
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 <path>]
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
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec pos ssh load-keys "$@"
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec pos vbox "$@"
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec pos network checkport "$@"
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec pos docker compose "$@"
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec pos docker ps "$@"
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec pos network ip "$@"
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec pos network scan "$@"
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec pos system firewall "$@"
+118
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK8u4mvN1oWUGjWZCjUT0742u6AJEIHfi+PAQgZuNnPv
Executable
+144
View File
@@ -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 <<EOF
Usage: ./install.sh [OPTIONS]
Bootstrap a fresh Debian/Ubuntu install.
Options:
--apps Run interactive app picker after core install
--full Core install + all optional apps (non-interactive)
--dry-run Show what would be done without executing
--skip <phase> Skip a phase (repeatable):
preinstall, scripts, postinstall, scalepoint, apps
--steps <spec> 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
+121
View File
@@ -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
Executable
+94
View File
@@ -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."
Executable
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/lib/common.sh"
DRY_RUN=0
usage() {
cat <<EOF
Usage: preinstall.sh [OPTIONS]
Install system packages and tools.
Options:
--dry-run Show what would be done without executing
-h, --help Show this help message
EOF
exit 0
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage ;;
*) err "Unknown option: $1" ;;
esac
done
PACKAGES=(
git curl wget vim nano tmux tree jq
unzip zip rsync htop btop telnet
net-tools iputils-ping traceroute tcpdump nmap
openssh-client openssh-server ufw fail2ban
ca-certificates gnupg lsb-release
python3 python3-pip rclone
)
spawn "apt update" sudo apt update
spawn "Installing packages" sudo apt install -y "${PACKAGES[@]}"
#spawn "add user to sudo list" usermod -aG sudo $USER
spawn "Installing yt-dlp" sudo curl -L \
https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp \
-o /usr/local/bin/yt-dlp
run sudo chmod a+rx /usr/local/bin/yt-dlp
log "Verifying installations..."
for cmd in git yt-dlp; do
if command -v "$cmd" &>/dev/null; then
run "$cmd" --version
fi
done
log "Pre-install completed."
+13
View File
@@ -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
+14
View File
@@ -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