devroxy

VHost Proxy Server for localhost
git clone git://git.lair.cx/devroxy
Log | Files | Refs | README

commit 09aa9db87601fbdcf6cdf27068de65e448ab05b3
Author: Yongbin Kim <iam@yongbin.kim>
Date:   Fri, 23 Sep 2022 14:16:53 +0900

first commit

Signed-off-by: Yongbin Kim <iam@yongbin.kim>

Diffstat:
A.gitignore | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A.idea/.gitignore | 8++++++++
A.idea/devroxy.iml | 18++++++++++++++++++
A.idea/modules.xml | 9+++++++++
A.idea/vcs.xml | 7+++++++
AMakefile | 13+++++++++++++
AREADME | 40++++++++++++++++++++++++++++++++++++++++
Ago.mod | 12++++++++++++
Ago.sum | 26++++++++++++++++++++++++++
Ainternal/devroxy/api.go | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/devroxy/binds.go | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/devroxy/devroxy.go | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/devroxy/utils.go | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amain.go | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
14 files changed, 698 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,196 @@ +# TLS related files +*.pem +*.cert +*.key + +# Created by https://www.toptal.com/developers/gitignore/api/macos,windows,linux,goland +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows,linux,goland + +### GoLand ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### GoLand Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/macos,windows,linux,goland diff --git a/.idea/.gitignore b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/devroxy.iml b/.idea/devroxy.iml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<module type="WEB_MODULE" version="4"> + <component name="Go" enabled="true"> + <buildTags> + <option name="customFlags"> + <array> + <option value="wireinject" /> + </array> + </option> + </buildTags> + </component> + <component name="NewModuleRootManager"> + <content url="file://$MODULE_DIR$" /> + <orderEntry type="inheritedJdk" /> + <orderEntry type="sourceFolder" forTests="false" /> + </component> +</module> +\ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="ProjectModuleManager"> + <modules> + <module fileurl="file://$PROJECT_DIR$/.idea/devroxy.iml" filepath="$PROJECT_DIR$/.idea/devroxy.iml" /> + </modules> + </component> +</project> +\ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="VcsDirectoryMappings"> + <mapping directory="$PROJECT_DIR$" vcs="Git" /> + </component> +</project> +\ No newline at end of file diff --git a/Makefile b/Makefile @@ -0,0 +1,13 @@ +BUILD_OUT := builds/devroxy + +.PHONY := help +.DEFAULT_GOAL := help +help: + @echo TBD + +build: + mkdir -p $(dir ${BUILD_OUT}) + go build -o ${BUILD_OUT} . + +run: + go run . -binds binds.yaml -cert cert.pem -key key.pem diff --git a/README b/README @@ -0,0 +1,40 @@ +# Devroxy + +Simple http(s) proxy for localhost development. + +# Getting started + +I recommend that you set up a DNS server on localhost. + +``` +*.internal. IN A 127.0.0.1 +``` + +`/etc/hosts` can be used if you prefer this way. + +``` +api.internal 127.0.0.1 +app.internal 127.0.0.1 +... +``` + +Devroxy does not matter what you use. + +After you setup the dns server, starts devroxy server with command below: + +``` + +sudo devroxy serve + +# Port 7199 is in use? +devroxy serve -p 8080 +``` + +and bind the domain to destination port. + +``` +# You can use any http client, even the browser. +http://localhost:7199/bind?api.internal=8080 +``` + +Done! Now you can access to `http://api.internal`, devroxy will proxy to port 8080. diff --git a/go.mod b/go.mod @@ -0,0 +1,12 @@ +module devroxy + +go 1.18 + +require ( + github.com/google/wire v0.5.0 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/rs/zerolog v1.28.0 // indirect + golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum @@ -0,0 +1,26 @@ +github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8= +github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY= +github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 h1:foEbQz/B0Oz6YIqu/69kfXPYeFQAuuMYFkjaqXzl5Wo= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/devroxy/api.go b/internal/devroxy/api.go @@ -0,0 +1,55 @@ +package devroxy + +import ( + "net/http" + "strconv" + + "github.com/rs/zerolog/log" +) + +func (d *Devroxy) handleListBinds(w http.ResponseWriter, _ *http.Request) { + sendJSON(w, http.StatusOK, d.binds) +} + +func (d *Devroxy) handleRegisterBind(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + domain := query.Get("domain") + port, err := strconv.ParseInt(query.Get("port"), 10, 32) + if err != nil { + sendMessage(w, http.StatusBadRequest, "invalid port number") + return + } + + d.registerBind(domain, int(port)) + sendOK(w) +} + +func (d *Devroxy) handleRemoveBind(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + domain := query.Get("domain") + d.removeBind(domain) + sendOK(w) +} + +func (d *Devroxy) handleSaveBinds(w http.ResponseWriter, _ *http.Request) { + err := d.saveBinds() + if err != nil { + log.Err(err).Msg("binds/save: save failed") + sendError(w, http.StatusBadRequest, err) + return + } + sendOK(w) +} + +func (d *Devroxy) handleNotFound(w http.ResponseWriter, _ *http.Request) { + sendMessage(w, http.StatusNotFound, "not found") +} + +func (d *Devroxy) handleProxyError(w http.ResponseWriter, r *http.Request, err error) { + log.Err(err). + Str("host", r.Host). + Str("path", r.URL.Path). + Msg("proxy error") + sendError(w, http.StatusBadGateway, err) +} diff --git a/internal/devroxy/binds.go b/internal/devroxy/binds.go @@ -0,0 +1,54 @@ +package devroxy + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +func loadBindImpl(out map[string]int, bindsFile string) error { + fp, err := os.Open(bindsFile) + if err != nil { + return err + } + defer fp.Close() + + err = yaml.NewDecoder(fp).Decode(out) + if err != nil { + return fmt.Errorf("failed to decode bind file '%q': %w", bindsFile, err) + } + + return nil +} + +func (d *Devroxy) registerBind(domain string, port int) { + d.mutex.Lock() + defer d.mutex.Unlock() + + d.binds[domain] = port +} + +func (d *Devroxy) removeBind(domain string) { + d.mutex.Lock() + defer d.mutex.Unlock() + + delete(d.binds, domain) +} + +func (d *Devroxy) saveBinds() error { + d.mutex.Lock() + defer d.mutex.Unlock() + + // is in-memory only? + if len(d.bindsDest) == 0 { + return nil + } + + data, err := yaml.Marshal(d.binds) + if err != nil { + return err + } + + return os.WriteFile(d.bindsDest, data, 0644) +} diff --git a/internal/devroxy/devroxy.go b/internal/devroxy/devroxy.go @@ -0,0 +1,95 @@ +package devroxy + +import ( + "fmt" + "net/http" + "net/http/httputil" + "sync" + + "github.com/rs/zerolog/log" +) + +type Devroxy struct { + mutex sync.RWMutex + + proxyHandler *httputil.ReverseProxy + mux *http.ServeMux + + binds map[string]int + bindsDest string + + internalIP string + internalAddr string +} + +func New(addr string) *Devroxy { + ip, port, err := parseAddr(addr) + if err != nil { + log.Fatal().Str("addr", addr).Err(err).Msg("devroxy: invalid address") + } + + return (&Devroxy{ + binds: make(map[string]int), + + internalIP: ip, + internalAddr: fmt.Sprintf("%s:%d", ip, port), + }).setup() +} + +func (d *Devroxy) setup() *Devroxy { + d.proxyHandler = &httputil.ReverseProxy{ + Director: d.director, + ErrorHandler: d.handleProxyError, + } + + // select * from table where cond=1 order by asc desc; + + d.mux = http.NewServeMux() + d.mux.HandleFunc("/devroxy/binds", d.handleListBinds) + d.mux.HandleFunc("/devroxy/binds/register", d.handleRegisterBind) + d.mux.HandleFunc("/devroxy/binds/remove", d.handleRemoveBind) + d.mux.HandleFunc("/devroxy/binds/save", d.handleSaveBinds) + // d.mux.HandleFunc("/devroxy/not-found", d.handleNotFound) + d.mux.HandleFunc("/devroxy/", d.handleNotFound) + d.mux.Handle("/", d.proxyHandler) + + return d +} + +func (d *Devroxy) Handler() http.Handler { + d.mutex.RLock() + defer d.mutex.RUnlock() + return d.mux +} + +func (d *Devroxy) LoadBinds(bindsFile string) error { + d.bindsDest = bindsFile + return loadBindImpl(d.binds, bindsFile) +} + +func (d *Devroxy) director(r *http.Request) { + d.mutex.RLock() + defer d.mutex.RUnlock() + + var dest string + if port, ok := d.binds[r.Host]; ok { + dest = fmt.Sprintf("%s:%d", d.internalIP, port) + } else { + dest = d.internalAddr + r.URL.Path = "/devroxy/not-found" + r.URL.RawPath = "/devroxy/not-found" + r.URL.RawQuery = "" + } + + log.Info(). + Str("host", r.Host). + Str("path", r.URL.Path). + Msg("proxy requested") + + r.URL.Scheme = "http" + r.URL.Host = dest + + if _, ok := r.Header["User-Agent"]; !ok { + r.Header.Set("User-Agent", "") + } +} diff --git a/internal/devroxy/utils.go b/internal/devroxy/utils.go @@ -0,0 +1,66 @@ +package devroxy + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/rs/zerolog/log" +) + +func sendOK(w http.ResponseWriter) { + sendMessage(w, http.StatusOK, "ok") +} + +func sendMessage(w http.ResponseWriter, status int, message string) { + sendJSON(w, status, struct { + Message string `json:"message"` + }{ + Message: message, + }) +} + +func sendError(w http.ResponseWriter, status int, err error) { + sendJSON(w, status, struct { + Error string `json:"error"` + }{ + Error: err.Error(), + }) +} + +func sendJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json;charset=utf-8") + + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + log.Err(err).Msg("sendJSON: response marshaling failed") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"message":"internal error"}`)) + return + } + + w.WriteHeader(status) + w.Write(data) +} + +func parseAddr(addr string) (string, int, error) { + i := strings.LastIndexByte(addr, ':') + if i < 0 || i == len(addr)-1 { + return "", 0, errors.New("invalid address") + } + + port, err := strconv.ParseInt(addr[i+1:], 10, 32) + if err != nil || port < 10 || port > 65535 { + return "", 0, fmt.Errorf("invalid port number: %s", addr[i+1:]) + } + + ip := addr[:i] + if len(ip) == 0 { + ip = "127.0.0.1" + } + + return ip, int(port), nil +} diff --git a/main.go b/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "flag" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "devroxy/internal/devroxy" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +var ( + flagAddr = flag.String("addr", "", "Address that server listening for. if omitted, server uses default port (:80 or :443)") + flagBinds = flag.String("binds", "", "Bind file location. if omitted, devroxy runs in-memory mode.") + flagCert = flag.String("cert", "", "SSL certification. if omitted, server will listen for http request.") + flagKey = flag.String("key", "", "SSL key. if omitted, server will listen for http request.") +) + +const ( + ReadTimeout = time.Second + WriteTimeout = time.Second +) + +func main() { + flag.Parse() + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + + useTLS := len(*flagCert) > 0 && len(*flagKey) > 0 + if !useTLS && (len(*flagCert) > 0 && len(*flagKey) > 0) { + + } + if len(*flagAddr) == 0 { + if useTLS { + *flagAddr = ":80" + } else { + *flagAddr = ":443" + } + } + + d := devroxy.New(*flagAddr) + + if len(*flagBinds) > 0 { + err := d.LoadBinds(*flagBinds) + if err != nil && !os.IsNotExist(err) { + log.Fatal().Err(err).Msg("failed to load binds file") + } + } + + server := &http.Server{ + Addr: *flagAddr, + Handler: d.Handler(), + ReadTimeout: ReadTimeout, + WriteTimeout: WriteTimeout, + } + + errChan := make(chan error, 1) + go func() { + log.Info(). + Str("addr", *flagAddr). + Bool("tls", useTLS). + Msg("server started") + + var err error + if useTLS { + err = server.ListenAndServe() + } else { + err = server.ListenAndServeTLS(*flagCert, *flagKey) + } + + if err != http.ErrServerClosed { + errChan <- err + } + }() + + sigChan := make(chan os.Signal) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + select { + case err := <-errChan: + if err != nil { + log.Fatal().Err(err).Msg("failed to start the server") + } + + case <-sigChan: + signal.Stop(sigChan) + close(sigChan) + + err := server.Shutdown(context.Background()) + if err != nil { + log.Fatal().Err(err).Msg("shutdown failed") + } + } +}