Files
OpenFlux/node_wizard.go
T
Meepo 647b856b03 Своя нода: выбор транспортов канала и автообновление ноды (#125)
* Node wizard: choose the channel's transports; self-updating nodes

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

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

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

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

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

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

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

* provision: pin node-install.sh 1f7bd2a

* cupsonline: say why new rooms were refused

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

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

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

* provision: pin node-install.sh 122ab88

* provision: pin node-install.sh fe9dc8b

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

* mobile: OfferExitCookies hands a sign-in to the exit over the Session
2026-09-29 20:46:03 +03:00

308 lines
10 KiB
Go

//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"
"strings"
"time"
"openflux/provision"
"openflux/transport/cupsonline"
"openflux/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"`
// Transports are the channel's carriers besides direct. Without them,
// DocumentURL alone means a Yandex document (older apps).
Transports []provision.ChannelTransport `json:"transports"`
AutoUpdate bool `json:"autoUpdate"`
}
// transports is the channel's carriers from the request.
func (p wizardParams) transports() []provision.ChannelTransport {
if p.Transports == nil && p.DocumentURL != "" {
return []provision.ChannelTransport{{Type: "vyandex", URL: p.DocumentURL}}
}
return p.Transports
}
// channel is the channel the request describes, key and cookies aside.
func (p wizardParams) channel() provision.Channel {
return provision.Channel{ID: p.Channel, Transports: p.transports(), Port: p.ChannelPort, AutoUpdate: p.AutoUpdate}
}
// nodeWizard holds the SSH connection between calls.
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
newRooms func(context.Context) (string, error)
}
func newNodeWizard() *nodeWizard {
return &nodeWizard{
dial: provision.Dial,
checkDoc: func(u string) (yandex.VolgaDocument, error) { return yandex.CheckVolgaDocument(u, nil) },
newScript: provision.Pinned,
newRooms: cupsonline.CreateRoomList,
}
}
// 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.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 := p.channel()
ch.Key = p.Key
if p.Cookies != "" {
if ch.Cookies, err = provision.ChannelCookies(ch.Transports, 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 "createRooms":
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
rooms, err := w.newRooms(ctx)
if err != nil {
return wizardFailure(fmt.Errorf("не удалось создать комнаты cups.online: %v", err), nil)
}
return wizardOK(map[string]interface{}{"rooms": rooms})
case "shareLink":
link, err := provision.ShareLink(p.Name, p.Key, p.Host, p.ChannelPort, p.transports())
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})
}