mirror of
https://github.com/p1neappleXpress/OpenFlux.git
synced 2026-10-02 05:04:39 +08:00
Своя нода: выбор транспортов канала и автообновление ноды (#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.sh1f7bd2a* 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.sh122ab88* provision: pin node-install.shfe9dc8b* 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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user