Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,16 @@ plugins:
docker: {enabled: true, timeout_seconds: 8, max_length: 300}
```

`status`, `calc`, `foods`, `sports`, `car`, and `weapons` are local. The
`calc`, `foods`, `sports`, `car`, and `weapons` are local. Without a target,
`status` (and its `uptime`/`ping` aliases) also uses only local bot state.
With a public hostname or HTTP(S) URL it checks that website's HTTP status
and response time on port 80/443, independently of the IRC network. Website
checks default to a six-second timeout; `plugins.status.timeout_seconds`
accepts 1–15 seconds. They are not ICMP probes or historical uptime monitors.
See [Status and uptime](plugins.md#status-and-uptime) for examples and safety
limits.

The
`ask` plugin uses DuckDuckGo Search Assist first when `search_assist_enabled` is true.
This is a best-effort web integration that first uses the lightweight Search
Assist request and then, when enabled, a local Chromium/Chrome browser to read
Expand Down
35 changes: 32 additions & 3 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Plugins are enabled or disabled under plugins.<name>.enabled in config.yaml.
- port: local bidirectional well-known port/service lookup
- audit: package vulnerability discovery through OSV
- docker: Docker Hub image metadata lookup
- status: local connection, uptime, and counter status
- status: public website HTTP status/response time, or local bot connection, uptime, and counters
- xkcd: latest or numbered XKCD comics
- lastfm: current or recently played Last.fm tracks
- cats: short cat facts
Expand Down Expand Up @@ -839,8 +839,37 @@ returns one cleaned and length-limited message, and does not download images.

## Status and uptime

These commands use only GoBot's local state and do not call an external
service:
With a hostname or HTTP(S) URL, these commands check that website from the
GoBot host, independently of the IRC network:

~~~text
!status example.org
!status https://example.org/health
!ping example.org
!ping https://example.org/
!uptime example.org
~~~

Bare hostnames default to HTTPS. Results include the HTTP status code and
elapsed HTTP response time (including connection setup and redirects), not
ICMP ping/round-trip time. HTTP 403/404/429 responses still mean the server
responded; they are reported accurately rather than called a connection
failure. DNS, TLS verification, timeout, and blocked-target failures are
reported separately. A single check cannot measure a remote site's historical
uptime, including when invoked as `!uptime <target>`.

Only public HTTP(S) targets on ports 80/443 are allowed. Credentials, private,
loopback, link-local, shared, reserved, and other special-purpose addresses
are blocked, including redirects and DNS answers containing a private address.
Connections are pinned to validated IPs; environment proxies are disabled and
TLS certificates are verified. Response headers and redirects are bounded,
page bodies are not read or parsed, and URL query parameters/fragments are not
echoed. Up to four checks can run concurrently across networks. Configure
`plugins.status.timeout_seconds` from 1 to 15 seconds (default 6); existing
command cooldowns also apply.

Without a target, the commands retain their original local-only behavior and
do not call an external service:

~~~text
!status
Expand Down
53 changes: 47 additions & 6 deletions plugins/status.go
Original file line number Diff line number Diff line change
@@ -1,29 +1,70 @@
package plugins

import (
"context"
"fmt"
"net/http"
"strings"
"time"

"github.com/variablenix/GoBot/bot"
"github.com/variablenix/GoBot/storage"
)

// Status reports local process and connection state without making a network
// request. It is intentionally useful when external APIs are unavailable.
type Status struct{}
// Status reports local state without a target, or checks a public website when
// a host/URL is supplied. Website checks do not depend on the IRC network.
type Status struct {
client *http.Client
timeout time.Duration
}

func (p *Status) Name() string { return "status" }
func (p *Status) Commands() []string { return []string{"status", "uptime", "ping"} }
func (p *Status) Help() string {
return "!status — show connection, uptime, and counters (aliases: !uptime, !ping)"
return "!status [public host|http(s) URL] — HTTP status and response time (ports 80/443); without a target: bot connection, uptime, counters (aliases: !uptime, !ping; HTTP, not ICMP)"
}
func (p *Status) Init(cfg bot.PluginConfig, _ *storage.DB) error {
seconds := cfg.Int("timeout_seconds", 6)
if seconds < 1 || seconds > 15 {
seconds = 6
}
p.timeout = time.Duration(seconds) * time.Second
p.client = newStatusHTTPClient(p.timeout)
return nil
}
func (p *Status) Init(_ bot.PluginConfig, _ *storage.DB) error { return nil }

func (p *Status) Handle(b *bot.Bot, m bot.Message) bool {
cmd, _, ok := bot.IsCommand(m, b.Config.CommandPrefix)
cmd, arg, ok := bot.IsCommand(m, b.Config.CommandPrefix)
if !ok || !isStatusCommand(cmd) {
return false
}
if strings.TrimSpace(arg) != "" {
target, err := parseStatusTarget(arg)
if err != nil {
b.Send(m.ReplyTarget(), ircColor(ircYellow, "Use one public hostname or http(s) URL on port 80/443; private addresses and credentials are not allowed."))
return true
}
select {
case statusCheckSlots <- struct{}{}:
defer func() { <-statusCheckSlots }()
default:
b.Send(m.ReplyTarget(), ircColor(ircYellow, "Website checks are busy; try again shortly."))
return true
}
timeout := p.timeout
if timeout <= 0 {
timeout = 6 * time.Second
}
client := p.client
if client == nil {
client = newStatusHTTPClient(timeout)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
result, err := checkStatusSite(ctx, client, target)
b.Send(m.ReplyTarget(), formatStatusSite(target, result, err))
return true
}
if b.Stats == nil {
b.Send(m.ReplyTarget(), ircColor(ircYellow, "GoBot status is unavailable"))
return true
Expand Down
245 changes: 245 additions & 0 deletions plugins/status_http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
package plugins

import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
"unicode"

"golang.org/x/net/idna"
)

var (
errStatusTargetBlocked = errors.New("non-public website target")
errStatusRedirectLimit = errors.New("website redirect limit reached")
// Shared across networks; user cooldowns also apply in the command router.
statusCheckSlots = make(chan struct{}, 4)
)

func parseStatusTarget(raw string) (*url.URL, error) {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > 2048 || strings.ContainsFunc(raw, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) {
return nil, errStatusTargetBlocked
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Opaque != "" || u.User != nil || u.Hostname() == "" {
return nil, errStatusTargetBlocked
}
port := u.Port()
if (port != "" && port != "80" && port != "443") || strings.HasSuffix(u.Host, ":") {
return nil, errStatusTargetBlocked
}
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
if ip, err := netip.ParseAddr(host); err == nil {
if !statusPublicIP(ip) {
return nil, errStatusTargetBlocked
}
if ip.Is6() {
host = "[" + host + "]"
}
} else {
host, err = idna.Lookup.ToASCII(host)
if err != nil || len(host) > 253 || !strings.Contains(host, ".") {
return nil, errStatusTargetBlocked
}
for _, suffix := range []string{".localhost", ".local", ".internal", ".home.arpa"} {
if strings.HasSuffix(host, suffix) || host == strings.TrimPrefix(suffix, ".") {
return nil, errStatusTargetBlocked
}
}
labels := strings.Split(host, ".")
for _, label := range labels {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' || strings.ContainsFunc(label, func(r rune) bool {
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-')
}) {
return nil, errStatusTargetBlocked
}
}
// Reject legacy shortened/octal IPv4 spellings, not just dotted quads.
if strings.Trim(labels[len(labels)-1], "0123456789") == "" {
return nil, errStatusTargetBlocked
}
}
u.Host = host
if port != "" {
u.Host += ":" + port
}
u.Fragment, u.RawFragment = "", ""
return u, nil
}

// Restrict checks to ordinary globally routed addresses. In addition to local
// ranges, exclude shared, documentation, benchmark, and transition space from
// IANA's special-purpose registries (including IPv4-mapped private addresses).
var statusBlockedPrefixes = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("192.0.0.0/24"), netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("192.88.99.0/24"), netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"), netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("240.0.0.0/4"), netip.MustParsePrefix("2001::/23"),
netip.MustParsePrefix("2001:db8::/32"), netip.MustParsePrefix("2002::/16"),
netip.MustParsePrefix("3fff::/20"),
}

var statusIPv6Global = netip.MustParsePrefix("2000::/3")

func statusPublicIP(ip netip.Addr) bool {
if !ip.IsValid() || ip.Zone() != "" {
return false
}
ip = ip.Unmap()
if !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.Is6() && !statusIPv6Global.Contains(ip) {
return false
}
for _, prefix := range statusBlockedPrefixes {
if prefix.Contains(ip) {
return false
}
}
return true
}

func newStatusHTTPClient(timeout time.Duration) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil // Never let an environment proxy bypass IP validation.
transport.DisableKeepAlives = true
transport.DisableCompression = true
transport.TLSHandshakeTimeout = 3 * time.Second
transport.ResponseHeaderTimeout = 4 * time.Second
transport.MaxResponseHeaderBytes = 64 << 10
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
return dialStatusPublic(ctx, network, address, net.DefaultResolver.LookupNetIP, (&net.Dialer{Timeout: 2 * time.Second}).DialContext)
}
return &http.Client{Timeout: timeout, Transport: transport, CheckRedirect: func(req *http.Request, via []*http.Request) error {
if _, err := parseStatusTarget(req.URL.String()); err != nil {
return err
}
if len(via) >= 5 {
return errStatusRedirectLimit
}
return nil
}}
}

func dialStatusPublic(ctx context.Context, network, address string,
lookup func(context.Context, string, string) ([]netip.Addr, error),
dial func(context.Context, string, string) (net.Conn, error),
) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil || (port != "80" && port != "443") {
return nil, errStatusTargetBlocked
}
var ips []netip.Addr
if ip, parseErr := netip.ParseAddr(host); parseErr == nil {
ips = []netip.Addr{ip}
} else {
ips, err = lookup(ctx, "ip", host)
if err != nil {
return nil, err
}
}
if len(ips) == 0 {
return nil, &net.DNSError{IsNotFound: true}
}
// Validate the entire answer before dialing even one address. Pin each
// connection to a validated IP; never resolve the hostname a second time.
for _, ip := range ips {
if !statusPublicIP(ip) {
return nil, errStatusTargetBlocked
}
}
for _, ip := range ips {
conn, dialErr := dial(ctx, network, net.JoinHostPort(ip.Unmap().String(), port))
if dialErr == nil {
return conn, nil
}
err = dialErr
if ctx.Err() != nil {
break
}
}
return nil, err
}

type statusSiteResult struct {
code int
elapsed time.Duration
finalURL *url.URL
}

func checkStatusSite(ctx context.Context, client *http.Client, target *url.URL) (statusSiteResult, error) {
started := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
return statusSiteResult{}, err
}
req.Header.Set("User-Agent", "GoBot/1.0 (public website availability check)")
res, err := client.Do(req)
result := statusSiteResult{elapsed: time.Since(started), finalURL: target}
if err != nil {
return result, err
}
defer res.Body.Close() // Only response headers are needed, never a page body.
result.code = res.StatusCode
if res.Request != nil {
result.finalURL = res.Request.URL
}
return result, nil
}

func statusTargetLabel(target *url.URL, maxBytes int) string {
// Omit queries/fragments so tokens or other URL parameters are not echoed.
return truncateUTF8Bytes(cleanExternalText(target.Scheme+"://"+target.Host+target.EscapedPath()), maxBytes)
}

func formatStatusSite(target *url.URL, result statusSiteResult, err error) string {
label := "[Status] " + statusTargetLabel(target, 180)
if err != nil {
return label + " | " + ircColor(ircYellow, statusSiteError(err))
}
color := ircGreen
if result.code >= 400 {
color = ircYellow
}
if result.code >= 500 {
color = ircRed
}
text := fmt.Sprintf("%s | reachable | %s | HTTP response %d ms", label, ircColor(color, fmt.Sprintf("HTTP %d %s", result.code, http.StatusText(result.code))), result.elapsed.Milliseconds())
if result.finalURL != nil && result.finalURL.String() != target.String() {
text += " | redirected to " + statusTargetLabel(result.finalURL, 100)
}
return text
}

func statusSiteError(err error) string {
var dnsErr *net.DNSError
var certErr *tls.CertificateVerificationError
var unknownCA x509.UnknownAuthorityError
var hostnameErr x509.HostnameError
var netErr net.Error
switch {
case errors.Is(err, errStatusTargetBlocked):
return "blocked non-public target or redirect"
case errors.Is(err, errStatusRedirectLimit):
return "too many redirects"
case errors.Is(err, context.DeadlineExceeded) || errors.As(err, &netErr) && netErr.Timeout():
return "website check timed out"
case errors.As(err, &dnsErr):
return "DNS lookup failed"
case errors.As(err, &certErr) || errors.As(err, &unknownCA) || errors.As(err, &hostnameErr):
return "TLS certificate verification failed"
default:
return "HTTP connection failed"
}
}
Loading
Loading