package e2e import ( "fmt" "html" "io" "net/http" "net/http/cookiejar" "net/url" "regexp" "strings" "time" ) // panelBaseURL is where compose.override.yml publishes the panel's plain-HTTP // port (PANEL_COOKIE_SECURE=false — no reverse proxy in this stand). const panelBaseURL = "http://127.0.0.1:20080" // panelClient drives the panel exactly as a browser would: an HTTP client // with a cookie jar, HTML forms posted as the templates render them, and // responses scraped for the bits a later step needs (a domain's id, an // application's one-shot password, the DKIM record to publish). This is // deliberate — it is also the only way to prove that what the panel tells an // administrator to publish is the record that actually verifies (plan C.4). type panelClient struct { http *http.Client } func newPanelClient() (*panelClient, error) { jar, err := cookiejar.New(nil) if err != nil { return nil, err } return &panelClient{http: &http.Client{ Jar: jar, Timeout: 15 * time.Second, }}, nil } // waitForPanelReady polls the host-published panel port until /healthz // returns 200. Supervisor reporting the panel process RUNNING is not enough: // the setup-token file is written in Start() before ListenAndServe, and // Docker's host-port publish can lag the in-container bind — either race // makes the first setup POST fail and leaves sc.panel nil for later steps. func waitForPanelReady() error { client := &http.Client{Timeout: 2 * time.Second} return waitFor("panel /healthz on "+panelBaseURL, 60*time.Second, 200*time.Millisecond, func() (bool, error) { resp, err := client.Get(panelBaseURL + "/healthz") if err != nil { return false, err } defer resp.Body.Close() _, _ = io.Copy(io.Discard, resp.Body) if resp.StatusCode != http.StatusOK { return false, fmt.Errorf("status %d", resp.StatusCode) } return true, nil }) } // readSetupToken reads the one-time setup URL from /data/setup-token inside // the running selfpost container. The file is mode 0600 owned by the panel // UID (security.md / setup.go); on a typical CI bind mount that is not the // host runner's UID, so a host-side os.ReadFile returns permission denied // even though the panel already wrote the token (CI: setup_and_login). // Reading via compose exec matches docs/guide.md ("docker compose exec // selfpost cat /data/setup-token"). func readSetupToken(s *stack) (string, error) { var raw string err := waitFor("setup-token to appear", 30*time.Second, 300*time.Millisecond, func() (bool, error) { out, err := s.execIn("selfpost", "cat", "/data/setup-token") if err != nil { return false, err } raw = out return strings.TrimSpace(out) != "", nil }) if err != nil { return "", err } u, err := url.Parse(strings.TrimSpace(raw)) if err != nil { return "", fmt.Errorf("parse setup token file: %w", err) } return strings.TrimPrefix(u.Path, "/setup/"), nil } func (c *panelClient) postForm(path string, form url.Values) (*http.Response, string, error) { resp, err := c.http.PostForm(panelBaseURL+path, form) if err != nil { return nil, "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) return resp, string(body), err } func (c *panelClient) get(path string) (*http.Response, string, error) { resp, err := c.http.Get(panelBaseURL + path) if err != nil { return nil, "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) return resp, string(body), err } // setup creates the administrator through the one-time /setup/ form. func (c *panelClient) setup(token, username, password string) error { resp, body, err := c.postForm("/setup/"+token, url.Values{ "username": {username}, "password": {password}, "password_confirm": {password}, }) if err != nil { return err } if resp.StatusCode != http.StatusOK { return fmt.Errorf("setup: status %d: %s", resp.StatusCode, body) } return nil } // login authenticates and stores the session cookie in the client's jar. func (c *panelClient) login(username, password string) error { resp, body, err := c.postForm("/login", url.Values{ "username": {username}, "password": {password}, }) if err != nil { return err } if resp.Request.URL.Path != "/overview" { return fmt.Errorf("login did not land on /overview (landed on %s): %s", resp.Request.URL.Path, body) } return nil } // addDomain submits the add-domain form and returns its assigned id, read // back from the redirect target /outbound/domains/{id}. func (c *panelClient) addDomain(name string) (string, error) { resp, body, err := c.postForm("/outbound/domains", url.Values{"name": {name}}) if err != nil { return "", err } m := regexp.MustCompile(`^/outbound/domains/(\d+)$`).FindStringSubmatch(resp.Request.URL.Path) if m == nil { return "", fmt.Errorf("add domain %q: unexpected landing page %s: %s", name, resp.Request.URL.Path, body) } return m[1], nil } // dkimRecord fetches a domain's page and scrapes the DKIM DNS record it tells // the administrator to publish. func (c *panelClient) dkimRecord(domainID string) (name, value string, err error) { _, body, err := c.get("/outbound/domains/" + domainID) if err != nil { return "", "", err } name, ok := extractCodeRow(body, "Host / name") if !ok { return "", "", fmt.Errorf("could not find DKIM record name on domain page") } value, ok = extractCodeRow(body, "Value to publish") if !ok { return "", "", fmt.Errorf("could not find DKIM record value on domain page") } return name, value, nil } // addApplication submits the add-application form and returns the one-shot // login/password the panel renders inline (security.md — never recoverable // later, so this is the only place to read it). func (c *panelClient) addApplication(domainID, login, mode, addresses string) (appLogin, password string, err error) { resp, body, err := c.postForm("/outbound/domains/"+domainID+"/applications/new", url.Values{ "login": {login}, "mode": {mode}, "addresses": {addresses}, }) if err != nil { return "", "", err } if resp.StatusCode != http.StatusCreated { return "", "", fmt.Errorf("add application %q: status %d: %s", login, resp.StatusCode, body) } appLogin, ok := extractCodeRow(body, "Login") if !ok { return "", "", fmt.Errorf("could not find new application login in response") } password, ok = extractCodeRow(body, "Password") if !ok { return "", "", fmt.Errorf("could not find new application password in response") } return appLogin, password, nil } // setApplicationLimit saves a manual level-2 limit (guide § Rate limiting) on // an application. The application form is one POST, so the sender rule goes // with it: the application stays on the domain wildcard it was created with. func (c *panelClient) setApplicationLimit(domainID, appID string, maxMessages, windowSeconds int) error { vals := url.Values{ "mode": {"wildcard"}, "rl_mode": {"manual"}, "max_messages": {fmt.Sprintf("%d", maxMessages)}, "window_seconds": {fmt.Sprintf("%d", windowSeconds)}, } resp, body, err := c.postForm("/outbound/domains/"+domainID+"/applications/"+appID, vals) if err != nil { return err } if resp.StatusCode != http.StatusOK { return fmt.Errorf("set rate limit: status %d: %s", resp.StatusCode, body) } return nil } // sendLogRows returns the raw /outbound/log/fragment HTML fragment, filtered to one // domain and optionally to one application login, for polling a row's status // without parsing full HTML into structs. The application is a filter rather // than something to search the returned rows for: the log's columns identify a // message (time, from, to, subject, status) and the sending application is only // named on a row's own /outbound/log/{id} page. func (c *panelClient) sendLogRows(domain, app string) (string, error) { q := url.Values{"domain": {domain}} if app != "" { q.Set("app", app) } _, body, err := c.get("/outbound/log/fragment?" + q.Encode()) return body, err } // status fetches the authenticated landing page — used after a container // restart to confirm the session cookie is still accepted (plan C.4 check 8). func (c *panelClient) status() (*http.Response, error) { resp, _, err := c.get("/overview") return resp, err } // applicationID scrapes an application's numeric id off its row of the domain // page, keyed by login — needed to build the application's own address // (/outbound/domains/{id}/applications/{aid}), which the add-application // response (just the login/password) does not carry. The id is taken from the // row's Edit link, so only the login cell itself is anchored on. func (c *panelClient) applicationID(domainID, login string) (string, error) { _, body, err := c.get("/outbound/domains/" + domainID) if err != nil { return "", err } pattern := `(?s)` + regexp.QuoteMeta(login) + `.*?/applications/(\d+)"` m := regexp.MustCompile(pattern).FindStringSubmatch(body) if m == nil { return "", fmt.Errorf("could not find application id for login %q", login) } return m[1], nil } // extractCodeRow scrapes the value of a copy field — a "" followed by its read-only input or textarea (the panel's // copy_field partial, internal/web/view/templates/components.html) — from a // rendered panel page. It is deliberately anchored to the label text rather // than position, so it survives unrelated template reordering. html/template's // escaper is conservative about which characters it entity-encodes — a DKIM // value's base64 "+" comes back as "+" — so the match is HTML-unescaped // before returning. func extractCodeRow(body, label string) (string, bool) { pattern := `\s*
` + `(?:|)` m := regexp.MustCompile(pattern).FindStringSubmatch(body) if m == nil { return "", false } value := m[1] if value == "" { value = m[2] } return html.UnescapeString(strings.TrimSpace(value)), true }