mirror of
https://github.com/p1neappleXpress/OpenFlux.git
synced 2026-10-02 05:04:39 +08:00
Стандарт 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:
+20
-2
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -8,7 +8,7 @@ options:
|
||||
settings:
|
||||
base:
|
||||
MARKETING_VERSION: "1.2.0"
|
||||
CURRENT_PROJECT_VERSION: "65"
|
||||
CURRENT_PROJECT_VERSION: "67"
|
||||
DEVELOPMENT_TEAM: "8GQH8GQ252"
|
||||
|
||||
targets:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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!! Ссылка содержит секрет шифрования — обращайтесь с ней как с файлом ключа.")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user