feat(deploy): add install script and untrack share.config

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
This commit is contained in:
2026-07-27 02:07:21 +04:00
parent 6f6b7552fc
commit 7af9aec0b3
8 changed files with 392 additions and 24 deletions
+2
View File
@@ -1,3 +1,5 @@
.certs/
__pycache__/
*.pyc
share.config
share-deploy*.zip
+22 -4
View File
@@ -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
+152
View File
@@ -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 <repo-url> ~/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://<lan-ip>: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 |
Executable
+146
View File
@@ -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 <key> <value> — 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
-20
View File
@@ -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
+49
View File
@@ -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
+3
View File
@@ -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)
+18
View File
@@ -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