Стандарт openflux://v1, канонический KDF-контекст, подпись ключом ASC

Стандарт ссылок и QR. Пакет share/ перенесён из upstream дословно, вместе
с их тестами: формат openflux://v1/<base64url(flate(JSON))>, белый список
типов, правила валидации, QR с коррекцией Medium. Дословно — в этом и
смысл привязки: ссылку должны понимать CLI и Android. CLI получил --share
и --share-host (печатает ссылку и QR в терминал). Прежний OFLUX1 оставлен
на ПРИЁМ, чтобы старые QR у тестеров работали; генерируем только новый.

На iOS разбор и сборка идут через Go-мост (share_bridge.go), а не на
Swift: формат — flate + base64url + JSON плюс набор правил валидации, и
вторая реализация рано или поздно разъехалась бы с CLI. Swift только
раскладывает готовый JSON по полям.

Отображение моделей. Стандарт описывает одну сессию из нескольких
носителей с приоритетами, у нас один носитель на профиль плюс мультиплекс
по документам одного типа:

  носители одного типа  -> один профиль, URL через запятую;
  разные типы           -> несколько профилей;
  direct рядом с доками -> не отдельный профиль, а прямой канал внутри них.

Мульти-формат до этого был сломан в обе стороны: экспорт брал только
первый URL профиля (мультиплекс молча терял остальные документы), импорт
брал один носитель и выбрасывал прочие. Теперь экспорт выпускает все
документы, а импорт заводит столько профилей, сколько нужно. Правило
стандарта: несколько носителей требуют negotiate и секрет от 16 символов,
поэтому мультиплекс без шифрования ссылкой невыразим — в этом случае
честно не выпускаем ничего вместо тихой потери документов.

KDF-контекст по канону Android. Из ветки hotfix/oflx-encrypted-logging-
context (ee90659) порядок вывода такой:

  явный --session-context -> --url, если задан и не "http://#"
  -> URL приоритетного транспорта -> запасное "http://#"

Следствие: у узла БЕЗ документа (direct, oneme) контекст равен
плейсхолдеру "http://#". Здесь стояло самодельное "direct", то есть мы
сходились только сами с собой и были несовместимы с Android и с CLI
upstream. Расхождение контекста не даёт никакой ошибки: ключи просто
разные, каждый пакет не проходит аутентификацию и молча отбрасывается, а
снаружи это выглядит как таймауты. Правило вынесено в pickSessionContext
с комментарием, контекст печатается в лог, добавлен флаг
--session-context. Ключи от этого меняются — узел и приложение надо
обновлять вместе.

Оттуда же c837df7: Stop() закрывает соединение с документом. Без этого
читатель сидит в ReadMessage до следующего сообщения сервера и оставляет
сокет открытым — участник продолжает висеть в документе уже после
остановки транспорта.

Сборка. build_ios_app.sh больше не зависит от Apple ID, залогиненного в
Xcode: подпись cloud-managed, без аккаунта нет распространительной
идентичности и экспорт падает с "No Accounts" — ровно это и случилось,
когда сессия Xcode слетела. Идентификатор ключа берётся из имени файла
AuthKey_*.p8, issuer из $ASC_ISSUER_ID или ~/.appstoreconnect/issuer_id;
ни ключ, ни issuer в репозиторий не попадают, без ключа поведение прежнее.
This commit is contained in:
saharev1
2026-09-27 02:53:03 +03:00
parent dc477d0d24
commit 59c4f61e93
15 changed files with 841 additions and 24 deletions
+20 -2
View File
@@ -23,12 +23,30 @@ echo "==> [3/5] Generating Xcode project"
cd "$APP_DIR"
xcodegen generate
# App Store Connect API key, if present. Without it xcodebuild needs an Apple ID
# signed into Xcode's Accounts, and signing here is cloud-managed: no account
# means no distribution identity and the export dies with "No Accounts". The key
# is the CI-sanctioned way in and keeps the pipeline working headless.
# Key id comes from the file name; the issuer from $ASC_ISSUER_ID or
# ~/.appstoreconnect/issuer_id. Neither is a secret — the .p8 is, and it stays
# outside the repo.
ASC_ARGS=()
ASC_KEY=$(ls "$HOME"/.appstoreconnect/private_keys/AuthKey_*.p8 2>/dev/null | head -1)
ASC_ISSUER="${ASC_ISSUER_ID:-$(cat "$HOME/.appstoreconnect/issuer_id" 2>/dev/null)}"
if [ -n "$ASC_KEY" ] && [ -n "$ASC_ISSUER" ]; then
ASC_KEY_ID=$(basename "$ASC_KEY" .p8); ASC_KEY_ID=${ASC_KEY_ID#AuthKey_}
ASC_ARGS=(-authenticationKeyPath "$ASC_KEY" \
-authenticationKeyID "$ASC_KEY_ID" \
-authenticationKeyIssuerID "$ASC_ISSUER")
echo " (signing via App Store Connect key $ASC_KEY_ID)"
fi
echo "==> [4/5] Archiving (Release)"
rm -rf build/OpenFlux.xcarchive
xcodebuild -project OpenFlux.xcodeproj -scheme OpenFlux -configuration Release \
-destination 'generic/platform=iOS' \
-archivePath build/OpenFlux.xcarchive \
-allowProvisioningUpdates \
-allowProvisioningUpdates "${ASC_ARGS[@]}" \
clean archive
echo "==> [5/5] Exporting App Store IPA"
@@ -37,7 +55,7 @@ xcodebuild -exportArchive \
-archivePath build/OpenFlux.xcarchive \
-exportPath build/export \
-exportOptionsPlist ExportOptions.plist \
-allowProvisioningUpdates
-allowProvisioningUpdates "${ASC_ARGS[@]}"
echo ""
echo "IPA ready: $APP_DIR/build/export/OpenFlux.ipa"
+7 -5
View File
@@ -267,11 +267,13 @@ func wrapEncryption(inner transport.Transport, transportType, docURL string) (tr
if secret == "" {
return inner, nil
}
// Та же развилка, что в CLI: у direct контекстом служит имя транспорта,
// потому что адрес прослушивания на узле и адрес набора у клиента — разные
// строки, и ключи бы разъехались.
context := transportType
if docURL != "" && transportType != "direct" {
// Правило то же, что в CLI и у клиента OpenFlux-Android: --url, если он
// задан и не плейсхолдер, иначе сам плейсхолдер. Для direct URL профиля —
// это адрес узла, а он у сторон разный (узел слушает 0.0.0.0, клиент
// набирает публичный IP), поэтому он в контекст не идёт и остаётся
// плейсхолдер — ровно как у них.
context := contextPlaceholder
if transportType != "direct" && docURL != "" && docURL != contextPlaceholder {
context = docURL
}
enc, err := transport.NewEncryptedTransport(inner, secret, context, false)
+1
View File
@@ -17,6 +17,7 @@ require (
github.com/klauspost/compress v1.20.0
github.com/pierrec/lz4/v4 v4.1.27
github.com/pion/webrtc/v3 v3.3.6
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/wlynxg/anet v0.0.5
golang.org/x/crypto v0.49.0
golang.org/x/net v0.52.0
+2
View File
@@ -63,6 +63,8 @@ github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+56 -2
View File
@@ -276,7 +276,11 @@ struct ContentView: View {
port: Int(socksPort) ?? 10808)
}
.sheet(item: $editorTask) { task in
ProfileEditorView(profile: task.profile) { saved in
ProfileEditorView(profile: task.profile,
onImportMany: { imported in
ShareImporter.apply(imported, to: store)
testHint = "Из ссылки заведено профилей: \(imported.profiles.count)."
}) { saved in
store.upsert(saved)
}
}
@@ -674,6 +678,9 @@ struct ContentView: View {
struct ProfileEditorView: View {
let profile: Profile?
/// Ссылка может нести несколько носителей — тогда из неё выходит НЕСКОЛЬКО
/// профилей, и одного onSave уже мало.
var onImportMany: ((ShareImporter.Imported) -> Void)? = nil
let onSave: (Profile) -> Void
@Environment(\.dismiss) private var dismiss
@@ -770,7 +777,7 @@ struct ProfileEditorView: View {
} label: {
Label("Сканировать QR-код", systemImage: "qrcode.viewfinder")
}
Text("Подойдёт обычная ссылка на документ (disk.yandex.ru / boards.yandex.ru / cloud.mail.ru) или конфиг OFLUX1.")
Text("Подойдёт ссылка openflux://v1 (общий стандарт с CLI и Android), обычная ссылка на документ (disk.yandex.ru / boards.yandex.ru / cloud.mail.ru) или старый конфиг OFLUX1.")
.font(.caption2).foregroundColor(.secondary)
if let m = importMsg {
Text(m).font(.caption2).foregroundColor(.secondary)
@@ -842,6 +849,23 @@ struct ProfileEditorView: View {
@discardableResult
private func ingest(_ raw: String) -> Bool {
let s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if ShareLink.looksLikeLink(s) {
let (cfg, err) = ShareLink.decode(s)
if let cfg = cfg {
// Несколько носителей — это несколько профилей; в одну форму
// они не помещаются, поэтому заводим их сразу и закрываемся.
let imported = ShareImporter.build(from: cfg)
if imported.profiles.count > 1, let many = onImportMany {
many(imported)
dismiss()
return true
}
applyShareConfig(cfg)
return true
}
importMsg = "Ссылка не принята: \(err ?? "неизвестная ошибка")"
return false
}
if applyParsed(parseOFLUX(s)) { return true }
guard s.lowercased().hasPrefix("http") else { return false }
if s.lowercased().contains("cloud.mail.ru") {
@@ -858,6 +882,36 @@ struct ProfileEditorView: View {
return true
}
/// Раскладывает конфигурацию из openflux://-ссылки по полям редактора.
///
/// Стандарт умеет больше, чем наша модель: несколько носителей с
/// приоритетами и согласованную сессию. Мы берём самый приоритетный
/// поддерживаемый носитель как основной, а идущий рядом `direct`
/// раскладываем в поля прямого канала — он у нас и нужен ровно для этого.
private func applyShareConfig(_ cfg: ShareConfig) {
if let kind = cfg.primaryKind {
transportRaw = kind.rawValue
}
if let p = cfg.primary {
let value = (p.type == "direct" ? p.dial : p.url) ?? ""
switch transport {
case .yandex: url1 = value
case .volga, .boards, .mail, .direct: single = value
case .max: break
}
}
if let s = cfg.secret, !s.isEmpty {
if transport == .direct { encryptionKey = s } else { directKey = s }
}
if let d = cfg.directDial, transport != .direct {
nodeAddr = d
}
if name.trimmingCharacters(in: .whitespaces).isEmpty {
name = cfg.name ?? transport.title
}
importMsg = "Принята ссылка openflux://v1"
}
/// Fill the editor fields from a parsed OFLUX config. Returns false if nil.
@discardableResult
private func applyParsed(_ parsed: (kind: String, urls: [String])?) -> Bool {
+5 -1
View File
@@ -129,7 +129,11 @@ struct ShareQRView: View {
var body: some View {
NavigationView {
VStack(spacing: 20) {
if let s = makeOFLUXString(profile), let img = makeQRImage(s) {
// Сначала общий стандарт openflux://v1 — его понимают CLI и
// Android. OFLUX1 остаётся запасным, чтобы старые сборки iOS
// всё ещё могли считать наш QR.
if let s = ShareLink.encode(profile: profile) ?? makeOFLUXString(profile),
let img = makeQRImage(s) {
Text(profile.name).font(.headline)
Image(uiImage: img)
.interpolation(.none)
+236
View File
@@ -0,0 +1,236 @@
import Foundation
/// The `openflux://v1/…` share standard.
///
/// Encoding and decoding deliberately go through the Go core rather than being
/// reimplemented here: the format is flate + base64url + JSON plus a set of
/// validation rules, and a second implementation would drift from the CLI and
/// from Android sooner or later. Swift only maps the resulting JSON onto a
/// Profile.
///
/// The link carries the encryption secret — whoever sees it can join the exit.
/// Treat it like the key file.
struct ShareConfig: Decodable {
struct Transport: Decodable {
let type: String
let name: String?
let url: String?
let priority: Int?
let dial: String?
}
let name: String?
let negotiate: Bool?
let codec: String?
let secret: String?
let context: String?
let transports: [Transport]
/// The carrier this app should actually run: the highest-priority one we
/// support. `direct` is skipped here — we carry it separately as the
/// profile's bootstrap channel rather than as the main transport.
var primary: Transport? {
let usable = transports.filter { TransportKind(rawValue: normalized($0.type)) != nil && $0.type != "direct" }
if let best = usable.max(by: { ($0.priority ?? 0) < ($1.priority ?? 0) }) { return best }
// Ссылка только с direct — тогда он и есть основной носитель.
return transports.first { $0.type == "direct" }
}
/// A `direct` entry alongside the main one: the channel used to pass an
/// interactive captcha from the exit's address.
var directDial: String? {
transports.first { $0.type == "direct" }?.dial
}
/// Стандарт зовёт VOLGA "vyandex", у нас это "volga".
private func normalized(_ t: String) -> String { t == "vyandex" ? "volga" : t }
var primaryKind: TransportKind? {
guard let p = primary else { return nil }
return TransportKind(rawValue: normalized(p.type))
}
}
/// Раскладывает конфигурацию из ссылки в наши профили.
///
/// Модель стандарта богаче нашей: он описывает ОДНУ сессию из нескольких
/// носителей с приоритетами, а у нас один носитель на профиль плюс мультиплекс
/// по нескольким документам одного типа. Отображение получается такое:
///
/// * носители одного типа -> один профиль, URL через запятую (наш мультиплекс);
/// * разные типы -> несколько профилей;
/// * direct рядом с доками -> не отдельный профиль, а прямой канал внутри них
/// (он у нас ровно для прохождения капчи и нужен); direct в одиночку -> свой профиль.
enum ShareImporter {
struct Imported {
var profiles: [Profile]
/// Секреты по id профиля: основной и ключ прямого канала.
var secrets: [UUID: (main: String, direct: String)]
}
static func build(from cfg: ShareConfig) -> Imported {
let secret = cfg.secret ?? ""
let directDial = cfg.transports.first { $0.type == "direct" }?.dial ?? ""
// Группируем по нашему виду транспорта, сохраняя порядок появления.
var order: [TransportKind] = []
var byKind: [TransportKind: [String]] = [:]
for t in cfg.transports where t.type != "direct" {
guard let k = kind(of: t.type) else { continue }
let value = (t.url ?? "").trimmingCharacters(in: .whitespaces)
guard !value.isEmpty else { continue }
if byKind[k] == nil { order.append(k); byKind[k] = [] }
byKind[k]?.append(value)
}
var out = Imported(profiles: [], secrets: [:])
for k in order {
let urls = byKind[k] ?? []
let p = Profile(name: profileName(cfg, k, multi: order.count > 1),
transport: k.rawValue,
url: urls.joined(separator: ","),
nodeAddr: directDial.isEmpty ? nil : directDial)
out.profiles.append(p)
// Для доковых профилей секрет из ссылки относится к прямому каналу,
// если он там есть: контекст "direct" и ключ у них общий.
let mainSecret = directDial.isEmpty ? secret : ""
out.secrets[p.id] = (main: mainSecret, direct: directDial.isEmpty ? "" : secret)
}
// Ссылка вообще без доковых носителей — значит это чистый direct.
if out.profiles.isEmpty, !directDial.isEmpty {
let p = Profile(name: cfg.name ?? "Прямой TCP",
transport: TransportKind.direct.rawValue,
url: directDial)
out.profiles.append(p)
out.secrets[p.id] = (main: secret, direct: "")
}
return out
}
/// Записывает профили и их секреты. Секреты идут в Keychain, а не в профиль:
/// сам профиль лежит в UserDefaults открытым текстом.
@MainActor
static func apply(_ imported: Imported, to store: ProfileStore) {
for p in imported.profiles {
if let s = imported.secrets[p.id] {
Secrets.setEncryptionKey(s.main, for: p.id)
Secrets.setDirectKey(s.direct, for: p.id)
}
store.upsert(p)
}
}
private static func kind(of type: String) -> TransportKind? {
TransportKind(rawValue: type == "vyandex" ? "volga" : type)
}
private static func profileName(_ cfg: ShareConfig, _ k: TransportKind, multi: Bool) -> String {
let base = cfg.name?.trimmingCharacters(in: .whitespaces) ?? ""
if base.isEmpty { return k.title }
return multi ? "\(base) — \(k.title)" : base
}
}
enum ShareLink {
private struct Result: Decodable {
let error: String?
let config: ShareConfig?
let link: String?
}
static func looksLikeLink(_ s: String) -> Bool {
s.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("openflux://")
}
/// Returns the parsed config, or the error text the Go validator produced.
static func decode(_ link: String) -> (config: ShareConfig?, error: String?) {
let trimmed = link.trimmingCharacters(in: .whitespacesAndNewlines)
guard let c = trimmed.withCString({ OpenFluxShareDecode(UnsafeMutablePointer(mutating: $0)) })
else { return (nil, "нет ответа от ядра") }
let json = String(cString: c)
OpenFluxFreeString(c)
guard let data = json.data(using: .utf8),
let r = try? JSONDecoder().decode(Result.self, from: data)
else { return (nil, "не разобрать ответ ядра") }
if let e = r.error, !e.isEmpty { return (nil, e) }
return (r.config, nil)
}
/// Builds a link from a profile. Validation happens in Go, so an invalid
/// combination never leaves the app as a broken link.
static func encode(profile p: Profile) -> String? {
var transports: [[String: Any]] = []
var cfg: [String: Any] = ["name": p.name]
let secret = Secrets.encryptionKey(for: p.id) ?? ""
let directSecret = Secrets.directKey(for: p.id) ?? ""
let dial = (p.nodeAddr ?? "").trimmingCharacters(in: .whitespaces)
switch p.transportKind {
case .max:
return nil // токен MAX принадлежит аккаунту узла, делиться им нельзя
case .direct:
guard !dialOrURL(p).isEmpty, !secret.isEmpty else { return nil }
transports.append(["type": "direct", "dial": dialOrURL(p)])
cfg["negotiate"] = true // стандарт требует для direct
cfg["secret"] = secret
cfg["context"] = "http://#" // как pickSessionContext: узел без документа
default:
// ВСЕ документы профиля, а не только первый: мультиплекс у нас
// выражается списком через запятую, а в стандарте — несколькими
// носителями одного типа. Раньше терялись все, кроме первого.
let urls = p.url.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
guard let first = urls.first else { return nil }
for u in urls {
transports.append(["type": standardType(p.transportKind), "url": u])
}
let url = first
if !secret.isEmpty {
cfg["secret"] = secret
cfg["context"] = url
}
// Прямой канал профиля едет вторым носителем — так принимающая
// сторона получает и способ пройти капчу, а не только документ.
if !dial.isEmpty, !directSecret.isEmpty, secret.isEmpty || secret == directSecret {
transports.append(["type": "direct", "dial": dial])
cfg["negotiate"] = true
cfg["secret"] = directSecret
cfg["context"] = "http://#" // как pickSessionContext: узел без документа
}
}
// Правило стандарта: несколько носителей = согласованная сессия, а она
// требует секрета от 16 символов. Без него валидная ссылка невозможна —
// лучше честно ничего не выпустить, чем молча выбросить документы.
if transports.count > 1 {
cfg["negotiate"] = true
if (cfg["secret"] as? String)?.isEmpty ?? true { return nil }
}
cfg["transports"] = transports
guard let data = try? JSONSerialization.data(withJSONObject: cfg),
let json = String(data: data, encoding: .utf8),
let c = json.withCString({ OpenFluxShareEncode(UnsafeMutablePointer(mutating: $0)) })
else { return nil }
let out = String(cString: c)
OpenFluxFreeString(c)
guard let d = out.data(using: .utf8),
let r = try? JSONDecoder().decode(Result.self, from: d),
let link = r.link, !link.isEmpty
else { return nil }
return link
}
private static func dialOrURL(_ p: Profile) -> String {
let n = (p.nodeAddr ?? "").trimmingCharacters(in: .whitespaces)
return n.isEmpty ? p.url.trimmingCharacters(in: .whitespaces) : n
}
/// Наше имя транспорта → имя в стандарте.
private static func standardType(_ k: TransportKind) -> String {
k == .volga ? "vyandex" : k.rawValue
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ options:
settings:
base:
MARKETING_VERSION: "1.2.0"
CURRENT_PROJECT_VERSION: "65"
CURRENT_PROJECT_VERSION: "67"
DEVELOPMENT_TEAM: "8GQH8GQ252"
targets:
+15 -11
View File
@@ -80,6 +80,15 @@ func main() {
encryptionKeyFile := flag.String("encryption-key-file", "",
"Path to a file holding the shared secret for end-to-end AES-256-GCM. "+
"Both peers must pass the same secret; empty = no encryption.")
sessionContext := flag.String("session-context", "",
"KDF context for the encryption key. Default: --url when set, otherwise the "+
"placeholder \"http://#\". Both peers must derive the SAME string.")
shareLink := flag.Bool("share", false,
"Print an openflux:// link and QR for this configuration and exit. "+
"The link carries the encryption secret — treat it like the key file.")
shareHost := flag.String("share-host", "",
"Address clients should dial for --transport=direct in the shared link "+
"(the node's public host; the port is taken from --direct-addr).")
flag.Parse()
if *localIP != "" {
@@ -105,6 +114,11 @@ func main() {
log.Printf("Mode: %s", map[bool]string{true: "EXIT NODE", false: "CLIENT"}[*exitNode])
log.Printf("Transport: %s", *transportType)
if *shareLink {
emitShareLink(*transportType, globalDocUrl, *directAddr, *shareHost, *encryptionKeyFile)
return
}
config := transport.DefaultConfig()
var trans transport.Transport
@@ -164,17 +178,7 @@ func main() {
if err != nil {
log.Fatalf("Read encryption key file: %v", err)
}
// Контекст — публичная соль вывода ключа, но обе стороны обязаны взять
// ОДНУ И ТУ ЖЕ строку. Для доковых транспортов это URL документа, он у
// клиента и ноды одинаков. Для direct так нельзя: нода слушает
// 0.0.0.0:9443, а клиент набирает 64.118.154.75:9443 — строки разные, и
// ключи молча разъехались бы (каждый пакет не проходит аутентификацию,
// снаружи это выглядит как таймауты). Поэтому у direct контекст — имя
// транспорта, единственное, в чём стороны заведомо согласны.
context := *transportType
if globalDocUrl != "" && *transportType != "direct" {
context = globalDocUrl
}
context := pickSessionContext(*sessionContext, globalDocUrl)
encrypted, err := transport.NewEncryptedTransport(
trans, strings.TrimSpace(string(secretBytes)), context, *exitNode)
if err != nil {
+29
View File
@@ -0,0 +1,29 @@
package main
// contextPlaceholder — дефолт флага --url. Трактуется как «не задано», поэтому
// в вывод ключа не попадает как значимая строка; при этом он же остаётся
// запасным вариантом, чтобы узел без документа (direct, oneme) сохранил
// прежний ключ.
const contextPlaceholder = "http://#"
// pickSessionContext возвращает контекст вывода ключа шифрования.
//
// Порядок ровно такой же, как у клиента OpenFlux-Android и у CLI upstream —
// иначе ключи разъедутся, и каждый пакет будет молча отбрасываться как
// неаутентифицированный (снаружи это выглядит как таймауты, а не как ошибка):
//
// явный --session-context, если задан
// --url globalURL, если задан и не плейсхолдер
// запасное плейсхолдер "http://#"
//
// У нас один транспорт на процесс, поэтому промежуточного шага «URL самого
// приоритетного транспорта» нет: его URL и есть --url.
func pickSessionContext(explicit, globalURL string) string {
if explicit != "" {
return explicit
}
if globalURL != "" && globalURL != contextPlaceholder {
return globalURL
}
return contextPlaceholder
}
+183
View File
@@ -0,0 +1,183 @@
// Package share turns an OpenFlux client configuration into an openflux://
// link and a QR code, so a client (a phone, another phone running as an
// exit, a desktop) can be set up by scanning instead of copying keys and
// document URLs by hand.
//
// The link carries the encryption secret: whoever sees it can join the
// exit. Treat it like the key file.
package share
import (
"bytes"
"compress/flate"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
qrcode "github.com/skip2/go-qrcode"
)
// Prefix starts every link; the path segment is the format version.
const Prefix = "openflux://v1/"
// maxPayload bounds the decompressed JSON, so a crafted link cannot make
// the decoder allocate without limit.
const maxPayload = 16 << 10
// Transport is one transport the client should run.
type Transport struct {
Type string `json:"type"`
Name string `json:"name,omitempty"` // defaults to Type
URL string `json:"url,omitempty"`
Priority int `json:"priority,omitempty"`
Dial string `json:"dial,omitempty"` // direct: the exit's host:port
}
// Config is what a client needs to connect to one exit.
type Config struct {
// Name is a suggested profile name.
Name string `json:"name,omitempty"`
// Negotiate selects an authenticated session (--negotiate); several
// transports always need one.
Negotiate bool `json:"negotiate,omitempty"`
// Codec is "batched" (also when empty) or "legacy".
Codec string `json:"codec,omitempty"`
// Secret is the shared encryption secret; required for a session.
Secret string `json:"secret,omitempty"`
// Context is the encryption context, the exit's --url; both peers must
// use the same one.
Context string `json:"context,omitempty"`
Transports []Transport `json:"transports"`
}
// knownTypes are the transports a link can carry. MAX (oneme) is left out:
// the exit's MAX token belongs to the exit's account, and a client needs its
// own.
var knownTypes = map[string]bool{
"yandex": true, "vyandex": true, "boards": true, "mailru": true,
"cupsonline": true, "direct": true,
}
// Validate reports whether c describes something a client can connect with.
func (c *Config) Validate() error {
if len(c.Transports) == 0 {
return errors.New("share: no transports")
}
if len(c.Transports) > 1 && !c.Negotiate {
return errors.New("share: several transports need a negotiated session")
}
if c.Negotiate && len(c.Secret) < 16 {
return errors.New("share: a negotiated session needs a secret of at least 16 characters")
}
if c.Secret != "" && len(c.Secret) < 16 {
return errors.New("share: the secret must be at least 16 characters")
}
if c.Codec != "" && c.Codec != "batched" && c.Codec != "legacy" {
return fmt.Errorf("share: unknown codec %q", c.Codec)
}
for _, t := range c.Transports {
if !knownTypes[t.Type] {
return fmt.Errorf("share: unknown transport type %q", t.Type)
}
if t.Type == "direct" {
if t.Dial == "" {
return errors.New("share: direct needs the exit's address")
}
if !c.Negotiate {
return errors.New("share: direct only works in a negotiated session")
}
}
}
return nil
}
// Encode validates c and returns its openflux:// link.
func Encode(c Config) (string, error) {
if err := c.Validate(); err != nil {
return "", err
}
raw, err := json.Marshal(c)
if err != nil {
return "", err
}
var buf bytes.Buffer
w, err := flate.NewWriter(&buf, flate.BestCompression)
if err != nil {
return "", err
}
if _, err := w.Write(raw); err != nil {
return "", err
}
if err := w.Close(); err != nil {
return "", err
}
return Prefix + base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil
}
// Decode parses and validates an openflux:// link.
func Decode(link string) (Config, error) {
link = strings.TrimSpace(link)
if !strings.HasPrefix(link, Prefix) {
if strings.HasPrefix(link, "openflux://") {
return Config{}, errors.New("share: unsupported link version; update OpenFlux")
}
return Config{}, errors.New("share: not an openflux:// link")
}
packed, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(link, Prefix))
if err != nil {
return Config{}, fmt.Errorf("share: bad link encoding: %w", err)
}
raw, err := io.ReadAll(io.LimitReader(flate.NewReader(bytes.NewReader(packed)), maxPayload+1))
if err != nil {
return Config{}, fmt.Errorf("share: bad link payload: %w", err)
}
if len(raw) > maxPayload {
return Config{}, errors.New("share: link payload too large")
}
var c Config
if err := json.Unmarshal(raw, &c); err != nil {
return Config{}, fmt.Errorf("share: bad link payload: %w", err)
}
if err := c.Validate(); err != nil {
return Config{}, err
}
return c, nil
}
func qr(link string) (*qrcode.QRCode, error) {
// Medium correction: survives a slightly glared phone screen while
// keeping the code small enough to scan off another phone.
return qrcode.New(link, qrcode.Medium)
}
// Bitmap returns the QR code of link as rows of dark (true) modules,
// including the quiet zone.
func Bitmap(link string) ([][]bool, error) {
q, err := qr(link)
if err != nil {
return nil, err
}
return q.Bitmap(), nil
}
// PNG renders the QR code of link as a size x size PNG image.
func PNG(link string, size int) ([]byte, error) {
q, err := qr(link)
if err != nil {
return nil, err
}
return q.PNG(size)
}
// Terminal renders the QR code of link with half-block characters, for
// printing in a terminal or a service log.
func Terminal(link string) (string, error) {
q, err := qr(link)
if err != nil {
return "", err
}
return q.ToSmallString(false), nil
}
+115
View File
@@ -0,0 +1,115 @@
package share
import (
"bytes"
"compress/flate"
"encoding/base64"
"strings"
"testing"
)
func sample() Config {
return Config{
Name: "VDS",
Negotiate: true,
Secret: "a shared secret of 32 characters",
Context: "https://disk.yandex.ru/i/abc",
Transports: []Transport{
{Type: "direct", Priority: 100, Dial: "203.0.113.7:8445"},
{Type: "yandex", Priority: 50, URL: "https://disk.yandex.ru/i/abc"},
},
}
}
func TestRoundTrip(t *testing.T) {
link, err := Encode(sample())
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(link, Prefix) {
t.Fatalf("link %q lacks %q", link, Prefix)
}
// Scannable into a URL field or a messenger: no characters that need
// escaping.
for _, r := range strings.TrimPrefix(link, Prefix) {
if !strings.ContainsRune("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", r) {
t.Fatalf("link contains %q", r)
}
}
got, err := Decode(" " + link + "\n")
if err != nil {
t.Fatal(err)
}
want := sample()
if got.Name != want.Name || got.Secret != want.Secret || got.Context != want.Context ||
!got.Negotiate || len(got.Transports) != 2 || got.Transports[0] != want.Transports[0] ||
got.Transports[1] != want.Transports[1] {
t.Fatalf("round trip changed the config: %+v", got)
}
}
func TestValidateRejects(t *testing.T) {
for name, mutate := range map[string]func(*Config){
"no transports": func(c *Config) { c.Transports = nil },
"several without a session": func(c *Config) { c.Negotiate = false },
"short secret": func(c *Config) { c.Secret = "short" },
"unknown type": func(c *Config) { c.Transports[1].Type = "carrier-pigeon" },
"MAX token": func(c *Config) { c.Transports[1].Type = "oneme" },
"direct without address": func(c *Config) { c.Transports[0].Dial = "" },
"unknown codec": func(c *Config) { c.Codec = "gzip" },
} {
c := sample()
mutate(&c)
if _, err := Encode(c); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
func TestDecodeRejects(t *testing.T) {
bomb := bytes.Repeat([]byte("A"), maxPayload*4)
for name, link := range map[string]string{
"other scheme": "https://example.com",
"other version": "openflux://v9/abc",
"bad base64": Prefix + "***",
"not deflate": Prefix + base64.RawURLEncoding.EncodeToString([]byte("plain text")),
"oversized": Prefix + deflated(t, bomb),
"not json": Prefix + deflated(t, []byte("not json")),
"invalid config": Prefix + deflated(t, []byte(`{"transports":[]}`)),
} {
if _, err := Decode(link); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
func TestQRRenderings(t *testing.T) {
link, err := Encode(sample())
if err != nil {
t.Fatal(err)
}
bitmap, err := Bitmap(link)
if err != nil {
t.Fatal(err)
}
if len(bitmap) < 21 || len(bitmap[0]) != len(bitmap) {
t.Fatalf("bitmap is %dx%d", len(bitmap), len(bitmap[0]))
}
png, err := PNG(link, 512)
if err != nil || !bytes.HasPrefix(png, []byte("\x89PNG\r\n\x1a\n")) {
t.Fatalf("PNG: %v", err)
}
text, err := Terminal(link)
if err != nil || strings.Count(text, "\n") < 10 {
t.Fatalf("terminal rendering: %v", err)
}
}
func deflated(t *testing.T, raw []byte) string {
t.Helper()
var buf bytes.Buffer
w, _ := flate.NewWriter(&buf, flate.BestSpeed)
_, _ = w.Write(raw)
_ = w.Close()
return base64.RawURLEncoding.EncodeToString(buf.Bytes())
}
+72
View File
@@ -0,0 +1,72 @@
//go:build ios
package main
/*
#include <stdlib.h>
*/
import "C"
import (
"encoding/json"
"universal-bypass-tool/share"
)
// Мост к пакету share для приложения.
//
// Разбор и сборку openflux://-ссылки намеренно НЕ дублируем на Swift: формат
// это flate + base64url + JSON + набор правил валидации, и вторая реализация
// рано или поздно разъехалась бы с CLI. Приложение отдаёт сюда строку и
// получает JSON — одна реализация на все платформы, поведение гарантированно
// совпадает с узлом.
type shareResult struct {
Error string `json:"error,omitempty"`
Config *share.Config `json:"config,omitempty"`
Link string `json:"link,omitempty"`
}
func shareJSON(r shareResult) *C.char {
b, err := json.Marshal(r)
if err != nil {
return C.CString(`{"error":"marshal failed"}`)
}
return C.CString(string(b))
}
// OpenFluxShareDecode разбирает openflux://-ссылку и возвращает JSON вида
// {"config":{...}} либо {"error":"..."}. Результат C-аллоцирован; освобождать
// OpenFluxFreeString.
//
//export OpenFluxShareDecode
func OpenFluxShareDecode(link *C.char) *C.char {
if link == nil {
return shareJSON(shareResult{Error: "empty link"})
}
cfg, err := share.Decode(C.GoString(link))
if err != nil {
return shareJSON(shareResult{Error: err.Error()})
}
return shareJSON(shareResult{Config: &cfg})
}
// OpenFluxShareEncode собирает ссылку из JSON-описания конфигурации и
// возвращает {"link":"openflux://v1/..."} либо {"error":"..."}. Валидация — та
// же, что в CLI, поэтому невалидную ссылку приложение не выпустит.
//
//export OpenFluxShareEncode
func OpenFluxShareEncode(cfgJSON *C.char) *C.char {
if cfgJSON == nil {
return shareJSON(shareResult{Error: "empty config"})
}
var cfg share.Config
if err := json.Unmarshal([]byte(C.GoString(cfgJSON)), &cfg); err != nil {
return shareJSON(shareResult{Error: "bad config json: " + err.Error()})
}
link, err := share.Encode(cfg)
if err != nil {
return shareJSON(shareResult{Error: err.Error()})
}
return shareJSON(shareResult{Link: link})
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"fmt"
"log"
"net"
"os"
"strings"
"universal-bypass-tool/share"
)
// emitShareLink печатает openflux://-ссылку и QR для текущей конфигурации.
//
// Формат — общий стандарт из пакета share, тот же, что у Android и CLI
// upstream, поэтому ссылку можно отсканировать любым клиентом OpenFlux.
//
// В ссылке едет секрет шифрования: кто её увидел, тот может подключиться к
// узлу. Обращаться как с файлом ключа.
func emitShareLink(transportType, docURL, directAddr, shareHost, keyFile string) {
secret := ""
if keyFile != "" {
b, err := os.ReadFile(keyFile)
if err != nil {
log.Fatalf("--share: read key file: %v", err)
}
secret = strings.TrimSpace(string(b))
}
t := share.Transport{Type: transportType}
cfg := share.Config{Secret: secret}
if name, err := os.Hostname(); err == nil && name != "" {
cfg.Name = "OpenFlux " + name
} else {
cfg.Name = "OpenFlux"
}
switch transportType {
case "direct":
// Клиент должен набрать публичный адрес узла, а --direct-addr на узле
// это адрес прослушивания (обычно 0.0.0.0) — из него годится только порт.
_, port, err := net.SplitHostPort(directAddr)
if err != nil {
log.Fatalf("--share: --direct-addr must be host:port, got %q", directAddr)
}
if shareHost == "" {
log.Fatalf("--share: --transport=direct needs --share-host (the address clients dial)")
}
t.Dial = net.JoinHostPort(shareHost, port)
// Стандарт требует для direct согласованную сессию.
cfg.Negotiate = true
// Контекст пишем явно — тот же, что выведет pickSessionContext: у узла
// без документа это плейсхолдер. Так ключ сойдётся и с нашим клиентом,
// и с Android.
cfg.Context = contextPlaceholder
case "oneme":
log.Fatalf("--share: MAX is not shareable — its token belongs to this account, clients need their own")
default:
if docURL == "" || docURL == "http://#" {
log.Fatalf("--share: --url is required for transport %q", transportType)
}
t.URL = docURL
if secret != "" {
cfg.Context = docURL
}
}
cfg.Transports = []share.Transport{t}
link, err := share.Encode(cfg)
if err != nil {
log.Fatalf("--share: %v", err)
}
qr, err := share.Terminal(link)
if err != nil {
log.Fatalf("--share: render QR: %v", err)
}
fmt.Println(qr)
fmt.Println(link)
if secret != "" {
fmt.Fprintln(os.Stderr, "\n!! Ссылка содержит секрет шифрования — обращайтесь с ней как с файлом ключа.")
}
}
+16 -2
View File
@@ -85,8 +85,8 @@ var ErrLoginRequired = errors.New("yandex docs: login required")
type YandexDocsTransport struct {
*transport.BaseTransport
url string
session *DocSession
url string
session *DocSession
userCounter atomic.Int32
baseUserID string
@@ -236,6 +236,20 @@ func (t *YandexDocsTransport) Start() error {
return nil
}
// Stop также закрывает соединение с документом. Иначе читатель сидит в
// ReadMessage до следующего сообщения сервера и оставляет сокет открытым —
// участник продолжает висеть в документе уже после остановки транспорта.
func (t *YandexDocsTransport) Stop() error {
err := t.BaseTransport.Stop()
t.Mu.RLock()
session := t.session
t.Mu.RUnlock()
if session != nil && session.Conn != nil {
_ = session.Conn.Close()
}
return err
}
func (t *YandexDocsTransport) Send(data []byte) error {
if !t.IsConnected() {
return fmt.Errorf("transport not connected")