mirror of
https://github.com/p1neappleXpress/OpenFlux.git
synced 2026-10-02 05:04:39 +08:00
* Node wizard: choose the channel's transports; self-updating nodes A new channel is no longer Yandex-only: it takes any mix of a Yandex document, a Mail.ru public document and cups.online rooms, with direct always as the backup. The app creates the cups.online rooms (cupsonline.CreateRoomList) so the node starts with them in node.conf and keeps the same rooms, and link, across restarts. provision.ShareLink builds the link with the priorities and encryption context node-install.sh writes; TestNodeConfMatchesShareLink runs the script's write_node_conf and checks the core derives the same. node-install.sh update, run every 6 hours by the optional openflux-node-update.timer, moves the server to the newest node-v* release of p1neappleXpress/OpenFlux (or repo= in /etc/openflux-node/update.conf): the core must match both the release's node-install.sh at its tag and its SHA256SUMS. If a channel does not stay up on it the previous core comes back and that release is skipped. An older app's pinned script no longer downgrades a server the updater has moved on. TestInstallOnVDS covers the install with every transport, the updater, an update, a rollback from a crashing core and the cleanup. * provision: pin the node-install.sh with transport choice and the updater * node-install.sh: keep only cores it installed; autoupdate on|off for existing nodes A core some other installer left behind, such as an old fork's node-v1.4.0, has a version number that says nothing about this repository's releases. The no-downgrade rule and the updater now trust only the versions this script or the updater installed (bin/.managed), so such a server is moved to the pinned core by the wizard and to the newest release by the updater instead of being stuck on it. node-install.sh autoupdate on|off turns the updater on for channels installed before the wizard offered it. * provision: pin node-install.sh1f7bd2a* cupsonline: say why new rooms were refused The wizard showed "cups: транспорт остановлен" when cups.online turned the address away with 403 until its deadline. createRooms now keeps the last error, and CreateRoomList names a 403/429 refusal for what it is. * node-install.sh: remember which repository a core came from A core is now the release of one repository: .managed lists "repo tag", the plan says where the core comes from, and a script following another repository (a fork's, say) moves the server to that repository's core instead of keeping a release whose number only happens to be higher. The updater's script copy is replaced by a script from another repository in the same way. CORE_BASE now follows RELEASE_REPO. * provision: pin node-install.sh122ab88* provision: pin node-install.shfe9dc8b* mobile: the upstream link test on the transports-JSON NodeShareLink * mobile: OfferExitCookies hands a sign-in to the exit over the Session
308 lines
10 KiB
Go
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})
|
|
}
|