mirror of
https://git.robbyzambito.me/http-nats-proxy
synced 2025-12-20 08:14:51 +00:00
Add endpoint /proxy ... that can be used to proxy requests to other HTTP sites.
253 lines
7.7 KiB
Go
253 lines
7.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"flag"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/nats-io/nats.go/micro"
|
|
)
|
|
|
|
// printHelp outputs the usage information.
|
|
func printHelp() {
|
|
fmt.Println("Usage: HTTP-to-NATS proxy server")
|
|
fmt.Println("\nThe following environment variables are supported:")
|
|
fmt.Println(" NATS_URL - NATS connection URL (default: nats://127.0.0.1:4222)")
|
|
fmt.Println(" NATS_USER - NATS username for authentication (optional)")
|
|
fmt.Println(" NATS_PASSWORD - NATS password for authentication (optional)")
|
|
fmt.Println(" NATS_TOKEN - NATS token for authentication (optional)")
|
|
fmt.Println(" NATS_NKEY - NATS NKEY for authentication (optional)")
|
|
fmt.Println(" NATS_NKEY_SEED - NATS NKEY seed for authentication (optional)")
|
|
fmt.Println(" NATS_CREDS_FILE - Path to NATS credentials file (optional)")
|
|
fmt.Println(" NATS_INBOX_PREFIX - Subject prefix for NATS messages (default: _INBOX)")
|
|
fmt.Println(" HTTP_PORT - HTTP port to listen on (default: 8080)")
|
|
}
|
|
|
|
// URL to NATS subject conversion
|
|
func URLToNATS(urlPath string) (string, error) {
|
|
segments := strings.Split(strings.Trim(urlPath, "/"), "/")
|
|
for i, seg := range segments {
|
|
// Decode existing encoding first to prevent double-encoding
|
|
unescaped, err := url.PathUnescape(seg)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to unescape segment: %w", err)
|
|
}
|
|
|
|
// Encode special NATS-sensitive characters
|
|
encoded := url.PathEscape(unescaped)
|
|
encoded = strings.ReplaceAll(encoded, ".", "%2E") // Critical for token separation
|
|
encoded = strings.ReplaceAll(encoded, "*", "%2A") // Wildcard protection
|
|
encoded = strings.ReplaceAll(encoded, ">", "%3E") // Wildcard protection
|
|
|
|
segments[i] = encoded
|
|
}
|
|
return strings.Join(segments, "."), nil
|
|
}
|
|
|
|
// NATS subject to URL conversion
|
|
func NATSToURL(natsSubject string) (string, error) {
|
|
tokens := strings.Split(natsSubject, ".")
|
|
for i, token := range tokens {
|
|
// Reverse the special character encoding
|
|
decoded := strings.ReplaceAll(token, "%2E", ".")
|
|
decoded = strings.ReplaceAll(decoded, "%2A", "*")
|
|
decoded = strings.ReplaceAll(decoded, "%3E", ">")
|
|
|
|
// Unescape remaining URL encoding
|
|
unescaped, err := url.PathUnescape(decoded)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to unescape token: %w", err)
|
|
}
|
|
tokens[i] = unescaped
|
|
}
|
|
return "/" + strings.Join(tokens, "/"), nil
|
|
}
|
|
|
|
func main() {
|
|
helpFlag := flag.Bool("help", false, "Display help information about available environment variables")
|
|
flag.Parse()
|
|
if *helpFlag {
|
|
printHelp()
|
|
os.Exit(0)
|
|
}
|
|
|
|
// Read NATS connection info from environment variables
|
|
natsURL := os.Getenv("NATS_URL")
|
|
if natsURL == "" {
|
|
natsURL = nats.DefaultURL // defaults to "nats://127.0.0.1:4222"
|
|
}
|
|
natsUser := os.Getenv("NATS_USER")
|
|
natsPassword := os.Getenv("NATS_PASSWORD")
|
|
natsToken := os.Getenv("NATS_TOKEN")
|
|
natsNkey := os.Getenv("NATS_NKEY")
|
|
natsNkeySeed := os.Getenv("NATS_NKEY_SEED")
|
|
natsCredsFile := os.Getenv("NATS_CREDS_FILE")
|
|
natsInboxPrefix := os.Getenv("NATS_INBOX_PREFIX")
|
|
|
|
// Read HTTP port from environment variables
|
|
httpPort := os.Getenv("HTTP_PORT")
|
|
if httpPort == "" {
|
|
httpPort = "8080"
|
|
}
|
|
|
|
// Set up NATS connection options
|
|
opts := []nats.Option{nats.Name("Web proxy")}
|
|
|
|
if natsUser != "" && natsPassword != "" {
|
|
opts = append(opts, nats.UserInfo(natsUser, natsPassword))
|
|
} else if natsToken != "" {
|
|
opts = append(opts, nats.Token(natsToken))
|
|
} else if natsNkey != "" && natsNkeySeed != "" {
|
|
log.Fatalln("NKEY connection not supported")
|
|
} else if natsCredsFile != "" {
|
|
opts = append(opts, nats.UserCredentials(natsCredsFile))
|
|
}
|
|
|
|
if natsInboxPrefix != "" {
|
|
opts = append(opts, nats.CustomInboxPrefix(natsInboxPrefix))
|
|
}
|
|
|
|
nc, err := nats.Connect(natsURL, opts...)
|
|
if err != nil {
|
|
log.Fatal("Error connecting to NATS:", err)
|
|
}
|
|
defer nc.Close()
|
|
|
|
// HTTP handler to proxy all requests to NATS
|
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
// Read the entire request body (if any)
|
|
body, err := ioutil.ReadAll(r.Body)
|
|
if err != nil {
|
|
http.Error(w, "Error reading request body", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer r.Body.Close()
|
|
|
|
// Create the NATS subject.
|
|
// Remove the leading slash from the path and replace remaining slashes with dots.
|
|
// The subject is prefixed with "http.<host>.<method>.", where <method> is lower-case.
|
|
|
|
// Extract host and reverse domain components
|
|
host := r.Host
|
|
// Remove port if present
|
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
|
host = h
|
|
}
|
|
// Use "_" instead of "." in the domain to make it a single token.
|
|
domainParts := strings.ReplaceAll(host, ".", "_")
|
|
|
|
// Process path component
|
|
path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/")
|
|
subjectPath, err := URLToNATS(path)
|
|
if err != nil {
|
|
http.Error(w, "Error converting endpoint to NATS subject", http.StatusInternalServerError)
|
|
log.Println("Could not convert endpoint to NATS subject", err)
|
|
return
|
|
|
|
}
|
|
|
|
// Build final subject
|
|
subjectBase := "http"
|
|
subjectParts := []string{subjectBase, domainParts, strings.ToLower(r.Method)}
|
|
if subjectPath != "" {
|
|
subjectParts = append(subjectParts, subjectPath)
|
|
}
|
|
subject := strings.Join(subjectParts, ".")
|
|
|
|
// Create a new NATS message with the HTTP request body
|
|
msg := nats.Msg{
|
|
Subject: subject,
|
|
Data: body,
|
|
Header: nats.Header{},
|
|
}
|
|
|
|
// Copy over all the HTTP request headers to the NATS message headers
|
|
for key, values := range r.Header {
|
|
for _, value := range values {
|
|
msg.Header.Add(key, value)
|
|
}
|
|
}
|
|
|
|
// Add additional HTTP meta-data as headers
|
|
msg.Header.Set("X-HTTP-Method", r.Method)
|
|
msg.Header.Set("X-HTTP-Path", r.URL.Path)
|
|
msg.Header.Set("X-HTTP-Query", r.URL.RawQuery)
|
|
msg.Header.Set("X-HTTP-Host", r.Host)
|
|
msg.Header.Set("X-Remote-Addr", r.RemoteAddr)
|
|
|
|
// Send the NATS request and wait synchronously for a reply (timeout: 30 seconds)
|
|
reply, err := nc.RequestMsg(&msg, 30*time.Second)
|
|
if err != nil {
|
|
log.Println("NATS request error:", err)
|
|
|
|
// Handle specific NATS error cases
|
|
if err == nats.ErrNoResponders || strings.Contains(err.Error(), "no responders") {
|
|
http.Error(w, "No service available to handle request", http.StatusNotFound)
|
|
} else {
|
|
http.Error(w, "Error processing request", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Set any response headers from the NATS reply on the HTTP response
|
|
for key, values := range reply.Header {
|
|
for _, value := range values {
|
|
w.Header().Add(key, value)
|
|
}
|
|
}
|
|
|
|
// Write the reply body (from NATS) back to the HTTP client
|
|
w.Write(reply.Data)
|
|
})
|
|
|
|
_, err = micro.AddService(nc, micro.Config{
|
|
Name: "http-nats-proxy",
|
|
Version: "0.1.0",
|
|
Endpoint: µ.EndpointConfig{
|
|
Subject: "http.*.*.proxy.>",
|
|
Handler: micro.HandlerFunc(func(natsReq micro.Request) {
|
|
// http.host.method.proxy.host.endpoint.>
|
|
httpPath := natsReq.Headers()["X-HTTP-Path"][0]
|
|
httpReqURL := fmt.Sprintf("https:/%s", strings.TrimPrefix(httpPath, "/proxy"))
|
|
httpBody := bytes.NewReader(natsReq.Data())
|
|
httpMethod := natsReq.Headers()["X-HTTP-Method"][0]
|
|
|
|
// Create a new request
|
|
httpReq, err := http.NewRequest(httpMethod, httpReqURL, httpBody)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
client := &http.Client{}
|
|
resp, err := client.Do(httpReq)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
resBody, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
natsReq.Respond(resBody)
|
|
}),
|
|
},
|
|
})
|
|
if err != nil {
|
|
log.Fatal("Could not make NATS microservice:", err)
|
|
}
|
|
|
|
// Start the HTTP server
|
|
fmt.Println("Server is running on http://localhost:8080")
|
|
log.Fatal(http.ListenAndServe(":8080", nil))
|
|
}
|