Files
OpenFlux/node_wizard_test.go
8da36d909d node-install.sh: any systemd distro, remove by name, list, uninstall (#135)
* node-install.sh: any systemd from 219, remove by name, list, uninstall

- Portability: "systemctl --now" (systemd 220) and "show --value" (230)
  are gone, "ss -H" too (older iproute2); apply creates the channel's
  state directory and the unit allows it with ReadWritePaths=-, so a
  channel runs where StateDirectory= is unknown (before 235). The updater
  (restart, liveness by MainPID, rollback) works on those systems too.
- remove CHANNEL removes one channel by name from the server's shell (the
  apps keep passing a config file); list prints the channels with their
  state and port; uninstall removes every channel and everything the
  script installed (updater, unit, cores, folders, node user, firewall
  ports), stopping channel instances whose config is already gone.
- mobile: OfferExitCookies (a sign-in handed to the exit) is removed; the
  account feature it served is not coming.
- CI: .github/workflows/node-install.yml runs TestInstallOnVDS on Ubuntu
  24.04/22.04/20.04, Debian 12/11, Rocky 9, Alma 8, Fedora 42, Arch and
  openSUSE Leap 15.6 in systemd containers (deploy/test/vds-dockerfile.sh);
  the test now also covers list, remove by name and uninstall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* provision: pin node-install.sh 768745d

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Node wizard: no Yandex sign-in for the node

The apps no longer hand a node a Yandex sign-in (the wizard takes a link
to your own document), so the whole path goes:

- node-install.sh: cookies= and set-cookies (write_cookies); a node's
  own cookie store (CookieStore in node.conf, the transports' state)
  stays.
- provision: Channel.Cookies, CookieStore, ChannelCookies, SetCookies;
  Plan takes the channel only.
- --node-wizard: withCookies, cookies, setCookies, signedIn.
- mobile: NodePlan/NodeApply lose withCookies/cookieHeader;
  NodeSetCookies and NodeSignedIn are removed.

Apps already out pin their own script and bundle their own core, so
they are not affected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* provision: pin node-install.sh b6b0e3c

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI node-install: Debian 13 for EOL Debian 11, PAM on RHEL images, state dir owner

Debian 11's archive moved (apt-get update fails); RHEL-family images
get an authselect profile so sshd's PAM stack exists; systemd sets the
state directory's mode itself, so the test checks its owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* provision: run the multi-command VDS checks under sh; set test passwords by hash

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: node-install: sshd and PAM state on failure

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test VDS: /etc/shadow readable by root without CAP_DAC_OVERRIDE

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: p1neappleXpress <a@a.a>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:17:24 +03:00

248 lines
9.2 KiB
Go

//go:build !exitnode
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/p1neappleXpress/OpenFlux/provision"
"github.com/p1neappleXpress/OpenFlux/share"
"github.com/p1neappleXpress/OpenFlux/transport"
"github.com/p1neappleXpress/OpenFlux/transport/yandex"
)
func wizardCall(t *testing.T, w *nodeWizard, method string, params interface{}) map[string]interface{} {
t.Helper()
raw, _ := json.Marshal(params)
return w.handle(wizardRequest{ID: 1, Method: method, Params: raw})
}
func TestNodeWizardProtocolLines(t *testing.T) {
in := strings.NewReader("{\"id\":7,\"method\":\"newChannel\"}\n\nnot json\n{\"id\":9,\"method\":\"nope\"}\n")
var out bytes.Buffer
if code := runNodeWizard(in, &out); code != 0 {
t.Fatalf("exit code %d", code)
}
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != 3 {
t.Fatalf("want 3 responses, got %d: %q", len(lines), out.String())
}
var first, bad, unknown map[string]interface{}
for i, into := range []*map[string]interface{}{&first, &bad, &unknown} {
if err := json.Unmarshal([]byte(lines[i]), into); err != nil {
t.Fatal(err)
}
}
if first["id"] != float64(7) || first["ok"] != true || len(first["key"].(string)) != 64 || first["channel"] == "" {
t.Fatalf("newChannel: %v", first)
}
if bad["ok"] != false {
t.Fatalf("bad line: %v", bad)
}
if unknown["id"] != float64(9) || unknown["ok"] != false {
t.Fatalf("unknown method: %v", unknown)
}
}
func TestNodeWizardNeedsConnection(t *testing.T) {
w := newNodeWizard()
for _, m := range []string{"plan", "apply", "remove"} {
r := wizardCall(t, w, m, map[string]string{"channel": "c1"})
if r["ok"] != false || r["error"] != "нет подключения к серверу" {
t.Fatalf("%s: %v", m, r)
}
}
}
func TestNodeWizardHostKeyIsReportedNotTrusted(t *testing.T) {
w := newNodeWizard()
var got provision.Target
w.dial = func(_ context.Context, target provision.Target) (*provision.Conn, error) {
got = target
if target.HostKey == "" {
return nil, &provision.HostKeyError{Fingerprint: "SHA256:abc"}
}
return nil, &provision.HostKeyError{Fingerprint: "SHA256:new", Mismatch: true}
}
r := wizardCall(t, w, "connect", wizardParams{Host: " vds.example ", Port: 2222, User: " root ", Password: "pw"})
if r["ok"] != false || r["hostKey"] != "SHA256:abc" || r["trust"] != true || r["mismatch"] != false {
t.Fatalf("new server: %v", r)
}
if got.Host != "vds.example" || got.User != "root" || got.Port != 2222 || got.Password != "pw" {
t.Fatalf("target: %+v", got)
}
r = wizardCall(t, w, "connect", wizardParams{Host: "vds.example", User: "root", Password: "pw", HostKey: "SHA256:abc"})
if r["ok"] != false || r["mismatch"] != true || r["trust"] != false {
t.Fatalf("changed key: %v", r)
}
if strings.Contains(fmt.Sprint(r), "pw") {
t.Fatalf("password leaked into the reply: %v", r)
}
}
func TestNodeWizardCheckDocument(t *testing.T) {
w := newNodeWizard()
w.checkDoc = func(u string) (yandex.VolgaDocument, error) {
switch u {
case "edit":
return yandex.VolgaDocument{Editable: true}, nil
case "view":
return yandex.VolgaDocument{}, nil
case "pow":
return yandex.VolgaDocument{}, fmt.Errorf("captcha solve: %w", errors.New("captcha POST unexpected status 400"))
default:
return yandex.VolgaDocument{}, fmt.Errorf("wrapped: %w", yandex.ErrCaptchaRequired)
}
}
if r := wizardCall(t, w, "checkDocument", wizardParams{DocumentURL: "edit"}); r["ok"] != true || r["editable"] != true {
t.Fatalf("editable: %v", r)
}
if r := wizardCall(t, w, "checkDocument", wizardParams{DocumentURL: "view"}); r["ok"] != false || r["captcha"] == true {
t.Fatalf("view only: %v", r)
}
if r := wizardCall(t, w, "checkDocument", wizardParams{DocumentURL: "captcha"}); r["ok"] != false || r["captcha"] != true {
t.Fatalf("captcha: %v", r)
}
// A PoW captcha Yandex rejected says nothing about the document either.
if r := wizardCall(t, w, "checkDocument", wizardParams{DocumentURL: "pow"}); r["ok"] != false || r["captcha"] != true {
t.Fatalf("rejected PoW captcha: %v", r)
}
}
func TestNodeWizardShareLinkAndSignIn(t *testing.T) {
w := newNodeWizard()
key := strings.Repeat("ab", 32)
doc := "https://docs.yandex.ru/edit/d/AbCdEfGhIjKlMnOpQrStUv"
r := wizardCall(t, w, "shareLink", wizardParams{Name: "Нода", DocumentURL: doc, Key: key, Host: "203.0.113.5", ChannelPort: 8445})
if r["ok"] != true {
t.Fatalf("shareLink: %v", r)
}
c, err := share.Decode(r["link"].(string))
if err != nil {
t.Fatal(err)
}
if !c.Negotiate || c.Secret != key || c.Context != doc || len(c.Transports) != 2 ||
c.Transports[0].Type != "vyandex" || c.Transports[1].Dial != "203.0.113.5:8445" {
t.Fatalf("link config: %+v", c)
}
if r := wizardCall(t, w, "shareLink", wizardParams{DocumentURL: doc, Key: key}); r["ok"] != false {
t.Fatalf("no host: %v", r)
}
}
func TestNodeWizardShareLinkWithChosenTransports(t *testing.T) {
w := newNodeWizard()
key := strings.Repeat("ab", 32)
mailru := "https://cloud.mail.ru/public/DEmN/ETbZW2MPY"
r := wizardCall(t, w, "shareLink", wizardParams{Name: "Нода", Key: key, Host: "203.0.113.5", ChannelPort: 8445,
Transports: []provision.ChannelTransport{{Type: "cupsonline", URL: "WyJyb29tLTEiXQ"}, {Type: "mailru", URL: mailru}}})
if r["ok"] != true {
t.Fatalf("shareLink: %v", r)
}
c, err := share.Decode(r["link"].(string))
if err != nil {
t.Fatal(err)
}
if c.Context != mailru || len(c.Transports) != 3 || c.Transports[0].Type != "mailru" ||
c.Transports[1].Type != "cupsonline" || c.Transports[2].Dial != "203.0.113.5:8445" {
t.Fatalf("link config: %+v", c)
}
r = wizardCall(t, w, "shareLink", wizardParams{Key: key, Host: "203.0.113.5", ChannelPort: 8445,
Transports: []provision.ChannelTransport{{Type: "boards", URL: "https://boards.yandex.ru/x"}}})
if r["ok"] != false {
t.Fatalf("a transport the wizard does not offer: %v", r)
}
}
func TestNodeWizardCreateRooms(t *testing.T) {
w := newNodeWizard()
w.newRooms = func(context.Context) (string, error) { return "WyJyb29tLTEiXQ", nil }
if r := wizardCall(t, w, "createRooms", nil); r["ok"] != true || r["rooms"] != "WyJyb29tLTEiXQ" {
t.Fatalf("createRooms: %v", r)
}
w.newRooms = func(context.Context) (string, error) { return "", errors.New("403") }
if r := wizardCall(t, w, "createRooms", nil); r["ok"] != false || !strings.Contains(r["error"].(string), "cups.online") {
t.Fatalf("createRooms failure: %v", r)
}
}
// The node.conf node-install.sh writes and the link the wizard builds must
// agree on everything the two sides derive keys and routes from: the
// encryption context and each carrier's address and priority.
func TestNodeConfMatchesShareLink(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("no sh")
}
body, err := os.ReadFile("deploy/node-install.sh")
if err != nil {
t.Fatal(err)
}
// The script's functions without its command dispatch.
funcs := string(body)[:strings.Index(string(body), "\ncase \"${1:-}\" in")]
volga := "https://disk.yandex.ru/edit/d/AbCdEfGhIjKlMnOpQrStUv"
mailru := "https://cloud.mail.ru/public/DEmN/ETbZW2MPY"
rooms := "WyJyb29tLTEiLCJyb29tLTIiXQ"
key := strings.Repeat("cd", 32)
for _, ts := range [][]provision.ChannelTransport{
{{Type: "vyandex", URL: volga}},
{{Type: "vyandex", URL: volga}, {Type: "mailru", URL: mailru}, {Type: "cupsonline", URL: rooms}},
{{Type: "mailru", URL: mailru}, {Type: "cupsonline", URL: rooms}},
{{Type: "cupsonline", URL: rooms}},
nil,
} {
vars := "CHANNEL=of-test PORT=20443 URL='" + provision.TransportURL(ts, "vyandex") + "' MAILRU='" +
provision.TransportURL(ts, "mailru") + "' CUPS='" + provision.TransportURL(ts, "cupsonline") + "'\n"
cmd := exec.Command(sh)
cmd.Stdin = strings.NewReader(funcs + "\n" + vars + "write_node_conf\n")
out, err := cmd.Output()
if err != nil {
t.Fatalf("%+v: %v", ts, err)
}
path := filepath.Join(t.TempDir(), "node.conf")
if err := os.WriteFile(path, out, 0o600); err != nil {
t.Fatal(err)
}
conf, err := parseConf(path)
if err != nil {
t.Fatalf("%+v: %v\n%s", ts, err, out)
}
var specs []transportSpec
for _, s := range conf.Transports {
specs = append(specs, transportSpec{Name: s.Name, Type: s.Values["Type"], Priority: confInt(s.Values["Priority"], 50), URL: s.Values["URL"]})
}
link, err := provision.ShareLink("n", key, "203.0.113.5", 20443, ts)
if err != nil {
t.Fatal(err)
}
c, _ := share.Decode(link)
sources := make([]transport.ContextSource, len(specs))
for i, s := range specs {
sources[i] = transport.ContextSource{Type: s.Type, URL: s.URL, Priority: s.Priority}
}
if got, _ := transport.KDFContexts("", conf.Interface["URL"], sources); got != c.Context {
t.Fatalf("%+v: node derives context %q, link says %q\n%s", ts, got, c.Context, out)
}
if len(specs) != len(c.Transports) {
t.Fatalf("%+v: node has %d carriers, link %d\n%s", ts, len(specs), len(c.Transports), out)
}
for i, s := range specs {
lt := c.Transports[i]
// share.Make drops the priority of a lone carrier: nothing to rank.
samePriority := s.Priority == lt.Priority || len(c.Transports) == 1 && lt.Priority == 0
if s.Type != lt.Type || !samePriority || (s.Type != "direct" && s.URL != lt.URL) {
t.Fatalf("%+v: carrier %d: node %+v, link %+v", ts, i, s, lt)
}
}
}
}