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:
@@ -1,3 +1,5 @@
|
||||
.certs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
share.config
|
||||
share-deploy*.zip
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user