// This file is part of the happyDomain (R) project. // Copyright (c) 2020-2026 happyDomain // Authors: Pierre-Olivier Mercier, et al. // // This program is offered under a commercial and under the AGPL license. // For commercial licensing, contact us at . // // For AGPL licensing: // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . //go:build standalone package checker import ( "encoding/json" "errors" "fmt" "net" "net/http" "os" "strconv" "strings" "github.com/miekg/dns" sdk "git.happydns.org/checker-sdk-go/checker" happydns "git.happydns.org/happyDomain/model" "git.happydns.org/happyDomain/services/abstract" ) // RenderForm implements server.Interactive: the human-facing form // exposed at GET /check when the checker runs as a standalone binary. func (p *sshProvider) RenderForm() []sdk.CheckerOptionField { return []sdk.CheckerOptionField{ { Id: "domain", Type: "string", Label: "Host name", Placeholder: "ssh.example.com", Required: true, Description: "The SSH server hostname to probe. A/AAAA and SSHFP records are looked up live.", }, { Id: OptionPorts, Type: "string", Label: "Additional ports", Placeholder: "22, 2222", Description: "Comma-separated list of additional TCP ports to probe. Port 22 is always probed.", }, { Id: OptionProbeTimeoutMs, Type: "number", Label: "Per-endpoint probe timeout (ms)", Default: float64(DefaultProbeTimeoutMs), }, { Id: OptionIncludeAuthProbe, Type: "bool", Label: "Enumerate authentication methods", Default: true, }, } } // ParseForm implements server.Interactive: resolves the submitted // hostname into an abstract.Server payload and wraps it in the // ServiceMessage shape that Collect expects. func (p *sshProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { domain := strings.TrimSpace(r.FormValue("domain")) domain = strings.TrimSuffix(domain, ".") if domain == "" { return nil, errors.New("host name is required") } fqdn := dns.Fqdn(domain) resolver, err := systemResolver() if err != nil { return nil, fmt.Errorf("resolver: %w", err) } server := &abstract.Server{} if a, err := lookupA(resolver, fqdn); err != nil { return nil, fmt.Errorf("A lookup for %s: %w", domain, err) } else if a != nil { server.A = a } if aaaa, err := lookupAAAA(resolver, fqdn); err != nil { return nil, fmt.Errorf("AAAA lookup for %s: %w", domain, err) } else if aaaa != nil { server.AAAA = aaaa } if server.A == nil && server.AAAA == nil { return nil, fmt.Errorf("no A/AAAA records found for %s", domain) } if sshfp, err := lookupSSHFP(resolver, fqdn); err != nil { return nil, fmt.Errorf("SSHFP lookup for %s: %w", domain, err) } else { server.SSHFP = sshfp } svcBody, err := json.Marshal(server) if err != nil { return nil, fmt.Errorf("marshal abstract.Server: %w", err) } opts := sdk.CheckerOptions{ OptionService: happydns.ServiceMessage{ ServiceMeta: happydns.ServiceMeta{ Type: "abstract.Server", Domain: domain, }, Service: svcBody, }, } if ports := strings.TrimSpace(r.FormValue(OptionPorts)); ports != "" { opts[OptionPorts] = ports } if raw := strings.TrimSpace(r.FormValue(OptionProbeTimeoutMs)); raw != "" { v, err := strconv.ParseFloat(raw, 64) if err != nil { return nil, errors.New("timeout must be a number") } opts[OptionProbeTimeoutMs] = v } opts[OptionIncludeAuthProbe] = parseInteractiveBool(r, OptionIncludeAuthProbe, true) return opts, nil } // parseInteractiveBool reads a checkbox-style field. HTML forms omit // unchecked checkboxes entirely, so a missing key means false if the // form was submitted (detected via the required "domain" field). func parseInteractiveBool(r *http.Request, key string, def bool) bool { if _, ok := r.Form[key]; !ok { if _, submitted := r.Form["domain"]; submitted { return false } return def } v := strings.ToLower(strings.TrimSpace(r.FormValue(key))) switch v { case "", "0", "false", "off", "no": return false default: return true } } // systemResolver picks a DNS server to send explicit SSHFP/A/AAAA // queries to. Resolution order: // 1. CHECKER_DNS_RESOLVER env var (host or host:port) // 2. The OS resolver config when one exists (resolvConfPath) // 3. 1.1.1.1:53 as a last-resort public fallback func systemResolver() (string, error) { if env := strings.TrimSpace(os.Getenv("CHECKER_DNS_RESOLVER")); env != "" { if _, _, err := net.SplitHostPort(env); err != nil { env = net.JoinHostPort(env, "53") } return env, nil } if path := resolvConfPath(); path != "" { if cfg, err := dns.ClientConfigFromFile(path); err == nil && len(cfg.Servers) > 0 { return net.JoinHostPort(cfg.Servers[0], cfg.Port), nil } } return net.JoinHostPort("1.1.1.1", "53"), nil } // resolvConfPath returns the platform-specific resolver config file, or // "" if none is expected on this OS (e.g. Windows). func resolvConfPath() string { for _, p := range []string{"/etc/resolv.conf"} { if _, err := os.Stat(p); err == nil { return p } } return "" } func dnsExchange(resolver, name string, qtype uint16) (*dns.Msg, error) { msg := new(dns.Msg) msg.SetQuestion(name, qtype) msg.RecursionDesired = true c := new(dns.Client) in, _, err := c.Exchange(msg, resolver) if err != nil { return nil, err } if in.Rcode != dns.RcodeSuccess && in.Rcode != dns.RcodeNameError { return nil, fmt.Errorf("rcode %s", dns.RcodeToString[in.Rcode]) } return in, nil } func lookupA(resolver, fqdn string) (*dns.A, error) { in, err := dnsExchange(resolver, fqdn, dns.TypeA) if err != nil { return nil, err } for _, rr := range in.Answer { if a, ok := rr.(*dns.A); ok { return a, nil } } return nil, nil } func lookupAAAA(resolver, fqdn string) (*dns.AAAA, error) { in, err := dnsExchange(resolver, fqdn, dns.TypeAAAA) if err != nil { return nil, err } for _, rr := range in.Answer { if aaaa, ok := rr.(*dns.AAAA); ok { return aaaa, nil } } return nil, nil } func lookupSSHFP(resolver, fqdn string) ([]*dns.SSHFP, error) { in, err := dnsExchange(resolver, fqdn, dns.TypeSSHFP) if err != nil { return nil, err } var out []*dns.SSHFP for _, rr := range in.Answer { if s, ok := rr.(*dns.SSHFP); ok { out = append(out, s) } } return out, nil }