Compare commits

..

9 Commits
0.5.4 ... 0.5.7

Author SHA1 Message Date
yusing
d10d0e49fa Update default wake timeout to 30 seconds, fixed port selection, improved idlewatcher 2024-09-25 05:27:12 +08:00
yusing
dc3575c8fd Refactoring 2024-09-25 03:43:47 +08:00
yusing
17115cfb0b Refactor and fixed port and scheme assignment logic in FillMissingFields.
Fixed issue that when a container is stopped or network=host, it will be excluded.
2024-09-25 03:40:57 +08:00
yusing
498082f7e5 no longer exclude a stopped container that have user specified port 2024-09-25 00:56:33 +08:00
yusing
99216ffe59 fixed subdomain matching for sub-sub-subdomain and so on, now return 404 when subdomain is missing 2024-09-24 18:30:25 +08:00
yusing
f426dbc9cf removed save registration.json since it does not work properly 2024-09-24 18:18:37 +08:00
yusing
1c611cc9b9 example fix 2024-09-24 03:13:39 +08:00
yusing
dc43e26770 Format README for better clarity in setup instructions 2024-09-24 03:09:50 +08:00
yusing
79ae26f1b5 new simpler setup method, readme and doc update 2024-09-23 22:10:13 +08:00
33 changed files with 414 additions and 270 deletions

View File

@@ -19,6 +19,9 @@ COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /src/go-proxy /app/
COPY schema/ /app/schema
# copy cert required for setup
COPY --from=builder /etc/ssl/certs /etc/ssl/certs
ENV DOCKER_HOST=unix:///var/run/docker.sock
ENV GOPROXY_DEBUG=0

View File

@@ -1,6 +1,6 @@
.PHONY: all build up quick-restart restart logs get udp-server
all: build quick-restart logs
all: debug
setup:
mkdir -p config certs

View File

@@ -32,11 +32,12 @@ A lightweight, easy-to-use, and [performant](docs/benchmark_result.md) reverse p
- Easy to use
- Effortless configuration
- Simple multi-node setup
- Error messages is clear and detailed, easy troubleshooting
- Auto certificate obtaining and renewal (See [Supported DNS Challenge Providers](docs/dns_providers.md))
- Auto configuration for docker containers
- Auto hot-reload on container state / config file changes
- Stop containers on idle, wake it up on traffic _(optional, see [showcase](#idlesleeper))_
- **idlesleeper**: stop containers on idle, wake it up on traffic _(optional, see [showcase](#idlesleeper))_
- HTTP(s) reserve proxy
- TCP and UDP port forwarding
- Web UI for configuration and monitoring (See [screenshots](https://github.com/yusing/go-proxy-frontend?tab=readme-ov-file#screenshots))
@@ -48,18 +49,29 @@ A lightweight, easy-to-use, and [performant](docs/benchmark_result.md) reverse p
### Setup
1. Setup DNS Records, e.g.
1. Pull docker image
```shell
docker pull ghcr.io/yusing/go-proxy:latest
```
- A Record: `*.y.z` -> `10.0.10.1`
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
2. Create new directory, `cd` into it, then run setup
2. Setup `go-proxy` [See here](docs/docker.md)
```shell
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/go-proxy setup
```
3. Setup `docker-socket-proxy` (see [example](docs/docker_socket_proxy.md) other machine that is running docker (if any)
3. Setup DNS Records point to machine which runs `go-proxy`, e.g.
4. Configure `go-proxy`
- with text editor (e.g. Visual Studio Code)
- or with web config editor via `http://gp.y.z`
- A Record: `*.y.z` -> `10.0.10.1`
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
4. Setup `docker-socket-proxy` other docker nodes _(if any)_ (see [example](docs/docker_socket_proxy.md)) and then them inside `config.yml`
5. Done. You may now do some extra configuration
- With text editor (e.g. Visual Studio Code)
- With Web UI via `gp.y.z`
- For more info, [See docker.md](docs/docker.md)
[🔼Back to top](#table-of-content)

View File

@@ -15,14 +15,14 @@
# options:
# - auth_token: c1234565789-abcdefghijklmnopqrst # your zone API token
# 3. other providers, check readme for more
# 3. other providers, check docs/dns_providers.md for more
providers:
include:
- providers.yml # config/providers.yml
# add some more below if you want
# - file1.yml # config/file_1.yml
# - file2.yml
# include:
# - providers.yml # config/providers.yml
# # add some more below if you want
# - file1.yml # config/file_1.yml
# - file2.yml
docker:
# for value format, see https://docs.docker.com/reference/cli/dockerd/
# $DOCKER_HOST implies unix:///var/run/docker.sock by default
@@ -30,8 +30,7 @@ providers:
# add more docker providers if needed
# remote-1: tcp://10.0.2.1:2375
# remote-2: ssh://root:1234@10.0.2.2
# Fixed options (optional, non hot-reloadable)
# timeout_shutdown: 5
# redirect_to_https: false
# redirect_to_https: false # redirect http requests to https (if enabled)

View File

@@ -11,42 +11,70 @@
## Cloudflare
```yaml
autocert:
provider: cloudflare
options:
auth_token:
```
`auth_token` your zone API token
Follow [this guide](https://cloudkul.com/blog/automcatic-renew-and-generate-ssl-on-your-website-using-lego-client/) to create a new token with `Zone.DNS` read and edit permissions
## CloudDNS
- `client_id`
- `email`
- `password`
```yaml
autocert:
provider: clouddns
options:
client_id:
email:
password:
```
## DuckDNS
- `token`: DuckDNS Token
```yaml
autocert:
provider: duckdns
options:
token:
```
Tested by [earvingad](https://github.com/earvingad)
## OVHCloud
```yaml
autocert:
provider: ovh
options:
api_endpoint:
application_key:
application_secret:
consumer_key:
oauth2_config:
client_id:
client_secret:
```
_Note, `application_key` and `oauth2_config` **CANNOT** be used together_
- `api_endpoint`: Endpoint URL, or one of
- `ovh-eu`,
- `ovh-ca`,
- `ovh-us`,
- `kimsufi-eu`,
- `kimsufi-ca`,
- `soyoustart-eu`,
- `soyoustart-ca`
- `application_secret`
- `application_key`
- `consumer_key`
- `oauth2_config`: Client ID and Client Secret
- `client_id`
- `client_secret`
- `api_endpoint`: Endpoint URL, or one of
- `ovh-eu`,
- `ovh-ca`,
- `ovh-us`,
- `kimsufi-eu`,
- `kimsufi-ca`,
- `soyoustart-eu`,
- `soyoustart-ca`
- `application_secret`
- `application_key`
- `consumer_key`
- `oauth2_config`: Client ID and Client Secret
- `client_id`
- `client_secret`
## Implement other DNS providers

View File

@@ -6,7 +6,7 @@
- [Docker compose guide](#docker-compose-guide)
- [Table of content](#table-of-content)
- [Setup](#setup)
- [Additional setup](#additional-setup)
- [Labels](#labels)
- [Syntax](#syntax)
- [Fields](#fields)
@@ -16,34 +16,11 @@
- [Docker compose examples](#docker-compose-examples)
- [Services URLs for above examples](#services-urls-for-above-examples)
## Setup
## Additional setup
1. Install `wget` if not already
1. Enable HTTPs _(optional)_
- Ubuntu based: `sudo apt install -y wget`
- Fedora based: `sudo yum install -y wget`
- Arch based: `sudo pacman -Sy wget`
2. Run setup script
`bash <(wget -qO- https://github.com/yusing/go-proxy/raw/main/setup-docker.sh)`
It will setup folder structure and required config files
3. Verify folder structure and then `cd go-proxy`
```plain
go-proxy
├── certs
├── compose.yml
└── config
├── config.yml
└── providers.yml
```
4. Enable HTTPs _(optional)_
Mount a folder (to store obtained certs) or (containing existing cert)
Mount a folder to store obtained certs or to load existing cert
```yaml
services:
@@ -69,15 +46,16 @@
```yaml
autocert:
provider: local
cert_path: /app/certs/cert.crt
key_path: /app/certs/priv.key
```
5. Modify `compose.yml` to fit your needs
2. Modify `compose.yml` to fit your needs
6. Run `docker compose up -d` to start the container
3. Run `docker compose up -d` to start the container
7. Navigate to Web panel `http://gp.yourdomain.com` or use **Visual Studio Code (provides schema check)** to edit proxy config
4. Navigate to Web panel `http://gp.yourdomain.com` or use **Visual Studio Code (provides schema check)** to edit proxy config
[🔼Back to top](#table-of-content)
@@ -90,7 +68,7 @@
| `proxy.aliases` | comma separated aliases for subdomain and label matching | `gitlab,gitlab-reg,gitlab-ssh` | `container_name` | any |
| `proxy.exclude` | to be excluded from `go-proxy` | | false | boolean |
| `proxy.idle_timeout` | time for idle (no traffic) before put it into sleep **(http/s only)**<br> _**NOTE: idlewatcher will only be enabled containers that has non-empty `idle_timeout`**_ | `1h` | empty or `0` **(disabled)** | `number[unit]...`, e.g. `1m30s` |
| `proxy.wake_timeout` | time to wait for target site to be ready | | `10s` | `number[unit]...` |
| `proxy.wake_timeout` | time to wait for target site to be ready | | `30s` | `number[unit]...` |
| `proxy.stop_method` | method to stop after `idle_timeout` | | `stop` | `stop`, `pause`, `kill` |
| `proxy.stop_timeout` | time to wait for stop command | | `10s` | `number[unit]...` |
| `proxy.stop_signal` | signal sent to container for `stop` and `kill` methods | | docker's default | `SIGINT`, `SIGTERM`, `SIGHUP`, `SIGQUIT` and those without **SIG** prefix |
@@ -100,16 +78,16 @@
### Fields
| Field | Description | Default | Allowed Values / Syntax |
| --------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scheme` | proxy protocol | <ul><li>`http` for numeric port</li><li>`tcp` for `x:y` port</li></ul> | `http`, `https`, `tcp`, `udp` |
| `host` | proxy host | <ul><li>Docker: docker client IP / hostname </li><li>File: `localhost`</li></ul> | IP address, hostname |
| `port` | proxy port **(http/s)** | first port returned from docker | number in range of `1 - 65535` |
| `port` **(required)** | proxy port **(tcp/udp)** | N/A | `x:y` <br><ul><li>**x**: port for `go-proxy` to listen on.<br>**x** can be 0, which means listen on a random port</li><li>**y**: port or [_service name_](../src/common/constants.go#L55) of target container</li></ul> |
| `no_tls_verify` | whether skip tls verify **(https only)** | `false` | boolean |
| `path_patterns` | proxy path patterns **(http/s only)**<br> only requests that matched a pattern will be proxied | empty **(proxy all requests)** | yaml style list[<sup>1</sup>](#list-example) of ([path patterns](https://pkg.go.dev/net/http#hdr-Patterns-ServeMux)) |
| `set_headers` | header to set **(http/s only)** | empty | yaml style key-value mapping[<sup>2</sup>](#key-value-mapping-example) of header-value pairs |
| `hide_headers` | header to hide **(http/s only)** | empty | yaml style list[<sup>1</sup>](#list-example) of headers |
| Field | Description | Default | Allowed Values / Syntax |
| --------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scheme` | proxy protocol | <ul><li>`http` for numeric port</li><li>`tcp` for `x:y` port</li></ul> | `http`, `https`, `tcp`, `udp` |
| `host` | proxy host | <ul><li>Docker: docker client IP / hostname </li><li>File: `localhost`</li></ul> | IP address, hostname |
| `port` | proxy port **(http/s)** | first port returned from docker | number in range of `1 - 65535` |
| `port` | proxy port **(tcp/udp)** | `0:first_port` | `x:y` <br><ul><li>**x**: port for `go-proxy` to listen on.<br>**x** can be 0, which means listen on a random port</li><li>**y**: port or [_service name_](../src/common/constants.go#L55) of target container</li></ul> |
| `no_tls_verify` | whether skip tls verify **(https only)** | `false` | boolean |
| `path_patterns` | proxy path patterns **(http/s only)**<br> only requests that matched a pattern will be proxied | `/` **(proxy all requests)** | yaml style list[<sup>1</sup>](#list-example) of ([path patterns](https://pkg.go.dev/net/http#hdr-Patterns-ServeMux)) |
| `set_headers` | header to set **(http/s only)** | empty | yaml style key-value mapping[<sup>2</sup>](#key-value-mapping-example) of header-value pairs |
| `hide_headers` | header to hide **(http/s only)** | empty | yaml style list[<sup>1</sup>](#list-example) of headers |
[🔼Back to top](#table-of-content)

View File

@@ -23,7 +23,7 @@ docker-proxy:
ports:
- 2375:2375
# or more secure
- <machine_ip>:2375:2375
- <machine_private_ip>:2375:2375
```
```yml

View File

@@ -1,12 +1,11 @@
example: # matching `app.y.z`
example: # matching `example.y.z`
scheme: https
host: 10.0.0.1
port: 80
path_patterns: # Check https://pkg.go.dev/net/http#hdr-Patterns-ServeMux for syntax
- GET / # accept any GET request
- POST /auth # for /auth and /auth/* accept only POST
- GET /home/{$}
- /b/{bucket}/o/{any}
- GET /home/{$} # for exactly /home
no_tls_verify: false
set_headers:
HEADER_A: VALUE_A, VALUE_B
@@ -17,6 +16,6 @@ example: # matching `app.y.z`
app1: # app1 -> localhost:8080
port: 8080
app2:
scheme: tcp
scheme: udp
host: 10.0.0.2
port: 20000:tcp
port: 2223:dns

View File

@@ -1,14 +0,0 @@
#!/bin/sh
set -e
if [ -z "$BRANCH" ]; then
BRANCH="v0.5"
fi
BASE_URL="https://github.com/yusing/go-proxy/raw/${BRANCH}"
mkdir -p go-proxy
cd go-proxy
mkdir -p config
mkdir -p certs
[ -f compose.yml ] || wget -cO - ${BASE_URL}/compose.example.yml > compose.yml
[ -f config/config.yml ] || wget -cO - ${BASE_URL}/config.example.yml > config/config.yml
[ -f config/providers.yml ] || touch config/providers.yml

View File

@@ -71,11 +71,9 @@ func (p *Provider) ObtainCert() (res E.NestedError) {
}
if p.user.Registration == nil {
if err := p.loadRegistration(); err.HasError() {
if err := p.registerACME(); err.HasError() {
b.Add(E.FailWith("register ACME", err))
return
}
if err := p.registerACME(); err.HasError() {
b.Add(E.FailWith("register ACME", err))
return
}
}
@@ -89,16 +87,18 @@ func (p *Provider) ObtainCert() (res E.NestedError) {
b.Add(err)
return
}
err = p.saveCert(cert)
if err.HasError() {
if err = p.saveCert(cert); err.HasError() {
b.Add(E.FailWith("save certificate", err))
return
}
tlsCert, err := E.Check(tls.X509KeyPair(cert.Certificate, cert.PrivateKey))
if err.HasError() {
b.Add(E.FailWith("parse obtained certificate", err))
return
}
expiries, err := getCertExpiries(&tlsCert)
if err.HasError() {
b.Add(E.FailWith("get certificate expiry", err))
@@ -187,29 +187,9 @@ func (p *Provider) registerACME() E.NestedError {
}
p.user.Registration = reg
if err := p.saveRegistration(); err.HasError() {
logger.Warn(err)
}
return nil
}
func (p *Provider) loadRegistration() E.NestedError {
if p.user.Registration != nil {
return nil
}
reg := &registration.Resource{}
err := U.LoadJson(RegistrationFile, reg)
if err.HasError() {
return E.FailWith("parse registration file", err)
}
p.user.Registration = reg
return nil
}
func (p *Provider) saveRegistration() E.NestedError {
return U.SaveJson(RegistrationFile, p.user.Registration, 0o600)
}
func (p *Provider) saveCert(cert *certificate.Resource) E.NestedError {
err := os.WriteFile(p.cfg.KeyPath, cert.PrivateKey, 0o600) // -rw-------
if err != nil {

View File

@@ -3,7 +3,7 @@ package autocert
type CertState int
const (
CertStateValid CertState = 0
CertStateExpired CertState = iota
CertStateMismatch CertState = iota
CertStateValid CertState = iota
CertStateExpired
CertStateMismatch
)

View File

@@ -13,6 +13,7 @@ type Args struct {
const (
CommandStart = ""
CommandSetup = "setup"
CommandValidate = "validate"
CommandListConfigs = "ls-config"
CommandListRoutes = "ls-routes"
@@ -23,6 +24,7 @@ const (
var ValidCommands = []string{
CommandStart,
CommandSetup,
CommandValidate,
CommandListConfigs,
CommandListRoutes,

View File

@@ -10,36 +10,31 @@ const (
KeepAlive = 5 * time.Second
)
const (
ProviderKind_Docker = "docker"
ProviderKind_File = "file"
)
// file, folder structure
const (
ConfigBasePath = "config/"
ConfigFileName = "config.yml"
ConfigPath = ConfigBasePath + ConfigFileName
ConfigBasePath = "config"
ConfigFileName = "config.yml"
ConfigExampleFileName = "config.example.yml"
ConfigPath = ConfigBasePath + "/" + ConfigFileName
)
const (
TemplatesBasePath = "templates/"
PanelTemplatePath = TemplatesBasePath + "panel/index.html"
ConfigEditorTemplatePath = TemplatesBasePath + "config_editor/index.html"
SchemaBasePath = "schema"
ConfigSchemaPath = SchemaBasePath + "/config.schema.json"
FileProviderSchemaPath = SchemaBasePath + "/providers.schema.json"
)
const (
SchemaBasePath = "schema/"
ConfigSchemaPath = SchemaBasePath + "config.schema.json"
FileProviderSchemaPath = SchemaBasePath + "providers.schema.json"
ComposeFileName = "compose.yml"
ComposeExampleFileName = "compose.example.yml"
)
const DockerHostFromEnv = "$DOCKER_HOST"
const (
IdleTimeoutDefault = "0"
WakeTimeoutDefault = "10s"
WakeTimeoutDefault = "30s"
StopTimeoutDefault = "10s"
StopMethodDefault = "stop"
)

View File

@@ -7,18 +7,18 @@ import (
)
var (
NoSchemaValidation = getEnvBool("GOPROXY_NO_SCHEMA_VALIDATION")
IsDebug = getEnvBool("GOPROXY_DEBUG")
ProxyHTTPAddr = getEnv("GOPROXY_HTTP_ADDR", ":80")
ProxyHTTPSAddr = getEnv("GOPROXY_HTTPS_ADDR", ":443")
APIHTTPAddr = getEnv("GOPROXY_API_ADDR", "127.0.0.1:8888")
NoSchemaValidation = GetEnvBool("GOPROXY_NO_SCHEMA_VALIDATION")
IsDebug = GetEnvBool("GOPROXY_DEBUG")
ProxyHTTPAddr = GetEnv("GOPROXY_HTTP_ADDR", ":80")
ProxyHTTPSAddr = GetEnv("GOPROXY_HTTPS_ADDR", ":443")
APIHTTPAddr = GetEnv("GOPROXY_API_ADDR", "127.0.0.1:8888")
)
func getEnvBool(key string) bool {
func GetEnvBool(key string) bool {
return U.ParseBool(os.Getenv(key))
}
func getEnv(key string, defaultValue string) string {
func GetEnv(key string, defaultValue string) string {
value, ok := os.LookupEnv(key)
if !ok {
value = defaultValue

View File

@@ -10,14 +10,15 @@ var (
}
ServiceNamePortMapTCP = map[string]int{
"mssql": 1433,
"mysql": 3306,
"mariadb": 3306,
"postgres": 5432,
"rabbitmq": 5672,
"redis": 6379,
"memcached": 11211,
"mongo": 27017,
"mssql": 1433,
"mysql": 3306,
"mariadb": 3306,
"postgres": 5432,
"rabbitmq": 5672,
"redis": 6379,
"memcached": 11211,
"mongo": 27017,
"minecraft-server": 25565,
"ssh": 22,
"ftp": 21,
@@ -53,7 +54,7 @@ var (
"immich": 3001,
"jellyfin": 8096,
"lidarr": 8686,
"minecraft-server": 25565,
"microbin": 8080,
"nginx": 80,
"nginx-proxy-manager": 81,
"open-webui": 8080,

View File

@@ -9,31 +9,11 @@ import (
U "github.com/yusing/go-proxy/utils"
)
type ProxyProperties struct {
DockerHost string `yaml:"-" json:"docker_host"`
ContainerName string `yaml:"-" json:"container_name"`
ImageName string `yaml:"-" json:"image_name"`
PublicPortMapping PortMapping `yaml:"-" json:"public_port_mapping"` // non-zero publicPort:types.Port
PrivatePortMapping PortMapping `yaml:"-" json:"private_port_mapping"` // privatePort:types.Port
NetworkMode string `yaml:"-" json:"network_mode"`
Aliases []string `yaml:"-" json:"aliases"`
IsExcluded bool `yaml:"-" json:"is_excluded"`
IdleTimeout string `yaml:"-" json:"idle_timeout"`
WakeTimeout string `yaml:"-" json:"wake_timeout"`
StopMethod string `yaml:"-" json:"stop_method"`
StopTimeout string `yaml:"-" json:"stop_timeout"` // stop_method = "stop" only
StopSignal string `yaml:"-" json:"stop_signal"` // stop_method = "stop" | "kill" only
Running bool `yaml:"-" json:"running"`
}
type Container struct {
*types.Container
*ProxyProperties
}
type PortMapping = map[string]types.Port
func FromDocker(c *types.Container, dockerHost string) (res Container) {
res.Container = c
res.ProxyProperties = &ProxyProperties{
@@ -119,9 +99,6 @@ func (c Container) getPublicPortMapping() PortMapping {
func (c Container) getPrivatePortMapping() PortMapping {
res := make(PortMapping)
for _, v := range c.Ports {
if v.PublicPort == 0 {
continue
}
res[fmt.Sprint(v.PrivatePort)] = v
}
return res

View File

@@ -3,7 +3,6 @@ package idlewatcher
import (
"context"
"net/http"
"time"
)
type (
@@ -18,14 +17,14 @@ func (rt roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
}
func (w *watcher) roundTrip(origRoundTrip roundTripFunc, req *http.Request) (*http.Response, error) {
// wake the container
w.wakeCh <- struct{}{}
// target site is ready, passthrough
if w.ready.Load() {
return origRoundTrip(req)
}
// wake the container
w.wakeCh <- struct{}{}
// initial request
targetUrl := req.Header.Get(headerGoProxyTargetURL)
if targetUrl == "" {
@@ -57,7 +56,6 @@ func (w *watcher) roundTrip(origRoundTrip roundTripFunc, req *http.Request) (*ht
rtDone <- resp
return
}
time.Sleep(time.Millisecond * 200)
}
}
}()
@@ -66,6 +64,10 @@ func (w *watcher) roundTrip(origRoundTrip roundTripFunc, req *http.Request) (*ht
select {
case resp := <-rtDone:
return w.makeSuccResp(targetUrl, resp)
case err := <-w.wakeDone:
if err != nil {
return w.makeErrResp("error waking up %s\n%s", w.ContainerName, err.Error())
}
case <-ctx.Done():
if ctx.Err() == context.DeadlineExceeded {
return w.makeErrResp("Timed out waiting for %s to fully wake", w.ContainerName)

View File

@@ -78,8 +78,8 @@ func Register(entry *P.ReverseProxyEntry) (*watcher, E.NestedError) {
ReverseProxyEntry: entry,
client: client,
refCount: &sync.WaitGroup{},
wakeCh: make(chan struct{}, 1),
wakeDone: make(chan E.NestedError, 1),
wakeCh: make(chan struct{}),
wakeDone: make(chan E.NestedError),
l: logger.WithField("container", entry.ContainerName),
}
w.refCount.Add(1)

View File

@@ -0,0 +1,22 @@
package docker
import "github.com/docker/docker/api/types"
type PortMapping = map[string]types.Port
type ProxyProperties struct {
DockerHost string `yaml:"-" json:"docker_host"`
ContainerName string `yaml:"-" json:"container_name"`
ImageName string `yaml:"-" json:"image_name"`
PublicPortMapping PortMapping `yaml:"-" json:"public_port_mapping"` // non-zero publicPort:types.Port
PrivatePortMapping PortMapping `yaml:"-" json:"private_port_mapping"` // privatePort:types.Port
NetworkMode string `yaml:"-" json:"network_mode"`
Aliases []string `yaml:"-" json:"aliases"`
IsExcluded bool `yaml:"-" json:"is_excluded"`
IdleTimeout string `yaml:"-" json:"idle_timeout"`
WakeTimeout string `yaml:"-" json:"wake_timeout"`
StopMethod string `yaml:"-" json:"stop_method"`
StopTimeout string `yaml:"-" json:"stop_timeout"` // stop_method = "stop" only
StopSignal string `yaml:"-" json:"stop_signal"` // stop_method = "stop" | "kill" only
Running bool `yaml:"-" json:"running"`
}

View File

@@ -133,13 +133,19 @@ func (ne NestedError) Subject(s any) NestedError {
if ne == nil {
return ne
}
var subject string
switch ss := s.(type) {
case string:
ne.subject = ss
subject = ss
case fmt.Stringer:
ne.subject = ss.String()
subject = ss.String()
default:
ne.subject = fmt.Sprint(s)
subject = fmt.Sprint(s)
}
if ne.subject == "" {
ne.subject = subject
} else {
ne.subject = fmt.Sprintf("%s > %s", subject, ne.subject)
}
return ne
}

View File

@@ -30,6 +30,12 @@ import (
func main() {
args := common.GetArgs()
if args.Command == common.CommandSetup {
Setup()
return
}
l := logrus.WithField("module", "main")
onShutdown := F.NewSlice[func()]()

View File

@@ -34,62 +34,66 @@ var NewProxyEntries = F.NewMapOf[string, *RawEntry]
func (e *RawEntry) FillMissingFields() bool {
isDocker := e.ProxyProperties != nil
if !isDocker {
e.ProxyProperties = &D.ProxyProperties{}
}
if e.Port == "" {
if port, ok := ServiceNamePortMapTCP[e.ImageName]; ok {
e.Port = strconv.Itoa(port)
} else if port, ok := ImageNamePortMap[e.ImageName]; ok {
e.Port = strconv.Itoa(port)
lp, pp, extra := e.splitPorts()
if port, ok := ServiceNamePortMapTCP[e.ImageName]; ok {
if pp == "" {
pp = strconv.Itoa(port)
}
e.Scheme = "tcp"
} else if port, ok := ImageNamePortMap[e.ImageName]; ok {
if pp == "" {
pp = strconv.Itoa(port)
}
e.Scheme = "http"
} else if pp == "" && e.Scheme == "https" {
pp = "443"
} else if pp == "" {
if p, ok := F.FirstValueOf(e.PrivatePortMapping); ok {
pp = fmt.Sprint(p.PrivatePort)
} else {
switch {
case e.Scheme == "https":
e.Port = "443"
case !isDocker:
e.Port = "80"
pp = "80"
}
}
// replace private port with public port (if any)
if isDocker && e.NetworkMode != "host" {
if p, ok := e.PrivatePortMapping[pp]; ok {
pp = fmt.Sprint(p.PublicPort)
}
if _, ok := e.PublicPortMapping[pp]; !ok { // port is not exposed, but specified
// try to fallback to first public port
if p, ok := F.FirstValueOf(e.PublicPortMapping); ok {
pp = fmt.Sprint(p.PublicPort)
}
// ignore only if it is NOT RUNNING
// because stopped containers
// will have empty port mapping got from docker
if e.Running {
return false
}
}
}
if e.PublicPortMapping != nil && e.NetworkMode != "host" {
if _, ok := e.PublicPortMapping[e.Port]; !ok { // port is not exposed, but specified
// try to fallback to first public port
if len(e.PublicPortMapping) == 0 {
return false
}
for _, p := range e.PublicPortMapping {
e.Port = fmt.Sprint(p.PublicPort)
break
}
if e.Scheme == "" && isDocker {
if p, ok := e.PublicPortMapping[pp]; ok && p.Type == "udp" {
e.Scheme = "udp"
}
}
if e.Scheme == "" {
if _, ok := ServiceNamePortMapTCP[e.ImageName]; ok {
if lp != "" {
e.Scheme = "tcp"
} else if strings.ContainsRune(e.Port, ':') {
e.Scheme = "tcp"
} else if _, ok := WellKnownHTTPPorts[e.Port]; ok {
e.Scheme = "http"
} else if e.Port == "443" {
} else if strings.HasSuffix(pp, "443") {
e.Scheme = "https"
} else if isDocker {
if e.Port == "" {
return false
}
if p, ok := e.PublicPortMapping[e.Port]; ok {
if p.Type == "udp" {
e.Scheme = "udp"
} else {
e.Scheme = "http"
}
} else {
return false
}
} else if _, ok := WellKnownHTTPPorts[pp]; ok {
e.Scheme = "http"
} else {
// assume its http
e.Scheme = "http"
}
}
@@ -97,7 +101,6 @@ func (e *RawEntry) FillMissingFields() bool {
if e.Host == "" {
e.Host = "localhost"
}
if e.IdleTimeout == "" {
e.IdleTimeout = IdleTimeoutDefault
}
@@ -111,5 +114,35 @@ func (e *RawEntry) FillMissingFields() bool {
e.StopMethod = StopMethodDefault
}
e.Port = joinPorts(lp, pp, extra)
return true
}
func (e *RawEntry) splitPorts() (lp string, pp string, extra string) {
portSplit := strings.Split(e.Port, ":")
if len(portSplit) == 1 {
pp = portSplit[0]
} else {
lp = portSplit[0]
pp = portSplit[1]
}
if len(portSplit) > 2 {
extra = strings.Join(portSplit[2:], ":")
}
return
}
func joinPorts(lp string, pp string, extra string) string {
s := make([]string, 0, 3)
if lp != "" {
s = append(s, lp)
}
if pp != "" {
s = append(s, pp)
}
if extra != "" {
s = append(s, extra)
}
return strings.Join(s, ":")
}

View File

@@ -54,7 +54,7 @@ func ValidateEntry(m *M.RawEntry) (any, E.NestedError) {
}
var entry any
e := E.NewBuilder("error validating proxy entry")
e := E.NewBuilder("error validating entry")
if scheme.IsStream() {
entry = validateStreamEntry(m, e)
} else {

View File

@@ -7,6 +7,6 @@ import (
type Host string
type Subdomain = Alias
func ValidateHost(s string) (Host, E.NestedError) {
func ValidateHost[String ~string](s String) (Host, E.NestedError) {
return Host(s), nil
}

View File

@@ -8,8 +8,8 @@ import (
type Port int
func ValidatePort(v string) (Port, E.NestedError) {
p, err := strconv.Atoi(v)
func ValidatePort[String ~string](v String) (Port, E.NestedError) {
p, err := strconv.Atoi(string(v))
if err != nil {
return ErrPort, E.Invalid("port number", v).With(err)
}

View File

@@ -6,7 +6,7 @@ import (
type Scheme string
func NewScheme(s string) (Scheme, E.NestedError) {
func NewScheme[String ~string](s String) (Scheme, E.NestedError) {
switch s {
case "http", "https", "tcp", "udp":
return Scheme(s), nil

View File

@@ -26,18 +26,19 @@ func ValidateStreamPort(p string) (StreamPort, E.NestedError) {
listeningPort, err := ValidatePort(split[0])
if err != nil {
return ErrStreamPort, err
return ErrStreamPort, err.Subject("listening port")
}
proxyPort, err := ValidatePort(split[1])
if err.Is(E.ErrOutOfRange) {
return ErrStreamPort, err
return ErrStreamPort, err.Subject("proxy port")
} else if proxyPort == 0 {
return ErrStreamPort, E.Invalid("stream port", p).With("proxy port cannot be 0")
return ErrStreamPort, E.Invalid("proxy port", p)
} else if err != nil {
proxyPort, err = parseNameToPort(split[1])
if err != nil {
return ErrStreamPort, E.Invalid("stream port", p).With(proxyPort)
return ErrStreamPort, E.Invalid("proxy port", proxyPort)
}
}

View File

@@ -4,7 +4,6 @@ import (
"fmt"
"regexp"
"strconv"
"strings"
D "github.com/yusing/go-proxy/docker"
E "github.com/yusing/go-proxy/error"
@@ -123,6 +122,10 @@ func (p *DockerProvider) OnEvent(event W.Event, routes R.Routes) (res EventResul
func (p *DockerProvider) entriesFromContainerLabels(container D.Container) (M.RawEntries, E.NestedError) {
entries := M.NewProxyEntries()
if container.IsExcluded {
return entries, nil
}
// init entries map for all aliases
for _, a := range container.Aliases {
entries.Store(a, &M.RawEntry{
@@ -137,30 +140,11 @@ func (p *DockerProvider) entriesFromContainerLabels(container D.Container) (M.Ra
errors.Add(p.applyLabel(container, entries, key, val))
}
// selecting correct host port
replacePrivPorts := func() {
if container.HostConfig.NetworkMode != "host" {
entries.RangeAll(func(_ string, entry *M.RawEntry) {
entryPortSplit := strings.Split(entry.Port, ":")
n := len(entryPortSplit)
// if the port matches the proxy port, replace it with the public port
if p, ok := container.PrivatePortMapping[entryPortSplit[n-1]]; ok {
entryPortSplit[n-1] = fmt.Sprint(p.PublicPort)
entry.Port = strings.Join(entryPortSplit, ":")
}
})
}
}
replacePrivPorts()
// remove all entries that failed to fill in missing fields
entries.RemoveAll(func(re *M.RawEntry) bool {
return !re.FillMissingFields()
})
// do it again since the port may got filled in
replacePrivPorts()
return entries, errors.Build().Subject(container.ContainerName)
}

View File

@@ -249,7 +249,9 @@ func TestImplicitExclude(t *testing.T) {
Labels: map[string]string{
D.LabelAliases: "a",
"proxy.a.no_tls_verify": "true",
}}, ""))
},
State: "running",
}, ""))
ExpectNoError(t, err.Error())
_, ok := entries.Load("a")
@@ -264,6 +266,7 @@ func TestImplicitExcludeNoExposedPort(t *testing.T) {
Ports: []types.Port{
{Type: "tcp", PrivatePort: 6379, PublicPort: 0}, // not exposed
},
State: "running",
}, ""))
ExpectNoError(t, err.Error())
@@ -271,7 +274,7 @@ func TestImplicitExcludeNoExposedPort(t *testing.T) {
ExpectFalse(t, ok)
}
func TestExcludeNonExposedPort(t *testing.T) {
func TestNotExcludeSpecifiedPort(t *testing.T) {
var p DockerProvider
entries, err := p.entriesFromContainerLabels(D.FromDocker(&types.Container{
Image: "redis",
@@ -280,13 +283,13 @@ func TestExcludeNonExposedPort(t *testing.T) {
{Type: "tcp", PrivatePort: 6379, PublicPort: 0}, // not exposed
},
Labels: map[string]string{
"proxy.redis.port": "6379:6379", // should be excluded even specified
"proxy.redis.port": "6379:6379", // but specified in label
},
}, ""))
ExpectNoError(t, err.Error())
_, ok := entries.Load("redis")
ExpectFalse(t, ok)
ExpectTrue(t, ok)
}
func TestNotExcludeNonExposedPortHostNetwork(t *testing.T) {
@@ -298,7 +301,7 @@ func TestNotExcludeNonExposedPortHostNetwork(t *testing.T) {
{Type: "tcp", PrivatePort: 6379, PublicPort: 0}, // not exposed
},
Labels: map[string]string{
"proxy.redis.port": "6379:6379", // should be excluded even specified
"proxy.redis.port": "6379:6379",
},
}
cont.HostConfig.NetworkMode = "host"

View File

@@ -148,7 +148,12 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) {
}
func findMux(host string) (*http.ServeMux, E.NestedError) {
sd := strings.Split(host, ".")[0]
hostSplit := strings.Split(host, ".")
n := len(hostSplit)
if n <= 2 {
return nil, E.Missing("subdomain")
}
sd := strings.Join(hostSplit[:n-2], ".")
if r, ok := httpRoutes.Load(PT.Alias(sd)); ok {
return r.mux, nil
}

115
src/setup.go Normal file
View File

@@ -0,0 +1,115 @@
package main
import (
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
. "github.com/yusing/go-proxy/common"
)
var branch = GetEnv("GOPROXY_BRANCH", "v0.5")
var baseUrl = fmt.Sprintf("https://github.com/yusing/go-proxy/raw/%s", branch)
var requiredConfigs = []Config{
{ConfigBasePath, true, false, ""},
{ComposeFileName, false, true, ComposeExampleFileName},
{path.Join(ConfigBasePath, ConfigFileName), false, true, ConfigExampleFileName},
}
type Config struct {
Pathname string
IsDir bool
NeedDownload bool
DownloadFileName string
}
func Setup() {
log.Println("setting up go-proxy")
log.Println("branch:", branch)
os.Chdir("/setup")
for _, config := range requiredConfigs {
config.setup()
}
log.Println("done")
}
func (c *Config) setup() {
if c.IsDir {
mkdir(c.Pathname)
return
}
if !c.NeedDownload {
touch(c.Pathname)
return
}
fetch(c.DownloadFileName, c.Pathname)
}
func hasFileOrDir(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func mkdir(pathname string) {
_, err := os.Stat(pathname)
if err != nil && os.IsNotExist(err) {
log.Printf("creating directory %q\n", pathname)
err := os.MkdirAll(pathname, 0o755)
if err != nil {
log.Fatalf("failed: %s\n", err)
}
return
}
if err != nil {
log.Fatalf("failed: %s\n", err)
}
}
func touch(pathname string) {
if hasFileOrDir(pathname) {
return
}
log.Printf("creating file %q\n", pathname)
_, err := os.Create(pathname)
if err != nil {
log.Fatalf("failed: %s\n", err)
}
}
func fetch(remoteFilename string, outFileName string) {
if hasFileOrDir(outFileName) {
return
}
log.Printf("downloading %q\n", remoteFilename)
url, err := url.JoinPath(baseUrl, remoteFilename)
if err != nil {
log.Fatalf("unexpected error: %s\n", err)
}
resp, err := http.Get(url)
if err != nil {
log.Fatalf("http request failed: %s\n", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatalf("error reading response body: %s\n", err)
}
err = os.WriteFile(outFileName, body, 0o644)
if err != nil {
log.Fatalf("failed to write to file: %s\n", err)
}
log.Printf("downloaded %q\n", outFileName)
}

View File

@@ -0,0 +1,8 @@
package functional
func FirstValueOf[KT comparable, VT any](m map[KT]VT) (_ VT, ok bool) {
for _, v := range m {
return v, true
}
return
}

View File

@@ -100,7 +100,6 @@ func Serialize(data any) (SerializedObject, E.NestedError) {
}
}
default:
// return nil, fmt.Errorf("unsupported type: %s", value.Kind())
return nil, E.Unsupported("type", value.Kind())
}