feat(chain): multi-device registry (v2.2.0-alpha1)
PR #1 of the multi-device roadmap. Adds per-device X25519 keys registered on-chain so senders can fan out envelopes across all of a recipient's physical devices — fixes the single-device limitation where a second phone / desktop loses messages as soon as the first one reads them. Chain (blockchain/): - New event types LINK_DEVICE / UNLINK_DEVICE, signed by the identity's master Ed25519. - LinkDevicePayload {x25519_pub_key, device_name} + UnlinkDevicePayload {x25519_pub_key} on the wire. - State: prefixDevice + x25519_pub → DeviceRecord{owner, name, added_at, revoked_at?}; reverse index prefixDevicesByOwner for O(k) listing. Revoke is a soft-delete — the row stays as a visible tombstone so offline clients can detect their own revocation and wipe local state. - MaxDevicesPerOwner = 10 slot cap; MaxDeviceNameLen = 64. - Strict lowercase-hex validation on x25519_pub so clients can't desync on letter case. - Same-owner re-link is a rename/refresh (recreates reverse index too — needed after a revoke). - Chain.DevicesOf(master_pub) returns the active records; empty slice for legacy identities so senders can fall back to IdentityInfo.X25519Pub. HTTP (node/): - GET /api/devices/{master_pub_or_addr} — returns {master_pub, count, devices[]}. Revoked records filtered out. - /api/identity/{pub} gains `device_count` so senders can decide upfront whether to fan out or take the legacy path. Tests (blockchain/devices_test.go): - Happy paths (1, 3 devices), foreign-owner rejection, same-owner refresh after revoke, unlink removes from active set, foreign-signer unlink rejection, idempotent double-unlink, malformed pub/name rejection, MaxDevices cap + recovery after unlink frees a slot, empty list for unknown master. Also in this commit: - deploy/single/join.sh — convenience script operators have been iterating on in this session (joiner-node bring-up + firewall port patching + Caddy opt-out). - client-app/app.json — `usesCleartextTraffic: true` on Android so installed APKs can talk to http:// dev nodes without TLS. See docs/ROADMAP.md for PRs #2..#4 (client fan-out, pairing flow, desktop Electron shell).
This commit is contained in:
202
deploy/single/join.sh
Normal file
202
deploy/single/join.sh
Normal file
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env bash
|
||||
# rejoin.sh — полная переустановка dchain joiner-ноды с нуля.
|
||||
# Публичный доступ без Caddy/TLS, без API-токена (на свой страх и риск).
|
||||
# Запускать БЕЗ sudo.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── КОНФИГ ─────────────────────────────────────────────────────────────
|
||||
SEED_HTTP="${SEED_HTTP:-http://62.171.151.182:8082}"
|
||||
SEED_P2P_IP="${SEED_P2P_IP:-62.171.151.182}"
|
||||
SEED_P2P_PORT="${SEED_P2P_PORT:-4005}"
|
||||
REPO_URL="${REPO_URL:-https://git.vsecoder.vodka/vsecoder/dchain.git}"
|
||||
WORKDIR="${WORKDIR:-$HOME/dchain}"
|
||||
LOCAL_P2P_PORT="${LOCAL_P2P_PORT:-4001}"
|
||||
LOCAL_HTTP_PORT="${LOCAL_HTTP_PORT:-8082}"
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31mXX\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
need() { command -v "$1" >/dev/null || die "need $1 installed"; }
|
||||
|
||||
# ── 0. Требования ──────────────────────────────────────────────────────
|
||||
[[ $EUID -eq 0 ]] && die "don't run as root — use your user account, sudo is used per-command"
|
||||
need docker; need git; need curl; need jq
|
||||
sudo -v
|
||||
|
||||
# ── 1. Снести предыдущее состояние ─────────────────────────────────────
|
||||
log "stopping any existing dchain stack & purging volumes"
|
||||
if [[ -d "$WORKDIR/deploy/single" ]]; then
|
||||
(cd "$WORKDIR/deploy/single" && sudo docker compose down -v 2>/dev/null) || true
|
||||
fi
|
||||
sudo docker rm -f dchain_node dchain_caddy dchain 2>/dev/null || true
|
||||
mapfile -t stale_vols < <(sudo docker volume ls -q | grep -E '^(dchain|dchain-single)' || true)
|
||||
(( ${#stale_vols[@]} > 0 )) && sudo docker volume rm "${stale_vols[@]}" 2>/dev/null || true
|
||||
|
||||
# ── 2. Свежий репозиторий ──────────────────────────────────────────────
|
||||
log "fetching repo → $WORKDIR"
|
||||
if [[ -d "$WORKDIR/.git" ]]; then
|
||||
git -C "$WORKDIR" fetch --all --tags --prune
|
||||
git -C "$WORKDIR" reset --hard origin/main
|
||||
else
|
||||
rm -rf "$WORKDIR"
|
||||
git clone "$REPO_URL" "$WORKDIR"
|
||||
fi
|
||||
cd "$WORKDIR/deploy/single"
|
||||
|
||||
# ── 3. keys/ под UID 100 ───────────────────────────────────────────────
|
||||
log "preparing keys/ for container UID 100:101"
|
||||
mkdir -p keys
|
||||
sudo chown 100:101 keys
|
||||
sudo chmod 755 keys
|
||||
[[ -f keys/node.json ]] && { sudo chown 100:101 keys/node.json; sudo chmod 600 keys/node.json; }
|
||||
|
||||
# ── 4. Slim-образ ──────────────────────────────────────────────────────
|
||||
log "building dchain image (slim)"
|
||||
IMAGE=$(sudo docker build -q -f ../prod/Dockerfile.slim ../..)
|
||||
[[ -z "$IMAGE" ]] && die "docker build failed"
|
||||
|
||||
# ── 5. Ключ ноды ───────────────────────────────────────────────────────
|
||||
if [[ ! -f keys/node.json ]]; then
|
||||
log "generating node identity"
|
||||
sudo docker run --rm --entrypoint /usr/local/bin/client \
|
||||
--user 100:101 \
|
||||
-v "$PWD/keys:/out" \
|
||||
"$IMAGE" \
|
||||
keygen --out /out/node.json
|
||||
else
|
||||
log "reusing existing keys/node.json"
|
||||
fi
|
||||
sudo chown 100:101 keys/node.json
|
||||
sudo chmod 600 keys/node.json
|
||||
|
||||
# ── 6. peer_id seed'a ──────────────────────────────────────────────────
|
||||
log "fetching seed peer_id from $SEED_HTTP/stats"
|
||||
SEED_PEER_ID=$(curl -sfL "$SEED_HTTP/stats" | jq -r '.node.peer_id // empty')
|
||||
[[ -z "$SEED_PEER_ID" ]] && die "can't reach seed at $SEED_HTTP or peer_id missing"
|
||||
log "seed peer_id = $SEED_PEER_ID"
|
||||
|
||||
# ── 7. Публичный IP ────────────────────────────────────────────────────
|
||||
PUBLIC_IP="${PUBLIC_IP:-$(curl -sfL https://api.ipify.org || true)}"
|
||||
[[ -z "$PUBLIC_IP" ]] && die "can't detect public IP; export PUBLIC_IP=x.x.x.x и перезапусти"
|
||||
log "public IP = $PUBLIC_IP"
|
||||
|
||||
# ── 8. node.env (без токена, без genesis) ──────────────────────────────
|
||||
log "writing node.env"
|
||||
cp -f node.env.example node.env
|
||||
|
||||
# удалить из example'а значения, которые мы хотим держать под своим контролем,
|
||||
# чтобы они не перекрыли наши append'ы снизу
|
||||
sudo sed -i -E '/^(#\s*)?DCHAIN_(GENESIS|API_TOKEN|API_PRIVATE|JOIN|PEERS|ANNOUNCE|DB|MAILBOX_DB|FEED_DB|REGISTER_RELAY|RELAY_FEE|FEED_DISK_LIMIT_MB|CHAIN_DISK_LIMIT_MB|UPDATE_SOURCE_URL)=/d' node.env
|
||||
|
||||
sudo tee -a node.env > /dev/null <<EOF
|
||||
|
||||
# ── AUTO-GENERATED BY rejoin.sh ─────────────────────────────────────
|
||||
# joiner → seed $SEED_HTTP (public node, no token)
|
||||
DCHAIN_JOIN=$SEED_HTTP
|
||||
DCHAIN_PEERS=/ip4/$SEED_P2P_IP/tcp/$SEED_P2P_PORT/p2p/$SEED_PEER_ID
|
||||
DCHAIN_ANNOUNCE=/ip4/$PUBLIC_IP/tcp/$LOCAL_P2P_PORT
|
||||
|
||||
DCHAIN_DB=/data/chain
|
||||
DCHAIN_MAILBOX_DB=/data/mailbox
|
||||
DCHAIN_FEED_DB=/data/feed
|
||||
|
||||
DCHAIN_REGISTER_RELAY=true
|
||||
DCHAIN_RELAY_FEE=1000
|
||||
|
||||
DCHAIN_FEED_DISK_LIMIT_MB=4096
|
||||
DCHAIN_CHAIN_DISK_LIMIT_MB=20480
|
||||
|
||||
DCHAIN_UPDATE_SOURCE_URL=https://git.vsecoder.vodka/api/v1/repos/vsecoder/dchain/releases/latest
|
||||
EOF
|
||||
|
||||
# ── 9. Compose: прямой проброс 4001+8080 наружу, Caddy снести навсегда ─
|
||||
log "patching docker-compose.yml: direct ports, caddy removed"
|
||||
python3 - <<PY
|
||||
import re, pathlib
|
||||
p = pathlib.Path('docker-compose.yml')
|
||||
src = p.read_text()
|
||||
|
||||
# 9a. Вырезать весь сервис caddy (от " caddy:" до следующего сервиса
|
||||
# того же уровня или конца блока services).
|
||||
src = re.sub(
|
||||
r'(?ms)^ caddy:\n(?:(?: .*\n)|\n)+?(?=^ [A-Za-z_-]+:|\Z)',
|
||||
'', src,
|
||||
)
|
||||
|
||||
# 9b. Убрать зависимости от caddy, если где-то остались.
|
||||
src = re.sub(r'(?m)^\s*-\s*caddy\s*$\n', '', src)
|
||||
src = re.sub(r'(?m)^\s*depends_on:\s*\n(\s*-\s*caddy\s*\n)+', '', src)
|
||||
|
||||
# 9c. Заставить node светить наружу 4001 (libp2p) и 8080 (HTTP).
|
||||
# Ищем первый ports: в сервисе node; если его нет — инжектим.
|
||||
m = re.search(r'(?ms)^ node:\n(.*?)(?=^ [A-Za-z_-]+:|\Z)', src)
|
||||
if not m:
|
||||
raise SystemExit('no `node:` service in compose')
|
||||
node_block = m.group(1)
|
||||
|
||||
wanted = f' ports:\n - "$LOCAL_P2P_PORT:4001"\n - "$LOCAL_HTTP_PORT:8080"\n'
|
||||
|
||||
if re.search(r'(?m)^ ports:', node_block):
|
||||
# Заменить существующий блок ports целиком
|
||||
node_block_new = re.sub(
|
||||
r'(?ms)^ ports:\n(?: -.*\n)+',
|
||||
wanted, node_block,
|
||||
)
|
||||
else:
|
||||
# Добавить сразу после строки "container_name:" (или restart:)
|
||||
node_block_new = re.sub(
|
||||
r'(?m)^( (?:container_name|restart):.*\n)',
|
||||
r'\\1' + wanted, node_block, count=1,
|
||||
)
|
||||
if node_block_new == node_block: # fallback — в начало блока
|
||||
node_block_new = wanted + node_block
|
||||
|
||||
src = src[:m.start(1)] + node_block_new + src[m.end(1):]
|
||||
|
||||
# 9d. Убрать блок expose у node — не нужен, раз ports наружу.
|
||||
src = re.sub(
|
||||
r'(?ms)^ expose:\n(?: -.*\n)+', '', src,
|
||||
)
|
||||
|
||||
p.write_text(src)
|
||||
PY
|
||||
|
||||
# ── 10. Подъём ─────────────────────────────────────────────────────────
|
||||
log "docker compose up -d"
|
||||
sudo docker compose up -d --build
|
||||
|
||||
# ── 11. Sanity checks ──────────────────────────────────────────────────
|
||||
sleep 3
|
||||
log "sanity checks (дай ~30 сек чтобы healthcheck подхватился):"
|
||||
set +e
|
||||
echo "──── docker compose ps ────"
|
||||
sudo docker compose ps
|
||||
echo "──── docker logs dchain_node | tail ────"
|
||||
sudo docker logs --tail 30 dchain_node 2>&1
|
||||
echo "──── /api/netstats ────"
|
||||
curl -s "http://localhost:$LOCAL_HTTP_PORT/api/netstats" | jq '.'
|
||||
echo "──── seed height (для сверки) ────"
|
||||
curl -s "$SEED_HTTP/api/netstats" | jq '.total_blocks'
|
||||
|
||||
cat <<EOM
|
||||
|
||||
======================================================================
|
||||
✓ Готово. Нода публичная, без токена и без TLS.
|
||||
|
||||
API:
|
||||
http://$PUBLIC_IP:$LOCAL_HTTP_PORT/api/netstats
|
||||
http://$PUBLIC_IP:$LOCAL_HTTP_PORT/swagger
|
||||
|
||||
Следи за sync'ом:
|
||||
sudo docker logs -f dchain_node | grep -E 'applied|height|peer'
|
||||
|
||||
Повторный полный сброс:
|
||||
./rejoin.sh
|
||||
|
||||
Открой в firewall/security-group на этом VPS:
|
||||
- $LOCAL_P2P_PORT/tcp (libp2p)
|
||||
- $LOCAL_HTTP_PORT/tcp (HTTP API)
|
||||
======================================================================
|
||||
EOM
|
||||
Reference in New Issue
Block a user