From 7af9aec0b37b1a062f59a9379a07205ff48d015a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AD=D0=BB=D1=8C=D0=BD=D0=B0=D1=80?= Date: Mon, 27 Jul 2026 02:07:21 +0400 Subject: [PATCH] feat(deploy): add install script and untrack share.config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Package the service for setup on a new machine: - install.sh generates a token, writes share.config (mode 600, token stored as a SHA-256 hash) and installs the systemd user unit from share.service.template - share.config is now gitignored, with share.config.example as the tracked template — the config holds the secret and must not be in the repo - warn at startup when token is empty, since that means open access - SETUP.md documents install, verification and security posture --- .gitignore | 2 + README.md | 26 +++++-- SETUP.md | 152 +++++++++++++++++++++++++++++++++++++++++ install.sh | 146 +++++++++++++++++++++++++++++++++++++++ share.config | 20 ------ share.config.example | 49 +++++++++++++ share.py | 3 + share.service.template | 18 +++++ 8 files changed, 392 insertions(+), 24 deletions(-) create mode 100644 SETUP.md create mode 100755 install.sh delete mode 100644 share.config create mode 100644 share.config.example create mode 100644 share.service.template diff --git a/.gitignore b/.gitignore index 72f2ced..4d99e7d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .certs/ __pycache__/ *.pyc +share.config +share-deploy*.zip diff --git a/README.md b/README.md index 697c095..b3dfcfa 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,35 @@ Local network file sharing via web page. Drag & drop, Ctrl+V paste, download. No dependencies — Python 3 stdlib only. +Setting this up on a new machine? See [SETUP.md](SETUP.md). + ## Quick start ``` +./install.sh # generates a token, installs + starts the systemd service +``` + +Or run it in the foreground without installing anything: + +``` +cp share.config.example share.config # then set `token` in it python3 share.py ``` -Open `http://localhost:8888` in a browser. The network address is printed at startup. +Open `https://localhost:3001` in a browser. The network address is printed at +startup. The cert is self-signed, so expect a browser warning on first visit. ## Options +CLI flags override `share.config`. + ``` python3 share.py -p 3001 -d ~/Downloads/shared --ttl 7 ``` | Flag | Default | Description | |------|---------|-------------| -| `-p`, `--port` | `8888` | Port | +| `-p`, `--port` | `3001` | Port | | `-d`, `--dir` | `~/Downloads/shared` | Upload directory | | `--ttl` | `7` | File lifetime in days (0 = keep forever) | | `--token` | | Auth token (empty = open access) | @@ -29,7 +41,11 @@ python3 share.py -p 3001 -d ~/Downloads/shared --ttl 7 ## Authentication -Set `token` in `share.config` or pass `--token`. All endpoints require a valid token. +Set `token` in `share.config` or pass `--token`. All endpoints require a valid +token. An empty `token` means open access to everyone on the network. + +`share.config` is gitignored because it holds the secret — `share.config.example` +is the tracked template. The token can be stored as plain text or as a SHA-256 hash: @@ -45,7 +61,9 @@ If the value is a 64-character hex string, it is treated as a hash. Otherwise it ## systemd (Arch) -Service file: `~/.config/systemd/user/share.service` +Installed by `./install.sh` from `share.service.template` to +`~/.config/systemd/user/share.service`. Restart after editing `share.config` — +it is only read at startup. ``` systemctl --user enable --now share # start + autostart diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..d1c5533 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,152 @@ +# Deploying Share on a new machine + +Instructions for a fresh Claude Code session (or a human) bringing this service up +from a clean clone. Read this top to bottom before running anything. + +## What this is + +A LAN file-sharing web app: drag & drop upload, clipboard paste, download, delete, +auto-expiry of old files. Python 3 stdlib only — no pip install, no virtualenv. + +It serves **HTTPS with a self-signed certificate**. That is not optional decoration: +the browser Clipboard API (`navigator.clipboard.write`) only works on a secure +origin, and the paste/copy features depend on it. + +## Requirements + +- Python **3.10+** (the code uses `X | None` annotations evaluated at runtime) +- `openssl` on PATH (used once, to generate the self-signed cert) +- Linux with systemd user units for the service install; without systemd it still + runs fine in the foreground + +## Install + +```bash +git clone ~/projects/share +cd ~/projects/share +./install.sh +``` + +That will: + +1. check python/openssl versions, +2. generate a random token, hash it, and write `share.config` (mode 600), +3. install `~/.config/systemd/user/share.service` pointing at wherever you cloned, +4. `enable --now` the service and confirm it actually came up. + +**The generated token is printed once, at the end. Save it.** Only its SHA-256 hash +is stored, so it cannot be recovered afterwards — losing it means re-running with +`--force` to set a new one. + +### Setting your own secret + +```bash +./install.sh --token 'my-own-secret' +``` + +Or after the fact, without re-running the installer: + +```bash +python3 share.py hash 'my-own-secret' # prints the SHA-256 hash +# paste that hash as the `token = ` value in share.config +systemctl --user restart share +``` + +Both forms are equivalent — `share.config` never holds the plaintext. A 64-char hex +value is treated as an already-hashed token; anything else is hashed at startup +(convenient, but then the plaintext sits on disk — prefer the hash). + +### Other install options + +| Flag | Effect | +|------|--------| +| `--token S` | use secret `S` instead of generating one | +| `--port N` | listen port (default `3001`) | +| `--dir P` | upload directory (default `~/Downloads/shared`) | +| `--ttl N` | file lifetime in days, `0` = keep forever | +| `--san IP` | extra IP in the cert SAN; repeatable | +| `--lang ru\|en` | UI language | +| `--no-service` | write config only, skip systemd | +| `--force` | overwrite an existing `share.config` | + +Re-running `install.sh` without `--force` keeps the existing config, so it is safe +to run again just to reinstall the unit file. + +## Verify it works + +```bash +systemctl --user status share +curl -sk -o /dev/null -w '%{http_code}\n' https://localhost:3001/files +# → 401 (no token: correct, auth is on) +curl -sk -H "Authorization: Bearer YOUR_TOKEN" https://localhost:3001/files +# → [] (empty file list: correct) +``` + +A `401` from the first call is the success signal — it means the token gate is +active. A `200` there means `token` is empty in `share.config` and **anyone on the +network can read and write your files**. + +Then open `https://:3001` from a phone or laptop on the same network. The +browser will show a certificate warning; that is expected for a self-signed cert — +accept it once. Enter the token on the login page; it is stored in an `HttpOnly` +cookie for a year. + +## Networking notes + +- The server binds `0.0.0.0`, so it is reachable from the whole LAN. +- The cert's SAN list gets `127.0.0.1` plus the auto-detected LAN IP. If you reach + the box by another address (a router doing NAT, a second interface, a static IP + that differs from the detected one), pass it with `--san` or the cert will not + validate for that address. +- Changing SANs later: delete `.certs/` and restart — the cert regenerates. +- Open the port in the firewall if one is active (`ufw allow 3001/tcp`, or the + `firewalld`/`iptables` equivalent). +- The service starts on **login**, not boot. For a headless box that should serve + without anyone logging in: `sudo loginctl enable-linger $USER`. + +## Managing the service + +```bash +systemctl --user restart share # after editing share.config +systemctl --user stop share +systemctl --user disable --now share +journalctl --user -u share -f # live logs +``` + +`share.config` is read only at startup — every config change needs a restart. + +## Running without systemd + +```bash +python3 share.py # foreground, reads share.config +python3 share.py -p 4000 --ttl 0 # CLI flags override share.config +``` + +## Security posture — know what this is + +This is a LAN convenience tool, not a hardened public service. Deploy accordingly: + +- The token is a single shared bearer secret. Everyone who has it has full + read/write/delete access to the upload directory. There are no user accounts. +- An empty `token` disables auth completely. Never leave it empty. +- The cert is self-signed, so clients cannot distinguish it from a + man-in-the-middle cert on first use. Fine on a home LAN, not fine on a hostile + network. +- Do not port-forward this to the internet. It is designed for a trusted LAN. +- `share.config` holds the secret hash and is gitignored — keep it that way. Copy + it between machines out of band, never through the repo. + +## Files + +| Path | Role | +|------|------| +| `share.py` | entry point, startup wiring, `hash` subcommand | +| `server.py` | HTTP handler, routing, auth, upload/download | +| `config.py` | config file + CLI arg loading, constants | +| `cert.py` | self-signed cert generation, LAN IP detection | +| `cleanup.py` | background thread expiring files past `ttl` | +| `static/index.html` | the entire frontend, single file | +| `share.config.example` | tracked template — copy to `share.config` | +| `share.config` | **gitignored**, holds the token hash | +| `share.service.template` | tracked systemd unit template | +| `install.sh` | the setup script described above | diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..3c1d624 --- /dev/null +++ b/install.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Share — setup on a new machine. +# +# ./install.sh generate a random token, install + start service +# ./install.sh --token mysecret use your own token +# ./install.sh --port 4000 --dir ~/box override port / upload dir +# ./install.sh --san 192.168.0.1 extra IP in the TLS cert SAN (repeatable) +# ./install.sh --no-service write config only, don't touch systemd +# +# Re-running is safe: an existing share.config is kept unless you pass --force. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG="$DIR/share.config" +UNIT_DIR="$HOME/.config/systemd/user" + +TOKEN="" PORT="" UPLOAD_DIR="" LANG_OPT="" TTL="" +SANS=() SERVICE=1 FORCE=0 + +die() { printf '\033[31merror:\033[0m %s\n' "$1" >&2; exit 1; } +info() { printf ' %s\n' "$1"; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --token) TOKEN="${2:-}"; shift 2 ;; + --port) PORT="${2:-}"; shift 2 ;; + --dir) UPLOAD_DIR="${2:-}"; shift 2 ;; + --ttl) TTL="${2:-}"; shift 2 ;; + --lang) LANG_OPT="${2:-}"; shift 2 ;; + --san) SANS+=("${2:-}"); shift 2 ;; + --no-service) SERVICE=0; shift ;; + --force) FORCE=1; shift ;; + -h|--help) sed -n '2,10p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +# ── Dependencies ────────────────────────────────────────────────────────────── +PYTHON="$(command -v python3)" || die "python3 not found" +"$PYTHON" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' \ + || die "python3 >= 3.10 required (found $("$PYTHON" -V 2>&1))" +command -v openssl >/dev/null || die "openssl not found — needed to generate the TLS cert" + +# ── Token ───────────────────────────────────────────────────────────────────── +GENERATED=0 +if [[ -z "$TOKEN" ]]; then + TOKEN="$("$PYTHON" -c 'import secrets; print(secrets.token_urlsafe(24))')" + GENERATED=1 +fi +TOKEN_HASH="$("$PYTHON" -c 'import hashlib,sys; print(hashlib.sha256(sys.argv[1].encode()).hexdigest())' "$TOKEN")" + +# ── Config ──────────────────────────────────────────────────────────────────── +KEPT=0 +if [[ -f "$CONFIG" && $FORCE -eq 0 ]]; then + KEPT=1 + info "share.config exists — keeping it (pass --force to overwrite)" +else + [[ -f "$DIR/share.config.example" ]] || die "share.config.example missing" + cp "$DIR/share.config.example" "$CONFIG" + + set_opt() { # set_opt — replace the first bare "key =" line + local key="$1" val="$2" + "$PYTHON" - "$CONFIG" "$key" "$val" <<'PY' +import re, sys +path, key, val = sys.argv[1], sys.argv[2], sys.argv[3] +text = open(path).read() +text, n = re.subn(rf'(?m)^{re.escape(key)}\s*=.*$', f'{key} = {val}', text, count=1) +if n == 0: + raise SystemExit(f'key not found in config template: {key}') +open(path, 'w').write(text) +PY + } + + set_opt token "$TOKEN_HASH" + [[ -n "$PORT" ]] && set_opt port "$PORT" + [[ -n "$UPLOAD_DIR" ]] && set_opt dir "$UPLOAD_DIR" + [[ -n "$TTL" ]] && set_opt ttl "$TTL" + [[ -n "$LANG_OPT" ]] && set_opt lang "$LANG_OPT" + [[ ${#SANS[@]} -gt 0 ]] && set_opt san "${SANS[*]}" + chmod 600 "$CONFIG" + info "wrote share.config (token stored as SHA-256 hash)" +fi + +EFF_PORT="$("$PYTHON" -c ' +import configparser, sys +cp = configparser.ConfigParser(); cp.read(sys.argv[1]); print(cp.get("server", "port", fallback="3001"))' "$CONFIG")" + +# ── systemd user service ────────────────────────────────────────────────────── +if [[ $SERVICE -eq 1 ]]; then + if ! command -v systemctl >/dev/null; then + info "systemctl not found — skipping service install" + SERVICE=0 + else + mkdir -p "$UNIT_DIR" + sed -e "s|__DIR__|$DIR|g" -e "s|__PYTHON__|$PYTHON|g" \ + -e '/^;;/d' "$DIR/share.service.template" > "$UNIT_DIR/share.service" + systemctl --user daemon-reload + systemctl --user enable share.service + # restart, not `enable --now`: --now is a no-op on an already-running service, + # so a re-run would leave the old unit/config in effect. + systemctl --user restart share.service + info "installed $UNIT_DIR/share.service and (re)started it" + # Give it a moment to bind the port / fail loudly. + for _ in 1 2 3 4 5 6 7 8 9 10; do + systemctl --user is-active --quiet share.service && break + sleep 0.3 + done + if ! systemctl --user is-active --quiet share.service; then + systemctl --user status share.service --no-pager -n 20 || true + die "service failed to start — see the status output above" + fi + fi +fi + +# ── Summary ─────────────────────────────────────────────────────────────────── +IP="$("$PYTHON" -c ' +import socket +try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.connect(("8.8.8.8", 80)) + print(s.getsockname()[0]); s.close() +except OSError: + print("127.0.0.1")')" + +echo +printf '\033[32m Share is set up.\033[0m\n\n' +info "Local: https://localhost:$EFF_PORT" +info "Network: https://$IP:$EFF_PORT" +echo +if [[ $KEPT -eq 1 ]]; then + info "Token: unchanged — the existing share.config was kept." + info " To set a new one: ./install.sh --force --token 'newsecret'" + echo +elif [[ $GENERATED -eq 1 ]]; then + printf '\033[33m Token (generated — save it now, it is not stored in plaintext):\033[0m\n\n' + printf ' %s\n\n' "$TOKEN" +else + info "Token: the one you passed via --token" + echo +fi +info "The cert is self-signed — the browser will warn on first visit. That is expected." +if [[ $SERVICE -eq 1 ]]; then + info "Logs: journalctl --user -u share -f" + info "Autostart at boot without login: sudo loginctl enable-linger $USER" +else + info "Run manually: python3 $DIR/share.py" +fi diff --git a/share.config b/share.config deleted file mode 100644 index 4cdb378..0000000 --- a/share.config +++ /dev/null @@ -1,20 +0,0 @@ -[server] -port = 3001 -dir = ~/Downloads/shared -ttl = 7 -san = -lang = ru -refresh = 5 -token = 755b7c9ca04986c0dd496353e9d94ce713f2df0657b9b4008cb4d617679e103f -per_page = 10,25,50,100 - -[internal] -secs_per_day = 86400 -cleanup_interval = 3600 -ssl_handshake_timeout = 5 -sniff_size = 8192 -chunk_size = 65536 -cert_days = 3650 -cert_key_bits = 2048 -toast_ms = 2500 -progress_hide_ms = 1200 diff --git a/share.config.example b/share.config.example new file mode 100644 index 0000000..ba8fafb --- /dev/null +++ b/share.config.example @@ -0,0 +1,49 @@ +; Copy to share.config and edit. share.config is gitignored — it holds the secret. +; cp share.config.example share.config +; Or let install.sh do it for you. + +[server] +; Port to listen on (all interfaces, 0.0.0.0) +port = 3001 + +; Upload directory — created at startup if missing +dir = ~/Downloads/shared + +; File lifetime in days (0 = keep forever) +ttl = 7 + +; Extra IPs to put in the TLS cert SAN, space-separated. +; 127.0.0.1 and the detected LAN IP are always included. +; Add the router IP here if you reach the box through it, e.g. 192.168.0.1 +san = + +; UI language: ru | en +lang = ru + +; File list auto-refresh interval, seconds +refresh = 5 + +; Auth token. REQUIRED — an empty value means open access to anyone on the LAN. +; Store the SHA-256 hash, not the plaintext: +; python3 share.py hash mysecret +; A 64-char hex value is treated as a hash; anything else is hashed at startup. +token = + +; Page-size options offered in the UI +per_page = 10,25,50,100 + +; Explicit TLS cert/key. Leave empty to auto-generate a self-signed pair in .certs/ +cert = +key = + +; Values below are implementation constants — no need to touch them. +[internal] +secs_per_day = 86400 +cleanup_interval = 3600 +ssl_handshake_timeout = 5 +sniff_size = 8192 +chunk_size = 65536 +cert_days = 3650 +cert_key_bits = 2048 +toast_ms = 2500 +progress_hide_ms = 1200 diff --git a/share.py b/share.py index f81fec5..5d50718 100755 --- a/share.py +++ b/share.py @@ -45,6 +45,9 @@ def main(): ctx.load_cert_chain(cert, key) token = conf["token"] + if not token: + print("\n WARNING: no token set — anyone on the network has full access.") + print(" Set `token` in share.config (see SETUP.md) or pass --token.") # If token looks like a SHA-256 hash, pass it as pre-hashed is_hash = len(token) == 64 and all(c in "0123456789abcdef" for c in token) server = ShareServer(("0.0.0.0", port), Handler, upload_dir=upload_dir, html=html, ssl_ctx=ctx, token=token, token_is_hash=is_hash) diff --git a/share.service.template b/share.service.template new file mode 100644 index 0000000..ab4ece7 --- /dev/null +++ b/share.service.template @@ -0,0 +1,18 @@ +;; Template — install.sh substitutes __DIR__ and __PYTHON__ and installs the +;; result to ~/.config/systemd/user/share.service. Do not install this file directly. +[Unit] +Description=Share — local file sharing +After=network-online.target +Wants=network-online.target + +[Service] +; Unbuffered — otherwise Python block-buffers stdout when it is not a tty and the +; startup banner / warnings never reach journalctl. +Environment=PYTHONUNBUFFERED=1 +ExecStart=__PYTHON__ __DIR__/share.py +WorkingDirectory=__DIR__ +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=default.target