Files
mesh-catalog/modules/brother/cmd/brother/admin.go
T

232 lines
7.8 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
)
// Admin is a session on the printer's admin web page, over HTTPS to the one certificate pinned (ADR 0467
// clauses 3 and 4). The device names the password field itself, so every login reads the form first.
type Admin struct {
Host string
Password string
HTTP *http.Client
}
// loginStart is where a login starts: the root, which the printer answers with a redirect to the page it serves
// its login form on (/home/status.html). Asking for a page behind the login instead is answered by a redirect
// to an error page, and a loginurl naming a page this firmware lacks is a 404 after the login (issue 1514).
const loginStart = "/"
// maxPage bounds a page of the admin site.
const maxPage = 4 << 20
// NewAdmin is a client for the printer's host that trusts the certificate with this fingerprint alone, with a
// cookie jar for the session.
func NewAdmin(host, fingerprint, password string) (*Admin, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
return &Admin{
Host: host,
Password: password,
HTTP: &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return checkRedirect(host, req, via)
},
Transport: &http.Transport{
TLSClientConfig: pinnedTLS(fingerprint),
DisableKeepAlives: true,
},
},
}, nil
}
// checkRedirect follows a redirect only on https on the printer's own host, and never with a body: a 307 or 308
// answer to the login post would make the client send the post again, the password with it (issue 1515).
func checkRedirect(host string, req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("the printer redirected more than ten times")
}
if req.URL.Scheme != "https" || req.URL.Host != host {
return fmt.Errorf("the printer redirected to %s, not to https on the printer %s: not followed", req.URL.Scheme+"://"+req.URL.Host, host)
}
if req.Method != http.MethodGet && req.Method != http.MethodHead {
return fmt.Errorf("the printer redirected a %s with its body (a 307 or 308): not followed, so the password is not sent again", via[len(via)-1].Method)
}
return nil
}
func (a *Admin) url(path string) string { return "https://" + a.Host + path }
// fetch is one request; the body is read whole, up to maxPage. The final URL is the one after redirects.
func (a *Admin) fetch(ctx context.Context, method, rawURL string, form url.Values) ([]byte, *url.URL, error) {
var req *http.Request
var err error
if form != nil {
req, err = http.NewRequestWithContext(ctx, method, rawURL, strings.NewReader(form.Encode()))
if err == nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
} else {
req, err = http.NewRequestWithContext(ctx, method, rawURL, nil)
}
if err != nil {
return nil, nil, err
}
resp, err := a.HTTP.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
buf, err := io.ReadAll(io.LimitReader(resp.Body, maxPage+1))
if err != nil {
return nil, nil, fmt.Errorf("the page %s cannot be read: %w", rawURL, err)
}
if len(buf) > maxPage {
return nil, nil, fmt.Errorf("the page %s is larger than %d bytes", rawURL, maxPage)
}
if resp.StatusCode >= 400 {
if resp.StatusCode == http.StatusNotFound {
return nil, nil, &notFoundError{Path: resp.Request.URL.Path}
}
return nil, nil, fmt.Errorf("the printer answered %s for %s", resp.Status, resp.Request.URL.Path)
}
return buf, resp.Request.URL, nil
}
// passwordForm is the form of a page that holds a password field, or nil.
func passwordForm(p Page) (*Form, *Field) {
for i := range p.Forms {
for j := range p.Forms[i].Fields {
if p.Forms[i].Fields[j].Type == "password" {
return &p.Forms[i], &p.Forms[i].Fields[j]
}
}
}
return nil, nil
}
func hasField(f *Form, name string) bool {
for _, fl := range f.Fields {
if fl.Name == name {
return true
}
}
return false
}
// Login asks for the printer's login page, reads its login form, and posts the form's own fields with the
// password in the field the form names and the form's loginurl set to the default page, which every firmware has; the page asked for is a request of its own afterwards. A login
// is failed when the answer is a login page again.
func (a *Admin) Login(ctx context.Context) error {
body, at, err := a.fetch(ctx, http.MethodGet, a.url(loginStart), nil)
if err != nil {
return fmt.Errorf("the printer's admin page cannot be asked for its login: %w", err)
}
form, pw := passwordForm(ParsePage(body))
if form == nil {
return fmt.Errorf("the printer's answer to %s holds no login form with a password field", at.Path)
}
target, err := at.Parse(form.Action)
if err != nil {
return fmt.Errorf("the login form's action %q is not a URL: %w", form.Action, err)
}
if target.Scheme != "https" || target.Host != a.Host {
return fmt.Errorf("the login form posts to %s, not to https on the printer %s: the password is not sent", target.Scheme+"://"+target.Host, a.Host)
}
vals := url.Values{}
for _, f := range form.Fields {
if f.Type == "hidden" {
vals.Set(f.Name, f.Value)
}
}
if _, ok := vals["loginurl"]; ok {
vals.Set("loginurl", DefaultPage)
}
vals.Set(pw.Name, a.Password)
answer, _, err := a.fetch(ctx, http.MethodPost, target.String(), vals)
if err != nil {
// The post may have opened a session before its answer failed: end it if there is one.
if home, hat, herr := a.Get(context.Background(), DefaultPage); herr == nil {
a.Logout(context.Background(), home, hat)
}
return fmt.Errorf("the login was not answered: %w", err)
}
// A login page again is a form with a password field and the loginurl field; a page that was asked for may
// hold a password field of its own.
if again, _ := passwordForm(ParsePage(answer)); again != nil && hasField(again, "loginurl") {
return fmt.Errorf("the login failed: the printer showed its login page again, so the secret admin-password is not its password")
}
return nil
}
// Get is a page of the admin site, after a login.
func (a *Admin) Get(ctx context.Context, path string) ([]byte, *url.URL, error) {
return a.fetch(ctx, http.MethodGet, a.url(path), nil)
}
// Logout ends the session through the logout link or form of a page of it, and says what it did. A page with
// none leaves the session to expire, and the answer says so.
func (a *Admin) Logout(ctx context.Context, page []byte, at *url.URL) string {
p := ParsePage(page)
isLogout := func(s string) bool {
s = strings.ToLower(strings.ReplaceAll(s, " ", ""))
return strings.Contains(s, "logout") || strings.Contains(s, "logoff")
}
for _, f := range p.Forms {
if !isLogout(f.Action) {
continue
}
target, err := at.Parse(f.Action)
if err != nil || target.Host != a.Host {
continue
}
vals := url.Values{}
for _, fl := range f.Fields {
if fl.Type == "hidden" {
vals.Set(fl.Name, fl.Value)
}
}
method := http.MethodPost
var send url.Values = vals
if f.Method == "get" {
method, send = http.MethodGet, nil
target.RawQuery = vals.Encode()
}
if _, _, err := a.fetch(ctx, method, target.String(), send); err != nil {
return "logout failed: " + err.Error()
}
return "logged out"
}
for _, l := range p.Links {
if !isLogout(l.Href) && !isLogout(l.Text) {
continue
}
target, err := at.Parse(l.Href)
if err != nil || target.Host != a.Host {
continue
}
if _, _, err := a.fetch(ctx, http.MethodGet, target.String(), nil); err != nil {
return "logout failed: " + err.Error()
}
return "logged out"
}
return "no logout link or form on the page: the session is left to expire on the printer"
}
// notFoundError is the printer's 404 for a page.
type notFoundError struct{ Path string }
func (e *notFoundError) Error() string { return "the printer answered 404 Not Found for " + e.Path }