Compare commits

..

10 Commits

Author SHA1 Message Date
Dobromir Popov
1936e7b92d misc 2026-02-18 09:33:21 +02:00
Dobromir Popov
4a2d11399e sound options 2026-02-17 10:50:56 +02:00
Dobromir Popov
eb0288313b 15m notification util 2026-02-17 10:30:18 +02:00
Dobromir Popov
22b302d98d fix polymarket routing 2026-02-16 14:58:05 +02:00
Dobromir Popov
7636fcf462 linux antivir 2026-02-16 14:35:29 +02:00
Dobromir Popov
3857ae0adb openwrt polymarket without additoional packages 2026-02-16 14:35:09 +02:00
Dobromir Popov
a2c33734fb openwrt routing 2026-02-16 14:13:35 +02:00
Dobromir Popov
b92a02a770 fix gitea db 2026-02-13 11:26:47 +02:00
Dobromir Popov
f5b9a90c9f add conteiner to infrastructure_default network for container 2026-02-12 17:39:28 +02:00
Dobromir Popov
de85ff494c vs code git configs 2026-02-12 12:56:43 +02:00
15 changed files with 1019 additions and 1 deletions

View File

@@ -34,6 +34,10 @@ services:
echo "Generated code-server password (saved in /config/.password-generated): $$PASSWORD";
fi;
fi;
HOME=/config git config --global http.postBuffer 524288000;
HOME=/config git config --global http.lowSpeedLimit 0;
HOME=/config git config --global http.lowSpeedTime 999999;
[ -f /config/.gitconfig ] && chown $${PUID}:$${PGID} /config/.gitconfig;
exec /init
'

View File

@@ -16,6 +16,13 @@ services:
volumes:
- novnc-workspace:/headless
shm_size: "256m"
networks:
- default
- infrastructure_default
volumes:
novnc-workspace:
networks:
infrastructure_default:
external: true

View File

@@ -23,3 +23,10 @@ services:
exec /app/entrypoint.sh
"
restart: unless-stopped
networks:
- default
- infrastructure_default
networks:
infrastructure_default:
external: true

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Mark Gitea users for deletion by creation date (e.g. after a hack/spam wave).
# Users created ON or AFTER the cutoff date will be marked inactive; then run
# Gitea admin task "Delete all unactivated accounts" to remove them.
#
# Usage:
# ./delete-users-by-creation-date.sh 2025-02-01
# ./delete-users-by-creation-date.sh "2025-02-10 14:00"
# ./delete-users-by-creation-date.sh 2025-02-01 --whitelist "admin,myuser"
#
# Steps after running this script:
# 1. Backup your Gitea database.
# 2. Run the generated SQL against your DB (PostgreSQL or SQLite).
# 3. In Gitea: Site Administration -> Maintenance -> "Delete all unactivated accounts".
# 4. Consider DISABLE_REGISTRATION = true in app.ini if registration was abused.
set -e
CUTOFF_DATE="${1:?Usage: $0 YYYY-MM-DD [--whitelist user1,user2]}"
WHITELIST=""
if [[ "$2" == "--whitelist" && -n "$3" ]]; then
WHITELIST="$3"
fi
# Convert cutoff to Unix timestamp (GNU date on Linux)
CUTOFF_UNIX=$(date -d "$CUTOFF_DATE" +%s 2>/dev/null) || true
if [[ -z "$CUTOFF_UNIX" ]]; then
echo "Invalid date: $CUTOFF_DATE (use YYYY-MM-DD or \"YYYY-MM-DD HH:MM\")"
echo "Run this script on the Gitea server (Linux) or pass the Unix timestamp manually."
exit 1
fi
echo "# Cutoff: $CUTOFF_DATE -> Unix $CUTOFF_UNIX (users created on or after this will be marked inactive)"
echo "# Backup your database before running any of the below."
echo ""
if [[ -n "$WHITELIST" ]]; then
# Build NOT IN list for SQL
WL=$(echo "$WHITELIST" | sed "s/,/','/g" | sed "s/^/'/; s/$/'/")
WHERE_EXTRA=" AND name NOT IN ($WL)"
WHERE_EXTRA_PG=" AND name NOT IN ($WL)"
else
WHERE_EXTRA=""
WHERE_EXTRA_PG=""
fi
echo "# --- PostgreSQL (table: public.user) ---"
echo "UPDATE public.user SET is_active = false WHERE created_unix >= $CUTOFF_UNIX${WHERE_EXTRA_PG};"
echo ""
echo "# --- SQLite / MySQL (table: user) ---"
echo "UPDATE user SET is_active = 0 WHERE created_unix >= $CUTOFF_UNIX${WHERE_EXTRA};"
echo ""
echo "# Then in Gitea: Site Administration -> Maintenance -> 'Delete all unactivated accounts'"

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Fix PostgreSQL "collation version mismatch" (e.g. DB created with 2.36, OS provides 2.41).
# Reindexes then refreshes collation version. REINDEX will be canceled if Gitea (or other
# clients) keep using the DB, so either stop Gitea first or use --terminate-connections.
#
# Usage (from host):
# ./fix-postgres-collation-version.sh <postgres_container_name>
# ./fix-postgres-collation-version.sh <postgres_container_name> gitea
# ./fix-postgres-collation-version.sh <postgres_container_name> gitea --terminate-connections
#
# Option: --terminate-connections Terminate all other sessions on the DB so REINDEX can
# run without being canceled. Gitea will disconnect; restart it after.
# REINDEX DATABASE gitea;
# ALTER DATABASE gitea REFRESH COLLATION VERSION;
set -e
CONTAINER="${1:?Usage: $0 <postgres_container_name> [database_name] [--terminate-connections]}"
DB="${2:-gitea}"
TERMINATE=""
if [[ "$2" == "--terminate-connections" ]]; then
TERMINATE=1
DB="gitea"
elif [[ "$3" == "--terminate-connections" ]]; then
TERMINATE=1
fi
if [[ -z "$TERMINATE" ]]; then
echo "Stop the Gitea container (and any other app using database $DB) before continuing,"
echo "otherwise REINDEX may be canceled. Press Enter when ready, or Ctrl+C to abort."
read -r
fi
if [[ -n "$TERMINATE" ]]; then
echo "Terminating other connections to $DB..."
docker exec "$CONTAINER" psql -U postgres -d "$DB" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB' AND pid <> pg_backend_pid();" || true
sleep 2
fi
echo "Reindexing database $DB (may take a while)..."
docker exec "$CONTAINER" psql -U postgres -d "$DB" -c "REINDEX DATABASE $DB;"
echo "Refreshing collation version..."
docker exec "$CONTAINER" psql -U postgres -d "$DB" -c "ALTER DATABASE $DB REFRESH COLLATION VERSION;"
echo "Done. Start Gitea again if you stopped it or used --terminate-connections."

18
linux/antivirus.sh Normal file
View File

@@ -0,0 +1,18 @@
# Install ClamAV
sudo apt install clamav clamav-daemon
# Update signatures
sudo freshclam
# Scan Docker volumes
sudo clamscan -r -i /var/lib/docker/volumes/
# Install Lynis
cd /opt/
sudo wget https://downloads.cisofy.com/lynis/lynis-3.1.6.tar.gz
sudo tar xvzf lynis-3.1.6.tar.gz
sudo mv lynis /usr/local/
sudo ln -s /usr/local/lynis/lynis /usr/local/bin/lynis
# Run security audit
sudo lynis audit system

113
linux/docker-host-setup.sh Normal file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Setup a fresh Linux server as Docker host with Portainer and Nginx Proxy Manager.
# Run over SSH: copy script to server, then: chmod +x docker-host-setup.sh && sudo ./docker-host-setup.sh
#
# Optional env (set before running):
# DOCKER_DATA_ROOT - e.g. /mnt/data/docker (default: leave Docker's default)
# VOL_BASE - volume base for portainer and proxy (default: /opt/docker-vol)
# usage: sudo DOCKER_DATA_ROOT=/mnt/data/docker VOL_BASE=/mnt/data/docker-vol ./docker-host-setup.sh
set -e
VOL_BASE="${VOL_BASE:-/opt/docker-vol}"
DOCKER_DATA_ROOT="${DOCKER_DATA_ROOT:-}"
# --- 1. Detect OS and install Docker ---
if ! command -v docker &>/dev/null; then
if [ -f /etc/os-release ]; then
. /etc/os-release
else
echo "Cannot detect OS. Install Docker manually and re-run for Portainer/NPM only."
exit 1
fi
if [[ "$ID" != "ubuntu" && "$ID" != "debian" ]]; then
echo "This script supports Ubuntu/Debian. For other distros install Docker and re-run."
exit 1
fi
echo "Installing Docker (Ubuntu/Debian)..."
apt-get update -qq
apt-get install -y -qq ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/"$ID"/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/$ID $VERSION_CODENAME stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
fi
# --- 2. Optional custom Docker data root ---
if [ -n "$DOCKER_DATA_ROOT" ]; then
echo "Setting Docker data-root to $DOCKER_DATA_ROOT"
mkdir -p "$DOCKER_DATA_ROOT"
if [ -f /etc/docker/daemon.json ] && grep -q '"data-root"' /etc/docker/daemon.json; then
echo "Docker daemon.json already has data-root, skipping."
else
if [ -f /etc/docker/daemon.json ]; then
if command -v python3 &>/dev/null; then
python3 -c "
import json, sys
p = '/etc/docker/daemon.json'
with open(p) as f: d = json.load(f)
d['data-root'] = sys.argv[1]
with open(p, 'w') as f: json.dump(d, f, indent=2)
" "$DOCKER_DATA_ROOT"
else
echo "Add manually to /etc/docker/daemon.json: \"data-root\": \"$DOCKER_DATA_ROOT\""
fi
else
printf '%s\n' "{\"data-root\": \"$DOCKER_DATA_ROOT\"}" > /etc/docker/daemon.json
fi
fi
systemctl restart docker
sleep 2
fi
systemctl enable docker
systemctl start docker
# Add current user to docker group so they can run docker without sudo
if [ -n "${SUDO_USER:-}" ]; then
usermod -aG docker "$SUDO_USER"
echo "Added $SUDO_USER to group docker. Log out and back in (or newgrp docker) for it to take effect."
fi
# --- 3. Volume dirs for Portainer and Nginx Proxy Manager ---
mkdir -p "$VOL_BASE/portainer" "$VOL_BASE/proxy/data" "$VOL_BASE/proxy/letsencrypt"
echo "Volume base: $VOL_BASE"
# --- 4. Start Portainer ---
if ! docker ps -a --format '{{.Names}}' | grep -qx portainer; then
echo "Starting Portainer..."
docker run -d \
-p 8000:8000 -p 9000:9000 \
--name portainer --restart always \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$VOL_BASE/portainer:/data" \
portainer/portainer-ce:latest
echo "Portainer: http://<host>:9000 (set admin password on first visit)"
else
echo "Portainer container already exists. Start with: docker start portainer"
fi
# --- 5. Start Nginx Proxy Manager ---
if ! docker ps -a --format '{{.Names}}' | grep -qx proxy; then
echo "Starting Nginx Proxy Manager..."
docker run -d \
--name proxy --restart always \
-p 80:80 -p 443:443 -p 81:81 \
-p 2222:2222 \
-e PUID=0 -e PGID=0 \
-v "$VOL_BASE/proxy/data:/data" \
-v "$VOL_BASE/proxy/letsencrypt:/etc/letsencrypt" \
jc21/nginx-proxy-manager:latest
echo "NPM admin: http://<host>:81 (default login: admin@example.com / changeme)"
else
echo "Nginx Proxy Manager container already exists. Start with: docker start proxy"
fi
echo ""
echo "Done. Portainer: :9000 NPM admin: :81 NPM HTTP/HTTPS: :80 :443"
echo "Change NPM default password and Portainer admin password after first login."

View File

@@ -0,0 +1,4 @@
#!/bin/sh
# Copy to router: /etc/hotplug.d/iface/99-starlink-policy
# chmod +x /etc/hotplug.d/iface/99-starlink-policy
[ "$INTERFACE" = "wan2" ] && [ "$ACTION" = "ifup" ] && /etc/starlink-policy-route.sh setup

View File

@@ -0,0 +1,223 @@
# OpenWrt: Connect to Starlink WiFi (client) and route blocked sites via it (LuCI)
Step-by-step using the LuCI web UI where possible. Router: Archer C6, OpenWrt/LuCI. Goal: main WAN stays default; traffic to polymarket (and similar) goes via Starlink WiFi.
---
## Part 1: Connect router to Starlink WiFi (client mode)
You need one radio as **AP** (your LAN WiFi) and one as **Client** (Starlink). Archer C6 has 2.4 GHz and 5 GHz; use one for Starlink client.
### 1.1 Install WiFi client (if needed)
SSH into the router, then:
```bash
opkg update
opkg install wpad-mesh-openssl
```
(Some images already include this. If "Scan" works in LuCI, skip.)
### 1.2 Create the Starlink client interface in LuCI
1. Log in to LuCI (e.g. `http://192.168.0.1`).
2. Go to **Network****Wireless**.
3. You should see two radios (e.g. "Radio0 (2.4 GHz)", "Radio1 (5 GHz)").
4. On the radio you will use for Starlink (e.g. **Radio1 (5 GHz)**):
- Click **Scan**.
- Wait for the list; find your **Starlink WiFi SSID**.
- Click **Join network** next to it.
5. In the dialog:
- **Network**: leave as new (e.g. `wwan`) or set a name like `starlink`.
- **Wireless Security**: choose the encryption (usually **WPA2-PSK**) and enter the **Starlink WiFi password**.
- Leave other options default. Submit.
6. The new interface (e.g. `wwan` or `starlink`) appears under **Network****Wireless** as a **Client** network. Ensure it is **Enabled** and not disabled.
### 1.3 Create a WAN interface for Starlink and assign firewall
The client connection gets an IP via DHCP from Starlink. You must create a protocol interface for it and put it in the **wan** firewall zone so it is used as a WAN.
1. Go to **Network****Interfaces**.
2. Click **Add new interface**:
- **Name**: `wan2` (or `starlink`).
- **Protocol**: **DHCP client**.
- **Device**: select the device that corresponds to the Starlink client (e.g. `wwan` or the wireless device name shown for that client network). If unsure, check **Network****Wireless** and see which device the client is on (e.g. `wlan1`).
- Submit.
3. On the new interfaces page:
- **General Setup**: ensure "Bring up on boot" or similar is checked.
- **Firewall Settings**: assign to **wan** (same zone as your main WAN). Required for NAT and for policy routing (mwan3 or the script in Part 2b).
- **Save & Apply**.
### 1.4 Verify Starlink connectivity
- In **Network****Interfaces**, `wan2` should show an IP (from Starlinks DHCP).
- From a device on your LAN, you can ping 8.8.8.8 (main WAN is still default). To test Starlink alone youll confirm after Part 2 with a policy.
---
## Part 2: Install and configure mwan3 (Load Balancing)
mwan3 will use both WANs: default traffic via main WAN, and specific destination IPs (polymarket) via Starlink.
### 2.1 Install mwan3 (SSH)
LuCI app for mwan3 is not always preinstalled. On the router via SSH:
```bash
opkg update
opkg install mwan3 luci-app-mwan3
```
Then in LuCI you should see **Network****Load Balancing** (or **Multi-WAN**).
### 2.2 Configure interfaces (LuCI)
1. Go to **Network****Load Balancing****Configuration** (or **Interfaces** tab).
2. **Interfaces**:
- You should see **wan** (main) and **wan2** (Starlink). If not, add **wan2**:
- **Interface**: `wan2`
- **Enable**: checked
- **Track IP**: e.g. `8.8.8.8` or `1.1.1.1` (used for health check).
- **Metric**: `20` (higher than wan so default route prefers main WAN).
- **Reliability**: e.g. `1`.
- Save.
- For **wan** (main WAN):
- **Metric**: `10` (lower = preferred for default).
- **Track IP**: e.g. `8.8.8.8`.
- Save.
3. **Members** tab:
- **wan** → member e.g. `wan_m1`, metric `1`.
- **wan2** → member e.g. `wan2_m1`, metric `1`.
4. **Policies** tab:
- **default_policy**: last resort; assign only **wan_m1** (main WAN only). So all traffic that doesnt match a rule uses main WAN.
- Add policy **starlink_only**: assign only **wan2_m1**. This will be used for polymarket IPs.
5. **Rules** tab (order matters; more specific first):
- Add a rule for polymarket:
- **Name**: e.g. `polymarket_via_starlink`
- **Destination address**: see below (polymarket IPs). You can add one rule with multiple IPs/CIDRs or several rules.
- **Policy**: **starlink_only**
- **Sticky**: optional (e.g. 1 minute) so the same connection stays on Starlink.
- Ensure there is a **default** rule:
- **Destination address**: `0.0.0.0/0`
- **Policy**: **default_policy**
- Default rule must be **last** (lowest priority). Polymarket rule must be **above** it.
### 2.3 Polymarket destination IPs
mwan3 matches by **destination IP**, not domain. You need to add the IPs (or CIDRs) for polymarket.com and any related hostnames.
- Resolve from a PC (that can reach polymarket, or use any DNS):
- `nslookup polymarket.com`
- `nslookup www.polymarket.com`
- Add any other subdomains you use (e.g. `gamma-api.polymarket.com`).
- In LuCI **Load Balancing****Rules**, in the polymarket rule set **Destination address** to one of:
- Single IP: `a.b.c.d/32`
- Several IPs: add multiple rules with the same policy, or use a space-separated list if LuCI allows (e.g. `1.2.3.4/32 5.6.7.8/32`).
- CDN IPs can change. If the site stops working via Starlink, resolve the domains again and add/update the IPs in the rule. You can later automate this with a script that updates the mwan3 config or uses ipset.
**Example** (replace with real IPs you resolved):
- Destination address: `104.18.2.2/32 172.67.1.1/32` (example only; get real IPs for polymarket.com).
### 2.4 Save and apply
- **Save & Apply** in **Load Balancing** and in **Network****Interfaces** if you changed anything.
- Test: from a LAN device, open polymarket.com; it should go via Starlink. Other sites still via main WAN.
---
## Part 2b: Policy routing without mwan3 (low flash)
If you cannot install mwan3 (e.g. only ~80 KB free on flash), you can get the same behaviour using **ip rules** and a **custom routing table**. No extra packages: uses `ip`, `resolveip`, `ubus`, `jsonfilter` (all default on OpenWrt).
### 2b.1 Copy and run the script
1. Copy `starlink-policy-route.sh` to the router (e.g. `/etc/starlink-policy-route.sh`).
2. Make it executable: `chmod +x /etc/starlink-policy-route.sh`.
3. Run once when wan2 is up: `/etc/starlink-policy-route.sh setup`.
The script resolves `polymarket.com` and `www.polymarket.com` (via `resolveip`), gets wan2 gateway from `ubus`, adds a default route in table 100 via wan2, and adds `ip rule add to <ip> table 100` for each resolved IP. All other traffic keeps using the main WAN.
### 2b.2 Run on wan2 up (hotplug)
So routes are applied after Starlink (wan2) gets an IP, create a hotplug script:
```bash
# On router: create /etc/hotplug.d/iface/99-starlink-policy
#!/bin/sh
[ "$INTERFACE" = "wan2" ] && [ "$ACTION" = "ifup" ] && /etc/starlink-policy-route.sh setup
```
Make it executable: `chmod +x /etc/hotplug.d/iface/99-starlink-policy`.
### 2b.3 Optional: set IPs manually
If DNS is not ready when the script runs (e.g. wan2 up before main WAN), resolve the domains on a PC and set them in the script:
```bash
# In starlink-policy-route.sh set (replace with real IPs):
POLYMARKET_IPS="104.18.2.2 172.67.1.1"
```
Then the script skips `resolveip` and uses these IPs. Update them if the site stops working (CDN changes).
### 2b.4 Remove routes
To remove the policy routes: `/etc/starlink-policy-route.sh remove`.
---
## Part 2c: Policy routing via LuCI only (static routes)
You can achieve the same result **entirely in LuCI** without mwan3 or scripts: (1) override the hostname so DNS returns the **real** Polymarket IP (not your ISPs spoofed one), and (2) add a **static route** for that IP via wan2. Route type: **unicast** (default).
### 2c.1 Get the real IP
Your ISP may resolve polymarket.com to a block/fake IP. You need the **real** server IP. Resolve while connected to Starlink WiFi (or using DNS 8.8.8.8): `nslookup polymarket.com` and `nslookup www.polymarket.com`. Note the IPv4 address (e.g. `64.239.109.1`). Use that for the route and the hostname override below.
### 2c.2 Override the hostname in LuCI (so DNS returns the real IP)
So your LAN devices dont get the ISP-spoofed IP:
1. Go to **Network****Hostnames** (or **DHCP and DNS****Hostnames** / **Custom domain** / **Address** entries, depending on your LuCI).
2. Add an entry: **Hostname** `polymarket.com`, **IP** the real IP (e.g. `64.239.109.1`). Add another for `www.polymarket.com` with the same IP if needed.
3. **Save & Apply**.
### 2c.3 Add static route in LuCI
1. Go to **Network****Routes** (or **Static routes**).
2. Click **Add** (or **Add new IPv4 route**).
3. Set:
- **Target**: the real IP with `/32` (e.g. `64.239.109.1/32`).
- **Gateway**: use the **Starlink (wan2) gateway** — either choose **Use gateway from interface** and select **wan2**, or enter the **wan2 DHCP gateway IP** (the IP your router uses as default gateway on the Starlink side; see **Network****Interfaces** → wan2 for the gateway).
- **Type**: **unicast** (default).
- **Metric**: leave default.
4. **Save & Apply**.
Traffic to that IP now goes via wan2; DNS gives your devices the real IP, so the route is used. If the site stops working later, re-resolve the domain (e.g. on Starlink), update the hostname override and the route if the IP changed.
---
## Part 3: Quick reference (LuCI locations)
| Step | LuCI path |
|-------------------------|-------------------------------------|
| Create Starlink client | Network → Wireless → Scan → Join |
| WAN interface for WiFi | Network → Interfaces → Add (DHCP, wan zone) |
| Load Balancing config | Network → Load Balancing |
| Interfaces (wan, wan2) | Load Balancing → Interfaces |
| Policies | Load Balancing → Policies |
| Rules (polymarket, default) | Load Balancing → Rules |
| Policy without mwan3 | Part 2b: script + hotplug |
| Policy via LuCI only | Part 2c: Hostnames + Network → Routes |
---
## Troubleshooting
- **Starlink client not getting IP**: Check WiFi password; ensure Starlink router is in range; check **Network****Wireless** that the client network is enabled and associated.
- **All traffic still via main WAN**: Ensure the polymarket rule is **above** the default rule; check **Destination address** uses the correct IPs/CIDRs; ensure **starlink_only** policy uses only **wan2_m1**.
- **Polymarket works then stops**: CDN IPs changed; re-resolve the domain(s) and update the rules destination IPs.
- **LuCI "Load Balancing" missing**: Install `luci-app-mwan3` via SSH and refresh the page.
- **Using Part 2b (no mwan3)**: If polymarket stops working, CDN IPs may have changed; run `resolveip -4 polymarket.com` (or from a PC) and set `POLYMARKET_IPS` in the script, or ensure the script runs when DNS is available (e.g. after both WANs are up).

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Policy routing: send traffic to specified domains via Starlink interface.
# Run on Linux host (e.g. Linux Mint) that has both main connection and WiFi to Starlink.
# Usage: sudo ./policy-route-starlink.sh [setup|remove]
# Configure STARLINK_IF, STARLINK_GW, DOMAINS below or via env.
set -e
# Starlink WiFi interface (e.g. wlan0)
STARLINK_IF="${STARLINK_IF:-wlan0}"
# Starlink gateway IP (get from: ip route show dev "$STARLINK_IF" | head -1)
STARLINK_GW="${STARLINK_GW:-}"
# Domains to route via Starlink (space-separated)
DOMAINS="${DOMAINS:-polymarket.com www.polymarket.com}"
# Routing table id for Starlink
TABLE_ID=200
action="${1:-setup}"
resolve_domains() {
local list=""
for d in $DOMAINS; do
local ips
ips=$(getent ahosts "$d" 2>/dev/null | awk '$1 !~ /^:/ {print $1}' | sort -u) || true
for ip in $ips; do
[[ -n "$ip" ]] && list="$list $ip"
done
done
echo "$list"
}
get_starlink_gw() {
if [[ -n "$STARLINK_GW" ]]; then
echo "$STARLINK_GW"
return
fi
ip route show dev "$STARLINK_IF" 2>/dev/null | awk '/default via/ {print $3; exit}'
}
do_setup() {
if ! ip link show "$STARLINK_IF" &>/dev/null; then
echo "Interface $STARLINK_IF not found. Set STARLINK_IF (e.g. wlan0)."
exit 1
fi
local gw
gw=$(get_starlink_gw)
if [[ -z "$gw" ]]; then
echo "Could not determine Starlink gateway. Set STARLINK_GW or ensure $STARLINK_IF has a route."
exit 1
fi
# Ensure table 200 has default via Starlink (idempotent)
ip route replace default via "$gw" dev "$STARLINK_IF" table "$TABLE_ID" 2>/dev/null || true
local ips
ips=$(resolve_domains)
if [[ -z "$ips" ]]; then
echo "No IPs resolved for domains: $DOMAINS. Check DNS/connectivity."
exit 1
fi
for ip in $ips; do
ip rule add to "$ip" table "$TABLE_ID" 2>/dev/null || true
done
echo "Routing via Starlink ($STARLINK_IF): $ips"
}
do_remove() {
local ips
ips=$(resolve_domains)
for ip in $ips; do
ip rule del to "$ip" table "$TABLE_ID" 2>/dev/null || true
done
ip route flush table "$TABLE_ID" 2>/dev/null || true
echo "Removed policy rules for table $TABLE_ID."
}
case "$action" in
setup) do_setup ;;
remove) do_remove ;;
*)
echo "Usage: $0 [setup|remove]"
echo "Env: STARLINK_IF ($STARLINK_IF), STARLINK_GW, DOMAINS"
exit 1
;;
esac

View File

@@ -0,0 +1,120 @@
# Policy routing: send blocked sites via Starlink (WiFi)
Your main connection blocks some sites (e.g. polymarket.com). Starlink is available over WiFi. This routes only selected traffic via Starlink; the rest stays on the main link.
## Where to implement
| Place | When to use |
|-------|-------------|
| **OpenWrt router** | Starlink is a second WAN on the same router. One config, all LAN devices benefit. |
| **Linux host (Mint)** | Starlink is only reachable from this machine (e.g. WiFi to Starlink, Ethernet to main LAN). |
| **Docker** | No separate step. Containers use the hosts routing; fix it on the host (or router). |
So: **router** if Starlink is second WAN on OpenWrt; otherwise **Linux host**. Docker follows the host.
---
## Router as WiFi client to Starlink
Using the router to connect to Starlinks WiFi as a client gives you one device with two WANs (main + Starlink over WiFi). Then policy routing can send only blocked sites via Starlink.
**Stock TP-Link (e.g. Archer C6):**
Most stock firmwares do **not** support “connect to another WiFi as client and use it as a **second** WAN”. They may have “WISP” / “Wireless ISP” mode, which uses WiFi-as-WAN but typically **replaces** the main WAN, not adds a second one. So dual-WAN with one being WiFi client is usually **not** available on stock.
**OpenWrt:**
Supports this. You use one wireless interface in **Client** mode, connected to Starlinks SSID (and password). That interface gets an IP via DHCP from Starlink and acts as a second WAN. Your existing Ethernet WAN stays the first. Requirements:
- Router has OpenWrt installed (Archer C6 is supported; check [OpenWrt Table of Hardware](https://openwrt.org/toh/start)).
- Two wireless “sides”: one stays in AP mode for your LAN WiFi, the other is in **Client** mode to Starlink. On dual-band routers (e.g. 2.4 GHz + 5 GHz) you use one band for AP and the other for client, so both can run at once.
- Then configure mwan3 with two WANs and policy routing as in Option A. **Step-by-step LuCI guide:** see `openwrt-starlink-luci-setup.md`.
So: **yes, you can configure the router to connect to Starlink as a client**, but you need **OpenWrt** (or similar) to both join Starlink WiFi and use it as a second WAN next to your main connection.
---
## Option A: OpenWrt (router level)
Requirements:
- OpenWrt with two WANs: main (blocking) + Starlink.
- Starlink connected to OpenWrt: **Ethernet** to Starlink router, or **WiFi client** (router joins Starlinks WiFi as above).
Steps (short):
1. **Multi-WAN**: Install `mwan3`, configure two interfaces (e.g. `wan`, `wan2`), each with its gateway and metric.
2. **Policy**: In mwan3, add a policy that uses only the Starlink member for a specific rule.
3. **Matching traffic**:
- Either assign **source IP** of the Linux host (and optionally other devices) to use that policy, or
- Use **destination IP** (see “Domain → IP” below) in firewall/routing so only those IPs go via Starlink.
Domain → IP on OpenWrt: resolve the domain (e.g. via `nslookup polymarket.com` or a script), then add those IPs to a firewall fwmark or an mwan3 rule. Some use `dnsmasq` with `ipset` + firewall to mark by domain and then mwan3 routes by mark.
---
## Option B: Linux host (Mint) two interfaces
Your machine has:
- Main: e.g. Ethernet (default route, blocking).
- Starlink: WiFi to Starlink.
Idea: keep default route on main; add a second routing table whose default is via Starlink; use `ip rule` so that traffic to specific IPs (resolved from polymarket.com etc.) uses that table.
Steps:
1. **Identify interfaces and gateways**
- Main: `ip route show default` (e.g. `eth0`, gateway `192.168.0.1`).
- Starlink: connect WiFi, then `ip route` and note gateway on `wlan0` (e.g. `192.168.1.1`).
2. **Starlink routing table**
- Pick a table id, e.g. `200`.
- Add default via Starlink gateway in table 200 (see script).
3. **Which IPs to send via Starlink**
- Resolve domains (e.g. `polymarket.com`, `www.polymarket.com`, `gamma-api.polymarket.com` if needed). IPs can change (CDN), so either:
- Run a small script periodically (cron) that resolves domains and updates `ip rule`/routing, or
- Add a known set of IPs and update when blocking starts again.
4. **Rules**
- `ip rule add to <IP> table 200` for each IP (or use `ipset` + one rule `ip rule add to match set <setname> table 200`).
Use the script `policy-route-starlink.sh`: it wraps the above and can be run at boot and on a timer.
**Script usage (host):**
```bash
# One-time: set gateway if auto-detect fails
export STARLINK_IF=wlan0
export STARLINK_GW=192.168.1.1 # optional, else from default route on wlan0
export DOMAINS="polymarket.com www.polymarket.com" # optional
sudo ./policy-route-starlink.sh setup
# To remove: sudo ./policy-route-starlink.sh remove
```
Because CDN IPs can change, run `setup` after boot (e.g. systemd service or @reboot cron) and optionally every 1015 min via cron so new IPs get added.
---
## Option C: Docker
No extra layer. Containers use the hosts routing and DNS. Once policy routing works on the host (or router), traffic from Docker to polymarket.com will go via Starlink if the rule matches that traffic (same destination IPs).
---
## Summary
- **Router (OpenWrt):** Yes, if Starlink is second WAN; use mwan3 + policy + (optionally) domain→ipset.
- **Host (Linux Mint):** Yes; two interfaces, second routing table, `ip rule` for destination IPs of blocked domains; script can maintain IP list.
- **Docker:** No separate config; host (or router) handles it.
If only the Linux Mint box has Starlink WiFi, implement on the host with the script. If Starlink is a second WAN on OpenWrt, implement on the router.
---
## Pi-hole DNS on the Linux host
**Does Pi-hole help route blocked sites through Starlink?**
No. Pi-hole only does DNS (answers and forwarding). It does not decide which WAN is used for the actual traffic. Routing is done by the kernel (policy routing / mwan3).
- **If the block is DNS-only** (ISP DNS returns NXDOMAIN or a block page): Using a different DNS (e.g. Pi-hole with upstream 1.1.1.1 / 8.8.8.8) can give clients the real IP. Traffic still goes out the main WAN; if the ISP also blocks by IP or SNI, you still need policy routing.
- **If the block is IP/SNI/DPI**: You need policy routing so traffic to polymarkets IPs goes via Starlink. Pi-hole does not do that.
Pi-hole is useful for ad blocking and DNS control; use it together with policy routing (OpenWrt or host script), not as a substitute for it.

View File

@@ -0,0 +1,82 @@
#!/bin/sh
# Policy routing: send traffic to specified domains via Starlink (wan2).
# No mwan3 required. Uses ip rule + custom table. Run on OpenWrt.
# Usage: run from hotplug (wan2 ifup) or manually after wan2 is up.
# Requires: ip, resolveip, ubus, jsonfilter (all default on OpenWrt).
TABLE_ID=100
WAN2_IF=wan2
DOMAINS="polymarket.com www.polymarket.com"
# Optional: set IPs manually if resolveip fails (e.g. no DNS yet). Space-separated.
# POLYMARKET_IPS="1.2.3.4 5.6.7.8"
POLYMARKET_IPS=""
get_wan2_gw() {
local status
status=$(ubus call network.interface."$WAN2_IF" status 2>/dev/null) || return 1
echo "$status" | jsonfilter -e '@.route[0].nexthop' 2>/dev/null
}
get_wan2_dev() {
local status
status=$(ubus call network.interface."$WAN2_IF" status 2>/dev/null) || return 1
echo "$status" | jsonfilter -e '@.device' 2>/dev/null
}
resolve_domains() {
if [ -n "$POLYMARKET_IPS" ]; then
echo "$POLYMARKET_IPS"
return
fi
local list=""
for d in $DOMAINS; do
for ip in $(resolveip -4 -t 2 "$d" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+'); do
[ -n "$ip" ] && list="$list $ip"
done
done
echo "$list" | tr ' ' '\n' | sort -u | tr '\n' ' '
}
do_setup() {
local gw dev ips ip
gw=$(get_wan2_gw)
dev=$(get_wan2_dev)
if [ -z "$gw" ] || [ -z "$dev" ]; then
logger -t starlink-policy "wan2 not ready (gw=$gw dev=$dev)"
return 1
fi
ips=$(resolve_domains)
if [ -z "$ips" ]; then
logger -t starlink-policy "No IPs resolved for: $DOMAINS. Set POLYMARKET_IPS in script or check DNS."
return 1
fi
for ip in $ips; do
ip rule del to "$ip" table $TABLE_ID 2>/dev/null
done
ip route flush table $TABLE_ID 2>/dev/null
ip route add default via "$gw" dev "$dev" table $TABLE_ID
for ip in $ips; do
ip rule add to "$ip" table $TABLE_ID
done
logger -t starlink-policy "Routes via wan2 ($dev): $ips"
}
do_remove() {
local ips ip
ips=$(resolve_domains)
for ip in $ips; do
ip rule del to "$ip" table $TABLE_ID 2>/dev/null
done
ip route flush table $TABLE_ID 2>/dev/null
logger -t starlink-policy "Removed policy routes for table $TABLE_ID"
}
case "${1:-setup}" in
setup) do_setup ;;
remove) do_remove ;;
*)
echo "Usage: $0 [setup|remove]"
exit 1
;;
esac

1
python/utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Utils package

View File

@@ -0,0 +1,77 @@
<#
.SYNOPSIS
Plays a ding/timer sound at regular intervals aligned to round period marks (e.g. every 15 min),
with an optional offset so the sound plays a few seconds before each mark.
.DESCRIPTION
Default: period 15 minutes, offset 15 seconds. Notification plays at :14:45, :29:45, :44:45, :59:45.
.PARAMETER PeriodMinutes
Interval in minutes between notifications (default 15). Defines the "round" marks (0, 15, 30, 45 past the hour).
.PARAMETER OffsetSeconds
Seconds before each round mark to play the sound (default 15). E.g. 15 means play at XX:14:45, XX:29:45, etc.
.EXAMPLE
.\interval-timer-ding.ps1
.\interval-timer-ding.ps1 -PeriodMinutes 30 -OffsetSeconds 10
#>
param(
[int] $PeriodMinutes = 15,
[int] $OffsetSeconds = 15
)
$ErrorActionPreference = 'Stop'
if ($PeriodMinutes -lt 1) { throw "PeriodMinutes must be >= 1" }
if ($OffsetSeconds -lt 0) { throw "OffsetSeconds must be >= 0" }
$periodSec = $PeriodMinutes * 60
function Get-SecondsSinceMidnight {
$now = Get-Date
return $now.Hour * 3600 + $now.Minute * 60 + $now.Second
}
function Get-NextRingSeconds {
$nowSec = Get-SecondsSinceMidnight
$nextBoundarySec = [Math]::Ceiling($nowSec / $periodSec) * $periodSec
$ringSec = $nextBoundarySec - $OffsetSeconds
if ($ringSec -le $nowSec) {
$ringSec += $periodSec
}
return $ringSec
}
function Get-WaitSeconds {
$nowSec = Get-SecondsSinceMidnight
$ringSec = Get-NextRingSeconds
$wait = $ringSec - $nowSec
if ($wait -le 0) { $wait = 1 }
return [int]$wait
}
function Play-Ding {
try {
[System.Media.SystemSounds]::Asterisk.Play()
} catch {
[Console]::Beep(800, 200)
}
}
$nextRing = Get-NextRingSeconds
$nextH = [Math]::Floor($nextRing / 3600)
$nextM = [Math]::Floor(($nextRing % 3600) / 60)
$nextS = $nextRing % 60
Write-Host "Period: $PeriodMinutes min, offset: $OffsetSeconds s. Next ding at $($nextH.ToString('00')):$($nextM.ToString('00')):$($nextS.ToString('00')) (then every $PeriodMinutes min). Press Ctrl+C to stop."
while ($true) {
$waitSec = Get-WaitSeconds
if ($waitSec -gt 0) {
Start-Sleep -Seconds $waitSec
}
Play-Ding
$ts = Get-Date -Format "HH:mm:ss"
Write-Host "[$ts] ding"
}

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
Plays a ding/timer sound at regular intervals aligned to round period marks
(e.g. every 15 min), with an optional offset so the sound plays a few seconds
before each mark.
Default: period 15 min, offset 15 s, 1 ding, sound asterisk.
Notification at :14:45, :29:45, :44:45, :59:45. Windows + Linux (stdlib).
python python/utils/interval_timer_ding.py
python python/utils/interval_timer_ding.py -p 30 -o 10
python python/utils/interval_timer_ding.py -s exclamation -n 2
python python/utils/interval_timer_ding.py -s /path/to/notify.wav
"""
import argparse
import math
import subprocess
import sys
import time
from datetime import datetime
def _seconds_since_midnight(dt: datetime) -> int:
return dt.hour * 3600 + dt.minute * 60 + dt.second
def _next_ring_seconds(period_sec: int, offset_sec: int, now_sec: int) -> int:
next_boundary = math.ceil(now_sec / period_sec) * period_sec
ring_sec = next_boundary - offset_sec
if ring_sec <= now_sec:
ring_sec += period_sec
return ring_sec
def _wait_seconds(period_sec: int, offset_sec: int) -> int:
now = datetime.now()
now_sec = _seconds_since_midnight(now)
ring_sec = _next_ring_seconds(period_sec, offset_sec, now_sec)
wait = ring_sec - now_sec
return max(1, wait) if wait > 0 else 1
# Gap between repeated dings (Windows MessageBeep is async so need ~0.5s+)
_DING_GAP_SEC = 1.7
_WIN_BEEP_MS = 350
# Sound choice: None = use default, "asterisk"|"exclamation"|... = Windows preset, or file path
_sound: str | None = None
def _play_ding_windows() -> None:
import winsound
sound = _sound or "asterisk"
if sound in ("asterisk", "exclamation", "hand", "question", "beep"):
try:
if sound == "asterisk":
winsound.MessageBeep(winsound.MB_ICONASTERISK)
elif sound == "exclamation":
winsound.MessageBeep(winsound.MB_ICONEXCLAMATION)
elif sound == "hand":
winsound.MessageBeep(winsound.MB_ICONHAND)
elif sound == "question":
winsound.MessageBeep(winsound.MB_ICONQUESTION)
else: # beep
winsound.Beep(800, _WIN_BEEP_MS)
return
except Exception:
pass
# Custom file path (SND_FILENAME only = blocking until sound finishes)
try:
winsound.PlaySound(sound, winsound.SND_FILENAME)
return
except Exception:
pass
try:
winsound.Beep(800, _WIN_BEEP_MS)
except Exception:
sys.stdout.write("\a")
sys.stdout.flush()
def _play_ding_linux() -> None:
sound = _sound
if sound:
for cmd in (["paplay", sound], ["aplay", sound]):
try:
subprocess.run(cmd, capture_output=True, timeout=3)
return
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
continue
candidates = [
["paplay", "/usr/share/sounds/freedesktop/stereo/bell.oga"],
["paplay", "/usr/share/sounds/freedesktop/stereo/message.oga"],
["aplay", "/usr/share/sounds/alsa/Front_Center.wav"],
["aplay", "/usr/share/sounds/speech-dispatcher/test.wav"],
]
for cmd in candidates:
try:
subprocess.run(cmd, capture_output=True, timeout=2)
return
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
continue
sys.stdout.write("\a")
sys.stdout.flush()
def main() -> None:
parser = argparse.ArgumentParser(
description="Play a ding every N minutes, offset seconds before each round mark."
)
parser.add_argument(
"-p", "--period",
type=int,
default=15,
metavar="MIN",
help="Interval in minutes between notifications (default: 15)",
)
parser.add_argument(
"-o", "--offset",
type=int,
default=15,
metavar="SEC",
help="Seconds before each round mark to play (default: 15)",
)
parser.add_argument(
"-s", "--sound",
type=str,
default=None,
metavar="PRESET|PATH",
help="Notification sound: Windows presets asterisk|exclamation|hand|question|beep, or path to .wav file (default: asterisk)",
)
parser.add_argument(
"-n", "--dings",
type=int,
default=1,
metavar="N",
help="Number of dings per notification (default: 1, max: 10)",
)
args = parser.parse_args()
global _sound
_sound = args.sound
if args.period < 1:
parser.error("period must be >= 1")
if args.offset < 0:
parser.error("offset must be >= 0")
if not 1 <= args.dings <= 10:
parser.error("dings must be 1..10")
period_sec = args.period * 60
now = datetime.now()
now_sec = _seconds_since_midnight(now)
next_ring = _next_ring_seconds(period_sec, args.offset, now_sec)
h = next_ring // 3600
m = (next_ring % 3600) // 60
s = next_ring % 60
print(
f"Period: {args.period} min, offset: {args.offset} s. "
f"Next ding at {h:02d}:{m:02d}:{s:02d} (then every {args.period} min). "
"Press Ctrl+C to stop."
)
play = _play_ding_windows if sys.platform == "win32" else _play_ding_linux
while True:
wait_sec = _wait_seconds(period_sec, args.offset)
time.sleep(wait_sec)
for i in range(args.dings):
play()
if i < args.dings - 1:
time.sleep(_DING_GAP_SEC)
print(f"[{datetime.now().strftime('%H:%M:%S')}] ding")
if __name__ == "__main__":
main()