232 lines
7.8 KiB
Go
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, ¬FoundError{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 }
|