Skip to content

Commit

Permalink
Feature: add args to serve application behind external url or route p…
Browse files Browse the repository at this point in the history
…refix

Signed-off-by: kwilt <[email protected]>
  • Loading branch information
kwilt authored and kwilt committed Jan 2, 2025
1 parent d3c2e72 commit 7be5d67
Showing 1 changed file with 122 additions and 52 deletions.
174 changes: 122 additions & 52 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,21 @@
package main

import (
"errors"
"fmt"
"github.com/alecthomas/kingpin/v2"
"log/slog"
"net"
"net/http"
_ "net/http/pprof"
"net/url"
"os"
"os/signal"
"path"
"strings"
"sync"
"syscall"

"github.com/alecthomas/kingpin/v2"
"github.com/prometheus/client_golang/prometheus"
versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version"
"github.com/prometheus/client_golang/prometheus/promauto"
Expand All @@ -50,6 +54,8 @@ var (
concurrency = kingpin.Flag("snmp.module-concurrency", "The number of modules to fetch concurrently per scrape").Default("1").Int()
debugSNMP = kingpin.Flag("snmp.debug-packets", "Include a full debug trace of SNMP packet traffics.").Default("false").Bool()
expandEnvVars = kingpin.Flag("config.expand-environment-variables", "Expand environment variables to source secrets").Default("false").Bool()
externalURL = kingpin.Flag("web.external-url", "The URL under which snmp exporter is externally reachable (for example, if snmp exporter is served via a reverse proxy). Used for generating relative and absolute links back to snmp exporter itself. If the URL has a path portion, it will be used to prefix all HTTP endpoints served by snmp exporter. If omitted, relevant URL components will be derived automatically.").PlaceHolder("<url>").String()
routePrefix = kingpin.Flag("web.route-prefix", "Prefix for the internal routes of web endpoints. Defaults to path of --web.external-url.").PlaceHolder("<path>").String()
metricsPath = kingpin.Flag(
"web.telemetry-path",
"Path under which to expose metrics.",
Expand Down Expand Up @@ -228,6 +234,37 @@ func main() {
return
}

// Infer or set snmp exporter externalURL
listenAddrs := toolkitFlags.WebListenAddresses
if *externalURL == "" && *toolkitFlags.WebSystemdSocket {
logger.Error("Cannot automatically infer external URL with systemd socket listener. Please provide --web.external-url")
os.Exit(1)
} else if *externalURL == "" && len(*listenAddrs) > 1 {
logger.Info("Inferring external URL from first provided listen address")
}

beURL, err := computeExternalURL(*externalURL, (*listenAddrs)[0])
if err != nil {
logger.Error("Failed to determine external URL", "err", err)
os.Exit(1)
}
logger.Debug("External URL", "url", beURL.String())

// Default -web.route-prefix to path of -web.external-url
if *routePrefix == "" {
*routePrefix = beURL.Path
}

// routePrefix must always be at least '/'
*routePrefix = "/" + strings.Trim(*routePrefix, "/")
logger.Debug("Route prefix", "prefix", *routePrefix)
// routePrefix requires path to have trailing "/" in order
// for browsers to interpret the path-relative path correctly, instead of stripping it.
if *routePrefix != "/" {
*routePrefix = *routePrefix + "/"
}
logger.Debug(*routePrefix)

hup := make(chan os.Signal, 1)
reloadCh = make(chan chan error)
signal.Notify(hup, syscall.SIGHUP)
Expand Down Expand Up @@ -293,64 +330,59 @@ func main() {
),
}

http.Handle(*metricsPath, promhttp.Handler()) // Normal metrics endpoint for SNMP exporter itself.
// Match Prometheus behavior and redirect over externalURL for root path only
// if routePrefix is different from "/"
if *routePrefix != "/" {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, beURL.String(), http.StatusFound)
})
}

http.Handle(path.Join(*routePrefix, *metricsPath), promhttp.Handler()) // Normal metrics endpoint for SNMP exporter itself.
// Endpoint to do SNMP scrapes.
http.HandleFunc(proberPath, func(w http.ResponseWriter, r *http.Request) {
http.HandleFunc(path.Join(*routePrefix, proberPath), func(w http.ResponseWriter, r *http.Request) {
handler(w, r, logger, exporterMetrics)
})
http.HandleFunc("/-/reload", updateConfiguration) // Endpoint to reload configuration.
http.HandleFunc(path.Join(*routePrefix, "/-/reload"), updateConfiguration) // Endpoint to reload configuration.

if *metricsPath != "/" && *metricsPath != "" {
landingConfig := web.LandingConfig{
Name: "SNMP Exporter",
Description: "Prometheus Exporter for SNMP targets",
Version: version.Info(),
Form: web.LandingForm{
Action: proberPath,
Inputs: []web.LandingFormInput{
{
Label: "Target",
Type: "text",
Name: "target",
Placeholder: "X.X.X.X/[::X]",
Value: "::1",
},
{
Label: "Auth",
Type: "text",
Name: "auth",
Placeholder: "auth",
Value: "public_v2",
},
{
Label: "Module",
Type: "text",
Name: "module",
Placeholder: "module",
Value: "if_mib",
},
},
},
Links: []web.LandingLinks{
{
Address: configPath,
Text: "Config",
},
{
Address: *metricsPath,
Text: "Metrics",
},
},
}
landingPage, err := web.NewLandingPage(landingConfig)
if err != nil {
logger.Error("Error creating landing page", "err", err)
os.Exit(1)
}
http.Handle("/", landingPage)
http.HandleFunc(*routePrefix, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`<html>
<head>
<title>SNMP Exporter</title>
<style>
label{
display:inline-block;
width:75px;
}
form label {
margin: 10px;
}
form input {
margin: 10px;
}
</style>
</head>
<body>
<h1>SNMP Exporter</h1>
<form action="` + path.Join(*routePrefix, proberPath) + `">
<label>Target:</label> <input type="text" name="target" placeholder="X.X.X.X/[::X]" value="::1"><br>
<label>Auth:</label> <input type="text" name="auth" placeholder="auth" value="public_v2"><br>
<label>Module:</label> <input type="text" name="module" placeholder="module" value="if_mib"><br>
<input type="submit" value="Submit">
</form>
<p><a href="` + path.Join(*routePrefix, configPath) + `">Config</a></p>
<p><a href="` + path.Join(*routePrefix, *metricsPath) + `">Metrics</a></p>
</body>
</html>`))
})
}

http.HandleFunc(configPath, func(w http.ResponseWriter, r *http.Request) {
http.HandleFunc(path.Join(*routePrefix, configPath), func(w http.ResponseWriter, r *http.Request) {
sc.RLock()
c, err := yaml.Marshal(sc.C)
sc.RUnlock()
Expand All @@ -368,3 +400,41 @@ func main() {
os.Exit(1)
}
}

func startsOrEndsWithQuote(s string) bool {
return strings.HasPrefix(s, "\"") || strings.HasPrefix(s, "'") ||
strings.HasSuffix(s, "\"") || strings.HasSuffix(s, "'")
}

// computeExternalURL computes a sanitized external URL from a raw input. It infers unset
// URL parts from the OS and the given listen address.
func computeExternalURL(u, listenAddr string) (*url.URL, error) {
if u == "" {
hostname, err := os.Hostname()
if err != nil {
return nil, err
}
_, port, err := net.SplitHostPort(listenAddr)
if err != nil {
return nil, err
}
u = fmt.Sprintf("http://%s:%s/", hostname, port)
}

if startsOrEndsWithQuote(u) {
return nil, errors.New("URL must not begin or end with quotes")
}

eu, err := url.Parse(u)
if err != nil {
return nil, err
}

ppref := strings.TrimRight(eu.Path, "/")
if ppref != "" && !strings.HasPrefix(ppref, "/") {
ppref = "/" + ppref
}
eu.Path = ppref

return eu, nil
}

0 comments on commit 7be5d67

Please sign in to comment.