Своя нода: выбор транспортов канала и автообновление ноды (#125)

* Node wizard: choose the channel's transports; self-updating nodes

A new channel is no longer Yandex-only: it takes any mix of a Yandex
document, a Mail.ru public document and cups.online rooms, with direct
always as the backup. The app creates the cups.online rooms
(cupsonline.CreateRoomList) so the node starts with them in node.conf and
keeps the same rooms, and link, across restarts. provision.ShareLink
builds the link with the priorities and encryption context node-install.sh
writes; TestNodeConfMatchesShareLink runs the script's write_node_conf and
checks the core derives the same.

node-install.sh update, run every 6 hours by the optional
openflux-node-update.timer, moves the server to the newest node-v* release
of p1neappleXpress/OpenFlux (or repo= in /etc/openflux-node/update.conf):
the core must match both the release's node-install.sh at its tag and its
SHA256SUMS. If a channel does not stay up on it the previous core comes
back and that release is skipped. An older app's pinned script no longer
downgrades a server the updater has moved on.

TestInstallOnVDS covers the install with every transport, the updater,
an update, a rollback from a crashing core and the cleanup.

* provision: pin the node-install.sh with transport choice and the updater

* node-install.sh: keep only cores it installed; autoupdate on|off for existing nodes

A core some other installer left behind, such as an old fork's
node-v1.4.0, has a version number that says nothing about this
repository's releases. The no-downgrade rule and the updater now trust
only the versions this script or the updater installed (bin/.managed), so
such a server is moved to the pinned core by the wizard and to the newest
release by the updater instead of being stuck on it.

node-install.sh autoupdate on|off turns the updater on for channels
installed before the wizard offered it.

* provision: pin node-install.sh 1f7bd2a

* cupsonline: say why new rooms were refused

The wizard showed "cups: транспорт остановлен" when cups.online turned
the address away with 403 until its deadline. createRooms now keeps the
last error, and CreateRoomList names a 403/429 refusal for what it is.

* node-install.sh: remember which repository a core came from

A core is now the release of one repository: .managed lists "repo tag",
the plan says where the core comes from, and a script following another
repository (a fork's, say) moves the server to that repository's core
instead of keeping a release whose number only happens to be higher. The
updater's script copy is replaced by a script from another repository in
the same way. CORE_BASE now follows RELEASE_REPO.

* provision: pin node-install.sh 122ab88

* provision: pin node-install.sh fe9dc8b

* mobile: the upstream link test on the transports-JSON NodeShareLink

* mobile: OfferExitCookies hands a sign-in to the exit over the Session
This commit is contained in:
Meepo
2026-09-29 20:46:03 +03:00
committed by GitHub
parent a863877d62
commit 647b856b03
20 changed files with 1318 additions and 112 deletions
+16
View File
@@ -3,6 +3,22 @@
All notable changes to the OpenFlux core. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- The node wizard (`--node-wizard`, `mobile.Node*`) lets a new channel use
any mix of a Yandex document, a Mail.ru public document and cups.online
rooms besides direct (`provision.ChannelTransport`); the rooms are created
by the app (`cupsonline.CreateRoomList`) so the node keeps them, and its
link, across restarts. `provision.ShareLink` builds the link from the same
priorities and encryption context `node-install.sh` writes to node.conf.
- `node-install.sh update` and the optional `openflux-node-update.timer`:
the node moves itself to the newest `node-v*` release, verified against
that release's `node-install.sh` and `SHA256SUMS`, and rolls back if a
channel does not stay up. An app with an older pinned script no longer
downgrades a server the updater has moved on.
## [0.2.0] - 2026-09-28
Every client now behaves alike: peers of different builds and modes find
+9 -1
View File
@@ -177,7 +177,15 @@ RSTs generated locally by the exit-node kernel.
protocol (one object per line) for a desktop app to provision a new exit
node over SSH non-interactively: it drives `provision/` to connect, has the
VDS download and verify a pinned, hash-checked install script, and returns
the finished node's `openflux://` link. Secrets (SSH/sudo passwords, the
the finished node's `openflux://` link. A channel carries any mix of a
Yandex document, a Mail.ru public document and cups.online rooms (the
wizard creates them), with direct always as the backup. Optionally
`openflux-node-update.timer` keeps the server's core on the newest
`node-v*` release: every 6 hours it checks GitHub, verifies the core
against the release's own `node-install.sh` and `SHA256SUMS`, restarts
the channels and rolls back (and skips that release) if one does not stay
up. `repo=owner/name` in `/etc/openflux-node/update.conf` points it at
another repository's releases. Secrets (SSH/sudo passwords, the
private key, the channel key) only ever travel on stdin, never on the
command line or in a log.
- **Legacy codec** - `--codec=legacy` reverts to the old per-packet LZ4 codec
+9 -1
View File
@@ -184,7 +184,15 @@ RST, которые локально генерирует ядро выходн
чтобы развернуть новую выходную ноду по SSH неинтерактивно: он использует
`provision/`, чтобы подключиться, заставить VDS скачать и проверить
установочный скрипт с закреплённым хешем, и вернуть готовую ссылку
`openflux://` для новой ноды. Секреты (пароли SSH/sudo, приватный ключ,
`openflux://` для новой ноды. Канал может идти через любое сочетание
документа Яндекса, публичного документа Mail.ru и комнат cups.online
(мастер создаёт их сам), direct всегда остаётся резервом. По желанию
`openflux-node-update.timer` держит ядро сервера на последнем релизе
`node-v*`: раз в 6 часов проверяет GitHub, сверяет ядро с `node-install.sh`
и `SHA256SUMS` самого релиза, перезапускает каналы и откатывается (и
больше не ставит этот релиз), если канал не поднялся. Строка
`repo=owner/name` в `/etc/openflux-node/update.conf` переключает его на
релизы другого репозитория. Секреты (пароли SSH/sudo, приватный ключ,
ключ канала) передаются только через stdin, никогда через командную строку
или в лог.
- **Legacy-кодек** - `--codec=legacy` возвращает старый per-packet LZ4-кодек
+394 -43
View File
@@ -4,17 +4,21 @@
# Installs one OpenFlux exit channel on a Linux VDS. The "Своя нода" wizard
# of the OpenFlux apps downloads this file by a pinned commit, checks
# its SHA-256 and runs it over SSH. Every channel is independent: its own
# document, key, port and systemd instance (openflux-node@<channel>).
# Nothing outside the paths below is touched, so existing services (other
# OpenFlux installs, Docker, VPNs) keep running.
# transports (a Yandex document, a Mail.ru document, cups.online rooms, and
# always direct as the backup), key, port and systemd instance
# (openflux-node@<channel>). Nothing outside the paths below is touched, so
# existing services (other OpenFlux installs, Docker, VPNs) keep running.
#
# /opt/openflux-node/bin/ core binaries (from GitHub Releases)
# /opt/openflux-node/node-install.sh this script, for the updater
# /etc/openflux-node/<channel>/ node.conf, encryption-key (0640), port,
# firewall; the directory is 0751 so a
# plain user's plan sees the channel and
# its port but not its secrets
# /var/lib/openflux-node/<channel>/ cookie store (systemd StateDirectory)
# /etc/systemd/system/openflux-node@.service
# /etc/systemd/system/openflux-node-update.{service,timer}
# the core updater, when enabled
#
# Usage: node-install.sh probe
# node-install.sh plan|status (config on stdin)
@@ -23,8 +27,14 @@
# channel to this core)
# node-install.sh set-cookies CONFIG_FILE (run as root: replace a
# channel's Yandex login)
# The config is "key=value" lines: channel, url, key, port, and optionally
# cookies: the channel's Yandex sign-in as the core's cookie store JSON,
# node-install.sh update (run as root, by the timer:
# move every channel to the
# newest node-v* release)
# node-install.sh autoupdate on|off (run as root: the updater)
# The config is "key=value" lines: channel, key, port, the transports
# (vyandex= or its old name url=: a Yandex document; mailru=: a Mail.ru
# public document; cupsonline=: the packed room list), autoupdate=yes|no,
# and optionally cookies: the channel's Yandex sign-in as the core's cookie store JSON,
# base64-encoded. It goes to /var/lib/openflux-node/<channel>/cookies.json
# (0600, owned by the node user) and is never printed. apply and remove
# take it from a 0600 temp file, which they delete after reading, so that
@@ -37,15 +47,26 @@ set -u
umask 077
CORE_VERSION="node-v1.1.0"
CORE_BASE="https://github.com/p1neappleXpress/OpenFlux/releases/download/$CORE_VERSION"
SHA_amd64="9ec36c073749c1d02ca163516ba6fc257cc624a69833fb108de65ba4400e8f41"
SHA_arm64="b6d74ae230d9f4711cc4e03ba19d9eb7b4e3987ae6eeb45f86257743da9623ed"
SHA_arm="77c5afa26566db77b465e26bc0bdcde55e9f2babb08b386953a3d2f769667cf8"
# The repository this script and its core come from: the core is one of its
# node-v* releases, and the updater follows them (UPDATE_CONF may override
# that with a "repo=owner/name" line).
RELEASE_REPO="p1neappleXpress/OpenFlux"
GITHUB_API="https://api.github.com"
GITHUB_RAW="https://raw.githubusercontent.com"
GITHUB_WEB="https://github.com"
CORE_BASE="$GITHUB_WEB/$RELEASE_REPO/releases/download/$CORE_VERSION"
BIN_DIR="/opt/openflux-node/bin"
CONF_ROOT="/etc/openflux-node"
STATE_ROOT="/var/lib/openflux-node"
UNIT_FILE="/etc/systemd/system/openflux-node@.service"
UPDATE_SERVICE="/etc/systemd/system/openflux-node-update.service"
UPDATE_TIMER="/etc/systemd/system/openflux-node-update.timer"
UPDATE_CONF="$CONF_ROOT/update.conf"
SELF_COPY="/opt/openflux-node/node-install.sh"
NODE_USER="openflux-node"
MARKER="# Managed by OpenFlux node-install.sh"
@@ -134,6 +155,62 @@ list_channels() {
done
}
# version_gt A B: whether release tag A (node-vX.Y.Z) is newer than B. An
# empty or malformed B counts as older than anything.
version_gt() {
printf '%s\n%s\n' "${1#node-v}" "${2#node-v}" | awk -F. '
NR == 1 { for (i = 1; i <= 3; i++) a[i] = $i + 0; ok = ($0 ~ /^[0-9]+\.[0-9]+\.[0-9]+$/) }
NR == 2 { for (i = 1; i <= 3; i++) b[i] = $i + 0 }
END {
if (!ok) exit 1
for (i = 1; i <= 3; i++) { if (a[i] > b[i]) exit 0; if (a[i] < b[i]) exit 1 }
exit 1
}'
}
# installed_core: the release BIN_DIR/openflux points at, "" if none.
installed_core() {
target=$(readlink "$BIN_DIR/openflux" 2>/dev/null) || return 0
case "$target" in
openflux-node-v*) [ -x "$BIN_DIR/$target" ] && printf '%s' "${target#openflux-}" ;;
esac
}
# managed_core REPO: installed_core, if this script or the updater put it
# there from REPO's releases (BIN_DIR/.managed lists "repo tag" lines).
# A core from anywhere else counts as none: another repository's release
# numbers, like an old fork's node-v1.4.0, say nothing about REPO's.
managed_core() {
cur=$(installed_core)
[ -n "$cur" ] && grep -qsxF "$1 $cur" "$BIN_DIR/.managed" && printf '%s' "$cur"
return 0
}
# mark_managed REPO TAG: BIN_DIR/openflux-TAG is REPO's release now.
mark_managed() {
{ grep -sv " $2\$" "$BIN_DIR/.managed"; printf '%s %s\n' "$1" "$2"; } > "$BIN_DIR/.managed.new" \
&& mv -f "$BIN_DIR/.managed.new" "$BIN_DIR/.managed"
# A plain user's plan reads it too.
chmod 0644 "$BIN_DIR/.managed"
}
# core_to_use: this script's core, or the installed one when the updater has
# already moved the server to a newer release of the same repository (an
# older app must not downgrade every channel).
core_to_use() {
cur=$(managed_core "$RELEASE_REPO")
if [ -n "$cur" ] && version_gt "$cur" "$CORE_VERSION"; then printf '%s' "$cur"; else printf '%s' "$CORE_VERSION"; fi
}
autoupdate_on() {
[ -f "$UPDATE_TIMER" ] && systemctl is-enabled --quiet openflux-node-update.timer 2>/dev/null
}
release_repo() {
repo=$(sed -n 's/^repo=\([A-Za-z0-9_.-]*\/[A-Za-z0-9_.-]*\)$/\1/p' "$UPDATE_CONF" 2>/dev/null | tail -n 1)
printf '%s' "${repo:-$RELEASE_REPO}"
}
port_busy() { # PORT
if have ss; then
[ -n "$(ss -Hltn "sport = :$1" 2>/dev/null)" ]
@@ -162,7 +239,7 @@ pick_port() {
# ---- input ------------------------------------------------------------------
CHANNEL=""; URL=""; KEY=""; PORT=""; COOKIES=""
CHANNEL=""; URL=""; MAILRU=""; CUPS=""; KEY=""; PORT=""; COOKIES=""; AUTOUPDATE=""
# read_config [FILE]: reads stdin, or FILE and then deletes it.
read_config() {
@@ -176,9 +253,13 @@ read_config() {
case "$line" in
channel=*) CHANNEL=${line#channel=} ;;
url=*) URL=${line#url=} ;;
vyandex=*) URL=${line#vyandex=} ;;
mailru=*) MAILRU=${line#mailru=} ;;
cupsonline=*) CUPS=${line#cupsonline=} ;;
key=*) KEY=${line#key=} ;;
port=*) PORT=${line#port=} ;;
cookies=*) COOKIES=${line#cookies=} ;;
autoupdate=*) AUTOUPDATE=${line#autoupdate=} ;;
"") ;;
*) fail input "неизвестная строка конфигурации" ;;
esac
@@ -190,10 +271,40 @@ valid_key() { printf '%s' "$1" | grep -Eq '^[0-9a-f]{64}$'; }
valid_url() {
printf '%s' "$1" | grep -Eq '^https://(docs|disk)\.yandex\.(ru|com|by|kz|uz)/edit/d/[A-Za-z0-9_-]{16,200}$'
}
valid_mailru() {
printf '%s' "$1" | grep -Eq '^https://cloud\.mail\.ru/public/[A-Za-z0-9_-]{2,64}/[A-Za-z0-9_-]{2,128}$'
}
# The cups.online room list: base64url of a JSON list of room ids.
valid_rooms() { [ ${#1} -ge 8 ] && [ ${#1} -le 4096 ] && printf '%s' "$1" | grep -Eq '^[A-Za-z0-9_-]+$'; }
valid_port() {
printf '%s' "$1" | grep -Eq '^[0-9]{4,5}$' && [ "$1" -ge 1024 ] && [ "$1" -le 65535 ]
}
# check_transports: validates the chosen transports (plan and apply).
check_transports() {
[ -z "$URL" ] || valid_url "$URL" || fail input "адрес документа должен быть вида https://docs.yandex.ru/edit/d/..."
[ -z "$MAILRU" ] || valid_mailru "$MAILRU" || fail input "ссылка Mail.ru должна быть вида https://cloud.mail.ru/public/..."
[ -z "$CUPS" ] || valid_rooms "$CUPS" || fail input "неверный список комнат cups.online"
[ -z "$COOKIES" ] || [ -n "$URL" ] || fail input "вход в Яндекс нужен только каналу с документом Яндекса"
case "$AUTOUPDATE" in ""|yes|no) ;; *) fail input "autoupdate: yes или no" ;; esac
}
# transport_names: the channel's transports for people, primary first.
transport_names() {
names=""
[ -n "$URL" ] && names="Яндекс Документ"
[ -n "$MAILRU" ] && names="${names:+$names, }Mail.ru Документ"
[ -n "$CUPS" ] && names="${names:+$names, }cups.online"
printf '%s' "$names"
}
# session_context: the encryption context both sides derive, as the core's
# pickSessionContext does: the highest-priority transport's URL, cups.online
# aside. "" leaves node.conf without a URL line (the core's "http://#").
session_context() {
if [ -n "$URL" ]; then printf '%s' "$URL"; elif [ -n "$MAILRU" ]; then printf '%s' "$MAILRU"; fi
}
# write_cookies: decodes COOKIES into the channel's cookie store, which the
# node loads at start. Needs the node user to exist.
write_cookies() {
@@ -227,16 +338,19 @@ cmd_probe() {
[ -d /run/systemd/system ] && have systemctl && systemd=true
os=""
[ -r /etc/os-release ] && os=$(. /etc/os-release && printf '%s %s' "${ID:-linux}" "${VERSION_ID:-}")
autoupdate=false
autoupdate_on && autoupdate=true
# shellcheck disable=SC2046
printf '{"ok":true,"arch":"%s","os":"%s","systemd":%s,"sudo":"%s","downloader":"%s","firewall":"%s","core":"%s","channels":%s}\n' \
printf '{"ok":true,"arch":"%s","os":"%s","systemd":%s,"sudo":"%s","downloader":"%s","firewall":"%s","core":"%s","autoupdate":%s,"channels":%s}\n' \
"$arch" "$(json_escape "$os")" "$systemd" "$(sudo_mode)" "$(downloader)" "$(firewall_kind)" \
"$CORE_VERSION" "$(json_list $(list_channels))"
"$(core_to_use)" "$autoupdate" "$(json_list $(list_channels))"
}
# plan: read-only. Tells the app exactly what apply will change.
cmd_plan() {
read_config
check_channel
check_transports
arch=$(detect_arch)
[ -n "$arch" ] || fail plan "архитектура $(uname -m) не поддерживается"
[ -d /run/systemd/system ] && have systemctl || fail plan "на сервере нет systemd"
@@ -253,25 +367,43 @@ cmd_plan() {
fail plan "$UNIT_FILE создан не мастером OpenFlux, не трогаю его"
fi
core=$(core_to_use)
set --
id "$NODE_USER" >/dev/null 2>&1 || set -- "$@" "Создать системного пользователя $NODE_USER (без входа и домашней папки)"
if [ -x "$BIN_DIR/openflux-$CORE_VERSION" ]; then
set -- "$@" "Использовать уже установленное ядро OpenFlux $CORE_VERSION"
# The installed file counts only if it is this release of RELEASE_REPO
# (another repository may have a release with the same number).
if [ "$core" != "$CORE_VERSION" ] || { [ -x "$BIN_DIR/openflux-$core" ] && [ "$(sha256_of "$BIN_DIR/openflux-$core")" = "$(core_sha "$arch")" ]; }; then
set -- "$@" "Использовать уже установленное ядро OpenFlux $core ($RELEASE_REPO)"
else
set -- "$@" "Скачать ядро OpenFlux $CORE_VERSION (linux-$arch) с GitHub и сверить SHA-256 в $BIN_DIR"
set -- "$@" "Скачать ядро OpenFlux $core (linux-$arch) из релизов $RELEASE_REPO на GitHub и сверить SHA-256 в $BIN_DIR"
fi
set -- "$@" "Создать $CONF_ROOT/$CHANNEL: node.conf и ключ шифрования канала (права 0640)"
[ -n "$COOKIES" ] && set -- "$@" "Сохранить вход в Яндекс для этого канала в $STATE_ROOT/$CHANNEL/cookies.json (права 0600, только для ноды)"
[ -f "$UNIT_FILE" ] || set -- "$@" "Установить шаблон systemd $UNIT_FILE"
set -- "$@" "Запустить openflux-node@$CHANNEL: Яндекс Документ (основной) и direct на порту $PORT/tcp (резерв)"
names=$(transport_names)
if [ -n "$names" ]; then
set -- "$@" "Запустить openflux-node@$CHANNEL: $names и direct на порту $PORT/tcp (резерв)"
else
set -- "$@" "Запустить openflux-node@$CHANNEL: только direct на порту $PORT/tcp"
fi
case "$(firewall_kind)" in
ufw) set -- "$@" "Разрешить входящий $PORT/tcp в ufw" ;;
firewalld) set -- "$@" "Разрешить входящий $PORT/tcp в firewalld" ;;
esac
case "$AUTOUPDATE" in
yes)
if autoupdate_on; then
set -- "$@" "Автообновление ядра уже включено на сервере (openflux-node-update.timer)"
else
set -- "$@" "Включить автообновление ядра (openflux-node-update.timer): раз в 6 часов проверять релизы node-v* в $(release_repo) на GitHub, сверять SHA-256, перезапускать каналы и откатываться, если нода не поднялась"
fi ;;
no)
autoupdate_on && set -- "$@" "Выключить автообновление ядра на сервере (для всех каналов)" ;;
esac
# shellcheck disable=SC2046
printf '{"ok":true,"channel":"%s","port":%s,"arch":"%s","core":"%s","actions":%s,"untouched":%s}\n' \
"$CHANNEL" "$PORT" "$arch" "$CORE_VERSION" "$(json_list "$@")" "$(json_list $(list_channels))"
"$CHANNEL" "$PORT" "$arch" "$core" "$(json_list "$@")" "$(json_list $(list_channels))"
}
# Rollback state for apply: what this run created.
@@ -300,6 +432,8 @@ apply_fail() {
CORE_ERROR=""
install_core() {
mkdir -p "$BIN_DIR" && chmod 0755 /opt/openflux-node "$BIN_DIR"
# The updater already runs a newer release: keep it.
[ "$(core_to_use)" = "$CORE_VERSION" ] || return 0
core="$BIN_DIR/openflux-$CORE_VERSION"
want=$(core_sha "$1")
if [ ! -x "$core" ] || [ "$(sha256_of "$core")" != "$want" ]; then
@@ -318,6 +452,7 @@ install_core() {
CREATED_BIN=1
fi
ln -sfn "openflux-$CORE_VERSION" "$BIN_DIR/openflux"
mark_managed "$RELEASE_REPO" "$CORE_VERSION"
}
write_unit() {
@@ -352,14 +487,106 @@ EOF
chmod 0644 "$UNIT_FILE"
}
# script_core FILE: the CORE_VERSION a copy of this script pins.
script_core() { sed -n 's/^CORE_VERSION="\(node-v[0-9.]*\)"$/\1/p' "$1" 2>/dev/null | head -n 1; }
# script_repo FILE: the RELEASE_REPO a copy of this script follows.
script_repo() { sed -n 's/^RELEASE_REPO="\([^"]*\)"$/\1/p' "$1" 2>/dev/null | head -n 1; }
# install_self FILE: makes FILE the script the updater runs, unless the copy
# there follows the same repository and already pins a newer core (the
# updater put a newer release's script). A script from another repository
# always replaces it: the server now runs that repository's core.
install_self() {
if [ -f "$SELF_COPY" ] && [ "$(script_repo "$SELF_COPY")" = "$(script_repo "$1")" ] &&
version_gt "$(script_core "$SELF_COPY")" "$(script_core "$1")"; then
return 0
fi
mkdir -p /opt/openflux-node && chmod 0755 /opt/openflux-node || return 1
tmp=$(mktemp /opt/openflux-node/.node-install.XXXXXX) || return 1
if cat "$1" > "$tmp" && chmod 0755 "$tmp" && mv -f "$tmp" "$SELF_COPY"; then return 0; fi
rm -f "$tmp"
return 1
}
# enable_updater: openflux-node-update.timer runs "update" from the copy of
# this script: shortly after boot or install, then every 6 hours.
enable_updater() {
install_self "$0" || return 1
cat > "$UPDATE_SERVICE" <<EOF
$MARKER
[Unit]
Description=Update the OpenFlux node core to the newest node-v* release
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/bin/sh $SELF_COPY update
EOF
cat > "$UPDATE_TIMER" <<EOF
$MARKER
[Unit]
Description=Check for a newer OpenFlux node core
[Timer]
OnBootSec=15min
OnUnitActiveSec=6h
RandomizedDelaySec=30min
[Install]
WantedBy=timers.target
EOF
chmod 0644 "$UPDATE_SERVICE" "$UPDATE_TIMER"
systemctl daemon-reload >/dev/null 2>&1 && systemctl enable --now openflux-node-update.timer >/dev/null 2>&1
}
disable_updater() {
systemctl disable --now openflux-node-update.timer >/dev/null 2>&1
for f in "$UPDATE_TIMER" "$UPDATE_SERVICE"; do
[ -f "$f" ] && grep -qF "$MARKER" "$f" && rm -f "$f"
done
rm -f "$SELF_COPY"
systemctl daemon-reload >/dev/null 2>&1
}
# apply_autoupdate: AUTOUPDATE yes turns the updater on (and refreshes the
# script it runs), no turns it off; empty leaves it as it is.
apply_autoupdate() {
case "$AUTOUPDATE" in
yes) enable_updater ;;
no) autoupdate_on && disable_updater; return 0 ;;
*) autoupdate_on && install_self "$0"; return 0 ;;
esac
}
# write_node_conf: the channel's node.conf on stdout. Priorities and the
# URL line (the encryption context) match what the app puts into the
# channel's link (provision.ShareLink).
write_node_conf() {
cat <<EOF
# OpenFlux node channel $CHANNEL, written by node-install.sh
Role = exit
Mode = l4
EncryptionKeyFile = $CONF_ROOT/$CHANNEL/encryption-key
CookieStore = $STATE_ROOT/$CHANNEL/cookies.json
EOF
context=$(session_context)
[ -n "$context" ] && printf 'URL = %s\n' "$context"
[ -n "$URL" ] && printf '\n[Transport vyandex]\nType = vyandex\nPriority = 100\nURL = %s\n' "$URL"
[ -n "$MAILRU" ] && printf '\n[Transport mailru]\nType = mailru\nPriority = 90\nURL = %s\n' "$MAILRU"
[ -n "$CUPS" ] && printf '\n[Transport cupsonline]\nType = cupsonline\nPriority = 70\nURL = %s\n' "$CUPS"
printf '\n[Transport direct]\nType = direct\nPriority = 50\nListen = 0.0.0.0:%s\n' "$PORT"
}
cmd_apply() {
[ "$(id -u)" = 0 ] || fail apply "нужны права root (sudo)"
read_config "$@"
check_channel
valid_key "$KEY" || fail input "ключ канала должен быть 64 hex-символа"
valid_url "$URL" || fail input "адрес документа должен быть вида https://docs.yandex.ru/edit/d/..."
check_transports
valid_port "$PORT" || fail input "не указан порт из плана"
[ -z "$COOKIES" ] || printf '%s' "$COOKIES" | grep -Eq '^[A-Za-z0-9+/]+={0,2}$' || fail input "cookies должны быть в base64"
[ -z "$COOKIES" ] || printf '%s' "$COOKIES" | grep -Eq '^[A-Za-z0-9+/]+={0,2}$' || fail input "cookies должны быть в base64"
arch=$(detect_arch)
[ -n "$arch" ] || fail apply "архитектура $(uname -m) не поддерживается"
[ -d "$CONF_ROOT/$CHANNEL" ] && fail apply "канал $CHANNEL уже существует на сервере"
@@ -389,26 +616,8 @@ cmd_apply() {
mkdir "$dir" || apply_fail config "не удалось создать $dir"
CREATED_CONF=1
printf '%s\n' "$KEY" > "$dir/encryption-key"
cat > "$dir/node.conf" <<EOF
# OpenFlux node channel $CHANNEL, written by node-install.sh
Role = exit
Mode = l4
EncryptionKeyFile = $dir/encryption-key
CookieStore = $STATE_ROOT/$CHANNEL/cookies.json
URL = $URL
[Transport vyandex]
Type = vyandex
Priority = 100
URL = $URL
[Transport direct]
Type = direct
Priority = 50
Listen = 0.0.0.0:$PORT
EOF
printf '%s
' "$PORT" > "$dir/port"
write_node_conf > "$dir/node.conf"
printf '%s\n' "$PORT" > "$dir/port"
chown -R "root:$NODE_USER" "$dir"
chmod 0751 "$dir"
chmod 0640 "$dir/encryption-key" "$dir/node.conf"
@@ -443,7 +652,11 @@ EOF
logs=$(journalctl -u "openflux-node@$CHANNEL" -n 8 -o cat --no-pager 2>/dev/null | tail -n 8)
apply_fail start "нода не запустилась: $logs"
fi
printf '{"ok":true,"channel":"%s","port":%s,"core":"%s"}\n' "$CHANNEL" "$PORT" "$CORE_VERSION"
# The channel runs; the updater is extra, and its failure only shows.
autoupdate=false
apply_autoupdate
autoupdate_on && autoupdate=true
printf '{"ok":true,"channel":"%s","port":%s,"core":"%s","autoupdate":%s}\n' "$CHANNEL" "$PORT" "$(core_to_use)" "$autoupdate"
}
cmd_remove() {
@@ -463,6 +676,7 @@ cmd_remove() {
rm -rf "${CONF_ROOT:?}/$CHANNEL" "${STATE_ROOT:?}/$CHANNEL"
if [ -z "$(list_channels)" ]; then
# The last channel is gone: remove everything this script installed.
disable_updater
rm -f "$UNIT_FILE"
systemctl daemon-reload >/dev/null 2>&1
rm -rf /opt/openflux-node "$CONF_ROOT" "$STATE_ROOT"
@@ -487,11 +701,130 @@ cmd_upgrade() {
fi
done
# Older cores nothing points at any more.
core=$(core_to_use)
for old in "$BIN_DIR"/openflux-node-v*; do
[ "$old" = "$BIN_DIR/openflux-$CORE_VERSION" ] || rm -f "$old"
[ "$old" = "$BIN_DIR/openflux-$core" ] || rm -f "$old"
done
printf '{"ok":true,"core":"%s","restarted":%s}
' "$CORE_VERSION" "$(json_list "$@")"
apply_autoupdate
printf '{"ok":true,"core":"%s","restarted":%s}\n' "$core" "$(json_list "$@")"
}
# latest_release FILE: the newest node-vX.Y.Z tag in a GitHub releases
# listing (JSON in FILE), prereleases left out.
latest_release() {
best=""
# Every release has one tag_name and one prerelease, in either order;
# the objects nested in it (author, assets) have neither.
for tag in $(tr ',{}' '\n\n\n' < "$1" | awk '
function pair() {
if (tag != "" && pre != "") {
if (pre == "false" && tag ~ /^node-v[0-9]+\.[0-9]+\.[0-9]+$/) print tag
tag = ""; pre = ""
}
}
/"tag_name"[ \t]*:/ { tag = $0; sub(/.*"tag_name"[ \t]*:[ \t]*"/, "", tag); sub(/".*/, "", tag); pair() }
/"prerelease"[ \t]*:[ \t]*false/ { pre = "false"; pair() }
/"prerelease"[ \t]*:[ \t]*true/ { pre = "true"; pair() }'); do
version_gt "$tag" "$best" && best=$tag
done
printf '%s' "$best"
}
# restart_channels: restarts every enabled or running channel and prints
# their names.
restart_channels() {
for ch in $(list_channels); do
if systemctl is-active --quiet "openflux-node@$ch" || systemctl is-enabled --quiet "openflux-node@$ch"; then
systemctl restart "openflux-node@$ch" >/dev/null 2>&1
printf '%s\n' "$ch"
fi
done
}
# channels_stay_up CHANNEL...: whether each channel is running and did not
# restart during 20 seconds (a crashing core restarts every 5).
channels_stay_up() {
sleep 3
pids=""
for ch in "$@"; do pids="$pids $ch=$(systemctl show -p MainPID --value "openflux-node@$ch" 2>/dev/null)"; done
sleep 20
for pair in $pids; do
ch=${pair%%=*}; pid=${pair#*=}
systemctl is-active --quiet "openflux-node@$ch" || return 1
[ -n "$pid" ] && [ "$pid" != 0 ] && [ "$pid" = "$(systemctl show -p MainPID --value "openflux-node@$ch" 2>/dev/null)" ] || return 1
done
}
# update: what openflux-node-update.timer runs. When RELEASE_REPO has a
# node-v* release newer than the installed core, moves every channel to it:
# the release's own node-install.sh (at its tag) pins the core's SHA-256,
# and the release's SHA256SUMS must say the same. If a channel does not stay
# up on the new core, the previous one comes back and that release is
# skipped from then on.
cmd_update() {
[ "$(id -u)" = 0 ] || fail update "нужны права root"
if [ -z "$(list_channels)" ]; then
printf '{"ok":true,"updated":false,"reason":"на сервере нет каналов"}\n'
return 0
fi
arch=$(detect_arch)
[ -n "$arch" ] || fail update "архитектура $(uname -m) не поддерживается"
repo=$(release_repo)
current=$(managed_core "$repo")
work=$(mktemp -d /tmp/openflux-node-update.XXXXXX) || fail update "не удалось создать временную папку"
trap 'rm -rf "$work"' EXIT
fetch "$GITHUB_API/repos/$repo/releases?per_page=30" "$work/releases.json" \
|| fail update "GitHub не ответил на список релизов $repo"
latest=$(latest_release "$work/releases.json")
[ -n "$latest" ] || fail update "в $repo нет релизов node-v*"
skip="$STATE_ROOT/update-skip"
if [ -n "$current" ] && ! version_gt "$latest" "$current"; then
printf '{"ok":true,"updated":false,"core":"%s"}\n' "$current"
return 0
fi
if grep -qsx "$latest" "$skip"; then
printf '{"ok":true,"updated":false,"core":"%s","skipped":"%s"}\n' "$current" "$latest"
return 0
fi
fetch "$GITHUB_RAW/$repo/$latest/deploy/node-install.sh" "$work/node-install.sh" \
|| fail update "не удалось скачать node-install.sh релиза $latest"
grep -qxF "$MARKER" "$work/node-install.sh" && [ "$(script_core "$work/node-install.sh")" = "$latest" ] \
|| fail update "node-install.sh в $latest не относится к этому релизу"
want=$(sed -n "s/^SHA_$arch=\"\([0-9a-f]\{64\}\)\"\$/\1/p" "$work/node-install.sh")
[ -n "$want" ] || fail update "node-install.sh релиза $latest не знает хеш ядра для $arch"
base="$GITHUB_WEB/$repo/releases/download/$latest"
fetch "$base/SHA256SUMS" "$work/SHA256SUMS" || fail update "не удалось скачать SHA256SUMS релиза $latest"
grep -qx "$want openflux-linux-$arch" "$work/SHA256SUMS" \
|| fail update "SHA256SUMS релиза $latest расходится с его node-install.sh"
fetch "$base/openflux-linux-$arch" "$work/core" || fail update "не удалось скачать ядро $latest"
[ "$(sha256_of "$work/core")" = "$want" ] || fail update "SHA-256 скачанного ядра $latest не совпал"
new="openflux-$latest"
mkdir -p "$BIN_DIR" && chmod 0755 /opt/openflux-node "$BIN_DIR"
cp "$work/core" "$BIN_DIR/.$new.new" && chmod 0755 "$BIN_DIR/.$new.new" && mv -f "$BIN_DIR/.$new.new" "$BIN_DIR/$new" \
|| fail update "не удалось записать ядро в $BIN_DIR"
prev=$(readlink "$BIN_DIR/openflux" 2>/dev/null)
ln -sfn "$new" "$BIN_DIR/openflux"
# shellcheck disable=SC2046
set -- $(restart_channels)
if [ $# -gt 0 ] && ! channels_stay_up "$@"; then
if [ -n "$prev" ] && [ -x "$BIN_DIR/$prev" ]; then
ln -sfn "$prev" "$BIN_DIR/openflux"
restart_channels >/dev/null
fi
rm -f "$BIN_DIR/$new"
printf '%s\n' "$latest" >> "$skip"
fail update "на ядре $latest каналы не поднялись, вернул ${prev#openflux-}; этот релиз больше не ставлю"
fi
mark_managed "$repo" "$latest"
install_self "$work/node-install.sh" || true
# Keep the previous core for a manual rollback, drop the older ones.
for old in "$BIN_DIR"/openflux-node-v*; do
case "${old##*/}" in "$new"|"$prev") ;; *) rm -f "$old" ;; esac
done
printf '{"ok":true,"updated":true,"core":"%s","previous":"%s","restarted":%s}\n' \
"$latest" "$(json_escape "${prev#openflux-}")" "$(json_list "$@")"
}
# set-cookies: replaces a channel's Yandex sign-in and restarts it.
@@ -503,8 +836,24 @@ cmd_set_cookies() {
[ -n "$COOKIES" ] || fail set-cookies "нет cookies"
write_cookies || fail set-cookies "не удалось сохранить вход в Яндекс на сервере"
systemctl restart "openflux-node@$CHANNEL" || fail set-cookies "не удалось перезапустить openflux-node@$CHANNEL"
printf '{"ok":true,"channel":"%s"}
' "$CHANNEL"
printf '{"ok":true,"channel":"%s"}\n' "$CHANNEL"
}
# autoupdate on|off: turns the core updater on or off for the whole server,
# e.g. on channels installed before the wizard offered it.
cmd_autoupdate() {
[ "$(id -u)" = 0 ] || fail autoupdate "нужны права root (sudo)"
case "${1:-}" in
on)
[ -n "$(list_channels)" ] || fail autoupdate "на сервере нет каналов OpenFlux"
AUTOUPDATE=yes ;;
off) AUTOUPDATE=no ;;
*) fail usage "usage: node-install.sh autoupdate on|off" ;;
esac
apply_autoupdate || fail autoupdate "не удалось включить openflux-node-update.timer"
state=false
autoupdate_on && state=true
printf '{"ok":true,"autoupdate":%s}\n' "$state"
}
cmd_status() {
@@ -523,5 +872,7 @@ case "${1:-}" in
status) cmd_status ;;
upgrade) cmd_upgrade ;;
set-cookies) shift; cmd_set_cookies "$@" ;;
*) fail usage "usage: node-install.sh probe|plan|apply|remove|status|upgrade|set-cookies" ;;
update) cmd_update ;;
autoupdate) shift; cmd_autoupdate "$@" ;;
*) fail usage "usage: node-install.sh probe|plan|apply|remove|status|upgrade|set-cookies|update|autoupdate" ;;
esac
+59
View File
@@ -0,0 +1,59 @@
package mobile
import (
"fmt"
"strings"
"sync"
"openflux/transport/manager"
)
// clientSession is the running client Session's manager and its
// transports' types by name, so the app can hand the exit a sign-in.
var clientSession struct {
mu sync.Mutex
m *manager.Manager
types map[string]string
}
func setClientSession(m *manager.Manager, types map[string]string) {
clientSession.mu.Lock()
clientSession.m, clientSession.types = m, types
clientSession.mu.Unlock()
}
// OfferExitCookies sends a sign-in (a Cookie header, "a=1; b=2") to the
// exit for every transport of the client Session whose type is in
// transportTypes (comma-separated, e.g. "vyandex,yandex,boards"). The exit
// applies and keeps it. Returns how many transports it went to, or an
// error when no Session is connected.
func OfferExitCookies(transportTypes, cookieHeader string) (int, error) {
jar := parseCookieHeader(cookieHeader)
if len(jar) == 0 {
return 0, fmt.Errorf("нет cookies")
}
wanted := map[string]bool{}
for _, t := range strings.Split(transportTypes, ",") {
if t = strings.TrimSpace(t); t != "" {
wanted[t] = true
}
}
clientSession.mu.Lock()
m, types := clientSession.m, clientSession.types
clientSession.mu.Unlock()
if m == nil || !m.IsConnected() {
return 0, fmt.Errorf("нет подключения к ноде")
}
sent := 0
for name, typ := range types {
if !wanted[typ] {
continue
}
if err := m.OfferCookies(name, jar); err != nil {
return sent, fmt.Errorf("%s: %w", name, err)
}
sent++
}
appendLog(fmt.Sprintf("[ANDROID] Вход передан ноде: %d транспорт(ов)", sent))
return sent, nil
}
+13
View File
@@ -0,0 +1,13 @@
package mobile
import "testing"
func TestOfferExitCookiesWithoutSession(t *testing.T) {
setClientSession(nil, nil)
if _, err := OfferExitCookies("vyandex", "Session_id=s"); err == nil {
t.Fatal("want an error without a connected Session")
}
if _, err := OfferExitCookies("vyandex", ""); err == nil {
t.Fatal("want an error without cookies")
}
}
+56 -15
View File
@@ -13,14 +13,13 @@ import (
"io"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"openflux/provision"
"openflux/share"
"openflux/transport"
"openflux/transport/cupsonline"
"openflux/transport/yandex"
"openflux/tunnel"
)
@@ -130,14 +129,33 @@ func NodeNewChannel() string {
return result(map[string]interface{}{"id": id, "key": key})
}
// nodeTransports decodes the channel's carriers besides direct:
// [{"type": "vyandex"|"mailru"|"cupsonline", "url": ...}], "" or [] for
// direct only.
func nodeTransports(transportsJSON string) ([]provision.ChannelTransport, error) {
var ts []provision.ChannelTransport
if strings.TrimSpace(transportsJSON) == "" {
return nil, nil
}
if err := json.Unmarshal([]byte(transportsJSON), &ts); err != nil {
return nil, errors.New("неверный список транспортов")
}
return provision.CheckTransports(ts)
}
// NodePlan asks the VDS what installing the channel would change: {"plan"}.
// withCookies: the Yandex sign-in goes to the node too.
func NodePlan(channel string, port int, withCookies bool) string {
// transportsJSON: the carriers (see nodeTransports); withCookies: the Yandex
// sign-in goes to the node too; autoUpdate: the server's core updater.
func NodePlan(channel string, port int, transportsJSON string, withCookies, autoUpdate bool) string {
conn, err := nodeConn()
if err != nil {
return failure(err, nil)
}
plan, err := conn.Plan(channel, port, withCookies)
ts, err := nodeTransports(transportsJSON)
if err != nil {
return failure(err, nil)
}
plan, err := conn.Plan(provision.Channel{ID: channel, Port: port, Transports: ts, AutoUpdate: autoUpdate}, withCookies)
if err != nil {
return failure(err, nil)
}
@@ -147,16 +165,20 @@ func NodePlan(channel string, port int, withCookies bool) string {
// NodeApply installs and starts the channel. sudoPassword is only used when
// the account needs one; a wrong one comes back with "sudo": true.
// cookieHeader is the Yandex sign-in for the node ("" for none): the node
// then opens the document as that account, which gets it past the checks
// Yandex shows a server's address.
func NodeApply(channel, documentURL, key string, port int, sudoPassword, cookieHeader string) string {
// then opens the Yandex document as that account, which gets it past the
// checks Yandex shows a server's address.
func NodeApply(channel, transportsJSON, key string, port int, autoUpdate bool, sudoPassword, cookieHeader string) string {
conn, err := nodeConn()
if err != nil {
return failure(err, nil)
}
ch := provision.Channel{ID: channel, URL: documentURL, Key: key, Port: port}
ts, err := nodeTransports(transportsJSON)
if err != nil {
return failure(err, nil)
}
ch := provision.Channel{ID: channel, Transports: ts, Key: key, Port: port, AutoUpdate: autoUpdate}
if cookieHeader != "" {
if ch.Cookies, _, err = provision.CookieStore(documentURL, cookieHeader); err != nil {
if ch.Cookies, err = provision.ChannelCookies(ts, cookieHeader); err != nil {
return failure(err, nil)
}
}
@@ -366,10 +388,29 @@ func fetchIP(ctx context.Context, trans transport.Transport) (string, error) {
return "", lastErr
}
// NodeCreateCupsRooms creates cups.online rooms for a new channel's
// config: {"rooms"}, the packed list. The node starts with them, so its
// link stays the same across restarts.
func NodeCreateCupsRooms() string {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
rooms, err := cupsonline.CreateRoomList(ctx)
if err != nil {
return failure(fmt.Errorf("не удалось создать комнаты cups.online: %v", err), nil)
}
appendLog("[NODE] Созданы комнаты cups.online для канала")
return result(map[string]interface{}{"rooms": rooms})
}
// NodeShareLink returns the openflux:// link of a new channel: the Session
// profile a client needs (Yandex document first, direct to host:port as the
// backup). The app saves it through the same import path as a scanned QR,
// and shows it as a QR for another device. It carries the channel key.
func NodeShareLink(name, documentURL, key, host string, port int) (string, error) {
return share.MakeLink(share.NodeConfig(name, documentURL, key, net.JoinHostPort(host, strconv.Itoa(port))))
// profile a client needs (its carriers from transportsJSON, see
// nodeTransports, and direct to host:port as the backup). The app saves it
// through the same import path as a scanned QR, and shows it as a QR for
// another device. It carries the channel key.
func NodeShareLink(name, transportsJSON, key, host string, port int) (string, error) {
ts, err := nodeTransports(transportsJSON)
if err != nil {
return "", err
}
return provision.ShareLink(name, key, host, port, ts)
}
+1 -1
View File
@@ -45,7 +45,7 @@ func okResult(t *testing.T, out string) bool {
}
func TestNodeShareLinkRoundTrip(t *testing.T) {
link, err := NodeShareLink("Моя нода", "https://docs.yandex.ru/edit/d/abcdefghijklmnopqrstuv", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "203.0.113.5", 30123)
link, err := NodeShareLink("Моя нода", `[{"type":"vyandex","url":"https://docs.yandex.ru/edit/d/abcdefghijklmnopqrstuv"}]`, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "203.0.113.5", 30123)
if err != nil {
t.Fatal(err)
}
+1
View File
@@ -180,6 +180,7 @@ func buildSessionWith(specsJSON, secret string, exit bool, opt sessionOptions) (
proxy := &authProxy{demux: demux}
setAuthProxy(proxy)
attachSessionCaptcha(m, keys, proxy)
setClientSession(m, types)
appendLog("[ANDROID] Session: шифрование AES-256-GCM, согласование с нодой")
return demux, sess, nil
}
+4 -2
View File
@@ -121,8 +121,10 @@ func TestReadMakeShareLinkAnswerLikeShare(t *testing.T) {
t.Errorf("ReadShareLink(%q) = %s", in, got)
}
}
node, err := NodeShareLink("node", "https://docs.yandex.ru/edit/d/AbC", "a shared secret of 32 characters", "203.0.113.7", 9443)
if want := share.Make(share.NodeConfig("node", "https://docs.yandex.ru/edit/d/AbC", "a shared secret of 32 characters", "203.0.113.7:9443")).Link; err != nil || node != want {
// A Yandex-document channel's link is share.NodeConfig's.
doc := "https://docs.yandex.ru/edit/d/AbCdEfGhIjKlMnOpQrStUv"
node, err := NodeShareLink("node", `[{"type":"vyandex","url":"`+doc+`"}]`, "a shared secret of 32 characters", "203.0.113.7", 9443)
if want := share.Make(share.NodeConfig("node", doc, "a shared secret of 32 characters", "203.0.113.7:9443")).Link; err != nil || node != want {
t.Errorf("NodeShareLink %q (%v), want %q", node, err, want)
}
}
+33 -17
View File
@@ -21,13 +21,11 @@ import (
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
"time"
"openflux/provision"
"openflux/share"
"openflux/transport/cupsonline"
"openflux/transport/yandex"
)
@@ -53,6 +51,23 @@ type wizardParams struct {
SudoPassword string `json:"sudoPassword"`
Cookies string `json:"cookies"`
Name string `json:"name"`
// Transports are the channel's carriers besides direct. Without them,
// DocumentURL alone means a Yandex document (older apps).
Transports []provision.ChannelTransport `json:"transports"`
AutoUpdate bool `json:"autoUpdate"`
}
// transports is the channel's carriers from the request.
func (p wizardParams) transports() []provision.ChannelTransport {
if p.Transports == nil && p.DocumentURL != "" {
return []provision.ChannelTransport{{Type: "vyandex", URL: p.DocumentURL}}
}
return p.Transports
}
// channel is the channel the request describes, key and cookies aside.
func (p wizardParams) channel() provision.Channel {
return provision.Channel{ID: p.Channel, Transports: p.transports(), Port: p.ChannelPort, AutoUpdate: p.AutoUpdate}
}
// nodeWizard holds the SSH connection between calls.
@@ -62,6 +77,7 @@ type nodeWizard struct {
dial func(context.Context, provision.Target) (*provision.Conn, error)
checkDoc func(string) (yandex.VolgaDocument, error)
newScript func() provision.Script
newRooms func(context.Context) (string, error)
}
func newNodeWizard() *nodeWizard {
@@ -69,6 +85,7 @@ func newNodeWizard() *nodeWizard {
dial: provision.Dial,
checkDoc: func(u string) (yandex.VolgaDocument, error) { return yandex.CheckVolgaDocument(u, nil) },
newScript: provision.Pinned,
newRooms: cupsonline.CreateRoomList,
}
}
@@ -143,7 +160,7 @@ func (w *nodeWizard) handle(req wizardRequest) map[string]interface{} {
if err != nil {
return wizardFailure(err, nil)
}
plan, err := conn.Plan(p.Channel, p.ChannelPort, p.WithCookies)
plan, err := conn.Plan(p.channel(), p.WithCookies)
if err != nil {
return wizardFailure(err, nil)
}
@@ -153,9 +170,10 @@ func (w *nodeWizard) handle(req wizardRequest) map[string]interface{} {
if err != nil {
return wizardFailure(err, nil)
}
ch := provision.Channel{ID: p.Channel, URL: p.DocumentURL, Key: p.Key, Port: p.ChannelPort}
ch := p.channel()
ch.Key = p.Key
if p.Cookies != "" {
if ch.Cookies, _, err = provision.CookieStore(p.DocumentURL, p.Cookies); err != nil {
if ch.Cookies, err = provision.ChannelCookies(ch.Transports, p.Cookies); err != nil {
return wizardFailure(err, nil)
}
}
@@ -190,8 +208,16 @@ func (w *nodeWizard) handle(req wizardRequest) map[string]interface{} {
return wizardOK(map[string]interface{}{"signedIn": err == nil && signedIn})
case "checkDocument":
return w.checkDocument(p.DocumentURL)
case "createRooms":
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
rooms, err := w.newRooms(ctx)
if err != nil {
return wizardFailure(fmt.Errorf("не удалось создать комнаты cups.online: %v", err), nil)
}
return wizardOK(map[string]interface{}{"rooms": rooms})
case "shareLink":
link, err := nodeShareLink(p.Name, p.DocumentURL, p.Key, p.Host, p.ChannelPort)
link, err := provision.ShareLink(p.Name, p.Key, p.Host, p.ChannelPort, p.transports())
if err != nil {
return wizardFailure(err, nil)
}
@@ -279,13 +305,3 @@ func (w *nodeWizard) checkDocument(documentURL string) map[string]interface{} {
}
return wizardOK(map[string]interface{}{"editable": true})
}
// nodeShareLink is the openflux:// link of a new channel, as the wizard
// builds it: the Yandex document first, direct to host:port as the backup.
// It carries the channel key.
func nodeShareLink(name, documentURL, key, host string, port int) (string, error) {
if host == "" || port <= 0 || port > 65535 {
return "", errors.New("нет адреса или порта ноды")
}
return share.MakeLink(share.NodeConfig(name, documentURL, key, net.JoinHostPort(host, strconv.Itoa(port))))
}
+111
View File
@@ -8,11 +8,15 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"openflux/provision"
"openflux/share"
"openflux/transport"
"openflux/transport/yandex"
)
@@ -140,3 +144,110 @@ func TestNodeWizardShareLinkAndSignIn(t *testing.T) {
t.Fatalf("anonymous: %v", r)
}
}
func TestNodeWizardShareLinkWithChosenTransports(t *testing.T) {
w := newNodeWizard()
key := strings.Repeat("ab", 32)
mailru := "https://cloud.mail.ru/public/DEmN/ETbZW2MPY"
r := wizardCall(t, w, "shareLink", wizardParams{Name: "Нода", Key: key, Host: "203.0.113.5", ChannelPort: 8445,
Transports: []provision.ChannelTransport{{Type: "cupsonline", URL: "WyJyb29tLTEiXQ"}, {Type: "mailru", URL: mailru}}})
if r["ok"] != true {
t.Fatalf("shareLink: %v", r)
}
c, err := share.Decode(r["link"].(string))
if err != nil {
t.Fatal(err)
}
if c.Context != mailru || len(c.Transports) != 3 || c.Transports[0].Type != "mailru" ||
c.Transports[1].Type != "cupsonline" || c.Transports[2].Dial != "203.0.113.5:8445" {
t.Fatalf("link config: %+v", c)
}
r = wizardCall(t, w, "shareLink", wizardParams{Key: key, Host: "203.0.113.5", ChannelPort: 8445,
Transports: []provision.ChannelTransport{{Type: "boards", URL: "https://boards.yandex.ru/x"}}})
if r["ok"] != false {
t.Fatalf("a transport the wizard does not offer: %v", r)
}
}
func TestNodeWizardCreateRooms(t *testing.T) {
w := newNodeWizard()
w.newRooms = func(context.Context) (string, error) { return "WyJyb29tLTEiXQ", nil }
if r := wizardCall(t, w, "createRooms", nil); r["ok"] != true || r["rooms"] != "WyJyb29tLTEiXQ" {
t.Fatalf("createRooms: %v", r)
}
w.newRooms = func(context.Context) (string, error) { return "", errors.New("403") }
if r := wizardCall(t, w, "createRooms", nil); r["ok"] != false || !strings.Contains(r["error"].(string), "cups.online") {
t.Fatalf("createRooms failure: %v", r)
}
}
// The node.conf node-install.sh writes and the link the wizard builds must
// agree on everything the two sides derive keys and routes from: the
// encryption context and each carrier's address and priority.
func TestNodeConfMatchesShareLink(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("no sh")
}
body, err := os.ReadFile("deploy/node-install.sh")
if err != nil {
t.Fatal(err)
}
// The script's functions without its command dispatch.
funcs := string(body)[:strings.Index(string(body), "\ncase \"${1:-}\" in")]
volga := "https://disk.yandex.ru/edit/d/AbCdEfGhIjKlMnOpQrStUv"
mailru := "https://cloud.mail.ru/public/DEmN/ETbZW2MPY"
rooms := "WyJyb29tLTEiLCJyb29tLTIiXQ"
key := strings.Repeat("cd", 32)
for _, ts := range [][]provision.ChannelTransport{
{{Type: "vyandex", URL: volga}},
{{Type: "vyandex", URL: volga}, {Type: "mailru", URL: mailru}, {Type: "cupsonline", URL: rooms}},
{{Type: "mailru", URL: mailru}, {Type: "cupsonline", URL: rooms}},
{{Type: "cupsonline", URL: rooms}},
nil,
} {
vars := "CHANNEL=of-test PORT=20443 URL='" + provision.TransportURL(ts, "vyandex") + "' MAILRU='" +
provision.TransportURL(ts, "mailru") + "' CUPS='" + provision.TransportURL(ts, "cupsonline") + "'\n"
cmd := exec.Command(sh)
cmd.Stdin = strings.NewReader(funcs + "\n" + vars + "write_node_conf\n")
out, err := cmd.Output()
if err != nil {
t.Fatalf("%+v: %v", ts, err)
}
path := filepath.Join(t.TempDir(), "node.conf")
if err := os.WriteFile(path, out, 0o600); err != nil {
t.Fatal(err)
}
conf, err := parseConf(path)
if err != nil {
t.Fatalf("%+v: %v\n%s", ts, err, out)
}
var specs []transportSpec
for _, s := range conf.Transports {
specs = append(specs, transportSpec{Name: s.Name, Type: s.Values["Type"], Priority: confInt(s.Values["Priority"], 50), URL: s.Values["URL"]})
}
link, err := provision.ShareLink("n", key, "203.0.113.5", 20443, ts)
if err != nil {
t.Fatal(err)
}
c, _ := share.Decode(link)
sources := make([]transport.ContextSource, len(specs))
for i, s := range specs {
sources[i] = transport.ContextSource{Type: s.Type, URL: s.URL, Priority: s.Priority}
}
if got, _ := transport.KDFContexts("", conf.Interface["URL"], sources); got != c.Context {
t.Fatalf("%+v: node derives context %q, link says %q\n%s", ts, got, c.Context, out)
}
if len(specs) != len(c.Transports) {
t.Fatalf("%+v: node has %d carriers, link %d\n%s", ts, len(specs), len(c.Transports), out)
}
for i, s := range specs {
lt := c.Transports[i]
// share.Make drops the priority of a lone carrier: nothing to rank.
samePriority := s.Priority == lt.Priority || len(c.Transports) == 1 && lt.Priority == 0
if s.Type != lt.Type || !samePriority || (s.Type != "direct" && s.URL != lt.URL) {
t.Fatalf("%+v: carrier %d: node %+v, link %+v", ts, i, s, lt)
}
}
}
}
+201 -8
View File
@@ -2,13 +2,16 @@ package provision
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"sync"
"testing"
"time"
)
@@ -39,13 +42,30 @@ func TestInstallOnVDS(t *testing.T) {
if err != nil {
t.Fatal(err)
}
script := regexp.MustCompile(`(?m)^SHA_amd64=.*$`).
ReplaceAll(body, []byte(`SHA_amd64="`+os.Getenv("OPENFLUX_TEST_CORE_SHA")+`"`))
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write(script) })}
base := "http://" + ln.Addr().String()
// The script as a release at version pins core: GitHub is this server.
release := func(version, core string) []byte {
s := regexp.MustCompile(`(?m)^SHA_amd64=.*$`).ReplaceAll(body, []byte(`SHA_amd64="`+core+`"`))
s = regexp.MustCompile(`(?m)^CORE_VERSION=.*$`).ReplaceAll(s, []byte(`CORE_VERSION="`+version+`"`))
for _, v := range []string{"GITHUB_API", "GITHUB_RAW", "GITHUB_WEB"} {
s = regexp.MustCompile(`(?m)^`+v+`=.*$`).ReplaceAll(s, []byte(v+`="`+base+`"`))
}
return s
}
script := release("node-v1.0.1", os.Getenv("OPENFLUX_TEST_CORE_SHA"))
gh := newFakeReleases(t, os.Getenv("OPENFLUX_TEST_CORE_FILE"), release)
gh.add("node-v1.0.1", gh.good)
mux := http.NewServeMux()
mux.HandleFunc("/node-install.sh", func(w http.ResponseWriter, _ *http.Request) { w.Write(script) })
// The same script following another repository, like a fork's.
otherRepo := regexp.MustCompile(`(?m)^RELEASE_REPO=.*$`).ReplaceAll(script, []byte(`RELEASE_REPO="someone/OpenFlux"`))
mux.HandleFunc("/other-install.sh", func(w http.ResponseWriter, _ *http.Request) { w.Write(otherRepo) })
mux.Handle("/", gh)
srv := &http.Server{Handler: mux}
go srv.Serve(ln)
defer srv.Close()
allowPlainScriptURL = true
@@ -89,6 +109,11 @@ func TestInstallOnVDS(t *testing.T) {
if err != nil || p.Sudo != "root" || !p.Systemd {
t.Fatalf("root probe: %+v %v", p, err)
}
// A core some other installer left, named like a newer release of this
// repository (an old fork's node-v1.4.0), must not be kept.
if _, _, err := c.run("cd /opt/openflux-node/bin && cp openflux-node-v1.0.1 openflux-node-v1.4.0 && ln -sfn openflux-node-v1.4.0 openflux", nil); err != nil {
t.Fatal(err)
}
c.Close()
d := Target{Host: host, Port: port, User: user, Password: userPass, HostKey: root.HostKey}
@@ -104,17 +129,29 @@ func TestInstallOnVDS(t *testing.T) {
t.Fatalf("deploy probe: %+v %v", p, err)
}
id, _ := NewChannelID()
plan, err := c.Plan(id, 0, true)
volga := []ChannelTransport{
{Type: "vyandex", URL: os.Getenv("OPENFLUX_TEST_DOC")},
{Type: "mailru", URL: "https://cloud.mail.ru/public/AbCd/EfGhIjKlM"},
{Type: "cupsonline", URL: "WyJyb29tLTEiLCJyb29tLTIiXQ"},
}
plan, err := c.Plan(Channel{ID: id, Transports: volga, AutoUpdate: true}, true)
if err != nil {
t.Fatal(err)
}
t.Logf("plan: %+v", plan)
if plan.Core != "node-v1.0.1" {
t.Fatalf("plan keeps a core this script did not install: %s", plan.Core)
}
if actions := strings.Join(plan.Actions, "\n"); !strings.Contains(actions, "Яндекс Документ, Mail.ru Документ, cups.online и direct") ||
!strings.Contains(actions, "Включить автообновление") {
t.Fatalf("plan actions: %s", actions)
}
key, _ := NewKey()
cookies, signedIn, err := CookieStore(os.Getenv("OPENFLUX_TEST_DOC"), "Session_id=test-login-value; spravka=pass")
if err != nil || !signedIn {
t.Fatalf("CookieStore: %v %v", signedIn, err)
}
ch := Channel{ID: id, URL: os.Getenv("OPENFLUX_TEST_DOC"), Key: key, Port: plan.Port, Cookies: cookies}
ch := Channel{ID: id, Transports: volga, Key: key, Port: plan.Port, Cookies: cookies, AutoUpdate: true}
if err := c.Apply(ch, "wrong-password"); !errors.Is(err, ErrSudoPassword) {
t.Fatalf("wrong sudo password: got %v", err)
@@ -153,14 +190,14 @@ func TestInstallOnVDS(t *testing.T) {
if out, _, _ := c.run("sudo -S -p '' cat /var/lib/openflux-node/"+id+"/cookies.json", []byte(userPass+"\n")); !strings.Contains(string(out), "renewed-login") {
t.Fatalf("set-cookies did not replace the login: %q", out)
}
if _, err := c.Plan(id, 0, true); err == nil {
if _, err := c.Plan(Channel{ID: id, Transports: volga}, true); err == nil {
t.Fatal("planning an existing channel must fail")
}
again, err := c.Plan("other", plan.Port, false)
again, err := c.Plan(Channel{ID: "other", Port: plan.Port}, false)
if err == nil {
t.Fatalf("the channel's port must count as taken: %+v", again)
}
next, err := c.Plan("other", 0, false)
next, err := c.Plan(Channel{ID: "other"}, false)
if err != nil {
t.Fatal(err)
}
@@ -171,10 +208,166 @@ func TestInstallOnVDS(t *testing.T) {
if !found {
t.Fatalf("new channel missing from untouched: %+v", next.Untouched)
}
sudo := func(cmd string) string {
out, errb, _ := c.run("sudo -S -p '' "+cmd, []byte(userPass+"\n"))
return strings.TrimSpace(string(out) + string(errb))
}
conf := sudo("cat /etc/openflux-node/" + id + "/node.conf")
for _, want := range []string{
"URL = " + os.Getenv("OPENFLUX_TEST_DOC"), "[Transport vyandex]", "[Transport mailru]",
"URL = https://cloud.mail.ru/public/AbCd/EfGhIjKlM", "[Transport cupsonline]", "URL = WyJyb29tLTEiLCJyb29tLTIiXQ",
"Listen = 0.0.0.0:" + strconv.Itoa(plan.Port),
} {
if !strings.Contains(conf, want) {
t.Fatalf("node.conf lacks %q:\n%s", want, conf)
}
}
if out := sudo("systemctl is-enabled openflux-node-update.timer; test -x /opt/openflux-node/node-install.sh && echo copy"); out != "enabled\ncopy" {
t.Fatalf("updater not installed: %q", out)
}
if p, err := c.Probe(); err != nil || !p.AutoUpdate {
t.Fatalf("probe after install: %+v %v", p, err)
}
update := func() string { return sudo("sh /opt/openflux-node/node-install.sh update") }
core := func() string { return sudo("readlink /opt/openflux-node/bin/openflux") }
if out := update(); !strings.Contains(out, `"updated":false`) || core() != "openflux-node-v1.0.1" {
t.Fatalf("no newer release: %s, core %s", out, core())
}
gh.add("node-v1.1.0", gh.good)
gh.add("node-v9.0.0-rc1", gh.bad) // not a release tag the updater takes
gh.prerelease("node-v9.9.9", gh.bad)
if out := update(); !strings.Contains(out, `"updated":true`) || core() != "openflux-node-v1.1.0" {
t.Fatalf("update to node-v1.1.0: %s, core %s", out, core())
}
if out := sudo("systemctl is-active openflux-node@" + id); out != "active" {
t.Fatalf("channel after update: %s", out)
}
if out := sudo("grep '^CORE_VERSION=' /opt/openflux-node/node-install.sh"); out != `CORE_VERSION="node-v1.1.0"` {
t.Fatalf("the updater's script copy: %s", out)
}
gh.add("node-v1.2.0", gh.bad)
if out := update(); !strings.Contains(out, "не поднялись") || core() != "openflux-node-v1.1.0" {
t.Fatalf("a core that crashes must be rolled back: %s, core %s", out, core())
}
if out := sudo("systemctl is-active openflux-node@" + id); out != "active" {
t.Fatalf("channel after rollback: %s", out)
}
if out := update(); !strings.Contains(out, `"skipped":"node-v1.2.0"`) {
t.Fatalf("a rolled back release must not be tried again: %s", out)
}
if out := sudo("sh /opt/openflux-node/node-install.sh autoupdate off; systemctl is-enabled openflux-node-update.timer"); !strings.Contains(out, `"autoupdate":false`) {
t.Fatalf("autoupdate off: %s", out)
}
if out := sudo("sh " + c.script + " autoupdate on; systemctl is-enabled openflux-node-update.timer"); !strings.HasSuffix(out, "enabled") ||
!strings.Contains(out, `"autoupdate":true`) {
t.Fatalf("autoupdate on: %s", out)
}
// An app with an older pinned script must not take the server back.
older, err := c.Plan(Channel{ID: "other"}, false)
if err != nil || older.Core != "node-v1.1.0" {
t.Fatalf("plan after an update: %+v %v", older, err)
}
// A script from another repository does not keep this one's release,
// however new: the server moves to that repository's core.
pinned := c.script
if err := c.FetchScript(Script{URL: base + "/other-install.sh", SHA256: ScriptHash(otherRepo)}); err != nil {
t.Fatal(err)
}
switched, err := c.Plan(Channel{ID: "other"}, false)
if err != nil || switched.Core != "node-v1.0.1" || !strings.Contains(strings.Join(switched.Actions, "\n"), "someone/OpenFlux") {
t.Fatalf("plan from another repository: %+v %v", switched, err)
}
c.script = pinned
if err := c.Remove(id, userPass); err != nil {
t.Fatal(err)
}
if out, _, _ := c.run("test -e /etc/openflux-node/"+id+" && echo left", nil); strings.TrimSpace(string(out)) != "" {
t.Fatal("channel files left after remove")
}
if out, _, _ := c.run("test -e /etc/systemd/system/openflux-node-update.timer && echo left", nil); strings.TrimSpace(string(out)) != "" {
t.Fatal("updater left after the last channel was removed")
}
}
// fakeReleases is GitHub for the updater: the releases listing, each
// release's node-install.sh at its tag, its SHA256SUMS and core.
type fakeReleases struct {
t *testing.T
good, bad []byte
script func(version, core string) []byte
mu sync.Mutex
cores map[string][]byte
pre map[string]bool
order []string
}
func newFakeReleases(t *testing.T, coreFile string, script func(version, core string) []byte) *fakeReleases {
f := &fakeReleases{t: t, script: script, cores: map[string][]byte{}, pre: map[string]bool{}}
f.bad = []byte("#!/bin/sh\nexit 1\n")
if coreFile != "" {
b, err := os.ReadFile(coreFile)
if err != nil {
t.Fatal(err)
}
f.good = b
}
return f
}
func (f *fakeReleases) add(tag string, core []byte) {
f.mu.Lock()
defer f.mu.Unlock()
f.cores[tag] = core
f.order = append([]string{tag}, f.order...)
}
func (f *fakeReleases) prerelease(tag string, core []byte) {
f.add(tag, core)
f.mu.Lock()
f.pre[tag] = true
f.mu.Unlock()
}
func (f *fakeReleases) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
const repo = "/p1neappleXpress/OpenFlux/"
p := r.URL.Path
switch {
case p == "/repos"+repo+"releases":
var list []map[string]interface{}
for _, tag := range f.order {
list = append(list, map[string]interface{}{
"tag_name": tag, "draft": false, "prerelease": f.pre[tag],
"author": map[string]interface{}{"login": "x", "id": 1}, "assets": []interface{}{},
})
}
json.NewEncoder(w).Encode(list)
case strings.HasPrefix(p, repo+"releases/download/"):
tag, file, _ := strings.Cut(strings.TrimPrefix(p, repo+"releases/download/"), "/")
core, ok := f.cores[tag]
switch {
case !ok:
http.NotFound(w, r)
case file == "SHA256SUMS":
fmt.Fprintf(w, "%s openflux-linux-amd64\n", ScriptHash(core))
case file == "openflux-linux-amd64":
w.Write(core)
default:
http.NotFound(w, r)
}
case strings.HasSuffix(p, "/deploy/node-install.sh"):
tag := strings.TrimSuffix(strings.TrimPrefix(p, repo), "/deploy/node-install.sh")
core, ok := f.cores[tag]
if !ok {
http.NotFound(w, r)
return
}
w.Write(f.script(tag, ScriptHash(core)))
default:
http.NotFound(w, r)
}
}
+2 -2
View File
@@ -6,8 +6,8 @@ package provision
// changes, commit it, then point PinnedCommit at that commit.
const (
PinnedRepo = "p1neappleXpress/OpenFlux"
PinnedCommit = "21eca76e651ba02996df12c4077bf94815bc885a"
PinnedSHA256 = "39e44c449fd94ead02c3dabbe197d3682ab89fb8b99af27543c147f8d3c13067"
PinnedCommit = "fe9dc8b0fc4672339024765e38843c27b5834fb4"
PinnedSHA256 = "42f61bf3d92687fa500cf97af1edecc334d79373b0a3988359bde1555b652a5d"
)
// Pinned returns the script location for this build.
+50 -17
View File
@@ -75,13 +75,15 @@ var ErrSudoPassword = errors.New("sudo не принял пароль")
// Probe describes the VDS, as node-install.sh probe reports it.
type Probe struct {
Arch string `json:"arch"`
OS string `json:"os"`
Systemd bool `json:"systemd"`
Sudo string `json:"sudo"` // root, nopasswd, password, none
Downloader string `json:"downloader"`
Firewall string `json:"firewall"`
Core string `json:"core"`
Arch string `json:"arch"`
OS string `json:"os"`
Systemd bool `json:"systemd"`
Sudo string `json:"sudo"` // root, nopasswd, password, none
Downloader string `json:"downloader"`
Firewall string `json:"firewall"`
Core string `json:"core"`
// AutoUpdate: the server's core updater (openflux-node-update.timer) is on.
AutoUpdate bool `json:"autoupdate"`
Channels []string `json:"channels"`
}
@@ -97,13 +99,37 @@ type Plan struct {
// Channel is one channel's configuration.
type Channel struct {
ID string
URL string
Key string
ID string
// Transports are the carriers besides direct (see CheckTransports);
// none leaves the channel with direct only.
Transports []ChannelTransport
Key string
// Port is direct's port; 0 in a plan lets the script pick one.
Port int
// Cookies is the channel's Yandex sign-in for the node: the core's
// cookie store JSON, base64-encoded (see CookieStore). Empty: none.
Cookies string
// AutoUpdate turns the server's core updater on, or off: it is one
// timer for every channel on the server.
AutoUpdate bool
}
// config is the channel's part of node-install.sh's config, secrets aside.
func (ch Channel) config() (string, error) {
ts, err := CheckTransports(ch.Transports)
if err != nil {
return "", err
}
cfg := "channel=" + ch.ID + "\n" + transportLines(ts)
if ch.Port != 0 {
cfg += "port=" + strconv.Itoa(ch.Port) + "\n"
}
if ch.AutoUpdate {
cfg += "autoupdate=yes\n"
} else {
cfg += "autoupdate=no\n"
}
return cfg, nil
}
// Conn is an SSH connection to the VDS with the script downloaded.
@@ -272,12 +298,12 @@ func (c *Conn) Probe() (*Probe, error) {
return &p, nil
}
// Plan asks what apply would change. port 0 lets the script pick one;
// withCookies adds the Yandex sign-in step.
func (c *Conn) Plan(channel string, port int, withCookies bool) (*Plan, error) {
cfg := "channel=" + channel + "\n"
if port != 0 {
cfg += "port=" + strconv.Itoa(port) + "\n"
// Plan asks what apply would change: ch without its key, Port 0 lets the
// script pick one. withCookies adds the Yandex sign-in step.
func (c *Conn) Plan(ch Channel, withCookies bool) (*Plan, error) {
cfg, err := ch.config()
if err != nil {
return nil, err
}
if withCookies {
cfg += "cookies=yes\n"
@@ -292,7 +318,14 @@ func (c *Conn) Plan(channel string, port int, withCookies bool) (*Plan, error) {
// Apply installs and starts the channel. sudoPassword is used only when the
// account needs one.
func (c *Conn) Apply(ch Channel, sudoPassword string) error {
cfg := fmt.Sprintf("channel=%s\nurl=%s\nkey=%s\nport=%d\n", ch.ID, ch.URL, ch.Key, ch.Port)
if ch.Port == 0 {
return errors.New("не указан порт из плана")
}
cfg, err := ch.config()
if err != nil {
return err
}
cfg += "key=" + ch.Key + "\n"
if ch.Cookies != "" {
cfg += "cookies=" + ch.Cookies + "\n"
}
+151
View File
@@ -0,0 +1,151 @@
package provision
import (
"errors"
"fmt"
"net"
"regexp"
"sort"
"strconv"
"strings"
"openflux/share"
"openflux/transport"
)
// ChannelTransport is one of a channel's carriers besides direct, which
// every channel has as the backup.
type ChannelTransport struct {
// Type is vyandex (a Yandex document), mailru (a Mail.ru public
// document) or cupsonline (cups.online rooms).
Type string `json:"type"`
// URL is the document link, or cups.online's packed room list.
URL string `json:"url"`
}
// transportPriority is what node-install.sh's write_node_conf gives each
// carrier, and so what the channel's link must say too.
var transportPriority = map[string]int{"vyandex": 100, "mailru": 90, "cupsonline": 70}
const directPriority = 50
var (
volgaDocURL = regexp.MustCompile(`^https://(docs|disk)\.yandex\.(ru|com|by|kz|uz)/edit/d/[A-Za-z0-9_-]{16,200}$`)
mailruDocURL = regexp.MustCompile(`^https://cloud\.mail\.ru/public/[A-Za-z0-9_-]{2,64}/[A-Za-z0-9_-]{2,128}$`)
cupsRoomList = regexp.MustCompile(`^[A-Za-z0-9_-]{8,}$`)
)
// CheckTransports cleans the links (no query, fragment or trailing slash),
// checks each carrier the way node-install.sh does, and returns them in
// priority order. Each type may appear once; none at all leaves direct only.
func CheckTransports(ts []ChannelTransport) ([]ChannelTransport, error) {
out := make([]ChannelTransport, 0, len(ts))
seen := map[string]bool{}
for _, t := range ts {
t.Type = strings.TrimSpace(t.Type)
t.URL = strings.TrimSpace(t.URL)
if _, ok := transportPriority[t.Type]; !ok {
return nil, fmt.Errorf("транспорт %q нельзя выбрать для ноды", t.Type)
}
if seen[t.Type] {
return nil, fmt.Errorf("транспорт %s выбран дважды", t.Type)
}
seen[t.Type] = true
switch t.Type {
case "vyandex":
t.URL = cleanLink(t.URL)
if !volgaDocURL.MatchString(t.URL) {
return nil, errors.New("нужна ссылка на документ Яндекса вида https://docs.yandex.ru/edit/d/…")
}
case "mailru":
t.URL = cleanLink(t.URL)
if !mailruDocURL.MatchString(t.URL) {
return nil, errors.New("нужна публичная ссылка Mail.ru вида https://cloud.mail.ru/public/…/…")
}
case "cupsonline":
if !cupsRoomList.MatchString(t.URL) || len(t.URL) > 4096 {
return nil, errors.New("неверный список комнат cups.online")
}
}
out = append(out, t)
}
sort.SliceStable(out, func(i, j int) bool { return transportPriority[out[i].Type] > transportPriority[out[j].Type] })
return out, nil
}
func cleanLink(u string) string {
if i := strings.IndexAny(u, "?#"); i >= 0 {
u = u[:i]
}
return strings.TrimRight(u, "/")
}
// TransportURL returns the URL of the carrier of that type, "" if the
// channel has none.
func TransportURL(ts []ChannelTransport, typ string) string {
for _, t := range ts {
if t.Type == typ {
return t.URL
}
}
return ""
}
// SessionContext is the encryption context both sides of the channel
// derive, by the core's one rule (transport.KDFContexts): the
// highest-priority document, cups.online aside (its rooms are no
// document). node-install.sh's session_context picks the same.
func SessionContext(ts []ChannelTransport) string {
sources := make([]transport.ContextSource, 0, len(ts))
for _, t := range ts {
sources = append(sources, transport.ContextSource{Type: t.Type, URL: t.URL, Priority: transportPriority[t.Type]})
}
context, _ := transport.KDFContexts("", "", sources)
return context
}
// ShareLink is the openflux:// link of a new channel: its carriers in
// priority order and direct to host:port as the backup. It carries the
// channel key.
func ShareLink(name, key, host string, port int, ts []ChannelTransport) (string, error) {
if host == "" || port <= 0 || port > 65535 {
return "", errors.New("нет адреса или порта ноды")
}
ts, err := CheckTransports(ts)
if err != nil {
return "", err
}
c := share.Config{Name: name, Negotiate: true, Secret: key, Context: SessionContext(ts)}
for _, t := range ts {
c.Transports = append(c.Transports, share.Transport{Type: t.Type, URL: t.URL, Priority: transportPriority[t.Type]})
}
c.Transports = append(c.Transports, share.Transport{
Type: "direct", Dial: net.JoinHostPort(host, strconv.Itoa(port)), Priority: directPriority,
})
return share.MakeLink(c)
}
// transportLines is the carriers' part of node-install.sh's config.
func transportLines(ts []ChannelTransport) string {
var b strings.Builder
for _, t := range ts {
b.WriteString(t.Type + "=" + t.URL + "\n")
}
return b.String()
}
// ChannelCookies turns the app's Cookie header for the channel's Yandex
// document into what Channel.Cookies takes (see CookieStore), keyed by the
// document as the node's config names it.
func ChannelCookies(ts []ChannelTransport, header string) (string, error) {
ts, err := CheckTransports(ts)
if err != nil {
return "", err
}
doc := TransportURL(ts, "vyandex")
if doc == "" {
return "", errors.New("вход в Яндекс нужен только каналу с документом Яндекса")
}
cookies, _, err := CookieStore(doc, header)
return cookies, err
}
+131
View File
@@ -0,0 +1,131 @@
package provision
import (
"strings"
"testing"
"openflux/share"
)
const (
testVolga = "https://docs.yandex.ru/edit/d/AbCdEfGhIjKlMnOpQrStUv"
testMailru = "https://cloud.mail.ru/public/DEmN/ETbZW2MPY"
testRooms = "WyJyb29tLTEiLCJyb29tLTIiXQ"
)
func TestCheckTransportsCleansAndOrders(t *testing.T) {
got, err := CheckTransports([]ChannelTransport{
{Type: "cupsonline", URL: testRooms},
{Type: "mailru", URL: " " + testMailru + "/?weblink=x "},
{Type: "vyandex", URL: testVolga + "#section"},
})
if err != nil {
t.Fatal(err)
}
want := []ChannelTransport{{"vyandex", testVolga}, {"mailru", testMailru}, {"cupsonline", testRooms}}
if len(got) != len(want) {
t.Fatalf("got %+v", got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %+v, want %+v", got, want)
}
}
if got, err := CheckTransports(nil); err != nil || len(got) != 0 {
t.Fatalf("direct only: %v %v", got, err)
}
}
func TestCheckTransportsRefuses(t *testing.T) {
for name, ts := range map[string][]ChannelTransport{
"twice": {{"vyandex", testVolga}, {"vyandex", testVolga}},
"unknown": {{"boards", "https://boards.yandex.ru/whiteboard/?hash=1"}},
"direct": {{"direct", "1.2.3.4:5"}},
"max": {{"oneme", "token"}},
"volga link": {{"vyandex", "https://disk.yandex.ru/i/abc"}},
"mailru link": {{"mailru", "https://cloud.mail.ru/home/doc.docx"}},
"mailru other": {{"mailru", "https://evil.example/public/a/b"}},
"rooms": {{"cupsonline", "not base64!"}},
"no rooms": {{"cupsonline", ""}},
"newline": {{"mailru", testMailru + "\nkey=0"}},
} {
if _, err := CheckTransports(ts); err == nil {
t.Errorf("%s: accepted %+v", name, ts)
}
}
}
func TestSessionContextPicksTheTopDocument(t *testing.T) {
for _, c := range []struct {
ts []ChannelTransport
want string
}{
{[]ChannelTransport{{"vyandex", testVolga}, {"mailru", testMailru}}, testVolga},
{[]ChannelTransport{{"cupsonline", testRooms}, {"mailru", testMailru}}, testMailru},
{[]ChannelTransport{{"cupsonline", testRooms}}, "http://#"},
{nil, "http://#"},
} {
if got := SessionContext(c.ts); got != c.want {
t.Errorf("SessionContext(%+v) = %q, want %q", c.ts, got, c.want)
}
}
}
func TestShareLinkCarriesTheChosenTransports(t *testing.T) {
key := strings.Repeat("ab", 32)
link, err := ShareLink("Нода", key, "203.0.113.5", 8445, []ChannelTransport{{"cupsonline", testRooms}, {"mailru", testMailru}})
if err != nil {
t.Fatal(err)
}
c, err := share.Decode(link)
if err != nil {
t.Fatal(err)
}
if !c.Negotiate || c.Secret != key || c.Context != testMailru || c.Name != "Нода" {
t.Fatalf("link: %+v", c)
}
want := []share.Transport{
{Type: "mailru", URL: testMailru, Priority: 90},
{Type: "cupsonline", URL: testRooms, Priority: 70},
{Type: "direct", Dial: "203.0.113.5:8445", Priority: 50},
}
if len(c.Transports) != len(want) {
t.Fatalf("transports: %+v", c.Transports)
}
for i := range want {
if c.Transports[i] != want[i] {
t.Fatalf("transport %d: %+v, want %+v", i, c.Transports[i], want[i])
}
}
link, err = ShareLink("Только direct", key, "2001:db8::1", 8445, nil)
if err != nil {
t.Fatal(err)
}
if c, _ = share.Decode(link); len(c.Transports) != 1 || c.Transports[0].Dial != "[2001:db8::1]:8445" || c.Context != "http://#" {
t.Fatalf("direct only: %+v", c)
}
if _, err := ShareLink("x", key, "", 8445, nil); err == nil {
t.Fatal("no host must fail")
}
}
func TestChannelConfigLines(t *testing.T) {
ch := Channel{ID: "of-abc", Transports: []ChannelTransport{{"mailru", testMailru}, {"vyandex", testVolga}}, AutoUpdate: true}
cfg, err := ch.config()
if err != nil {
t.Fatal(err)
}
want := "channel=of-abc\nvyandex=" + testVolga + "\nmailru=" + testMailru + "\nautoupdate=yes\n"
if cfg != want {
t.Fatalf("config:\n%s\nwant:\n%s", cfg, want)
}
ch.Port, ch.AutoUpdate, ch.Transports = 20001, false, nil
if cfg, _ = ch.config(); cfg != "channel=of-abc\nport=20001\nautoupdate=no\n" {
t.Fatalf("direct only config:\n%s", cfg)
}
ch.Transports = []ChannelTransport{{"mailru", testMailru + "\nkey=" + strings.Repeat("0", 64)}}
if _, err := ch.config(); err == nil {
t.Fatal("a line break in a link must not reach the script")
}
}
+6 -2
View File
@@ -4,6 +4,7 @@ import (
"strings"
"testing"
"openflux/provision"
"openflux/share"
)
@@ -86,8 +87,11 @@ func TestLinkCommandsAnswerLikeShare(t *testing.T) {
t.Fatalf("--parse-link on garbage: exit %d %s", code, out.String())
}
link, err := nodeShareLink("node", "https://docs.yandex.ru/edit/d/AbC", "a shared secret of 32 characters", "203.0.113.7", 9443)
want := share.Make(share.NodeConfig("node", "https://docs.yandex.ru/edit/d/AbC", "a shared secret of 32 characters", "203.0.113.7:9443"))
// The wizard's link for a Yandex-document channel is share.NodeConfig's.
doc := "https://docs.yandex.ru/edit/d/AbCdEfGhIjKlMnOpQrStUv"
link, err := provision.ShareLink("node", "a shared secret of 32 characters", "203.0.113.7", 9443,
[]provision.ChannelTransport{{Type: "vyandex", URL: doc}})
want := share.Make(share.NodeConfig("node", doc, "a shared secret of 32 characters", "203.0.113.7:9443"))
if err != nil || link != want.Link {
t.Fatalf("wizard link %q (%v), share.Make %q", link, err, want.Link)
}
+23 -3
View File
@@ -403,9 +403,9 @@ func createRooms(ctx context.Context, baseURL string, n int, pause time.Duration
if delay <= 0 {
delay = 150 * time.Millisecond
}
var lastErr error
for i := 0; i < n; i++ {
var a *cupsAuth
var lastErr error
for attempt := 0; attempt < 6; attempt++ {
a, lastErr = authorize(ctx, baseURL, nil)
if lastErr == nil {
@@ -423,7 +423,7 @@ func createRooms(ctx context.Context, baseURL string, n int, pause time.Duration
}
utils.Debugf("[CUPS] room %d attempt %d failed: %v (wait %v)", i+1, attempt+1, lastErr, wait)
if !sleepCtx(ctx, wait) {
return nil, errStopped
return nil, fmt.Errorf("%w (%v)", errStopped, lastErr)
}
}
if a == nil {
@@ -437,11 +437,31 @@ func createRooms(ctx context.Context, baseURL string, n int, pause time.Duration
}
}
if len(out) == 0 {
return nil, fmt.Errorf("could not create any room")
return nil, fmt.Errorf("could not create any room: %v", lastErr)
}
return out, nil
}
// CreateRoomList creates new rooms, as an exit started without a room list
// does, and returns their packed list for --url. The "Своя нода" wizard
// creates them from the app so the node starts with the list in its config
// and keeps the same rooms, and the same link, across restarts.
func CreateRoomList(ctx context.Context) (string, error) {
cfg := DefaultCupsonlineConfig()
auths, err := createRooms(ctx, baseRoomURL, cfg.NumRooms, cfg.RoomCreatePause)
if err != nil {
if strings.Contains(err.Error(), "403") || strings.Contains(err.Error(), "429") {
return "", errors.New("cups.online отказывает этому адресу в новых комнатах (похоже на ограничение по частоте), попробуйте позже или выберите другой транспорт")
}
return "", err
}
ids := make([]string, len(auths))
for i, a := range auths {
ids[i] = a.roomUUID
}
return packRooms(ids), nil
}
func packRooms(ids []string) string {
raw, _ := json.Marshal(ids)
return base64.RawURLEncoding.EncodeToString(raw)
+48
View File
@@ -2,6 +2,7 @@ package cupsonline
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"fmt"
@@ -840,3 +841,50 @@ func TestPickRoomSpreadsAndSkipsDown(t *testing.T) {
t.Fatal("no room up: pickRoom must return nil")
}
}
// Rooms the wizard creates from the app are the exit's from its first start:
// it joins them instead of making its own, and a client with the same list
// reaches it.
func TestCreateRoomListIsWhatTheExitJoins(t *testing.T) {
f := newFakeCups(t)
list, err := CreateRoomList(context.Background())
if err != nil {
t.Fatal(err)
}
ids, err := unpackRooms(list)
if err != nil || len(ids) != DefaultCupsonlineConfig().NumRooms {
t.Fatalf("room list %q: %v %v", list, ids, err)
}
created := f.roomsCreated()
cfg := fastConfig()
exit := mustStart(t, list, false, cfg)
client := mustStart(t, list, true, cfg)
waitFor(t, 5*time.Second, "all channels up", func() bool {
return allConnected(exit) && allConnected(client)
})
if n := f.roomsCreated() - created; n != 0 {
t.Fatalf("the exit made %d rooms of its own", n)
}
if got := exit.RoomList(); got != list {
t.Fatalf("exit room list %q, want %q", got, list)
}
deliver(t, client, exit, []byte("up"), 2*time.Second)
deliver(t, exit, client, []byte("down"), 2*time.Second)
}
// cups.online turning an address away must reach the wizard as that, not
// as a stopped transport.
func TestCreateRoomListReportsRefusal(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) }))
defer srv.Close()
old := baseRoomURL
baseRoomURL = srv.URL + "/live-coding/"
defer func() { baseRoomURL = old }()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := CreateRoomList(ctx)
if err == nil || !strings.Contains(err.Error(), "ограничение") {
t.Fatalf("got %v", err)
}
}