Транспорт cups.online (CLI + iOS) и мастер установки узла

Две крупные вещи плюс их обвязка.

== cups.online как транспорт ==

Пакет transport/cupsonline/ перенесён из upstream/main дословно (с их
тестами на фейковом cups-сервере). Нода без --url создаёт комнаты на
interview.cups.online и печатает base64-строку, которую клиент подаёт
как --url; трафик прячется в сообщениях комнаты. Проведён в main.go через
buildMuxTransport — получает adaptive/batch-кодек (совместимость с
batched-only upstream) и мультиплекс, как остальные doc-транспорты.

Зависимость: не хватало utils.Infof (always-on лог, в отличие от Debugf) —
добавлен по образцу upstream.

KDF-контекст для cups (фикс из ветки hotfix bc9ada0). Список комнат
создаётся НОДОЙ при старте и раздаётся клиентам как URL, поэтому нода его
заранее не знает и берёт контекст "http://#", а клиент вывел бы его из
списка — разные ключи, рукопожатия нет, снаружи как таймаут. Теперь URL
cupsonline трактуется как отсутствующий (в main.go на call-site и в
iOS-мосте wrapEncryption), ровно как upstream-нода. Проверено: cups →
"http://#", yandex → URL документа не затронут.

iOS. Транспорт был в Go-lib, но недостижим из приложения: у мостов свой
switch. Добавлен case "cupsonline" в оба — export_ios.go (локальный SOCKS)
и export_ios_packet.go (System VPN). В Swift: TransportKind.cupsonline
(«Cups.online»), поле ввода строки комнат в редакторе профиля, иконка,
закрыты все исчерпывающие switch по транспорту. Ключ шифрования идёт как у
прочих — из Keychain по профилю. e2e по CLI подтверждён (200 через cups).

== Мастер установки узла ==

Порт из PR #110 (meepo161): установка узла с десктопа/телефона по SSH.
- provision/ — SSH-провижининг с пиннингом хоста (provision.go, pin.go);
- node_wizard.go — режим --node-wizard: ядро говорит с приложением по
  stdin/stdout построчным JSON, делает SSH-работу, проверяет документ
  канала и собирает openflux://-ссылку. Секреты идут только через stdin,
  не в лог и не в командную строку. Проверяется в main.go до разбора
  флагов;
- node_wizard_exitnode.go — под тегом exitnode мастер из сборки
  исключён (иначе пиннинг-инсталлер пинил бы бинарь, в котором сам лежит);
- deploy/node-install.sh + docker-compose для vyandex client/exit.

Мастеру нужна проверка документа Volga. Вместо переноса разошедшегося
auth-слоя из форка добавлен аддитивный шим CheckVolgaDocument поверх
нашего authorize() в transport/yandex/vyandex.go: успешная авторизация =
редактор открылся = канал годен; капча/логин отдаются как
ErrCaptchaRequired/ErrLoginRequired.
This commit is contained in:
saharev1
2026-09-27 05:08:50 +03:00
parent 59c4f61e93
commit 17557e472b
23 changed files with 4552 additions and 12 deletions
+527
View File
@@ -0,0 +1,527 @@
#!/bin/sh
# Managed by OpenFlux node-install.sh
#
# Installs one OpenFlux exit channel on a Linux VDS. The Android app's
# "Создать свою ноду" wizard 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.
#
# /opt/openflux-node/bin/ core binaries (from GitHub Releases)
# /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
#
# Usage: node-install.sh probe
# node-install.sh plan|status (config on stdin)
# node-install.sh apply|remove CONFIG_FILE (run as root)
# node-install.sh upgrade (run as root: move every
# 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,
# 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
# stdin stays free for `sudo -S` (a wrong sudo password would otherwise make
# sudo read the config as further password attempts). Secrets never appear
# in arguments, so they stay out of ps and shell history.
# Output is one JSON object on stdout.
set -u
umask 077
CORE_VERSION="node-v1.2.3"
CORE_BASE="https://github.com/meepo161/openfluxandroidfork/releases/download/$CORE_VERSION"
SHA_amd64="e44152f19fa48ec4082a406c509896de99d7d403b5a681a101c6f807419ca7e6"
SHA_arm64="0b7ec511c3e4c9875d85dbc3c54be20e26906a5a409ce6b97ace31d2ccb14328"
SHA_arm="3cb0b4c0db5a021cae46fad14d9c0e4fbdea382c69211128730b3656dd9f2900"
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"
NODE_USER="openflux-node"
MARKER="# Managed by OpenFlux node-install.sh"
# ---- output -----------------------------------------------------------------
# fail STEP MESSAGE: prints the error object and exits. Messages are fixed
# strings or validated values, never secrets.
fail() {
printf '{"ok":false,"step":"%s","error":"%s"}\n' "$1" "$(json_escape "$2")"
exit 1
}
json_escape() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr '\n\t\r' ' '
}
json_list() {
first=1
printf '['
for item in "$@"; do
[ "$first" = 1 ] || printf ','
printf '"%s"' "$(json_escape "$item")"
first=0
done
printf ']'
}
# ---- environment ------------------------------------------------------------
have() { command -v "$1" >/dev/null 2>&1; }
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo amd64 ;;
aarch64|arm64) echo arm64 ;;
armv7l|armv6l|armhf) echo arm ;;
*) echo "" ;;
esac
}
core_sha() {
case "$1" in
amd64) echo "$SHA_amd64" ;;
arm64) echo "$SHA_arm64" ;;
arm) echo "$SHA_arm" ;;
esac
}
downloader() {
if have curl; then echo curl; elif have wget; then echo wget; else echo ""; fi
}
fetch() { # URL DEST
case "$(downloader)" in
curl) curl -fsSL --retry 3 --connect-timeout 20 -o "$2" "$1" ;;
wget) wget -q -T 20 -t 3 -O "$2" "$1" ;;
*) return 1 ;;
esac
}
sha256_of() {
if have sha256sum; then sha256sum "$1" | cut -d' ' -f1
else openssl dgst -sha256 "$1" | sed 's/.*= //'
fi
}
firewall_kind() {
if have ufw && ufw status 2>/dev/null | grep -q '^Status: active'; then echo ufw
elif have firewall-cmd && firewall-cmd --state >/dev/null 2>&1; then echo firewalld
else echo none
fi
}
sudo_mode() {
if [ "$(id -u)" = 0 ]; then echo root
elif ! have sudo; then echo none
elif sudo -n true 2>/dev/null; then echo nopasswd
else echo password
fi
}
list_channels() {
[ -d "$CONF_ROOT" ] || return 0
for d in "$CONF_ROOT"/*/; do
[ -d "$d" ] && basename "$d"
done
}
port_busy() { # PORT
if have ss; then
[ -n "$(ss -Hltn "sport = :$1" 2>/dev/null)" ]
elif have netstat; then
netstat -ltn 2>/dev/null | awk '{print $4}' | grep -q "[:.]$1\$"
else
return 1
fi
}
port_claimed() { # PORT: another of our channels is configured for it
[ -d "$CONF_ROOT" ] || return 1
grep -qsx "$1" "$CONF_ROOT"/*/port
}
pick_port() {
seed=$(od -An -N2 -tu2 /dev/urandom | tr -d ' ')
i=0
while [ $i -lt 200 ]; do
p=$(( 20000 + (seed + i * 7919) % 40000 ))
if ! port_busy "$p" && ! port_claimed "$p"; then echo "$p"; return 0; fi
i=$((i + 1))
done
echo ""
}
# ---- input ------------------------------------------------------------------
CHANNEL=""; URL=""; KEY=""; PORT=""; COOKIES=""
# read_config [FILE]: reads stdin, or FILE and then deletes it.
read_config() {
if [ $# -gt 0 ]; then
[ -f "$1" ] || fail input "нет файла конфигурации"
read_config < "$1"
rm -f "$1"
return
fi
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
channel=*) CHANNEL=${line#channel=} ;;
url=*) URL=${line#url=} ;;
key=*) KEY=${line#key=} ;;
port=*) PORT=${line#port=} ;;
cookies=*) COOKIES=${line#cookies=} ;;
"") ;;
*) fail input "неизвестная строка конфигурации" ;;
esac
done
}
valid_channel() { printf '%s' "$1" | grep -Eq '^[a-z0-9][a-z0-9-]{0,30}$'; }
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_port() {
printf '%s' "$1" | grep -Eq '^[0-9]{4,5}$' && [ "$1" -ge 1024 ] && [ "$1" -le 65535 ]
}
# write_cookies: decodes COOKIES into the channel's cookie store, which the
# node loads at start. Needs the node user to exist.
write_cookies() {
printf '%s' "$COOKIES" | grep -Eq '^[A-Za-z0-9+/]+={0,2}$' || return 1
[ ${#COOKIES} -le 65536 ] || return 1
dir="$STATE_ROOT/$CHANNEL"
# umask 077 would make the parent 0700 and lock the node user out of
# its own state directory (systemd only creates it when missing).
mkdir -p "$STATE_ROOT" && chmod 0755 "$STATE_ROOT" || return 1
mkdir -p "$dir" || return 1
tmp="$dir/.cookies.json.new"
if have base64; then
printf '%s' "$COOKIES" | base64 -d > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
else
printf '%s' "$COOKIES" | openssl base64 -d -A > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
fi
head -c 1 "$tmp" | grep -q '{' || { rm -f "$tmp"; return 1; }
chown "$NODE_USER:$NODE_USER" "$dir" "$tmp" && chmod 0600 "$tmp" && mv -f "$tmp" "$dir/cookies.json"
}
check_channel() {
[ -n "$CHANNEL" ] || fail input "не указан канал"
valid_channel "$CHANNEL" || fail input "имя канала: только a-z, 0-9 и дефис, до 31 символа"
}
# ---- commands ---------------------------------------------------------------
cmd_probe() {
arch=$(detect_arch)
systemd=false
[ -d /run/systemd/system ] && have systemctl && systemd=true
os=""
[ -r /etc/os-release ] && os=$(. /etc/os-release && printf '%s %s' "${ID:-linux}" "${VERSION_ID:-}")
# shellcheck disable=SC2046
printf '{"ok":true,"arch":"%s","os":"%s","systemd":%s,"sudo":"%s","downloader":"%s","firewall":"%s","core":"%s","channels":%s}\n' \
"$arch" "$(json_escape "$os")" "$systemd" "$(sudo_mode)" "$(downloader)" "$(firewall_kind)" \
"$CORE_VERSION" "$(json_list $(list_channels))"
}
# plan: read-only. Tells the app exactly what apply will change.
cmd_plan() {
read_config
check_channel
arch=$(detect_arch)
[ -n "$arch" ] || fail plan "архитектура $(uname -m) не поддерживается"
[ -d /run/systemd/system ] && have systemctl || fail plan "на сервере нет systemd"
[ -n "$(downloader)" ] || fail plan "на сервере нет curl или wget"
[ -d "$CONF_ROOT/$CHANNEL" ] && fail plan "канал $CHANNEL уже существует на сервере"
if [ -n "$PORT" ]; then
valid_port "$PORT" || fail plan "порт должен быть в диапазоне 1024-65535"
if port_busy "$PORT" || port_claimed "$PORT"; then fail plan "порт $PORT занят"; fi
else
PORT=$(pick_port)
[ -n "$PORT" ] || fail plan "не удалось найти свободный порт"
fi
if [ -f "$UNIT_FILE" ] && ! grep -qF "$MARKER" "$UNIT_FILE"; then
fail plan "$UNIT_FILE создан не мастером OpenFlux, не трогаю его"
fi
set --
id "$NODE_USER" >/dev/null 2>&1 || set -- "$@" "Создать системного пользователя $NODE_USER (без входа и домашней папки)"
if [ -x "$BIN_DIR/openflux-$CORE_VERSION" ]; then
set -- "$@" "Использовать уже установленное ядро OpenFlux $CORE_VERSION"
else
set -- "$@" "Скачать ядро OpenFlux $CORE_VERSION (linux-$arch) с 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 (резерв)"
case "$(firewall_kind)" in
ufw) set -- "$@" "Разрешить входящий $PORT/tcp в ufw" ;;
firewalld) set -- "$@" "Разрешить входящий $PORT/tcp в firewalld" ;;
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))"
}
# Rollback state for apply: what this run created.
CREATED_USER=0; CREATED_UNIT=0; CREATED_BIN=0; CREATED_CONF=0; CREATED_FW=""; STARTED=0
rollback() {
[ "$STARTED" = 1 ] && systemctl disable --now "openflux-node@$CHANNEL" >/dev/null 2>&1
case "$CREATED_FW" in
ufw) ufw delete allow "$PORT/tcp" >/dev/null 2>&1 ;;
firewalld) firewall-cmd --permanent --remove-port="$PORT/tcp" >/dev/null 2>&1 && firewall-cmd --reload >/dev/null 2>&1 ;;
esac
[ "$CREATED_CONF" = 1 ] && rm -rf "${CONF_ROOT:?}/$CHANNEL" "${STATE_ROOT:?}/$CHANNEL"
[ "$CREATED_UNIT" = 1 ] && rm -f "$UNIT_FILE" && systemctl daemon-reload >/dev/null 2>&1
[ "$CREATED_BIN" = 1 ] && rm -f "$BIN_DIR/openflux-$CORE_VERSION"
[ "$CREATED_USER" = 1 ] && userdel "$NODE_USER" >/dev/null 2>&1
}
apply_fail() {
rollback
fail "$1" "$2"
}
# install_core ARCH: puts this script's core version into BIN_DIR, checked
# against its pinned SHA-256, and points BIN_DIR/openflux at it. Sets
# CREATED_BIN when it downloaded, CORE_ERROR on failure.
CORE_ERROR=""
install_core() {
mkdir -p "$BIN_DIR" && chmod 0755 /opt/openflux-node "$BIN_DIR"
core="$BIN_DIR/openflux-$CORE_VERSION"
want=$(core_sha "$1")
if [ ! -x "$core" ] || [ "$(sha256_of "$core")" != "$want" ]; then
tmp=$(mktemp "$BIN_DIR/.download.XXXXXX") || { CORE_ERROR="не удалось создать временный файл"; return 1; }
if ! fetch "$CORE_BASE/openflux-linux-$1" "$tmp"; then
rm -f "$tmp"
CORE_ERROR="не удалось скачать ядро с GitHub"
return 1
fi
if [ "$(sha256_of "$tmp")" != "$want" ]; then
rm -f "$tmp"
CORE_ERROR="SHA-256 скачанного ядра не совпал, установка остановлена"
return 1
fi
chmod 0755 "$tmp" && mv -f "$tmp" "$core"
CREATED_BIN=1
fi
ln -sfn "openflux-$CORE_VERSION" "$BIN_DIR/openflux"
}
write_unit() {
cat > "$UNIT_FILE" <<EOF
$MARKER
[Unit]
Description=OpenFlux node channel %i
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$NODE_USER
Group=$NODE_USER
StateDirectory=openflux-node/%i
WorkingDirectory=$STATE_ROOT/%i
ExecStart=$BIN_DIR/openflux --config $CONF_ROOT/%i/node.conf
Restart=always
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
[Install]
WantedBy=multi-user.target
EOF
chmod 0644 "$UNIT_FILE"
}
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/..."
valid_port "$PORT" || fail input "не указан порт из плана"
[ -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 уже существует на сервере"
if port_busy "$PORT" || port_claimed "$PORT"; then fail apply "порт $PORT занят"; fi
if [ -f "$UNIT_FILE" ] && ! grep -qF "$MARKER" "$UNIT_FILE"; then
fail apply "$UNIT_FILE создан не мастером OpenFlux"
fi
if ! id "$NODE_USER" >/dev/null 2>&1; then
nologin=/usr/sbin/nologin
[ -x "$nologin" ] || nologin=/sbin/nologin
[ -x "$nologin" ] || nologin=/bin/false
if have useradd; then
useradd --system --no-create-home --home-dir /nonexistent --shell "$nologin" "$NODE_USER" \
|| apply_fail user "не удалось создать пользователя $NODE_USER"
else
adduser -S -H -D -s "$nologin" "$NODE_USER" 2>/dev/null \
|| apply_fail user "не удалось создать пользователя $NODE_USER"
fi
CREATED_USER=1
fi
install_core "$arch" || apply_fail download "$CORE_ERROR"
mkdir -p "$CONF_ROOT" && chmod 0755 "$CONF_ROOT"
dir="$CONF_ROOT/$CHANNEL"
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"
chown -R "root:$NODE_USER" "$dir"
chmod 0751 "$dir"
chmod 0640 "$dir/encryption-key" "$dir/node.conf"
chmod 0644 "$dir/port"
if [ -n "$COOKIES" ]; then
write_cookies || apply_fail cookies "не удалось сохранить вход в Яндекс на сервере"
fi
if [ ! -f "$UNIT_FILE" ]; then
write_unit
CREATED_UNIT=1
fi
systemctl daemon-reload || apply_fail systemd "systemctl daemon-reload не удался"
case "$(firewall_kind)" in
ufw)
ufw allow "$PORT/tcp" comment "openflux-node $CHANNEL" >/dev/null 2>&1 \
|| apply_fail firewall "не удалось открыть порт в ufw"
CREATED_FW=ufw ;;
firewalld)
{ firewall-cmd --permanent --add-port="$PORT/tcp" && firewall-cmd --reload; } >/dev/null 2>&1 \
|| apply_fail firewall "не удалось открыть порт в firewalld"
CREATED_FW=firewalld ;;
esac
[ -n "$CREATED_FW" ] && printf '%s %s\n' "$CREATED_FW" "$PORT" > "$dir/firewall"
systemctl enable --now "openflux-node@$CHANNEL" >/dev/null 2>&1 \
|| apply_fail start "не удалось запустить openflux-node@$CHANNEL"
STARTED=1
sleep 4
if ! systemctl is-active --quiet "openflux-node@$CHANNEL"; then
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"
}
cmd_remove() {
[ "$(id -u)" = 0 ] || fail remove "нужны права root (sudo)"
read_config "$@"
check_channel
dir="$CONF_ROOT/$CHANNEL"
[ -d "$dir" ] || fail remove "канала $CHANNEL нет на сервере"
systemctl disable --now "openflux-node@$CHANNEL" >/dev/null 2>&1
if [ -f "$dir/firewall" ]; then
read -r kind port < "$dir/firewall"
case "$kind" in
ufw) ufw delete allow "$port/tcp" >/dev/null 2>&1 ;;
firewalld) firewall-cmd --permanent --remove-port="$port/tcp" >/dev/null 2>&1 && firewall-cmd --reload >/dev/null 2>&1 ;;
esac
fi
rm -rf "${CONF_ROOT:?}/$CHANNEL" "${STATE_ROOT:?}/$CHANNEL"
if [ -z "$(list_channels)" ]; then
# The last channel is gone: remove everything this script installed.
rm -f "$UNIT_FILE"
systemctl daemon-reload >/dev/null 2>&1
rm -rf /opt/openflux-node "$CONF_ROOT" "$STATE_ROOT"
userdel "$NODE_USER" >/dev/null 2>&1
fi
printf '{"ok":true,"channel":"%s"}\n' "$CHANNEL"
}
# upgrade: switches every channel to this script's core and restarts the
# running ones. Configs, keys and ports stay as they are.
cmd_upgrade() {
[ "$(id -u)" = 0 ] || fail upgrade "нужны права root (sudo)"
[ -n "$(list_channels)" ] || fail upgrade "на сервере нет каналов OpenFlux"
arch=$(detect_arch)
[ -n "$arch" ] || fail upgrade "архитектура $(uname -m) не поддерживается"
install_core "$arch" || fail upgrade "$CORE_ERROR"
set --
for ch in $(list_channels); do
if systemctl is-active --quiet "openflux-node@$ch"; then
systemctl restart "openflux-node@$ch" || fail upgrade "не удалось перезапустить openflux-node@$ch"
set -- "$@" "$ch"
fi
done
# Older cores nothing points at any more.
for old in "$BIN_DIR"/openflux-node-v*; do
[ "$old" = "$BIN_DIR/openflux-$CORE_VERSION" ] || rm -f "$old"
done
printf '{"ok":true,"core":"%s","restarted":%s}
' "$CORE_VERSION" "$(json_list "$@")"
}
# set-cookies: replaces a channel's Yandex sign-in and restarts it.
cmd_set_cookies() {
[ "$(id -u)" = 0 ] || fail set-cookies "нужны права root (sudo)"
read_config "$@"
check_channel
[ -d "$CONF_ROOT/$CHANNEL" ] || fail set-cookies "канала $CHANNEL нет на сервере"
[ -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"
}
cmd_status() {
read_config
check_channel
[ -d "$CONF_ROOT/$CHANNEL" ] || fail status "канала $CHANNEL нет на сервере"
state=$(systemctl is-active "openflux-node@$CHANNEL" 2>/dev/null)
printf '{"ok":true,"channel":"%s","state":"%s"}\n' "$CHANNEL" "$(json_escape "$state")"
}
case "${1:-}" in
probe) cmd_probe ;;
plan) cmd_plan ;;
apply) shift; cmd_apply "$@" ;;
remove) shift; cmd_remove "$@" ;;
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" ;;
esac
+21
View File
@@ -0,0 +1,21 @@
services:
client:
image: openflux:local
restart: unless-stopped
entrypoint: /usr/local/bin/openflux
command:
- --role=client
- --inbound=socks5
- --transport=vyandex
- --url=${DOC_URL:?Set DOC_URL in .env}
- --socks5=:1081
- --encryption-key-file=/run/secrets/channel.key
- --yandex-cookies-file=/run/secrets/yandex-cookies.txt
ports:
- 127.0.0.1:${SOCKS_PORT:-1081}:1081
volumes:
- ./channel.key:/run/secrets/channel.key:ro
- ./yandex-cookies.txt:/run/secrets/yandex-cookies.txt:ro
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
read_only: true
+18
View File
@@ -0,0 +1,18 @@
services:
exit:
image: openflux:local
restart: unless-stopped
cap_add: [NET_RAW, NET_ADMIN]
entrypoint: ["/bin/sh", "-ec"]
command: |
iptables -A OUTPUT -p tcp --tcp-flags RST RST -j DROP
exec /usr/local/bin/openflux \
--role=exit --mode=l4 --transport=vyandex \
--url="$$DOC_URL" \
--encryption-key-file=/run/secrets/channel.key \
--yandex-cookies-file=/run/secrets/yandex-cookies.txt
environment:
DOC_URL: ${DOC_URL:?Set DOC_URL in .env}
volumes:
- ./channel.key:/run/secrets/channel.key:ro
- ./yandex-cookies.txt:/run/secrets/yandex-cookies.txt:ro
+13 -1
View File
@@ -24,6 +24,7 @@ import (
"universal-bypass-tool/socks5"
"universal-bypass-tool/transport"
"universal-bypass-tool/transport/cupsonline"
"universal-bypass-tool/transport/mailru"
"universal-bypass-tool/transport/oneme"
"universal-bypass-tool/transport/yandex"
@@ -272,8 +273,13 @@ func wrapEncryption(inner transport.Transport, transportType, docURL string) (tr
// это адрес узла, а он у сторон разный (узел слушает 0.0.0.0, клиент
// набирает публичный IP), поэтому он в контекст не идёт и остаётся
// плейсхолдер — ровно как у них.
// direct: docURL — это host:port узла, у сторон разный, поэтому не идёт в
// контекст. cupsonline: docURL — список комнат, который узел создаёт при
// старте и раздаёт клиентам, поэтому у узла его нет (контекст "http://#");
// пустить его в контекст — значит развести ключи и молча потерять связь.
context := contextPlaceholder
if transportType != "direct" && docURL != "" && docURL != contextPlaceholder {
if transportType != "direct" && transportType != "cupsonline" &&
docURL != "" && docURL != contextPlaceholder {
context = docURL
}
enc, err := transport.NewEncryptedTransport(inner, secret, context, false)
@@ -390,6 +396,12 @@ func OpenFluxStartClient(transportType, url, socksAddr, maxToken, maxUid *C.char
t = buildDocTransport(docURL, config, func(u string) transport.Transport {
return mailru.NewMailruDocsTransport(u, config)
})
case "cupsonline":
// docURL — packed base64 список комнат, который узел печатает при
// старте (запуск без --url). Клиент здесь всегда isClient=true.
t = buildDocTransport(docURL, config, func(u string) transport.Transport {
return cupsonline.NewCupsonlineTransport(u, config, true)
})
case "oneme":
uidint, _ := strconv.ParseInt(mUid, 10, 64)
t = transport.NewCompressedTransport(oneme.NewOneMeTransport(false, mToken, uidint, config))
+6
View File
@@ -23,6 +23,7 @@ import (
"universal-bypass-tool/network"
"universal-bypass-tool/transport"
"universal-bypass-tool/transport/cupsonline"
"universal-bypass-tool/transport/mailru"
"universal-bypass-tool/transport/oneme"
"universal-bypass-tool/transport/yandex"
@@ -125,6 +126,11 @@ func OpenFluxStartPacketTunnel(transportType, url, maxToken, maxUid *C.char) (rc
t = buildDocTransport(docURL, config, func(u string) transport.Transport {
return mailru.NewMailruDocsTransport(u, config)
})
case "cupsonline":
// docURL — packed base64 список комнат (узел печатает при старте).
t = buildDocTransport(docURL, config, func(u string) transport.Transport {
return cupsonline.NewCupsonlineTransport(u, config, true)
})
case "oneme":
uidint, _ := strconv.ParseInt(mUid, 10, 64)
t = transport.NewCompressedTransport(oneme.NewOneMeTransport(false, mToken, uidint, config))
+2
View File
@@ -123,6 +123,8 @@ golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+12 -7
View File
@@ -648,6 +648,7 @@ struct ContentView: View {
case "boards": return "rectangle.3.group"
case "direct": return "arrow.left.arrow.right"
case "oneme": return "m.square"
case "cupsonline": return "chevron.left.forwardslash.chevron.right"
default: return "doc.text"
}
}
@@ -663,7 +664,7 @@ struct ContentView: View {
url = b.isEmpty ? a : "\(a),\(b)"
case .volga: url = volgaURL.trimmingCharacters(in: .whitespaces)
// boards появился уже после профилей — легаси-конфига для него не бывает
case .boards, .direct: break
case .boards, .direct, .cupsonline: break
case .mail: url = mailURL.trimmingCharacters(in: .whitespaces)
case .max: break
}
@@ -727,6 +728,10 @@ struct ProfileEditorView: View {
TextField("адрес узла, например 1.2.3.4:9443", text: $single).autocapitalization(.none).disableAutocorrection(true)
case .mail:
TextField("https://cloud.mail.ru/public/…", text: $single).autocapitalization(.none).disableAutocorrection(true)
case .cupsonline:
TextField("строка комнат из лога узла (base64)", text: $single).autocapitalization(.none).disableAutocorrection(true)
Text("Узел печатает её при запуске БЕЗ --url. Ключ шифрования — тот же, что на узле.")
.font(.caption2).foregroundColor(.secondary)
case .max:
TextField("MAX token", text: $maxToken).autocapitalization(.none).disableAutocorrection(true)
TextField("MAX user ID", text: $maxUid).keyboardType(.numberPad)
@@ -812,7 +817,7 @@ struct ProfileEditorView: View {
guard !name.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
switch transport {
case .yandex: return !url1.trimmingCharacters(in: .whitespaces).isEmpty
case .volga, .boards, .mail, .direct: return !single.trimmingCharacters(in: .whitespaces).isEmpty
case .volga, .boards, .mail, .direct, .cupsonline: return !single.trimmingCharacters(in: .whitespaces).isEmpty
case .max: return !maxToken.isEmpty && !maxUid.isEmpty
}
}
@@ -829,7 +834,7 @@ struct ProfileEditorView: View {
case .yandex:
url1 = parts.first ?? ""
if parts.count > 1 { url2 = parts[1] }
case .volga, .boards, .mail, .direct:
case .volga, .boards, .mail, .direct, .cupsonline:
single = parts.first ?? p.url
case .max: break
}
@@ -875,7 +880,7 @@ struct ProfileEditorView: View {
}
switch transport {
case .yandex: url1 = s
case .volga, .boards, .mail, .direct: single = s
case .volga, .boards, .mail, .direct, .cupsonline: single = s
case .max: return false
}
if name.trimmingCharacters(in: .whitespaces).isEmpty { name = transport.title }
@@ -896,7 +901,7 @@ struct ProfileEditorView: View {
let value = (p.type == "direct" ? p.dial : p.url) ?? ""
switch transport {
case .yandex: url1 = value
case .volga, .boards, .mail, .direct: single = value
case .volga, .boards, .mail, .direct, .cupsonline: single = value
case .max: break
}
}
@@ -921,7 +926,7 @@ struct ProfileEditorView: View {
case .yandex:
url1 = parsed.urls.first ?? ""
url2 = parsed.urls.count > 1 ? parsed.urls[1] : ""
case .volga, .boards, .mail, .direct:
case .volga, .boards, .mail, .direct, .cupsonline:
single = parsed.urls.first ?? ""
case .max: break
}
@@ -938,7 +943,7 @@ struct ProfileEditorView: View {
let a = url1.trimmingCharacters(in: .whitespaces)
let b = url2.trimmingCharacters(in: .whitespaces)
url = b.isEmpty ? a : "\(a),\(b)"
case .volga, .boards, .mail, .direct:
case .volga, .boards, .mail, .direct, .cupsonline:
url = single.trimmingCharacters(in: .whitespaces)
case .max:
url = ""
+4 -1
View File
@@ -7,6 +7,7 @@ enum TransportKind: String, CaseIterable, Identifiable {
case boards = "boards"
case direct = "direct"
case mail = "mailru"
case cupsonline = "cupsonline"
case max = "oneme"
var id: String { rawValue }
var title: String {
@@ -16,11 +17,13 @@ enum TransportKind: String, CaseIterable, Identifiable {
case .boards: return "Yandex Boards"
case .direct: return "Прямой TCP (нужен ключ)"
case .mail: return "Mail.ru"
case .cupsonline: return "Cups.online"
case .max: return "MAX"
}
}
/// Document-based transports that take a public document URL / weblink.
var usesDocURL: Bool { self == .yandex || self == .volga || self == .boards || self == .mail }
/// cupsonline берёт в это же поле не ссылку, а base64-строку комнат из лога узла.
var usesDocURL: Bool { self == .yandex || self == .volga || self == .boards || self == .mail || self == .cupsonline }
/// direct берёт в том же поле не ссылку на документ, а host:port узла.
var usesNodeAddr: Bool { self == .direct }
}
+1 -1
View File
@@ -8,7 +8,7 @@ options:
settings:
base:
MARKETING_VERSION: "1.2.0"
CURRENT_PROJECT_VERSION: "67"
CURRENT_PROJECT_VERSION: "70"
DEVELOPMENT_TEAM: "8GQH8GQ252"
targets:
+24 -2
View File
@@ -12,6 +12,7 @@ import (
_ "github.com/wlynxg/anet"
"universal-bypass-tool/socks5"
"universal-bypass-tool/transport"
"universal-bypass-tool/transport/cupsonline"
"universal-bypass-tool/transport/mailru"
"universal-bypass-tool/transport/oneme"
"universal-bypass-tool/transport/yandex"
@@ -57,6 +58,13 @@ func buildMuxTransport(urlSpec string, factory func(string) transport.Transport)
}
func main() {
// Мастер установки узла: отдельный режим, разговаривает с десктопным
// приложением по stdin/stdout построчным JSON. Проверяется до разбора
// флагов, потому что это не обычный запуск туннеля.
if len(os.Args) == 2 && os.Args[1] == "--node-wizard" {
os.Exit(runNodeWizard(os.Stdin, os.Stdout))
}
//os.Setenv("GODEBUG", "netdns=go")
fmt.Print("written by p1neappleXpress\n")
@@ -64,7 +72,7 @@ func main() {
client := flag.Bool("client", false, "Run as client")
debug := flag.Bool("debug", false, "Enable verbose debug logging")
socksAddr := flag.String("socks5", ":1080", "SOCKS5 address")
transportType := flag.String("transport", "yandex", "Transport type (yandex, google, custom)")
transportType := flag.String("transport", "yandex", "Transport type (yandex, vyandex, boards, mailru, cupsonline, direct, oneme)")
flag.StringVar(&globalDocUrl, "url", "http://#", "Document URL for Yandex.Docs transport. Comma-separated list = multiplex across N documents (client and exit node must pass the same list)")
flag.StringVar(&maxToken, "maxToken", "", "MAX call user id. If u use MAX transport")
flag.StringVar(&maxUid, "maxUid", "", "MAX Web token. If u use MAX transport")
@@ -157,6 +165,11 @@ func main() {
trans = buildMuxTransport(globalDocUrl, func(u string) transport.Transport {
return mailru.NewMailruDocsTransport(u, config)
})
case "cupsonline":
trans = buildMuxTransport(globalDocUrl, func(u string) transport.Transport {
// isClient = не exit-нода, как в upstream (role != exit).
return cupsonline.NewCupsonlineTransport(u, config, !*exitNode)
})
case "oneme":
uidint, _ := strconv.ParseInt(maxUid, 10, 64)
trans = transport.NewCompressedTransport(oneme.NewOneMeTransport(*exitNode, maxToken, uidint, config))
@@ -178,7 +191,16 @@ func main() {
if err != nil {
log.Fatalf("Read encryption key file: %v", err)
}
context := pickSessionContext(*sessionContext, globalDocUrl)
// Список комнат cupsonline создаётся НОДОЙ при старте и раздаётся клиентам
// как --url, поэтому нода его заранее не знает и выводит контекст из "http://#".
// Если пустить этот URL в контекст — у клиента и ноды получатся РАЗНЫЕ ключи,
// рукопожатия не будет, а снаружи это выглядит как таймаут. Трактуем URL
// cupsonline как отсутствующий, ровно как upstream-нода. (из ветки bc9ada0)
ctxURL := globalDocUrl
if *transportType == "cupsonline" {
ctxURL = ""
}
context := pickSessionContext(*sessionContext, ctxURL)
encrypted, err := transport.NewEncryptedTransport(
trans, strings.TrimSpace(string(secretBytes)), context, *exitNode)
if err != nil {
+300
View File
@@ -0,0 +1,300 @@
//go:build !exitnode
package main
// --node-wizard: the desktop app's "Своя нода" wizard talks to the core
// over stdin/stdout, one JSON object per line. The core does the SSH work
// (package provision), checks the channel's Yandex document and builds the
// channel's openflux:// link; the app proves the channel by connecting to it
// as usual.
//
// Request: {"id": 1, "method": "connect", "params": {...}}
// Response: {"id": 1, "ok": true, ...} or {"id": 1, "ok": false, "error": "..."}
//
// Secrets (SSH and sudo passwords, private key, channel key, Yandex
// cookies) arrive only on stdin and never go to the log or the command line.
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
"time"
"universal-bypass-tool/provision"
"universal-bypass-tool/share"
"universal-bypass-tool/transport/yandex"
)
type wizardRequest struct {
ID int64 `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type wizardParams struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
PrivateKey string `json:"privateKey"`
Passphrase string `json:"passphrase"`
HostKey string `json:"hostKey"`
Channel string `json:"channel"`
ChannelPort int `json:"channelPort"`
WithCookies bool `json:"withCookies"`
DocumentURL string `json:"documentUrl"`
Key string `json:"key"`
SudoPassword string `json:"sudoPassword"`
Cookies string `json:"cookies"`
Name string `json:"name"`
}
// nodeWizard holds the SSH connection between calls.
type nodeWizard struct {
conn *provision.Conn
// Tests replace these to run without a VDS or Yandex.
dial func(context.Context, provision.Target) (*provision.Conn, error)
checkDoc func(string) (yandex.VolgaDocument, error)
newScript func() provision.Script
}
func newNodeWizard() *nodeWizard {
return &nodeWizard{
dial: provision.Dial,
checkDoc: func(u string) (yandex.VolgaDocument, error) { return yandex.CheckVolgaDocument(u, nil) },
newScript: provision.Pinned,
}
}
// runNodeWizard serves requests until stdin closes.
func runNodeWizard(in io.Reader, out io.Writer) int {
w := newNodeWizard()
defer w.disconnect()
scanner := bufio.NewScanner(in)
scanner.Buffer(make([]byte, 64<<10), 1<<20)
enc := json.NewEncoder(out)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var req wizardRequest
var resp map[string]interface{}
if err := json.Unmarshal([]byte(line), &req); err != nil {
resp = wizardFailure(errors.New("неверный запрос"), nil)
} else {
resp = w.handle(req)
}
resp["id"] = req.ID
if err := enc.Encode(resp); err != nil {
return 1
}
}
return 0
}
func wizardOK(fields map[string]interface{}) map[string]interface{} {
if fields == nil {
fields = map[string]interface{}{}
}
fields["ok"] = true
return fields
}
func wizardFailure(err error, extra map[string]interface{}) map[string]interface{} {
fields := map[string]interface{}{"ok": false, "error": err.Error()}
for k, v := range extra {
fields[k] = v
}
return fields
}
func (w *nodeWizard) handle(req wizardRequest) map[string]interface{} {
var p wizardParams
if len(req.Params) > 0 {
if err := json.Unmarshal(req.Params, &p); err != nil {
return wizardFailure(errors.New("неверные параметры"), nil)
}
}
switch req.Method {
case "connect":
return w.connect(p)
case "disconnect":
w.disconnect()
return wizardOK(nil)
case "newChannel":
id, err := provision.NewChannelID()
if err != nil {
return wizardFailure(err, nil)
}
key, err := provision.NewKey()
if err != nil {
return wizardFailure(err, nil)
}
return wizardOK(map[string]interface{}{"channel": id, "key": key})
case "plan":
conn, err := w.connected()
if err != nil {
return wizardFailure(err, nil)
}
plan, err := conn.Plan(p.Channel, p.ChannelPort, p.WithCookies)
if err != nil {
return wizardFailure(err, nil)
}
return wizardOK(map[string]interface{}{"plan": plan})
case "apply":
conn, err := w.connected()
if err != nil {
return wizardFailure(err, nil)
}
ch := provision.Channel{ID: p.Channel, URL: p.DocumentURL, Key: p.Key, Port: p.ChannelPort}
if p.Cookies != "" {
if ch.Cookies, _, err = provision.CookieStore(p.DocumentURL, p.Cookies); err != nil {
return wizardFailure(err, nil)
}
}
if err := conn.Apply(ch, p.SudoPassword); err != nil {
return wizardFailure(err, map[string]interface{}{"sudo": errors.Is(err, provision.ErrSudoPassword)})
}
return wizardOK(nil)
case "setCookies":
conn, err := w.connected()
if err != nil {
return wizardFailure(err, nil)
}
cookies, _, err := provision.CookieStore(p.DocumentURL, p.Cookies)
if err != nil {
return wizardFailure(err, nil)
}
if err := conn.SetCookies(p.Channel, cookies, p.SudoPassword); err != nil {
return wizardFailure(err, map[string]interface{}{"sudo": errors.Is(err, provision.ErrSudoPassword)})
}
return wizardOK(nil)
case "remove":
conn, err := w.connected()
if err != nil {
return wizardFailure(err, nil)
}
if err := conn.Remove(p.Channel, p.SudoPassword); err != nil {
return wizardFailure(err, map[string]interface{}{"sudo": errors.Is(err, provision.ErrSudoPassword)})
}
return wizardOK(nil)
case "signedIn":
_, signedIn, err := provision.CookieStore("x", p.Cookies)
return wizardOK(map[string]interface{}{"signedIn": err == nil && signedIn})
case "checkDocument":
return w.checkDocument(p.DocumentURL)
case "shareLink":
link, err := nodeShareLink(p.Name, p.DocumentURL, p.Key, p.Host, p.ChannelPort)
if err != nil {
return wizardFailure(err, nil)
}
return wizardOK(map[string]interface{}{"link": link})
default:
return wizardFailure(fmt.Errorf("неизвестная команда %q", req.Method), nil)
}
}
// connect opens SSH, has the VDS download the pinned installer and probes
// it. A new server comes back with "hostKey" and "trust": true so the user
// can compare the fingerprint; a changed key with "mismatch": true.
func (w *nodeWizard) connect(p wizardParams) map[string]interface{} {
w.disconnect()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
conn, err := w.dial(ctx, provision.Target{
Host: strings.TrimSpace(p.Host), Port: p.Port, User: strings.TrimSpace(p.User),
Password: p.Password, PrivateKey: p.PrivateKey, Passphrase: p.Passphrase, HostKey: p.HostKey,
})
if err != nil {
var hk *provision.HostKeyError
if errors.As(err, &hk) {
return wizardFailure(err, map[string]interface{}{"hostKey": hk.Fingerprint, "trust": !hk.Mismatch, "mismatch": hk.Mismatch})
}
return wizardFailure(err, nil)
}
if err := conn.FetchScript(w.newScript()); err != nil {
conn.Close()
return wizardFailure(err, nil)
}
probe, err := conn.Probe()
if err != nil {
conn.Close()
return wizardFailure(err, nil)
}
if !probe.Systemd {
conn.Close()
return wizardFailure(errors.New("на сервере нет systemd: мастер поддерживает Debian, Ubuntu и похожие системы"), nil)
}
if probe.Sudo == "none" {
conn.Close()
return wizardFailure(errors.New("у пользователя нет root и sudo: войдите как root или пользователь с sudo"), nil)
}
w.conn = conn
return wizardOK(map[string]interface{}{"probe": probe})
}
func (w *nodeWizard) disconnect() {
if w.conn != nil {
w.conn.Close()
w.conn = nil
}
}
func (w *nodeWizard) connected() (*provision.Conn, error) {
if w.conn == nil {
return nil, errors.New("нет подключения к серверу")
}
return w.conn, nil
}
// checkDocument tells whether the vyandex transport can use the document as
// an anonymous visitor, like the node: {"editable"}. A check Yandex wants a
// person to pass comes back with "captcha": true.
func (w *nodeWizard) checkDocument(documentURL string) map[string]interface{} {
doc, err := w.checkDoc(documentURL)
if err != nil {
// A challenge this computer could not pass, SmartCaptcha or a PoW
// captcha Yandex rejected ("captcha solve: ..."), says nothing about
// the document: the node opens it from its own address.
captcha := errors.Is(err, yandex.ErrCaptchaRequired) || errors.Is(err, yandex.ErrLoginRequired) ||
strings.HasPrefix(err.Error(), "captcha solve:")
msg := err
switch {
case captcha:
msg = errors.New("Яндекс просит пройти проверку, повторите через минуту")
case strings.Contains(err.Error(), "client-config"), strings.Contains(err.Error(), "officeActionData"):
msg = errors.New("документ не открылся в редакторе Яндекса: проверьте доступ по ссылке")
}
return wizardFailure(msg, map[string]interface{}{"captcha": captcha})
}
if !doc.Editable {
return wizardFailure(errors.New("по ссылке документ открывается только на просмотр, нужен доступ на редактирование"), nil)
}
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.Encode(share.Config{
Name: name,
Negotiate: true,
Secret: key,
Context: documentURL,
Transports: []share.Transport{
{Type: "vyandex", URL: documentURL, Priority: 100},
{Type: "direct", Dial: net.JoinHostPort(host, strconv.Itoa(port)), Priority: 50},
},
})
}
+16
View File
@@ -0,0 +1,16 @@
//go:build exitnode
package main
import (
"fmt"
"io"
)
// The exit-node build (deploy/node-install.sh installs it) leaves the
// desktop wizard out: its pinned installer would otherwise be part of the
// very binary whose hashes that installer pins.
func runNodeWizard(_ io.Reader, out io.Writer) int {
fmt.Fprintln(out, `{"ok":false,"error":"--node-wizard is not part of the exit-node build"}`)
return 2
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"universal-bypass-tool/provision"
"universal-bypass-tool/share"
"universal-bypass-tool/transport/yandex"
)
func wizardCall(t *testing.T, w *nodeWizard, method string, params interface{}) map[string]interface{} {
t.Helper()
raw, _ := json.Marshal(params)
return w.handle(wizardRequest{ID: 1, Method: method, Params: raw})
}
func TestNodeWizardProtocolLines(t *testing.T) {
in := strings.NewReader("{\"id\":7,\"method\":\"newChannel\"}\n\nnot json\n{\"id\":9,\"method\":\"nope\"}\n")
var out bytes.Buffer
if code := runNodeWizard(in, &out); code != 0 {
t.Fatalf("exit code %d", code)
}
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != 3 {
t.Fatalf("want 3 responses, got %d: %q", len(lines), out.String())
}
var first, bad, unknown map[string]interface{}
for i, into := range []*map[string]interface{}{&first, &bad, &unknown} {
if err := json.Unmarshal([]byte(lines[i]), into); err != nil {
t.Fatal(err)
}
}
if first["id"] != float64(7) || first["ok"] != true || len(first["key"].(string)) != 64 || first["channel"] == "" {
t.Fatalf("newChannel: %v", first)
}
if bad["ok"] != false {
t.Fatalf("bad line: %v", bad)
}
if unknown["id"] != float64(9) || unknown["ok"] != false {
t.Fatalf("unknown method: %v", unknown)
}
}
func TestNodeWizardNeedsConnection(t *testing.T) {
w := newNodeWizard()
for _, m := range []string{"plan", "apply", "setCookies", "remove"} {
r := wizardCall(t, w, m, map[string]string{"channel": "c1"})
if r["ok"] != false || r["error"] != "нет подключения к серверу" {
t.Fatalf("%s: %v", m, r)
}
}
}
func TestNodeWizardHostKeyIsReportedNotTrusted(t *testing.T) {
w := newNodeWizard()
var got provision.Target
w.dial = func(_ context.Context, target provision.Target) (*provision.Conn, error) {
got = target
if target.HostKey == "" {
return nil, &provision.HostKeyError{Fingerprint: "SHA256:abc"}
}
return nil, &provision.HostKeyError{Fingerprint: "SHA256:new", Mismatch: true}
}
r := wizardCall(t, w, "connect", wizardParams{Host: " vds.example ", Port: 2222, User: " root ", Password: "pw"})
if r["ok"] != false || r["hostKey"] != "SHA256:abc" || r["trust"] != true || r["mismatch"] != false {
t.Fatalf("new server: %v", r)
}
if got.Host != "vds.example" || got.User != "root" || got.Port != 2222 || got.Password != "pw" {
t.Fatalf("target: %+v", got)
}
r = wizardCall(t, w, "connect", wizardParams{Host: "vds.example", User: "root", Password: "pw", HostKey: "SHA256:abc"})
if r["ok"] != false || r["mismatch"] != true || r["trust"] != false {
t.Fatalf("changed key: %v", r)
}
if strings.Contains(fmt.Sprint(r), "pw") {
t.Fatalf("password leaked into the reply: %v", r)
}
}
func TestNodeWizardCheckDocument(t *testing.T) {
w := newNodeWizard()
w.checkDoc = func(u string) (yandex.VolgaDocument, error) {
switch u {
case "edit":
return yandex.VolgaDocument{Editable: true}, nil
case "view":
return yandex.VolgaDocument{}, nil
default:
return yandex.VolgaDocument{}, fmt.Errorf("wrapped: %w", yandex.ErrCaptchaRequired)
}
}
if r := wizardCall(t, w, "checkDocument", wizardParams{DocumentURL: "edit"}); r["ok"] != true || r["editable"] != true {
t.Fatalf("editable: %v", r)
}
if r := wizardCall(t, w, "checkDocument", wizardParams{DocumentURL: "view"}); r["ok"] != false || r["captcha"] == true {
t.Fatalf("view only: %v", r)
}
if r := wizardCall(t, w, "checkDocument", wizardParams{DocumentURL: "captcha"}); r["ok"] != false || r["captcha"] != true {
t.Fatalf("captcha: %v", r)
}
}
func TestNodeWizardShareLinkAndSignIn(t *testing.T) {
w := newNodeWizard()
key := strings.Repeat("ab", 32)
doc := "https://docs.yandex.ru/edit/d/AbCdEfGhIjKlMnOpQrStUv"
r := wizardCall(t, w, "shareLink", wizardParams{Name: "Нода", DocumentURL: doc, Key: key, Host: "203.0.113.5", ChannelPort: 8445})
if r["ok"] != true {
t.Fatalf("shareLink: %v", r)
}
c, err := share.Decode(r["link"].(string))
if err != nil {
t.Fatal(err)
}
if !c.Negotiate || c.Secret != key || c.Context != doc || len(c.Transports) != 2 ||
c.Transports[0].Type != "vyandex" || c.Transports[1].Dial != "203.0.113.5:8445" {
t.Fatalf("link config: %+v", c)
}
if r := wizardCall(t, w, "shareLink", wizardParams{DocumentURL: doc, Key: key}); r["ok"] != false {
t.Fatalf("no host: %v", r)
}
if r := wizardCall(t, w, "signedIn", wizardParams{Cookies: "yandexuid=1; Session_id=s"}); r["signedIn"] != true {
t.Fatalf("signed in: %v", r)
}
if r := wizardCall(t, w, "signedIn", wizardParams{Cookies: "yandexuid=1"}); r["signedIn"] != false {
t.Fatalf("anonymous: %v", r)
}
}
+180
View File
@@ -0,0 +1,180 @@
package provision
import (
"context"
"errors"
"net"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"testing"
"time"
)
// TestInstallOnVDS drives the whole install against a real systemd host:
//
// OPENFLUX_TEST_SSH=127.0.0.1:22 sshd of the test VDS
// OPENFLUX_TEST_ROOT_PASSWORD=... root's password
// OPENFLUX_TEST_USER=deploy:... a sudoer that needs a password
// OPENFLUX_TEST_CORE_SHA=... sha256 of the core preinstalled
// as /opt/openflux-node/bin/openflux-<ver>
// OPENFLUX_TEST_DOC=https://docs.yandex.ru/edit/d/...
// OPENFLUX_TEST_PINNED=1 use the real pinned script and
// release core from GitHub instead
//
// The test process must share the VDS's loopback (docker --network
// container:<vds>): the VDS downloads the script from the test's server.
func TestInstallOnVDS(t *testing.T) {
addr := os.Getenv("OPENFLUX_TEST_SSH")
if addr == "" {
t.Skip("OPENFLUX_TEST_SSH not set")
}
host, portStr, _ := net.SplitHostPort(addr)
port, _ := strconv.Atoi(portStr)
user, userPass, _ := strings.Cut(os.Getenv("OPENFLUX_TEST_USER"), ":")
body, err := os.ReadFile("../deploy/node-install.sh")
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) })}
go srv.Serve(ln)
defer srv.Close()
allowPlainScriptURL = true
good := Script{URL: "http://" + ln.Addr().String() + "/node-install.sh", SHA256: ScriptHash(script)}
if os.Getenv("OPENFLUX_TEST_PINNED") != "" {
good = Pinned()
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
root := Target{Host: host, Port: port, User: "root", Password: os.Getenv("OPENFLUX_TEST_ROOT_PASSWORD")}
_, err = Dial(ctx, root)
var hk *HostKeyError
if !errors.As(err, &hk) || hk.Mismatch {
t.Fatalf("first dial: want an untrusted host key, got %v", err)
}
root.HostKey = "SHA256:not-it"
if _, err = Dial(ctx, root); !errors.As(err, &hk) || !hk.Mismatch {
t.Fatalf("want a host key mismatch, got %v", err)
}
root.HostKey = hk.Fingerprint
wrongPass := root
wrongPass.Password = "nope"
if _, err := Dial(ctx, wrongPass); err == nil || errors.As(err, &hk) {
t.Fatalf("want an auth failure, got %v", err)
}
c, err := Dial(ctx, root)
if err != nil {
t.Fatal(err)
}
if err := c.FetchScript(Script{URL: good.URL, SHA256: strings.Repeat("0", 64)}); err == nil {
t.Fatal("a script with another hash must be refused")
}
if err := c.FetchScript(good); err != nil {
t.Fatal(err)
}
p, err := c.Probe()
if err != nil || p.Sudo != "root" || !p.Systemd {
t.Fatalf("root probe: %+v %v", p, err)
}
c.Close()
d := Target{Host: host, Port: port, User: user, Password: userPass, HostKey: root.HostKey}
c, err = Dial(ctx, d)
if err != nil {
t.Fatal(err)
}
defer c.Close()
if err := c.FetchScript(good); err != nil {
t.Fatal(err)
}
if p, err = c.Probe(); err != nil || p.Sudo != "password" {
t.Fatalf("deploy probe: %+v %v", p, err)
}
id, _ := NewChannelID()
plan, err := c.Plan(id, 0, true)
if err != nil {
t.Fatal(err)
}
t.Logf("plan: %+v", plan)
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}
if err := c.Apply(ch, "wrong-password"); !errors.Is(err, ErrSudoPassword) {
t.Fatalf("wrong sudo password: got %v", err)
}
if out, _, _ := c.run("ls /tmp/openflux-node-conf.* 2>/dev/null | wc -l", nil); strings.TrimSpace(string(out)) != "0" {
t.Fatalf("config temp file left behind after a refused sudo: %s", out)
}
if err := c.Apply(ch, userPass); err != nil {
t.Fatal(err)
}
if out, _, _ := c.run("ls /tmp/openflux-node-conf.* 2>/dev/null | wc -l", nil); strings.TrimSpace(string(out)) != "0" {
t.Fatalf("config temp file left behind: %s", out)
}
if out, _, _ := c.run("systemctl is-active openflux-node@"+id, nil); strings.TrimSpace(string(out)) != "active" {
t.Fatalf("node not active: %s", out)
}
if out, _, _ := c.run("ps -eo args | grep -c '[o]penflux --config'", nil); strings.TrimSpace(string(out)) == "0" {
t.Fatal("no node process")
}
if out, _, _ := c.run("ps -eo args", nil); strings.Contains(string(out), key) {
t.Fatal("channel key visible in the process list")
}
if out, _, _ := c.run("stat -c '%a %U' /var/lib/openflux-node/"+id+"/cookies.json", nil); strings.TrimSpace(string(out)) != "600 openflux-node" {
t.Fatalf("cookies.json: %q", out)
}
if out, _, _ := c.run("sudo -S -p '' cat /var/lib/openflux-node/"+id+"/cookies.json", []byte(userPass+"\n")); !strings.Contains(string(out), "test-login-value") {
t.Fatalf("cookies.json content: %q", out)
}
if out, _, _ := c.run("ps -eo args; sudo -S -p '' journalctl -u openflux-node@"+id+" --no-pager", []byte(userPass+"\n")); strings.Contains(string(out), "test-login-value") {
t.Fatal("the Yandex login leaked into ps or the node's log")
}
fresh, _, _ := CookieStore(os.Getenv("OPENFLUX_TEST_DOC"), "Session_id=renewed-login")
if err := c.SetCookies(id, fresh, userPass); err != nil {
t.Fatal(err)
}
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 {
t.Fatal("planning an existing channel must fail")
}
again, err := c.Plan("other", 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)
if err != nil {
t.Fatal(err)
}
found := false
for _, u := range next.Untouched {
found = found || u == id
}
if !found {
t.Fatalf("new channel missing from untouched: %+v", next.Untouched)
}
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")
}
}
+19
View File
@@ -0,0 +1,19 @@
package provision
// Pinned is the node-install.sh this app build runs: the file at a fixed
// commit of the app's own repository and its SHA-256. TestPinnedScriptHash
// keeps the hash in step with deploy/node-install.sh; when the script
// changes, commit it, then point PinnedCommit at that commit.
const (
PinnedRepo = "meepo161/openfluxandroidfork"
PinnedCommit = "1d701ecca75657fee84c2123f04ff03cf651eeca"
PinnedSHA256 = "130fc5fef760bc144a007aeee41aad10e7e1d7ec168a6a51eb76fe70f9898fa0"
)
// Pinned returns the script location for this build.
func Pinned() Script {
return Script{
URL: "https://raw.githubusercontent.com/" + PinnedRepo + "/" + PinnedCommit + "/deploy/node-install.sh",
SHA256: PinnedSHA256,
}
}
+474
View File
@@ -0,0 +1,474 @@
// Package provision installs an OpenFlux exit channel on a user's VDS over
// SSH. It is the phone side of deploy/node-install.sh: it connects, has the
// VDS download the script by a pinned commit, checks the script's SHA-256
// and runs it. Each channel is an independent node (its own document, key,
// port and systemd instance), so installing one never touches another.
//
// Secrets (SSH and sudo passwords, the private key, the channel key and the
// document URL) never go into command lines or error messages: the channel
// config travels as a 0600 temp file, the sudo password on stdin.
package provision
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"regexp"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
var randRead = rand.Read
// allowPlainScriptURL lets tests serve the script over http from loopback.
var allowPlainScriptURL = false
// Script is where the VDS downloads node-install.sh from. The commit and the
// hash are pinned together: changing the script needs a new commit, a new
// pin and a new app build, so a changed file on GitHub is never run.
type Script struct {
URL string
SHA256 string
}
// Target is how to reach the VDS.
type Target struct {
Host string
Port int
User string
// Password or PrivateKey (PEM/OpenSSH), optionally with Passphrase.
Password string
PrivateKey string
Passphrase string
// HostKey is the trusted SHA256 fingerprint ("SHA256:..."); empty on
// the first connection, which then fails with a *HostKeyError so the
// user can compare it and trust it.
HostKey string
}
// HostKeyError reports a host key the user has not trusted yet (Mismatch
// false) or one that differs from the trusted key (Mismatch true).
type HostKeyError struct {
Fingerprint string
Mismatch bool
}
func (e *HostKeyError) Error() string {
if e.Mismatch {
return "ключ сервера изменился: " + e.Fingerprint + ". Возможна подмена сервера"
}
return "новый сервер, отпечаток ключа " + e.Fingerprint
}
// ErrSudoPassword means sudo rejected the password.
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"`
Channels []string `json:"channels"`
}
// Plan is what apply will change, for the confirmation screen.
type Plan struct {
Channel string `json:"channel"`
Port int `json:"port"`
Arch string `json:"arch"`
Core string `json:"core"`
Actions []string `json:"actions"`
Untouched []string `json:"untouched"`
}
// Channel is one channel's configuration.
type Channel struct {
ID string
URL string
Key string
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
}
// Conn is an SSH connection to the VDS with the script downloaded.
type Conn struct {
client *ssh.Client
script string // remote path of the verified script
sudo string
}
// Fingerprint formats a host key like OpenSSH does.
func Fingerprint(key ssh.PublicKey) string {
return ssh.FingerprintSHA256(key)
}
// Dial connects and authenticates. With an empty or different t.HostKey it
// fails with a *HostKeyError before sending any credentials.
func Dial(ctx context.Context, t Target) (*Conn, error) {
if t.Port == 0 {
t.Port = 22
}
if t.Host == "" || t.User == "" {
return nil, errors.New("укажите адрес сервера и логин")
}
var auths []ssh.AuthMethod
if t.PrivateKey != "" {
var signer ssh.Signer
var err error
if t.Passphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(t.PrivateKey), []byte(t.Passphrase))
} else {
signer, err = ssh.ParsePrivateKey([]byte(t.PrivateKey))
}
if err != nil {
var missing *ssh.PassphraseMissingError
if errors.As(err, &missing) {
return nil, errors.New("ключ защищён паролем: укажите его")
}
return nil, errors.New("не удалось прочитать приватный ключ")
}
auths = append(auths, ssh.PublicKeys(signer))
}
if t.Password != "" {
pw := t.Password
auths = append(auths, ssh.Password(pw),
ssh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
answers := make([]string, len(questions))
for i := range answers {
answers[i] = pw
}
return answers, nil
}))
}
if len(auths) == 0 {
return nil, errors.New("укажите пароль или приватный ключ")
}
config := &ssh.ClientConfig{
User: t.User,
Auth: auths,
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
fp := Fingerprint(key)
if t.HostKey == "" {
return &HostKeyError{Fingerprint: fp}
}
if fp != t.HostKey {
return &HostKeyError{Fingerprint: fp, Mismatch: true}
}
return nil
},
Timeout: 20 * time.Second,
}
addr := net.JoinHostPort(t.Host, strconv.Itoa(t.Port))
dialer := net.Dialer{Timeout: 20 * time.Second}
raw, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, fmt.Errorf("нет соединения с %s: %w", addr, err)
}
if deadline, ok := ctx.Deadline(); ok {
_ = raw.SetDeadline(deadline)
}
c, chans, reqs, err := ssh.NewClientConn(raw, addr, config)
if err != nil {
raw.Close()
var hk *HostKeyError
if errors.As(err, &hk) {
return nil, hk
}
if strings.Contains(err.Error(), "unable to authenticate") {
return nil, errors.New("сервер не принял логин, пароль или ключ")
}
return nil, fmt.Errorf("SSH: %v", err)
}
_ = raw.SetDeadline(time.Time{})
return &Conn{client: ssh.NewClient(c, chans, reqs)}, nil
}
// Close removes the downloaded script and closes the connection.
func (c *Conn) Close() error {
if c.script != "" {
_, _, _ = c.run("rm -f "+shellQuote(c.script), nil)
}
return c.client.Close()
}
func (c *Conn) run(cmd string, stdin []byte) (stdout, stderr []byte, err error) {
s, err := c.client.NewSession()
if err != nil {
return nil, nil, err
}
defer s.Close()
var out, errb bytes.Buffer
s.Stdout = &out
s.Stderr = &errb
if stdin != nil {
s.Stdin = bytes.NewReader(stdin)
}
err = s.Run(cmd)
return out.Bytes(), errb.Bytes(), err
}
var sha256Line = regexp.MustCompile(`(?m)^([0-9a-f]{64})\b`)
// FetchScript has the VDS download the installer and checks its SHA-256
// against the pinned one before anything runs it.
func (c *Conn) FetchScript(s Script) error {
secure := strings.HasPrefix(s.URL, "https://") ||
(allowPlainScriptURL && strings.HasPrefix(s.URL, "http://127.0.0.1:"))
if !secure || strings.ContainsAny(s.URL, "'\"\\ \n") {
return errors.New("неверный адрес скрипта")
}
cmd := `set -e; f=$(mktemp /tmp/openflux-node-install.XXXXXX); u='` + s.URL + `'
if command -v curl >/dev/null 2>&1; then curl -fsSL --retry 3 --connect-timeout 20 -o "$f" "$u"
elif command -v wget >/dev/null 2>&1; then wget -q -T 20 -t 3 -O "$f" "$u"
else echo "no-downloader" >&2; rm -f "$f"; exit 3; fi
chmod 0644 "$f"; echo "$f"
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$f"; else openssl dgst -sha256 -r "$f"; fi`
out, errb, err := c.run(cmd, nil)
if err != nil {
if strings.Contains(string(errb), "no-downloader") {
return errors.New("на сервере нет curl или wget")
}
return fmt.Errorf("сервер не смог скачать скрипт установки с GitHub: %s", firstLine(errb))
}
lines := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)
if len(lines) < 2 {
return errors.New("не удалось проверить скрипт установки")
}
path := strings.TrimSpace(lines[0])
m := sha256Line.FindStringSubmatch(lines[1])
if m == nil || !strings.HasPrefix(path, "/tmp/openflux-node-install.") {
return errors.New("не удалось проверить скрипт установки")
}
c.script = path
if !strings.EqualFold(m[1], s.SHA256) {
return errors.New("скрипт установки на GitHub отличается от проверенного, установка остановлена")
}
return nil
}
// Probe runs node-install.sh probe and remembers how to get root.
func (c *Conn) Probe() (*Probe, error) {
var p Probe
if err := c.script_("probe", nil, &p); err != nil {
return nil, err
}
c.sudo = p.Sudo
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"
}
if withCookies {
cfg += "cookies=yes\n"
}
var p Plan
if err := c.script_("plan", []byte(cfg), &p); err != nil {
return nil, err
}
return &p, nil
}
// 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.Cookies != "" {
cfg += "cookies=" + ch.Cookies + "\n"
}
return c.asRoot("apply", cfg, sudoPassword)
}
// SetCookies replaces an installed channel's Yandex sign-in (base64 cookie
// store JSON, see CookieStore) and restarts its node.
func (c *Conn) SetCookies(channel, cookies, sudoPassword string) error {
return c.asRoot("set-cookies", "channel="+channel+"\ncookies="+cookies+"\n", sudoPassword)
}
// CookieStore turns a Cookie header ("a=1; b=2", as the WebView keeps it
// for the document) into what Channel.Cookies takes: the core's cookie
// store JSON, keyed by the document URL like the node looks it up, then
// base64. signedIn reports whether the header holds a Yandex login.
func CookieStore(documentURL, header string) (cookies string, signedIn bool, err error) {
jar := map[string]string{}
for _, part := range strings.Split(header, ";") {
name, value, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok || name == "" || strings.ContainsAny(name+value, "\r\n") {
continue
}
jar[name] = value
}
if len(jar) == 0 {
return "", false, errors.New("нет cookies Яндекса")
}
raw, err := json.Marshal(map[string]map[string]string{documentURL: jar})
if err != nil {
return "", false, err
}
_, signedIn = jar["Session_id"]
return base64.StdEncoding.EncodeToString(raw), signedIn, nil
}
// Remove stops and deletes the channel; the last one also removes the
// binaries, unit and system user the script installed.
func (c *Conn) Remove(channel, sudoPassword string) error {
return c.asRoot("remove", "channel="+channel+"\n", sudoPassword)
}
func (c *Conn) asRoot(command, cfg, sudoPassword string) error {
if c.script == "" {
return errors.New("скрипт установки не загружен")
}
// The config goes into a private temp file (the script deletes it right
// after reading) so stdin is free for sudo's password.
out, _, err := c.run(`umask 077; f=$(mktemp /tmp/openflux-node-conf.XXXXXX) && cat > "$f" && echo "$f"`, []byte(cfg))
if err != nil {
return errors.New("не удалось передать конфигурацию на сервер")
}
conf := strings.TrimSpace(string(out))
if !strings.HasPrefix(conf, "/tmp/openflux-node-conf.") {
return errors.New("не удалось передать конфигурацию на сервер")
}
script := "sh " + shellQuote(c.script) + " " + command + " " + shellQuote(conf)
var stdin []byte
switch c.sudo {
case "root":
case "nopasswd":
script = "sudo -n " + script
case "password":
if sudoPassword == "" {
_, _, _ = c.run("rm -f "+shellQuote(conf), nil)
return errors.New("для sudo нужен пароль")
}
script = "sudo -S -p '' " + script
stdin = []byte(sudoPassword + "\n")
default:
_, _, _ = c.run("rm -f "+shellQuote(conf), nil)
return errors.New("у пользователя нет прав root и sudo")
}
stdout, stderr, runErr := c.run(script, stdin)
var res struct {
OK bool `json:"ok"`
Step string `json:"step"`
Error string `json:"error"`
}
if jerr := lastJSON(stdout, &res); jerr != nil {
// The script did not run (sudo refused, or it crashed). Its config
// file may still be there.
_, _, _ = c.run("rm -f "+shellQuote(conf), nil)
if isSudoRefusal(stderr) {
return ErrSudoPassword
}
if runErr != nil {
return fmt.Errorf("%s не выполнен: %s", command, firstLine(stderr))
}
return jerr
}
if !res.OK {
return fmt.Errorf("%s", res.Error)
}
return nil
}
// script_ runs a read-only command of the script and decodes its JSON.
func (c *Conn) script_(command string, stdin []byte, into interface{}) error {
if c.script == "" {
return errors.New("скрипт установки не загружен")
}
stdout, stderr, _ := c.run("sh "+shellQuote(c.script)+" "+command, stdin)
var res struct {
OK bool `json:"ok"`
Error string `json:"error"`
}
if err := lastJSON(stdout, &res); err != nil {
return fmt.Errorf("скрипт установки не ответил: %s", firstLine(stderr))
}
if !res.OK {
return fmt.Errorf("%s", res.Error)
}
return lastJSON(stdout, into)
}
func lastJSON(out []byte, into interface{}) error {
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for i := len(lines) - 1; i >= 0; i-- {
l := strings.TrimSpace(lines[i])
if strings.HasPrefix(l, "{") {
return json.Unmarshal([]byte(l), into)
}
}
return errors.New("пустой ответ скрипта установки")
}
func isSudoRefusal(stderr []byte) bool {
s := strings.ToLower(string(stderr))
return strings.Contains(s, "incorrect password") || strings.Contains(s, "sorry, try again") ||
strings.Contains(s, "password is required") || strings.Contains(s, "no password was provided") ||
strings.Contains(s, "неверный пароль")
}
func firstLine(b []byte) string {
s := strings.TrimSpace(string(b))
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
if len(s) > 200 {
s = s[:200]
}
return s
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// NewKey returns a fresh channel key: 32 random bytes, hex encoded, as
// --encryption-key-file and the app's Session profiles take it.
func NewKey() (string, error) {
var b [32]byte
if _, err := randRead(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
// NewChannelID returns a short random channel name, "of-" and 6 base32
// characters, valid for node-install.sh.
func NewChannelID() (string, error) {
const alphabet = "abcdefghijklmnopqrstuvwxyz234567"
var b [6]byte
if _, err := randRead(b[:]); err != nil {
return "", err
}
out := []byte("of-")
for _, x := range b {
out = append(out, alphabet[int(x)%len(alphabet)])
}
return string(out), nil
}
// ScriptHash returns the hex SHA-256 of a script body.
func ScriptHash(body []byte) string {
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
}
+87
View File
@@ -0,0 +1,87 @@
package provision
import (
"encoding/base64"
"encoding/json"
"os"
"regexp"
"testing"
)
func TestPinnedScriptHash(t *testing.T) {
body, err := os.ReadFile("../deploy/node-install.sh")
if err != nil {
t.Fatal(err)
}
if got := ScriptHash(body); got != PinnedSHA256 {
t.Fatalf("deploy/node-install.sh changed: sha256 %s, pinned %s; commit it and update pin.go", got, PinnedSHA256)
}
}
func TestNewChannelIDMatchesScript(t *testing.T) {
re := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,30}$`)
seen := map[string]bool{}
for i := 0; i < 200; i++ {
id, err := NewChannelID()
if err != nil {
t.Fatal(err)
}
if !re.MatchString(id) {
t.Fatalf("bad id %q", id)
}
seen[id] = true
}
if len(seen) < 190 {
t.Fatalf("ids repeat too often: %d unique of 200", len(seen))
}
}
func TestNewKey(t *testing.T) {
k, err := NewKey()
if err != nil {
t.Fatal(err)
}
if !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(k) {
t.Fatalf("bad key %q", k)
}
}
func TestLastJSON(t *testing.T) {
var v struct {
OK bool `json:"ok"`
}
if err := lastJSON([]byte("noise\n{\"ok\":true}\n\n"), &v); err != nil || !v.OK {
t.Fatalf("lastJSON: %v %v", err, v)
}
if err := lastJSON([]byte("no json"), &v); err == nil {
t.Fatal("want error")
}
}
func TestSudoRefusal(t *testing.T) {
if !isSudoRefusal([]byte("Sorry, try again.\nsudo: 1 incorrect password attempt")) {
t.Fatal("want refusal")
}
if isSudoRefusal([]byte("sh: 1: foo: not found")) {
t.Fatal("unexpected refusal")
}
}
func TestCookieStoreFormat(t *testing.T) {
b64, signedIn, err := CookieStore("https://docs.yandex.ru/edit/d/x", " Session_id=a=b ; yandexuid=1;bad;\nx=y")
if err != nil || !signedIn {
t.Fatalf("%v %v", signedIn, err)
}
raw, _ := base64.StdEncoding.DecodeString(b64)
var store map[string]map[string]string
if err := json.Unmarshal(raw, &store); err != nil {
t.Fatal(err)
}
jar := store["https://docs.yandex.ru/edit/d/x"]
if jar["Session_id"] != "a=b" || jar["yandexuid"] != "1" || len(jar) != 3 {
t.Fatalf("jar = %v", jar)
}
if _, signedIn, _ := CookieStore("u", "yandexuid=1"); signedIn {
t.Fatal("no Session_id means not signed in")
}
}
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
package cupsonline
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"universal-bypass-tool/transport"
)
const (
roomA = "11111111-1111-1111-1111-111111111111"
roomB = "22222222-2222-2222-2222-222222222222"
)
func TestParseRoomListAcceptsEveryForm(t *testing.T) {
packed := packRooms([]string{roomA, roomB})
for name, in := range map[string]string{
"bare base64": packed,
"?rooms=": "https://example/?rooms=" + packed,
"surrounding ": " " + packed + "\n",
} {
got := parseRoomList(in)
if strings.Join(got, ",") != roomA+","+roomB {
t.Errorf("%s: got %q", name, got)
}
}
if got := parseRoomList("https://example/?room=" + roomA); len(got) != 1 || got[0] != roomA {
t.Errorf("?room=: got %q", got)
}
for _, in := range []string{"", "http://#", "not base64 at all"} {
if got := parseRoomList(in); len(got) != 0 {
t.Errorf("%q: want no rooms, got %q", in, got)
}
}
}
// An exit node given the printed list must re-join those rooms rather than
// create new ones; without one it creates. A client can't start without one.
func TestExitNodeReusesRoomsFromURL(t *testing.T) {
packed := packRooms([]string{roomA, roomB})
cfg := transport.DefaultConfig()
exit := NewCupsonlineTransport(packed, cfg, false)
if len(exit.roomIDs) != 2 || exit.clientErr != nil {
t.Fatalf("exit with a list: roomIDs=%q err=%v", exit.roomIDs, exit.clientErr)
}
fresh := NewCupsonlineTransport("http://#", cfg, false)
if len(fresh.roomIDs) != 0 || fresh.clientErr != nil {
t.Fatalf("exit without a list must create rooms: roomIDs=%q err=%v", fresh.roomIDs, fresh.clientErr)
}
if client := NewCupsonlineTransport("", cfg, true); client.clientErr == nil {
t.Fatal("a client without a room list must refuse to start")
}
}
func TestAuthorizeReportsGoneRooms(t *testing.T) {
for name, handler := range map[string]http.HandlerFunc{
"404": func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) },
"no uuid": func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("<html>room closed</html>")) },
"server 502": func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadGateway) },
} {
srv := httptest.NewServer(handler)
_, err := authorize(context.Background(), srv.URL, nil)
srv.Close()
gone := errors.Is(err, errRoomGone)
if wantGone := name != "server 502"; gone != wantGone {
t.Errorf("%s: gone=%v want %v (err %v)", name, gone, wantGone, err)
}
}
}
func TestReadReplySurfacesRefusal(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
conn.WriteMessage(websocket.TextMessage, []byte(`{"id":1,"connect":{"client":"x"}}`))
conn.WriteMessage(websocket.TextMessage, []byte(`{"id":2,"error":{"code":109,"message":"token expired"}}`))
conn.ReadMessage()
}))
defer srv.Close()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := readReply(conn, time.Second, 1, "connect", nil); err != nil {
t.Fatalf("a normal reply must pass: %v", err)
}
err = readReply(conn, time.Second, 2, "subscribe", nil)
if err == nil || !strings.Contains(err.Error(), "token expired") || !errors.Is(err, errRefused) {
t.Fatalf("a refusal must come back as errRefused, got %v", err)
}
}
func TestFmtUptime(t *testing.T) {
for d, want := range map[time.Duration]string{
47*time.Hour + 12*time.Minute: "47ч12м",
3*time.Minute + 5*time.Second: "3м05с",
} {
if got := fmtUptime(d); got != want {
t.Errorf("%v: got %q want %q", d, got, want)
}
}
}
+842
View File
@@ -0,0 +1,842 @@
package cupsonline
import (
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"universal-bypass-tool/transport"
)
// fakeCups stands in for cups.online: room pages, subscription tokens and a
// Centrifugo that relays cursor updates to everyone in a room. The tests run
// an exit node and a client through it end to end, and break it on purpose.
type fakeCups struct {
srv *httptest.Server
mu sync.Mutex
users map[string]string // session cookie -> user uuid
rooms map[string]bool // room uuid -> still open
subs map[string]map[*fakeConn]bool
conns map[*fakeConn]bool
pageHits map[string]int
failPages map[string]int // room -> page loads still to fail with 502
failJoins bool // every existing room's page fails with 502; creating still works
// redirectGone makes a closed room's page hand out a brand-new room, the
// way cups does, instead of a 404.
redirectGone bool
// pack sends a ping and the push in one frame, one per line.
pack bool
// stall makes page loads hang until it's closed, like a dead network.
stall chan struct{}
created int
maxCursors int
}
type fakeConn struct {
ws *websocket.Conn
mu sync.Mutex
}
func (c *fakeConn) send(msg string) {
c.mu.Lock()
defer c.mu.Unlock()
c.ws.WriteMessage(websocket.TextMessage, []byte(msg))
}
func newFakeCups(t *testing.T) *fakeCups {
f := &fakeCups{
users: map[string]string{},
rooms: map[string]bool{},
subs: map[string]map[*fakeConn]bool{},
conns: map[*fakeConn]bool{},
pageHits: map[string]int{},
failPages: map[string]int{},
}
mux := http.NewServeMux()
mux.HandleFunc("/live-coding/", f.page)
mux.HandleFunc("/sub/", f.subToken)
mux.HandleFunc("/connection/websocket", f.centrifugo)
f.srv = httptest.NewServer(mux)
old := baseRoomURL
baseRoomURL = f.srv.URL + "/live-coding/"
t.Cleanup(func() {
baseRoomURL = old
f.mu.Lock()
for c := range f.conns {
c.ws.Close()
}
f.mu.Unlock()
f.srv.Close()
})
return f
}
func newUUID() string {
var b [16]byte
rand.Read(b[:])
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
func (f *fakeCups) page(w http.ResponseWriter, r *http.Request) {
var stall chan struct{}
f.locked(func() { stall = f.stall })
if stall != nil {
select {
case <-stall:
case <-r.Context().Done():
}
return
}
f.mu.Lock()
defer f.mu.Unlock()
user := ""
if c, err := r.Cookie("sessionid"); err == nil {
user = f.users[c.Value]
}
if user == "" {
session := newUUID()
user = newUUID()
f.users[session] = user
http.SetCookie(w, &http.Cookie{Name: "sessionid", Value: session, Path: "/"})
}
http.SetCookie(w, &http.Cookie{Name: "csrftoken", Value: "csrf-" + user, Path: "/"})
room := r.URL.Query().Get("room")
if room != "" {
f.pageHits[room]++
}
open, known := f.rooms[room]
switch {
case room != "" && (f.failJoins || f.failPages[room] > 0):
f.failPages[room]--
http.Error(w, "bad gateway", http.StatusBadGateway)
return
case room == "" || (known && !open && f.redirectGone):
room = newUUID()
f.rooms[room] = true
f.created++
case !open:
http.NotFound(w, r)
return
}
fmt.Fprintf(w, `<html><head>
<meta name="centrifuge-connection-token" content="conn-%s">
<meta name="centrifuge-connection-url" content="%s/connection">
<meta name="centrifuge-subscription-token-url" content="%s/sub/">
</head><body><div data-room="{&quot;uuid&quot;: &quot;%s&quot;}" data-user="{&quot;uuid&quot;: &quot;%s&quot;}"></div></body></html>`,
user, f.srv.URL, f.srv.URL, room, user)
}
func (f *fakeCups) subToken(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("csrftoken")
if r.Method != http.MethodPost || err != nil || r.Header.Get("X-CSRFToken") != c.Value {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
var body struct {
Channel string `json:"channel"`
}
json.NewDecoder(r.Body).Decode(&body)
json.NewEncoder(w).Encode(map[string]string{"token": "sub-" + body.Channel})
}
func (f *fakeCups) centrifugo(w http.ResponseWriter, r *http.Request) {
ws, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
c := &fakeConn{ws: ws}
f.mu.Lock()
f.conns[c] = true
f.mu.Unlock()
defer func() {
f.mu.Lock()
delete(f.conns, c)
for _, s := range f.subs {
delete(s, c)
}
f.mu.Unlock()
ws.Close()
}()
for {
_, raw, err := ws.ReadMessage()
if err != nil {
return
}
if string(raw) == "{}" {
continue
}
var cmd struct {
ID int `json:"id"`
Connect any `json:"connect"`
Subscribe *struct {
Channel string `json:"channel"`
} `json:"subscribe"`
RPC *struct {
Method string `json:"method"`
Data struct {
Cursors []struct {
Row json.Number `json:"row"`
Column json.Number `json:"column"`
} `json:"cursors"`
Room string `json:"room"`
User string `json:"user"`
} `json:"data"`
} `json:"rpc"`
}
if json.Unmarshal(raw, &cmd) != nil {
return
}
switch {
case cmd.Connect != nil:
c.send(fmt.Sprintf(`{"id":%d,"connect":{"client":"x","ping":25,"pong":true}}`, cmd.ID))
case cmd.Subscribe != nil:
room := strings.TrimPrefix(cmd.Subscribe.Channel, "$shared_editor:room-")
f.mu.Lock()
open := f.rooms[room]
if open {
if f.subs[room] == nil {
f.subs[room] = map[*fakeConn]bool{}
}
f.subs[room][c] = true
}
f.mu.Unlock()
if !open {
c.send(fmt.Sprintf(`{"id":%d,"error":{"code":103,"message":"permission denied"}}`, cmd.ID))
continue
}
c.send(fmt.Sprintf(`{"id":%d,"subscribe":{}}`, cmd.ID))
case cmd.RPC != nil:
c.send(fmt.Sprintf(`{"id":%d,"rpc":{}}`, cmd.ID))
if d := cmd.RPC.Data; cmd.RPC.Method == "shared_editor_change_cursors" && len(d.Cursors) > 0 {
// Model the real server: a non-numeric row/column is rejected,
// anything else is relayed verbatim and in order.
cursors := make([]any, len(d.Cursors))
for i, cur := range d.Cursors {
row, errR := cur.Row.Int64()
col, errC := cur.Column.Int64()
if errR != nil || errC != nil {
c.send(fmt.Sprintf(`{"id":%d,"error":{"code":400,"message":"column must be a number"}}`, cmd.ID))
cursors = nil
break
}
cursors[i] = map[string]any{"row": row, "column": col}
}
if cursors != nil {
f.relay(d.Room, d.User, cursors)
}
}
}
}
}
// relay hands a cursor update to everyone in the room, the sender included,
// the way Centrifugo does.
func (f *fakeCups) relay(room, user string, cursors []any) {
push, _ := json.Marshal(map[string]any{"push": map[string]any{
"channel": "$shared_editor:room-" + room,
"pub": map[string]any{"data": map[string]any{
"type": "cursors_update",
"payload": map[string]any{
"user_uuid": user,
"cursors": cursors,
},
}},
}})
msg := string(push)
f.mu.Lock()
f.maxCursors = max(f.maxCursors, len(cursors))
if f.pack {
msg = "{}\n" + msg
}
var to []*fakeConn
for c := range f.subs[room] {
to = append(to, c)
}
f.mu.Unlock()
for _, c := range to {
c.send(msg)
}
}
// closeRoom closes a room for good and hangs up on everyone in it.
func (f *fakeCups) closeRoom(room string) {
f.mu.Lock()
defer f.mu.Unlock()
f.rooms[room] = false
for c := range f.subs[room] {
c.ws.Close()
}
delete(f.subs, room)
}
// dropConns hangs up on everyone in a room that stays open, like a network
// blip.
func (f *fakeCups) dropConns(room string) {
f.mu.Lock()
defer f.mu.Unlock()
for c := range f.subs[room] {
c.ws.Close()
}
}
func (f *fakeCups) locked(fn func()) {
f.mu.Lock()
defer f.mu.Unlock()
fn()
}
func (f *fakeCups) hits(room string) (n int) {
f.locked(func() { n = f.pageHits[room] })
return
}
func (f *fakeCups) roomsCreated() (n int) {
f.locked(func() { n = f.created })
return
}
func (f *fakeCups) openConns() (n int) {
f.locked(func() { n = len(f.conns) })
return
}
// ---- helpers ----
func fastConfig() CupsonlineConfig {
c := DefaultCupsonlineConfig()
c.NumRooms = 3
c.RoomCreatePause = time.Millisecond
c.ReconnectMinDelay = 20 * time.Millisecond
c.ReconnectMaxDelay = 200 * time.Millisecond
c.RoomGoneRetryMin = 300 * time.Millisecond
c.RoomGoneRetryMax = 300 * time.Millisecond
c.WSHandshakeTimeout = 2 * time.Second
c.StatsInterval = time.Hour
c.SendInterval = 0 // no throttling against the in-process fake
return c
}
type peer struct {
*CupsonlineTransport
got chan []byte
}
func startPeer(t *testing.T, url string, isClient bool, cfg CupsonlineConfig) (*peer, error) {
tr := NewCupsonlineTransport(url, transport.DefaultConfig(), isClient)
tr.config = cfg
p := &peer{CupsonlineTransport: tr, got: make(chan []byte, 4096)}
tr.Receive(func(b []byte) { p.got <- append([]byte(nil), b...) })
t.Cleanup(func() { tr.Stop() })
if err := tr.Start(); err != nil {
return nil, err
}
return p, nil
}
func mustStart(t *testing.T, url string, isClient bool, cfg CupsonlineConfig) *peer {
t.Helper()
p, err := startPeer(t, url, isClient, cfg)
if err != nil {
t.Fatalf("start (client=%v): %v", isClient, err)
}
return p
}
// startPair brings up an exit node that creates the rooms and a client that
// joins them from the printed string, and waits until every channel is up.
func startPair(t *testing.T, cfg CupsonlineConfig) (exit, client *peer) {
t.Helper()
exit = mustStart(t, "", false, cfg)
client = mustStart(t, packRooms(exit.RoomUUIDs()), true, cfg)
waitFor(t, 5*time.Second, "all channels up", func() bool {
return allConnected(exit) && allConnected(client)
})
return exit, client
}
func allConnected(p *peer) bool {
for _, ws := range p.wss {
if !ws.connected.Load() {
return false
}
}
return true
}
func roomDead(p *peer, room string) bool {
for _, ws := range p.wss {
if ws.roomUUID == room {
return ws.dead.Load()
}
}
return false
}
func waitFor(t *testing.T, within time.Duration, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(within)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", what)
}
time.Sleep(10 * time.Millisecond)
}
}
// deliver keeps sending until a copy gets through: right after a room dies
// the first few can be lost, like packets on any link.
func deliver(t *testing.T, from, to *peer, payload []byte, within time.Duration) {
t.Helper()
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
from.Send(payload)
wait := time.After(100 * time.Millisecond)
drain:
for {
select {
case got := <-to.got:
if bytes.Equal(got, payload) {
return
}
case <-wait:
break drain
}
}
}
t.Fatalf("%q never got through", payload)
}
func next(t *testing.T, p *peer) []byte {
t.Helper()
select {
case got := <-p.got:
return got
case <-time.After(5 * time.Second):
t.Fatal("nothing arrived")
return nil
}
}
// drain discards anything already delivered to a peer, so one phase's
// leftovers don't bleed into the next.
func drain(p *peer) {
for {
select {
case <-p.got:
default:
return
}
}
}
// expectSet reads len(want) payloads and checks they are exactly want as a
// multiset. Order isn't asserted: frames spread across rooms can arrive in a
// different order, and only that every one arrives intact and once matters.
func expectSet(t *testing.T, p *peer, want [][]byte) {
t.Helper()
remaining := make(map[string]int, len(want))
for _, w := range want {
remaining[string(w)]++
}
for range want {
got := next(t, p)
k := string(got)
if remaining[k] == 0 {
t.Fatalf("unexpected or duplicate payload of %d bytes", len(got))
}
remaining[k]--
}
}
// roomsUsed counts how many of a peer's channels have sent at least one packet.
func roomsUsed(p *peer) (n int) {
for _, ws := range p.wss {
if ws.stats.packetsSent.Load() > 0 {
n++
}
}
return
}
// frame looks like what really reaches the transport: a codec frame (here
// the batched one, 0x02...), not an IP packet.
func frame(i int) []byte {
return append([]byte{0x02, 0x01, byte(i >> 8), byte(i)}, bytes.Repeat([]byte{0x5a}, 100)...)
}
// ---- tests ----
func TestFramesCrossBothWays(t *testing.T) {
newFakeCups(t)
exit, client := startPair(t, fastConfig())
var want [][]byte
for i := 0; i < 100; i++ {
f := frame(i)
want = append(want, f)
if err := client.Send(f); err != nil {
t.Fatal(err)
}
}
expectSet(t, exit, want)
// Spreading is the point: 100 frames must not all funnel through one room.
if used := roomsUsed(client); used < 2 {
t.Fatalf("frames used only %d room(s), expected them spread", used)
}
exit.Send(frame(1000))
if got := next(t, client); !bytes.Equal(got, frame(1000)) {
t.Fatalf("reply: got %x", got[:4])
}
}
// A room closes: the traffic it would have carried has to keep flowing through
// the rooms still up, both ways, instead of the tunnel going dark.
func TestTrafficSurvivesLosingARoom(t *testing.T) {
f := newFakeCups(t)
exit, client := startPair(t, fastConfig())
deliver(t, client, exit, []byte("before-up"), 2*time.Second)
deliver(t, exit, client, []byte("before-down"), 2*time.Second)
gone := client.wss[0].roomUUID
f.closeRoom(gone)
waitFor(t, 5*time.Second, "the closed room reported on both sides", func() bool {
return roomDead(client, gone) && roomDead(exit, gone)
})
// The surviving rooms still carry traffic both ways.
deliver(t, client, exit, []byte("after-up"), 5*time.Second)
deliver(t, exit, client, []byte("after-down"), 5*time.Second)
if !client.IsConnected() || !exit.IsConnected() {
t.Fatal("other rooms are up, the transport must still say connected")
}
}
func TestClosedRoomIsProbedSlowly(t *testing.T) {
f := newFakeCups(t)
f.redirectGone = true // every probe makes cups hand out a new room
cfg := fastConfig()
cfg.RoomGoneRetryMin, cfg.RoomGoneRetryMax = 500*time.Millisecond, 500*time.Millisecond
exit, client := startPair(t, cfg)
room := client.wss[0].roomUUID
f.closeRoom(room)
waitFor(t, 5*time.Second, "room reported closed on both sides", func() bool {
return roomDead(client, room) && roomDead(exit, room)
})
hits, created := f.hits(room), f.roomsCreated()
time.Sleep(2 * time.Second)
// Both peers probe it: at most 2 s / 500 ms + 1 each.
if n := f.hits(room) - hits; n > 10 {
t.Fatalf("closed room probed %d times in 2 s", n)
}
if n := f.roomsCreated() - created; n > 10 {
t.Fatalf("%d rooms made by probing a closed one in 2 s", n)
}
}
// A connection that drops comes back with the tokens it has. Loading the
// room page on every reconnect turns a flaky network into a flood of page
// loads, and that's how an IP gets rate-limited.
func TestDroppedConnectionReconnectsWithoutReloadingThePage(t *testing.T) {
f := newFakeCups(t)
exit, client := startPair(t, fastConfig())
room := client.wss[0].roomUUID
hits := f.hits(room)
for i := 0; i < 5; i++ {
f.dropConns(room)
waitFor(t, 5*time.Second, "reconnect", func() bool {
return client.wss[0].stats.reconnects.Load() > uint64(i) && allConnected(client) && allConnected(exit)
})
}
if n := f.hits(room) - hits; n != 0 {
t.Fatalf("room page loaded %d times over 5 dropped connections", n)
}
deliver(t, client, exit, []byte("still works"), 2*time.Second)
}
// A room the client couldn't enter at start still gets a channel and joins
// once it can. Dropping it left the exit node's traffic in it unheard.
func TestClientRetriesRoomItCouldNotEnterAtStart(t *testing.T) {
f := newFakeCups(t)
cfg := fastConfig()
exit := mustStart(t, "", false, cfg)
ids := exit.RoomUUIDs()
f.locked(func() { f.failPages[ids[1]] = 3 })
client := mustStart(t, packRooms(ids), true, cfg)
if len(client.wss) != len(ids) {
t.Fatalf("client keeps %d of %d rooms", len(client.wss), len(ids))
}
waitFor(t, 5*time.Second, "late room joined", func() bool { return client.wss[1].connected.Load() })
waitFor(t, 5*time.Second, "exit node up", func() bool { return allConnected(exit) })
// The late room must carry its share once it joins: send enough that the
// round-robin certainly uses it, and check it did and everything arrived.
before := exit.wss[1].stats.packetsSent.Load()
var want [][]byte
for i := 0; i < 12; i++ {
p := []byte{byte(i), 0xaa}
want = append(want, p)
if err := exit.Send(p); err != nil {
t.Fatal(err)
}
}
expectSet(t, client, want)
if exit.wss[1].stats.packetsSent.Load() == before {
t.Fatal("the late room carried no traffic")
}
}
func TestExitKeepsSavedRoomsThroughNetworkTrouble(t *testing.T) {
f := newFakeCups(t)
cfg := fastConfig()
first := mustStart(t, "", false, cfg)
ids := first.RoomUUIDs()
packed := packRooms(ids)
first.Stop()
created := f.roomsCreated()
f.locked(func() { f.failJoins = true })
if _, err := startPeer(t, packed, false, cfg); err == nil {
t.Fatal("exit node must refuse to start while its rooms don't answer")
}
if f.roomsCreated() != created {
t.Fatal("new rooms made over a network error: the phone's string is lost")
}
f.locked(func() { f.failJoins = false })
again := mustStart(t, packed, false, cfg)
if strings.Join(again.RoomUUIDs(), ",") != strings.Join(ids, ",") || f.roomsCreated() != created {
t.Fatal("restart with the saved string must reuse the same rooms")
}
again.Stop()
for _, id := range ids {
f.closeRoom(id)
}
fresh := mustStart(t, packed, false, cfg)
if f.roomsCreated() != created+cfg.NumRooms {
t.Fatalf("all saved rooms gone: want %d new rooms, got %d", cfg.NumRooms, f.roomsCreated()-created)
}
for _, id := range fresh.RoomUUIDs() {
if strings.Contains(packed, id) {
t.Fatal("reused a closed room")
}
}
}
func TestClientWithoutReachableRoomsFails(t *testing.T) {
f := newFakeCups(t)
exit := mustStart(t, "", false, fastConfig())
packed := packRooms(exit.RoomUUIDs())
f.locked(func() { f.failJoins = true })
if _, err := startPeer(t, packed, true, fastConfig()); err == nil {
t.Fatal("client with no room to enter must not report success")
}
}
func TestBatchesStayUnderCursorLimit(t *testing.T) {
f := newFakeCups(t)
cfg := fastConfig()
exit, client := startPair(t, cfg)
body := bytes.Repeat([]byte{0xab}, 7000)
var want [][]byte
for i := 0; i < 200; i++ {
p := append([]byte{byte(i)}, body...)
want = append(want, p)
if err := client.Send(p); err != nil {
t.Fatal(err)
}
}
expectSet(t, exit, want)
f.locked(func() {
if f.maxCursors > cfg.MaxCursors {
t.Fatalf("%d cursors in one message, limit %d", f.maxCursors, cfg.MaxCursors)
}
})
if err := client.Send(make([]byte, cfg.MaxPayloadBytes+1)); err == nil {
t.Fatal("a payload that can't fit in one message must be refused, not sent")
}
}
// A packet larger than one message must be split on send and stitched back
// on receive, in order and intact. This is the throughput path that broke
// against the real server when a whole batch went in one oversized message.
func TestLargePacketsSplitAndReassemble(t *testing.T) {
f := newFakeCups(t)
cfg := fastConfig()
exit, client := startPair(t, cfg)
// Each packet spans several messages; distinct contents so a swap shows.
const n = 40
sizes := []int{cfg.MaxMessageData - 5, cfg.MaxMessageData, cfg.MaxMessageData + 5, 3*cfg.MaxMessageData + 1, 8192}
want := make([][]byte, n)
for i := 0; i < n; i++ {
size := sizes[i%len(sizes)]
p := make([]byte, size)
for j := range p {
p[j] = byte(i*31 + j)
}
want[i] = p
if err := client.Send(p); err != nil {
t.Fatalf("packet %d (%d bytes): %v", i, size, err)
}
}
// Each packet reassembles within whichever room carried it; across rooms
// they may arrive in a different order, so check the set, not the order.
expectSet(t, exit, want)
f.locked(func() {
if f.maxCursors > cfg.MaxCursors {
t.Fatalf("%d cursors in one message, over the %d limit", f.maxCursors, cfg.MaxCursors)
}
})
}
func TestNumberPackingRoundTrips(t *testing.T) {
for _, n := range []int{0, 1, 2, 5, 6, 7, 11, 12, 13, 100, 4096} {
in := make([]byte, n)
for i := range in {
in[i] = byte(i*7 + 1) // never all-zero, so nothing looks like padding
}
out := bytesFromNumbers(packNumbers(in))
if len(out) < len(in) || !bytes.Equal(out[:len(in)], in) {
t.Fatalf("len %d did not round-trip", n)
}
// padding is only trailing zeros
for _, b := range out[len(in):] {
if b != 0 {
t.Fatalf("len %d: non-zero padding", n)
}
}
}
// values must stay inside what cups keeps exact (< 2^53).
for _, v := range packNumbers(bytes.Repeat([]byte{0xff}, 4096)) {
if v >= 1<<(8*bytesPerNumber) {
t.Fatalf("number %d exceeds %d bytes", v, bytesPerNumber)
}
}
}
// Centrifugo may pack several messages into one frame; parsing it whole
// used to lose every packet in it.
func TestPackedFramesAreTakenApart(t *testing.T) {
f := newFakeCups(t)
f.pack = true
exit, client := startPair(t, fastConfig())
deliver(t, client, exit, []byte("up"), 2*time.Second)
deliver(t, exit, client, []byte("down"), 2*time.Second)
}
func TestStopHangsUpRightAway(t *testing.T) {
f := newFakeCups(t)
_, client := startPair(t, fastConfig())
before := f.openConns()
client.Stop()
waitFor(t, time.Second, "client sockets closed", func() bool {
return f.openConns() == before-len(client.wss)
})
if client.IsConnected() {
t.Fatal("stopped transport says connected")
}
if err := client.Send([]byte("x")); err == nil {
t.Fatal("send after stop must fail")
}
client.Stop() // twice must be harmless
}
func TestStopDuringStartAbortsIt(t *testing.T) {
f := newFakeCups(t)
exit := mustStart(t, "", false, fastConfig())
packed := packRooms(exit.RoomUUIDs())
stall := make(chan struct{})
defer close(stall)
f.locked(func() { f.stall = stall })
tr := NewCupsonlineTransport(packed, transport.DefaultConfig(), true)
tr.config = fastConfig()
done := make(chan error, 1)
go func() { done <- tr.Start() }()
time.Sleep(100 * time.Millisecond)
tr.Stop()
select {
case err := <-done:
if err == nil {
t.Fatal("Start after Stop must not report success")
}
case <-time.After(2 * time.Second):
t.Fatal("Stop didn't cut Start short")
}
}
// gorilla allocates the socket buffers whole for every connection: at the
// old 32 MB each, four rooms took 256 MB on the phone, again on every
// reconnect.
func TestChannelsDontHoardMemory(t *testing.T) {
newFakeCups(t)
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
exit, client := startPair(t, fastConfig())
runtime.ReadMemStats(&after)
conns := len(exit.wss) + len(client.wss)
if grew := int64(after.HeapAlloc) - int64(before.HeapAlloc); grew > 64<<20 {
t.Fatalf("%d channels took %d MB of heap", conns, grew>>20)
}
}
func TestPickRoomSpreadsAndSkipsDown(t *testing.T) {
tr := &CupsonlineTransport{}
for i := 0; i < 3; i++ {
ws := &cupsWS{idx: i, roomUUID: fmt.Sprintf("room-%d-0000", i)}
ws.connected.Store(true)
tr.wss = append(tr.wss, ws)
}
// Round-robin visits every connected room.
seen := map[*cupsWS]bool{}
for i := 0; i < 30; i++ {
seen[tr.pickRoom()] = true
}
if len(seen) != 3 {
t.Fatalf("round-robin used %d of 3 rooms", len(seen))
}
// A disconnected room is skipped.
tr.wss[1].connected.Store(false)
for i := 0; i < 30; i++ {
if tr.pickRoom() == tr.wss[1] {
t.Fatal("pickRoom returned a disconnected room")
}
}
// None up -> nil.
for _, ws := range tr.wss {
ws.connected.Store(false)
}
if tr.pickRoom() != nil {
t.Fatal("no room up: pickRoom must return nil")
}
}
+234
View File
@@ -0,0 +1,234 @@
package cupsonline
import (
"bytes"
"context"
"fmt"
"os"
"strings"
"testing"
"time"
)
// TestLiveCups runs an exit node and a client through the real cups.online.
// It creates rooms there, so it only runs when asked to:
//
// OPENFLUX_CUPS_LIVE=1 go test -run TestLiveCups -v -timeout 15m ./transport/cupsonline/
//
// OPENFLUX_CUPS_LIVE_IDLE sets how long the channels sit idle (default 2m,
// longer than WSReadTimeout on purpose).
func TestLiveCups(t *testing.T) {
if os.Getenv("OPENFLUX_CUPS_LIVE") == "" {
t.Skip("set OPENFLUX_CUPS_LIVE=1 to run against the real cups.online")
}
idle := 2 * time.Minute
if v := os.Getenv("OPENFLUX_CUPS_LIVE_IDLE"); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
t.Fatal(err)
}
idle = d
}
cfg := DefaultCupsonlineConfig()
cfg.StatsInterval = time.Hour
start := time.Now()
exit := mustStart(t, "", false, cfg)
packed := packRooms(exit.RoomUUIDs())
client := mustStart(t, packed, true, cfg)
waitFor(t, 60*time.Second, "all channels up", func() bool {
return allConnected(exit) && allConnected(client)
})
t.Logf("%d rooms created and joined on both sides in %v", len(exit.wss), time.Since(start).Round(time.Millisecond))
t.Run("frames both ways", func(t *testing.T) {
drain(exit)
// Frames spread across rooms and may arrive in a different order, so
// check they all arrive intact, not the order.
var want [][]byte
for i := 0; i < 50; i++ {
f := frame(i)
want = append(want, f)
client.Send(f)
}
expectSet(t, exit, want)
drain(exit)
drain(client)
var rtts []time.Duration
for i := 0; i < 10; i++ {
sent := time.Now()
client.Send(frame(500 + i))
next(t, exit)
exit.Send(frame(600 + i))
next(t, client)
rtts = append(rtts, time.Since(sent))
}
t.Logf("round trip client->exit->client: %v", rtts)
})
t.Run("throughput", func(t *testing.T) {
// Feed packets steadily rather than all at once; the transport paces
// itself under that so the server doesn't drop the connection. Frames
// spread across all rooms and can arrive in a different order, so this
// checks that every one arrives intact, not the order.
drain(exit)
const n, size = 200, 8 << 10
body := bytes.Repeat([]byte{0x5a}, size)
seen := make([]bool, n)
done := make(chan int, 1)
go func() {
count := 0
for count < n {
select {
case p := <-exit.got:
id := int(p[0])<<8 | int(p[1])
if id < 0 || id >= n || seen[id] || len(p) != size+2 {
done <- -1
return
}
seen[id] = true
count++
case <-time.After(30 * time.Second):
done <- count
return
}
}
done <- n
}()
sent := time.Now()
for i := 0; i < n; i++ {
p := append([]byte{byte(i >> 8), byte(i)}, body...)
if err := client.Send(p); err != nil {
t.Fatal(err)
}
time.Sleep(15 * time.Millisecond) // offered load; the transport paces the rest
}
if got := <-done; got != n {
t.Fatalf("only %d of %d packets arrived intact", got, n)
}
took := time.Since(sent)
t.Logf("client->exit: %d KB across %d rooms in %v = %.0f KB/s",
n*size>>10, len(client.wss), took.Round(time.Millisecond), float64(n*size>>10)/took.Seconds())
})
t.Run("idle longer than the read timeout", func(t *testing.T) {
reconnects := func() (n uint64) {
for _, p := range []*peer{exit, client} {
for _, ws := range p.wss {
n += ws.stats.reconnects.Load()
}
}
return
}
before := reconnects()
t.Logf("sitting idle for %v (read timeout %v)", idle, cfg.WSReadTimeout)
time.Sleep(idle)
if n := reconnects() - before; n != 0 {
t.Errorf("%d reconnects while idle: keepalive doesn't keep the read side busy", n)
}
deliver(t, client, exit, []byte("after idle"), 10*time.Second)
deliver(t, exit, client, []byte("after idle back"), 10*time.Second)
})
t.Run("dropped socket comes back without a page reload", func(t *testing.T) {
ws := client.wss[0]
was := ws.auth()
ws.writeMu.Lock()
if ws.conn != nil {
ws.conn.Close()
}
ws.writeMu.Unlock()
deliver(t, client, exit, []byte("during reconnect"), 15*time.Second)
waitFor(t, 30*time.Second, "dropped channel back", func() bool { return ws.connected.Load() })
if ws.auth() != was {
t.Error("reconnect after a plain drop re-joined the room (page reload)")
}
})
t.Run("re-join keeps the session", func(t *testing.T) {
a := client.wss[0].auth()
again, err := joinRoom(context.Background(), a.roomUUID, a.httpClient)
if err != nil {
t.Fatal(err)
}
if again.userUUID != a.userUUID {
t.Errorf("same session, new user: %s -> %s", a.userUUID, again.userUUID)
}
})
t.Run("exit restart reuses its rooms", func(t *testing.T) {
exit.Stop()
again := mustStart(t, packed, false, cfg)
if strings.Join(again.RoomUUIDs(), ",") != strings.Join(exit.RoomUUIDs(), ",") {
t.Fatalf("restarted exit node is in other rooms: %v vs %v", again.RoomUUIDs(), exit.RoomUUIDs())
}
waitFor(t, 60*time.Second, "restarted exit node up", func() bool { return allConnected(again) })
deliver(t, client, again, []byte("to restarted exit"), 15*time.Second)
deliver(t, again, client, []byte("from restarted exit"), 15*time.Second)
})
}
// TestLiveThroughputCalibrate finds how fast one channel may send before
// cups.online starts dropping the connection. It drives a real pair at a few
// SendInterval values and reports, per value, how much arrived and how many
// times the channel had to reconnect. Pick the fastest interval with no loss
// and no reconnects for DefaultCupsonlineConfig.SendInterval.
//
// OPENFLUX_CUPS_LIVE=1 go test -run TestLiveThroughputCalibrate -v -timeout 15m ./transport/cupsonline/
func TestLiveThroughputCalibrate(t *testing.T) {
if os.Getenv("OPENFLUX_CUPS_LIVE") == "" {
t.Skip("set OPENFLUX_CUPS_LIVE=1 to run against the real cups.online")
}
cfg := DefaultCupsonlineConfig()
cfg.StatsInterval = time.Hour
exit := mustStart(t, "", false, cfg)
// Measure one channel: the client joins only the first room, so the whole
// offered load rides that single channel at the interval under test.
oneRoom := packRooms(exit.RoomUUIDs()[:1])
waitFor(t, 60*time.Second, "exit up", func() bool { return allConnected(exit) })
const m, size = 120, 8 << 10
body := bytes.Repeat([]byte{0x5a}, size)
t.Logf("%-10s %-12s %-12s %-10s", "interval", "arrived", "reconnects", "KB/s")
for _, iv := range []time.Duration{40, 25, 18, 12, 8, 5} {
interval := iv * time.Millisecond
pcfg := cfg
pcfg.SendInterval = interval
cl, err := startPeer(t, oneRoom, true, pcfg)
if err != nil {
t.Fatalf("interval %v: client start: %v", interval, err)
}
waitFor(t, 60*time.Second, "client up", func() bool { return allConnected(cl) })
drain(exit) // clear the previous phase's leftovers
reconBefore := cl.wss[0].stats.reconnects.Load()
start := time.Now()
for i := 0; i < m; i++ {
if err := cl.Send(append([]byte{byte(i >> 8), byte(i)}, body...)); err != nil {
break
}
}
arrived := 0
deadline := time.After(30 * time.Second)
collect:
for arrived < m {
select {
case <-exit.got:
arrived++
case <-deadline:
break collect
}
}
elapsed := time.Since(start)
recon := cl.wss[0].stats.reconnects.Load() - reconBefore
rate := float64(arrived*size>>10) / elapsed.Seconds()
t.Logf("%-10v %-12s %-12d %-10.0f", interval, fmt.Sprintf("%d/%d", arrived, m), recon, rate)
cl.Stop()
time.Sleep(2 * time.Second) // let the server settle between phases
}
exit.Stop()
}
+26
View File
@@ -153,6 +153,32 @@ type volgaAuth struct {
Cookies []*http.Cookie
}
// VolgaDocument is what CheckVolgaDocument learned about a document.
type VolgaDocument struct {
DocID string
Editable bool
}
// CheckVolgaDocument runs the transport's own authorization against docURL
// without joining the document, to tell whether the vyandex transport can
// use it. It reuses our authorize(): the first-tier PoW captcha is solved
// like on a real connect, and a SmartCaptcha or login wall comes back as
// ErrCaptchaRequired / ErrLoginRequired. Reaching a successful authorize
// means the Volga editor opened with an action_url, i.e. the link is
// usable, so Editable is reported true.
//
// jar is accepted for signature compatibility with the cookie-jar auth
// path that arrives with the fuller Volga session work; authorize() builds
// its own session, so a nil jar (the only caller today) changes nothing.
func CheckVolgaDocument(docURL string, jar http.CookieJar) (VolgaDocument, error) {
_ = jar
a, err := authorize(docURL)
if err != nil {
return VolgaDocument{}, err
}
return VolgaDocument{DocID: a.DocID, Editable: true}, nil
}
func authorize(docURL string) (*volgaAuth, error) {
utils.Debugf("[VOLGA] authorize(%s)", docURL)
+6
View File
@@ -36,6 +36,12 @@ func Debugf(format string, args ...interface{}) {
}
}
// Infof logs unconditionally (not gated by --debug), via the standard logger —
// same as upstream, so ported transports (e.g. cupsonline) keep their info logs.
func Infof(format string, args ...interface{}) {
log.Output(2, fmt.Sprintf(format, args...))
}
// SetDebug toggles verbose logging at runtime (off = Debugf becomes a no-op).
func SetDebug(on bool) {
if on {