mirror of
https://github.com/yusing/godoxy.git
synced 2025-07-22 20:24:03 +02:00
feat: godoxy agent
This commit is contained in:
parent
7420abf175
commit
47ab6b8a92
25 changed files with 1612 additions and 0 deletions
51
.github/workflows/agent-binary.yml
vendored
Normal file
51
.github/workflows/agent-binary.yml
vendored
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
name: GoDoxy agent binary
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- v*
|
||||||
|
paths:
|
||||||
|
- "agent/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- runner: ubuntu-latest
|
||||||
|
platform: linux/amd64
|
||||||
|
binary_name: godoxy-agent-linux-amd64
|
||||||
|
- runner: ubuntu-24.04-arm
|
||||||
|
platform: linux/arm64
|
||||||
|
binary_name: godoxy-agent-linux-arm64
|
||||||
|
name: Build ${{ matrix.platform }}
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
- name: Verify dependencies
|
||||||
|
run: go mod verify
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
make agent=1 NAME=${{ matrix.binary_name }} build
|
||||||
|
- name: Check binary
|
||||||
|
run: |
|
||||||
|
file bin/${{ matrix.binary_name }}
|
||||||
|
- name: Test
|
||||||
|
run: |
|
||||||
|
go test -v ./agent/...
|
||||||
|
- name: Upload
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.binary_name }}
|
||||||
|
path: bin/${{ matrix.binary_name }}
|
||||||
|
- name: Upload to release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
with:
|
||||||
|
files: bin/${{ matrix.binary_name }}
|
54
agent/cmd/main.go
Normal file
54
agent/cmd/main.go
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||||
|
"github.com/yusing/go-proxy/agent/pkg/env"
|
||||||
|
"github.com/yusing/go-proxy/agent/pkg/server"
|
||||||
|
"github.com/yusing/go-proxy/internal/gperr"
|
||||||
|
"github.com/yusing/go-proxy/internal/logging"
|
||||||
|
"github.com/yusing/go-proxy/internal/task"
|
||||||
|
"github.com/yusing/go-proxy/pkg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ca := &agent.PEMPair{}
|
||||||
|
err := ca.Load(env.AgentCACert)
|
||||||
|
if err != nil {
|
||||||
|
gperr.LogFatal("init CA error", err)
|
||||||
|
}
|
||||||
|
caCert, err := ca.ToTLSCert()
|
||||||
|
if err != nil {
|
||||||
|
gperr.LogFatal("init CA error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &agent.PEMPair{}
|
||||||
|
srv.Load(env.AgentSSLCert)
|
||||||
|
if err != nil {
|
||||||
|
gperr.LogFatal("init SSL error", err)
|
||||||
|
}
|
||||||
|
srvCert, err := srv.ToTLSCert()
|
||||||
|
if err != nil {
|
||||||
|
gperr.LogFatal("init SSL error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.Info().Msgf("GoDoxy Agent version %s", pkg.GetVersion())
|
||||||
|
logging.Info().Msgf("Agent name: %s", env.AgentName)
|
||||||
|
logging.Info().Msgf("Agent port: %d", env.AgentPort)
|
||||||
|
|
||||||
|
logging.Info().Msg(`
|
||||||
|
Tips:
|
||||||
|
1. To change the agent name, you can set the AGENT_NAME environment variable.
|
||||||
|
2. To change the agent port, you can set the AGENT_PORT environment variable.
|
||||||
|
`)
|
||||||
|
|
||||||
|
t := task.RootTask("agent", false)
|
||||||
|
opts := server.Options{
|
||||||
|
CACert: caCert,
|
||||||
|
ServerCert: srvCert,
|
||||||
|
Port: env.AgentPort,
|
||||||
|
}
|
||||||
|
|
||||||
|
server.StartAgentServer(t, opts)
|
||||||
|
|
||||||
|
task.WaitExit(3)
|
||||||
|
}
|
24
agent/pkg/agent/bare_metal.go
Normal file
24
agent/pkg/agent/bare_metal.go
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
installScript = `AGENT_NAME="{{.Name}}" \
|
||||||
|
AGENT_PORT="{{.Port}}" \
|
||||||
|
AGENT_CA_CERT="{{.CACert}}" \
|
||||||
|
AGENT_SSL_CERT="{{.SSLCert}}" \
|
||||||
|
bash -c "$(curl -fsSL https://raw.githubusercontent.com/yusing/go-proxy/main/scripts/install-agent.sh)"`
|
||||||
|
installScriptTemplate = template.Must(template.New("install.sh").Parse(installScript))
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *AgentEnvConfig) Generate() (string, error) {
|
||||||
|
buf := bytes.NewBuffer(make([]byte, 0, 4096))
|
||||||
|
if err := installScriptTemplate.Execute(buf, c); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(buf.String(), ";", "\\;"), nil
|
||||||
|
}
|
185
agent/pkg/agent/config.go
Normal file
185
agent/pkg/agent/config.go
Normal file
|
@ -0,0 +1,185 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/yusing/go-proxy/agent/pkg/certs"
|
||||||
|
"github.com/yusing/go-proxy/internal/gperr"
|
||||||
|
"github.com/yusing/go-proxy/internal/logging"
|
||||||
|
gphttp "github.com/yusing/go-proxy/internal/net/gphttp"
|
||||||
|
"github.com/yusing/go-proxy/internal/net/types"
|
||||||
|
"github.com/yusing/go-proxy/internal/task"
|
||||||
|
"github.com/yusing/go-proxy/pkg"
|
||||||
|
"golang.org/x/net/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentConfig struct {
|
||||||
|
Addr string
|
||||||
|
|
||||||
|
httpClient *http.Client
|
||||||
|
tlsConfig *tls.Config
|
||||||
|
name string
|
||||||
|
l zerolog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
EndpointVersion = "/version"
|
||||||
|
EndpointName = "/name"
|
||||||
|
EndpointProxyHTTP = "/proxy/http"
|
||||||
|
EndpointHealth = "/health"
|
||||||
|
EndpointLogs = "/logs"
|
||||||
|
EndpointSystemInfo = "/system_info"
|
||||||
|
|
||||||
|
AgentHost = CertsDNSName
|
||||||
|
|
||||||
|
APIEndpointBase = "/godoxy/agent"
|
||||||
|
APIBaseURL = "https://" + AgentHost + APIEndpointBase
|
||||||
|
|
||||||
|
DockerHost = "https://" + AgentHost
|
||||||
|
|
||||||
|
FakeDockerHostPrefix = "agent://"
|
||||||
|
FakeDockerHostPrefixLen = len(FakeDockerHostPrefix)
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
AgentURL = types.MustParseURL(APIBaseURL)
|
||||||
|
HTTPProxyURL = types.MustParseURL(APIBaseURL + EndpointProxyHTTP)
|
||||||
|
HTTPProxyURLPrefixLen = len(APIEndpointBase + EndpointProxyHTTP)
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsDockerHostAgent(dockerHost string) bool {
|
||||||
|
return strings.HasPrefix(dockerHost, FakeDockerHostPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAgentAddrFromDockerHost(dockerHost string) string {
|
||||||
|
return dockerHost[FakeDockerHostPrefixLen:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *AgentConfig) FakeDockerHost() string {
|
||||||
|
return FakeDockerHostPrefix + cfg.Addr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *AgentConfig) Parse(addr string) error {
|
||||||
|
cfg.Addr = addr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func withoutBuildTime(version string) string {
|
||||||
|
return strings.Split(version, "-")[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkVersion(a, b string) bool {
|
||||||
|
return withoutBuildTime(a) == withoutBuildTime(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *AgentConfig) StartWithCerts(parent task.Parent, ca, crt, key []byte) error {
|
||||||
|
clientCert, err := tls.X509KeyPair(crt, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// create tls config
|
||||||
|
caCertPool := x509.NewCertPool()
|
||||||
|
ok := caCertPool.AppendCertsFromPEM(ca)
|
||||||
|
if !ok {
|
||||||
|
return gperr.New("invalid ca certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.tlsConfig = &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{clientCert},
|
||||||
|
RootCAs: caCertPool,
|
||||||
|
ServerName: CertsDNSName,
|
||||||
|
}
|
||||||
|
|
||||||
|
// create transport and http client
|
||||||
|
cfg.httpClient = cfg.NewHTTPClient()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(parent.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// check agent version
|
||||||
|
version, _, err := cfg.Fetch(ctx, EndpointVersion)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
versionStr := string(version)
|
||||||
|
// skip version check for dev versions
|
||||||
|
if strings.HasPrefix(versionStr, "v") && !checkVersion(versionStr, pkg.GetVersion()) {
|
||||||
|
return gperr.Errorf("agent version mismatch: server: %s, agent: %s", pkg.GetVersion(), versionStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get agent name
|
||||||
|
name, _, err := cfg.Fetch(ctx, EndpointName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.name = string(name)
|
||||||
|
cfg.l = logging.With().Str("agent", cfg.name).Logger()
|
||||||
|
|
||||||
|
logging.Info().Msgf("agent %q initialized", cfg.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *AgentConfig) Start(parent task.Parent) gperr.Error {
|
||||||
|
certData, err := os.ReadFile(certs.AgentCertsFilename(cfg.Addr))
|
||||||
|
if err != nil {
|
||||||
|
return gperr.Wrap(err, "failed to read agent certs")
|
||||||
|
}
|
||||||
|
|
||||||
|
ca, crt, key, err := certs.ExtractCert(certData)
|
||||||
|
if err != nil {
|
||||||
|
return gperr.Wrap(err, "failed to extract agent certs")
|
||||||
|
}
|
||||||
|
|
||||||
|
return gperr.Wrap(cfg.StartWithCerts(parent, ca, crt, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *AgentConfig) NewHTTPClient() *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Transport: cfg.Transport(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *AgentConfig) Transport() *http.Transport {
|
||||||
|
return &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
if addr != AgentHost+":443" {
|
||||||
|
return nil, &net.AddrError{Err: "invalid address", Addr: addr}
|
||||||
|
}
|
||||||
|
if network != "tcp" {
|
||||||
|
return nil, &net.OpError{Op: "dial", Net: network, Source: nil, Addr: nil}
|
||||||
|
}
|
||||||
|
return cfg.DialContext(ctx)
|
||||||
|
},
|
||||||
|
TLSClientConfig: cfg.tlsConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *AgentConfig) DialContext(ctx context.Context) (net.Conn, error) {
|
||||||
|
return gphttp.DefaultDialer.DialContext(ctx, "tcp", cfg.Addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *AgentConfig) Name() string {
|
||||||
|
return cfg.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *AgentConfig) String() string {
|
||||||
|
return cfg.name + "@" + cfg.Addr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *AgentConfig) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(map[string]string{
|
||||||
|
"name": cfg.Name(),
|
||||||
|
"addr": cfg.Addr,
|
||||||
|
})
|
||||||
|
}
|
27
agent/pkg/agent/docker_compose.go
Normal file
27
agent/pkg/agent/docker_compose.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
_ "embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed templates/agent.compose.yml
|
||||||
|
agentComposeYAML string
|
||||||
|
agentComposeYAMLTemplate = template.Must(template.New("agent.compose.yml").Parse(agentComposeYAML))
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DockerImageProduction = "ghcr.io/yusing/godoxy-agent:latest"
|
||||||
|
DockerImageNightly = "ghcr.io/yusing/godoxy-agent:nightly"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *AgentComposeConfig) Generate() (string, error) {
|
||||||
|
buf := bytes.NewBuffer(make([]byte, 0, 1024))
|
||||||
|
if err := agentComposeYAMLTemplate.Execute(buf, c); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
17
agent/pkg/agent/env.go
Normal file
17
agent/pkg/agent/env.go
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
type (
|
||||||
|
AgentEnvConfig struct {
|
||||||
|
Name string
|
||||||
|
Port int
|
||||||
|
CACert string
|
||||||
|
SSLCert string
|
||||||
|
}
|
||||||
|
AgentComposeConfig struct {
|
||||||
|
Image string
|
||||||
|
*AgentEnvConfig
|
||||||
|
}
|
||||||
|
Generator interface {
|
||||||
|
Generate() (string, error)
|
||||||
|
}
|
||||||
|
)
|
139
agent/pkg/agent/new_agent.go
Normal file
139
agent/pkg/agent/new_agent.go
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CertsDNSName = "godoxy.agent"
|
||||||
|
KeySize = 2048
|
||||||
|
)
|
||||||
|
|
||||||
|
func toPEMPair(certDER []byte, key *rsa.PrivateKey) *PEMPair {
|
||||||
|
return &PEMPair{
|
||||||
|
Cert: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}),
|
||||||
|
Key: pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func b64Encode(data []byte) string {
|
||||||
|
return base64.StdEncoding.EncodeToString(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func b64Decode(data string) ([]byte, error) {
|
||||||
|
return base64.StdEncoding.DecodeString(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PEMPair struct {
|
||||||
|
Cert, Key []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PEMPair) String() string {
|
||||||
|
return b64Encode(p.Cert) + ";" + b64Encode(p.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PEMPair) Load(data string) (err error) {
|
||||||
|
parts := strings.Split(data, ";")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return errors.New("invalid PEM pair")
|
||||||
|
}
|
||||||
|
p.Cert, err = b64Decode(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Key, err = b64Decode(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PEMPair) ToTLSCert() (*tls.Certificate, error) {
|
||||||
|
cert, err := tls.X509KeyPair(p.Cert, p.Key)
|
||||||
|
return &cert, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgent() (ca, srv, client *PEMPair, err error) {
|
||||||
|
// Create the CA's certificate
|
||||||
|
caTemplate := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{
|
||||||
|
Organization: []string{"GoDoxy"},
|
||||||
|
CommonName: CertsDNSName,
|
||||||
|
},
|
||||||
|
NotBefore: time.Now(),
|
||||||
|
NotAfter: time.Now().AddDate(1000, 0, 0), // 1000 years
|
||||||
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
IsCA: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
caKey, err := rsa.GenerateKey(rand.Reader, KeySize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ca = toPEMPair(caDER, caKey)
|
||||||
|
|
||||||
|
// Generate a new private key for the server certificate
|
||||||
|
serverKey, err := rsa.GenerateKey(rand.Reader, KeySize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
srvTemplate := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(2),
|
||||||
|
Issuer: caTemplate.Subject,
|
||||||
|
Subject: caTemplate.Subject,
|
||||||
|
DNSNames: []string{CertsDNSName},
|
||||||
|
NotBefore: time.Now(),
|
||||||
|
NotAfter: time.Now().AddDate(1000, 0, 0), // Add validity period
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
}
|
||||||
|
|
||||||
|
srvCertDER, err := x509.CreateCertificate(rand.Reader, srvTemplate, caTemplate, &serverKey.PublicKey, caKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
srv = toPEMPair(srvCertDER, serverKey)
|
||||||
|
|
||||||
|
clientKey, err := rsa.GenerateKey(rand.Reader, KeySize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
clientTemplate := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(3),
|
||||||
|
Issuer: caTemplate.Subject,
|
||||||
|
Subject: caTemplate.Subject,
|
||||||
|
DNSNames: []string{CertsDNSName},
|
||||||
|
NotBefore: time.Now(),
|
||||||
|
NotAfter: time.Now().AddDate(1000, 0, 0),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||||
|
}
|
||||||
|
clientCertDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, caTemplate, &clientKey.PublicKey, caKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client = toPEMPair(clientCertDER, clientKey)
|
||||||
|
return
|
||||||
|
}
|
91
agent/pkg/agent/new_agent_test.go
Normal file
91
agent/pkg/agent/new_agent_test.go
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewAgent(t *testing.T) {
|
||||||
|
ca, srv, client, err := NewAgent()
|
||||||
|
ExpectNoError(t, err)
|
||||||
|
ExpectTrue(t, ca != nil)
|
||||||
|
ExpectTrue(t, srv != nil)
|
||||||
|
ExpectTrue(t, client != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPEMPair(t *testing.T) {
|
||||||
|
ca, srv, client, err := NewAgent()
|
||||||
|
ExpectNoError(t, err)
|
||||||
|
|
||||||
|
for i, p := range []*PEMPair{ca, srv, client} {
|
||||||
|
t.Run(fmt.Sprintf("load-%d", i), func(t *testing.T) {
|
||||||
|
var pp PEMPair
|
||||||
|
err := pp.Load(p.String())
|
||||||
|
ExpectNoError(t, err)
|
||||||
|
ExpectBytesEqual(t, p.Cert, pp.Cert)
|
||||||
|
ExpectBytesEqual(t, p.Key, pp.Key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPEMPairToTLSCert(t *testing.T) {
|
||||||
|
ca, srv, client, err := NewAgent()
|
||||||
|
ExpectNoError(t, err)
|
||||||
|
|
||||||
|
for i, p := range []*PEMPair{ca, srv, client} {
|
||||||
|
t.Run(fmt.Sprintf("toTLSCert-%d", i), func(t *testing.T) {
|
||||||
|
cert, err := p.ToTLSCert()
|
||||||
|
ExpectNoError(t, err)
|
||||||
|
ExpectTrue(t, cert != nil)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerClient(t *testing.T) {
|
||||||
|
ca, srv, client, err := NewAgent()
|
||||||
|
ExpectNoError(t, err)
|
||||||
|
|
||||||
|
srvTLS, err := srv.ToTLSCert()
|
||||||
|
ExpectNoError(t, err)
|
||||||
|
ExpectTrue(t, srvTLS != nil)
|
||||||
|
|
||||||
|
clientTLS, err := client.ToTLSCert()
|
||||||
|
ExpectNoError(t, err)
|
||||||
|
ExpectTrue(t, clientTLS != nil)
|
||||||
|
|
||||||
|
caPool := x509.NewCertPool()
|
||||||
|
ExpectTrue(t, caPool.AppendCertsFromPEM(ca.Cert))
|
||||||
|
|
||||||
|
srvTLSConfig := &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{*srvTLS},
|
||||||
|
ClientCAs: caPool,
|
||||||
|
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||||
|
}
|
||||||
|
|
||||||
|
clientTLSConfig := &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{*clientTLS},
|
||||||
|
RootCAs: caPool,
|
||||||
|
ServerName: CertsDNSName,
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
server.TLS = srvTLSConfig
|
||||||
|
server.StartTLS()
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: &http.Transport{TLSClientConfig: clientTLSConfig},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := httpClient.Get(server.URL)
|
||||||
|
ExpectNoError(t, err)
|
||||||
|
ExpectEqual(t, resp.StatusCode, http.StatusOK)
|
||||||
|
}
|
49
agent/pkg/agent/requests.go
Normal file
49
agent/pkg/agent/requests.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
"golang.org/x/net/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (cfg *AgentConfig) Do(ctx context.Context, method, endpoint string, body io.Reader) (*http.Response, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, APIBaseURL+endpoint, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cfg.httpClient.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *AgentConfig) Forward(req *http.Request, endpoint string) ([]byte, int, error) {
|
||||||
|
req = req.WithContext(req.Context())
|
||||||
|
req.URL.Host = AgentHost
|
||||||
|
req.URL.Scheme = "https"
|
||||||
|
req.URL.Path = APIEndpointBase + endpoint
|
||||||
|
req.RequestURI = ""
|
||||||
|
resp, err := cfg.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(resp.Body)
|
||||||
|
return data, resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *AgentConfig) Fetch(ctx context.Context, endpoint string) ([]byte, int, error) {
|
||||||
|
resp, err := cfg.Do(ctx, "GET", endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(resp.Body)
|
||||||
|
return data, resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *AgentConfig) Websocket(ctx context.Context, endpoint string) (*websocket.Conn, *http.Response, error) {
|
||||||
|
return websocket.Dial(ctx, APIBaseURL+endpoint, &websocket.DialOptions{
|
||||||
|
HTTPClient: cfg.NewHTTPClient(),
|
||||||
|
Host: AgentHost,
|
||||||
|
})
|
||||||
|
}
|
14
agent/pkg/agent/templates/agent.compose.yml
Normal file
14
agent/pkg/agent/templates/agent.compose.yml
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
services:
|
||||||
|
agent:
|
||||||
|
image: "{{.Image}}"
|
||||||
|
container_name: godoxy-agent
|
||||||
|
restart: always
|
||||||
|
network_mode: host # do not change this
|
||||||
|
environment:
|
||||||
|
AGENT_NAME: "{{.Name}}"
|
||||||
|
AGENT_PORT: "{{.Port}}"
|
||||||
|
AGENT_CA_CERT: "{{.CACert}}"
|
||||||
|
AGENT_SSL_CERT: "{{.SSLCert}}"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./data:/app/data
|
27
agent/pkg/agentproxy/headers.go
Normal file
27
agent/pkg/agentproxy/headers.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
package agentproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
HeaderXProxyHost = "X-Proxy-Host"
|
||||||
|
HeaderXProxyHTTPS = "X-Proxy-Https"
|
||||||
|
HeaderXProxySkipTLSVerify = "X-Proxy-Skip-Tls-Verify"
|
||||||
|
HeaderXProxyResponseHeaderTimeout = "X-Proxy-Response-Header-Timeout"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentProxyHeaders struct {
|
||||||
|
Host string
|
||||||
|
IsHTTPS bool
|
||||||
|
SkipTLSVerify bool
|
||||||
|
ResponseHeaderTimeout int
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetAgentProxyHeaders(r *http.Request, headers *AgentProxyHeaders) {
|
||||||
|
r.Header.Set(HeaderXProxyHost, headers.Host)
|
||||||
|
r.Header.Set(HeaderXProxyHTTPS, strconv.FormatBool(headers.IsHTTPS))
|
||||||
|
r.Header.Set(HeaderXProxySkipTLSVerify, strconv.FormatBool(headers.SkipTLSVerify))
|
||||||
|
r.Header.Set(HeaderXProxyResponseHeaderTimeout, strconv.Itoa(headers.ResponseHeaderTimeout))
|
||||||
|
}
|
76
agent/pkg/certs/zip.go
Normal file
76
agent/pkg/certs/zip.go
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
package certs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFile(zipWriter *zip.Writer, name string, data []byte) error {
|
||||||
|
w, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||||
|
Name: name,
|
||||||
|
Method: zip.Store,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFile(f *zip.File) ([]byte, error) {
|
||||||
|
r, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
return io.ReadAll(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ZipCert(ca, crt, key []byte) ([]byte, error) {
|
||||||
|
data := bytes.NewBuffer(make([]byte, 0, 6144))
|
||||||
|
zipWriter := zip.NewWriter(data)
|
||||||
|
defer zipWriter.Close()
|
||||||
|
|
||||||
|
if err := writeFile(zipWriter, "ca.pem", ca); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := writeFile(zipWriter, "cert.pem", crt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := writeFile(zipWriter, "key.pem", key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := zipWriter.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return data.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AgentCertsFilename(host string) string {
|
||||||
|
return filepath.Join(common.AgentCertsBasePath, host+".zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractCert(data []byte) (ca, crt, key []byte, err error) {
|
||||||
|
zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
for _, file := range zipReader.File {
|
||||||
|
switch file.Name {
|
||||||
|
case "ca.pem":
|
||||||
|
ca, err = readFile(file)
|
||||||
|
case "cert.pem":
|
||||||
|
crt, err = readFile(file)
|
||||||
|
case "key.pem":
|
||||||
|
key, err = readFile(file)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ca, crt, key, nil
|
||||||
|
}
|
19
agent/pkg/certs/zip_test.go
Normal file
19
agent/pkg/certs/zip_test.go
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
package certs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestZipCert(t *testing.T) {
|
||||||
|
ca, crt, key := []byte("test1"), []byte("test2"), []byte("test3")
|
||||||
|
zipData, err := ZipCert(ca, crt, key)
|
||||||
|
ExpectNoError(t, err)
|
||||||
|
|
||||||
|
ca2, crt2, key2, err := ExtractCert(zipData)
|
||||||
|
ExpectNoError(t, err)
|
||||||
|
ExpectBytesEqual(t, ca, ca2)
|
||||||
|
ExpectBytesEqual(t, crt, crt2)
|
||||||
|
ExpectBytesEqual(t, key, key2)
|
||||||
|
}
|
25
agent/pkg/env/env.go
vendored
Normal file
25
agent/pkg/env/env.go
vendored
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
package env
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DefaultAgentName() string {
|
||||||
|
name, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
return "agent"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
AgentName = common.GetEnvString("AGENT_NAME", DefaultAgentName())
|
||||||
|
AgentPort = common.GetEnvInt("AGENT_PORT", 8890)
|
||||||
|
AgentRegistrationPort = common.GetEnvInt("AGENT_REGISTRATION_PORT", 8891)
|
||||||
|
AgentSkipClientCertCheck = common.GetEnvBool("AGENT_SKIP_CLIENT_CERT_CHECK", false)
|
||||||
|
|
||||||
|
AgentCACert = common.GetEnvString("AGENT_CA_CERT", "")
|
||||||
|
AgentSSLCert = common.GetEnvString("AGENT_SSL_CERT", "")
|
||||||
|
)
|
78
agent/pkg/handler/check_health.go
Normal file
78
agent/pkg/handler/check_health.go
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||||
|
"github.com/yusing/go-proxy/internal/net/types"
|
||||||
|
"github.com/yusing/go-proxy/internal/watcher/health"
|
||||||
|
"github.com/yusing/go-proxy/internal/watcher/health/monitor"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultHealthConfig = health.DefaultHealthConfig()
|
||||||
|
|
||||||
|
func CheckHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query()
|
||||||
|
scheme := query.Get("scheme")
|
||||||
|
if scheme == "" {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result *health.HealthCheckResult
|
||||||
|
var err error
|
||||||
|
switch scheme {
|
||||||
|
case "fileserver":
|
||||||
|
path := query.Get("path")
|
||||||
|
if path == "" {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
result = &health.HealthCheckResult{Healthy: err == nil}
|
||||||
|
if err != nil {
|
||||||
|
result.Detail = err.Error()
|
||||||
|
}
|
||||||
|
case "http", "https": // path is optional
|
||||||
|
host := query.Get("host")
|
||||||
|
path := query.Get("path")
|
||||||
|
if host == "" {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err = monitor.NewHTTPHealthChecker(types.NewURL(&url.URL{
|
||||||
|
Scheme: scheme,
|
||||||
|
Host: host,
|
||||||
|
Path: path,
|
||||||
|
}), defaultHealthConfig).CheckHealth()
|
||||||
|
case "tcp", "udp":
|
||||||
|
host := query.Get("host")
|
||||||
|
if host == "" {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasPort := strings.Contains(host, ":")
|
||||||
|
port := query.Get("port")
|
||||||
|
if port != "" && !hasPort {
|
||||||
|
host = fmt.Sprintf("%s:%s", host, port)
|
||||||
|
} else {
|
||||||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err = monitor.NewRawHealthChecker(types.NewURL(&url.URL{
|
||||||
|
Scheme: scheme,
|
||||||
|
Host: host,
|
||||||
|
}), defaultHealthConfig).CheckHealth()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gphttp.RespondJSON(w, r, result)
|
||||||
|
}
|
216
agent/pkg/handler/check_health_test.go
Normal file
216
agent/pkg/handler/check_health_test.go
Normal file
|
@ -0,0 +1,216 @@
|
||||||
|
package handler_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||||
|
"github.com/yusing/go-proxy/agent/pkg/handler"
|
||||||
|
"github.com/yusing/go-proxy/internal/watcher/health"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckHealthHTTP(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setupServer func() *httptest.Server
|
||||||
|
queryParams map[string]string
|
||||||
|
expectedStatus int
|
||||||
|
expectedHealthy bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid",
|
||||||
|
setupServer: func() *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
queryParams: map[string]string{
|
||||||
|
"scheme": "http",
|
||||||
|
"host": "localhost",
|
||||||
|
"path": "/",
|
||||||
|
},
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
expectedHealthy: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "InvalidQuery",
|
||||||
|
setupServer: nil,
|
||||||
|
queryParams: map[string]string{
|
||||||
|
"scheme": "http",
|
||||||
|
},
|
||||||
|
expectedStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ConnectionError",
|
||||||
|
setupServer: nil,
|
||||||
|
queryParams: map[string]string{
|
||||||
|
"scheme": "http",
|
||||||
|
"host": "localhost:12345",
|
||||||
|
},
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
expectedHealthy: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var server *httptest.Server
|
||||||
|
if tt.setupServer != nil {
|
||||||
|
server = tt.setupServer()
|
||||||
|
defer server.Close()
|
||||||
|
u, _ := url.Parse(server.URL)
|
||||||
|
tt.queryParams["scheme"] = u.Scheme
|
||||||
|
tt.queryParams["host"] = u.Host
|
||||||
|
tt.queryParams["path"] = u.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
query := url.Values{}
|
||||||
|
for key, value := range tt.queryParams {
|
||||||
|
query.Set(key, value)
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(http.MethodGet, agent.APIEndpointBase+agent.EndpointHealth+"?"+query.Encode(), nil)
|
||||||
|
handler.CheckHealth(recorder, request)
|
||||||
|
|
||||||
|
require.Equal(t, recorder.Code, tt.expectedStatus)
|
||||||
|
|
||||||
|
if tt.expectedStatus == http.StatusOK {
|
||||||
|
var result health.HealthCheckResult
|
||||||
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &result))
|
||||||
|
require.Equal(t, result.Healthy, tt.expectedHealthy)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckHealthFileServer(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
expectedStatus int
|
||||||
|
expectedHealthy bool
|
||||||
|
expectedDetail string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ValidPath",
|
||||||
|
path: t.TempDir(),
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
expectedHealthy: true,
|
||||||
|
expectedDetail: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "InvalidPath",
|
||||||
|
path: "/invalid",
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
expectedHealthy: false,
|
||||||
|
expectedDetail: "stat /invalid: no such file or directory",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
query := url.Values{}
|
||||||
|
query.Set("scheme", "fileserver")
|
||||||
|
query.Set("path", tt.path)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodGet, agent.APIEndpointBase+agent.EndpointHealth+"?"+query.Encode(), nil)
|
||||||
|
handler.CheckHealth(recorder, request)
|
||||||
|
|
||||||
|
require.Equal(t, recorder.Code, tt.expectedStatus)
|
||||||
|
|
||||||
|
var result health.HealthCheckResult
|
||||||
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &result))
|
||||||
|
require.Equal(t, result.Healthy, tt.expectedHealthy)
|
||||||
|
require.Equal(t, result.Detail, tt.expectedDetail)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckHealthTCPUDP(t *testing.T) {
|
||||||
|
tcp, err := net.Listen("tcp", "localhost:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
go func() {
|
||||||
|
conn, err := tcp.Accept()
|
||||||
|
require.NoError(t, err)
|
||||||
|
conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
udp, err := net.ListenPacket("udp", "localhost:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
n, addr, err := udp.ReadFrom(buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, string(buf[:n]), "ping")
|
||||||
|
_, _ = udp.WriteTo([]byte("pong"), addr)
|
||||||
|
udp.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
scheme string
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
expectedStatus int
|
||||||
|
expectedHealthy bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ValidTCP",
|
||||||
|
scheme: "tcp",
|
||||||
|
host: "localhost",
|
||||||
|
port: tcp.Addr().(*net.TCPAddr).Port,
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
expectedHealthy: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "InvalidHost",
|
||||||
|
scheme: "tcp",
|
||||||
|
host: "invalid",
|
||||||
|
port: 8080,
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
expectedHealthy: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ValidUDP",
|
||||||
|
scheme: "udp",
|
||||||
|
host: "localhost",
|
||||||
|
port: udp.LocalAddr().(*net.UDPAddr).Port,
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
expectedHealthy: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "InvalidHost",
|
||||||
|
scheme: "udp",
|
||||||
|
host: "invalid",
|
||||||
|
port: 8080,
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
expectedHealthy: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
query := url.Values{}
|
||||||
|
query.Set("scheme", tt.scheme)
|
||||||
|
query.Set("host", tt.host)
|
||||||
|
query.Set("port", strconv.Itoa(tt.port))
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodGet, agent.APIEndpointBase+agent.EndpointHealth+"?"+query.Encode(), nil)
|
||||||
|
handler.CheckHealth(recorder, request)
|
||||||
|
|
||||||
|
require.Equal(t, recorder.Code, tt.expectedStatus)
|
||||||
|
|
||||||
|
var result health.HealthCheckResult
|
||||||
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &result))
|
||||||
|
require.Equal(t, result.Healthy, tt.expectedHealthy)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
31
agent/pkg/handler/docker_socket.go
Normal file
31
agent/pkg/handler/docker_socket.go
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
|
"github.com/yusing/go-proxy/internal/docker"
|
||||||
|
"github.com/yusing/go-proxy/internal/logging"
|
||||||
|
"github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy"
|
||||||
|
"github.com/yusing/go-proxy/internal/net/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func serviceUnavailable(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "docker socket is not available", http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DockerSocketHandler() http.HandlerFunc {
|
||||||
|
dockerClient, err := docker.NewClient(common.DockerHostFromEnv)
|
||||||
|
if err != nil {
|
||||||
|
logging.Warn().Err(err).Msg("failed to connect to docker client")
|
||||||
|
return serviceUnavailable
|
||||||
|
}
|
||||||
|
rp := reverseproxy.NewReverseProxy("docker", types.NewURL(&url.URL{
|
||||||
|
Scheme: "http",
|
||||||
|
Host: client.DummyHost,
|
||||||
|
}), dockerClient.HTTPClient().Transport)
|
||||||
|
|
||||||
|
return rp.ServeHTTP
|
||||||
|
}
|
49
agent/pkg/handler/handler.go
Normal file
49
agent/pkg/handler/handler.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||||
|
"github.com/yusing/go-proxy/agent/pkg/env"
|
||||||
|
v1 "github.com/yusing/go-proxy/internal/api/v1"
|
||||||
|
"github.com/yusing/go-proxy/internal/logging/memlogger"
|
||||||
|
"github.com/yusing/go-proxy/internal/metrics/systeminfo"
|
||||||
|
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServeMux struct{ *http.ServeMux }
|
||||||
|
|
||||||
|
func (mux ServeMux) HandleMethods(methods, endpoint string, handler http.HandlerFunc) {
|
||||||
|
for _, m := range strutils.CommaSeperatedList(methods) {
|
||||||
|
mux.ServeMux.HandleFunc(m+" "+agent.APIEndpointBase+endpoint, handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mux ServeMux) HandleFunc(endpoint string, handler http.HandlerFunc) {
|
||||||
|
mux.ServeMux.HandleFunc(agent.APIEndpointBase+endpoint, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type NopWriteCloser struct {
|
||||||
|
io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NopWriteCloser) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgentHandler() http.Handler {
|
||||||
|
mux := ServeMux{http.NewServeMux()}
|
||||||
|
|
||||||
|
mux.HandleFunc(agent.EndpointProxyHTTP+"/{path...}", ProxyHTTP)
|
||||||
|
mux.HandleMethods("GET", agent.EndpointVersion, v1.GetVersion)
|
||||||
|
mux.HandleMethods("GET", agent.EndpointName, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, env.AgentName)
|
||||||
|
})
|
||||||
|
mux.HandleMethods("GET", agent.EndpointHealth, CheckHealth)
|
||||||
|
mux.HandleMethods("GET", agent.EndpointLogs, memlogger.HandlerFunc())
|
||||||
|
mux.HandleMethods("GET", agent.EndpointSystemInfo, systeminfo.Poller.ServeHTTP)
|
||||||
|
mux.ServeMux.HandleFunc("/", DockerSocketHandler())
|
||||||
|
return mux
|
||||||
|
}
|
63
agent/pkg/handler/proxy_http.go
Normal file
63
agent/pkg/handler/proxy_http.go
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||||
|
"github.com/yusing/go-proxy/agent/pkg/agentproxy"
|
||||||
|
"github.com/yusing/go-proxy/internal/logging"
|
||||||
|
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||||
|
"github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy"
|
||||||
|
"github.com/yusing/go-proxy/internal/net/types"
|
||||||
|
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ProxyHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
host := r.Header.Get(agentproxy.HeaderXProxyHost)
|
||||||
|
isHTTPS := strutils.ParseBool(r.Header.Get(agentproxy.HeaderXProxyHTTPS))
|
||||||
|
skipTLSVerify := strutils.ParseBool(r.Header.Get(agentproxy.HeaderXProxySkipTLSVerify))
|
||||||
|
responseHeaderTimeout, err := strconv.Atoi(r.Header.Get(agentproxy.HeaderXProxyResponseHeaderTimeout))
|
||||||
|
if err != nil {
|
||||||
|
responseHeaderTimeout = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if host == "" {
|
||||||
|
http.Error(w, "missing required headers", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme := "http"
|
||||||
|
if isHTTPS {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
var transport *http.Transport
|
||||||
|
if skipTLSVerify {
|
||||||
|
transport = gphttp.NewTransportWithTLSConfig(&tls.Config{InsecureSkipVerify: true})
|
||||||
|
} else {
|
||||||
|
transport = gphttp.NewTransport()
|
||||||
|
}
|
||||||
|
|
||||||
|
if responseHeaderTimeout > 0 {
|
||||||
|
transport.ResponseHeaderTimeout = time.Duration(responseHeaderTimeout) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
r.URL.Scheme = ""
|
||||||
|
r.URL.Host = ""
|
||||||
|
r.URL.Path = r.URL.Path[agent.HTTPProxyURLPrefixLen:] // strip the {API_BASE}/proxy/http prefix
|
||||||
|
r.RequestURI = r.URL.String()
|
||||||
|
r.URL.Host = host
|
||||||
|
r.URL.Scheme = scheme
|
||||||
|
|
||||||
|
logging.Debug().Msgf("proxy http request: %s %s", r.Method, r.URL.String())
|
||||||
|
|
||||||
|
rp := reverseproxy.NewReverseProxy("agent", types.NewURL(&url.URL{
|
||||||
|
Scheme: scheme,
|
||||||
|
Host: host,
|
||||||
|
}), transport)
|
||||||
|
rp.ServeHTTP(w, r)
|
||||||
|
}
|
51
agent/pkg/server/server.go
Normal file
51
agent/pkg/server/server.go
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/yusing/go-proxy/agent/pkg/env"
|
||||||
|
"github.com/yusing/go-proxy/agent/pkg/handler"
|
||||||
|
"github.com/yusing/go-proxy/internal/logging"
|
||||||
|
"github.com/yusing/go-proxy/internal/net/gphttp/server"
|
||||||
|
"github.com/yusing/go-proxy/internal/task"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
CACert, ServerCert *tls.Certificate
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartAgentServer(parent task.Parent, opt Options) {
|
||||||
|
t := parent.Subtask("agent_server")
|
||||||
|
|
||||||
|
caCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: opt.CACert.Certificate[0]})
|
||||||
|
caCertPool := x509.NewCertPool()
|
||||||
|
caCertPool.AppendCertsFromPEM(caCertPEM)
|
||||||
|
|
||||||
|
// Configure TLS
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{*opt.ServerCert},
|
||||||
|
ClientCAs: caCertPool,
|
||||||
|
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||||
|
}
|
||||||
|
|
||||||
|
if env.AgentSkipClientCertCheck {
|
||||||
|
tlsConfig.ClientAuth = tls.NoClientCert
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := logging.GetLogger()
|
||||||
|
agentServer := &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", opt.Port),
|
||||||
|
Handler: handler.NewAgentHandler(),
|
||||||
|
TLSConfig: tlsConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
server.Start(t, agentServer, logger)
|
||||||
|
t.OnCancel("stop", func() {
|
||||||
|
server.Stop(agentServer, logger)
|
||||||
|
})
|
||||||
|
}
|
2
go.mod
2
go.mod
|
@ -28,6 +28,7 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/docker/cli v28.0.4+incompatible
|
||||||
github.com/docker/go-connections v0.5.0
|
github.com/docker/go-connections v0.5.0
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
)
|
)
|
||||||
|
@ -75,6 +76,7 @@ require (
|
||||||
github.com/prometheus/client_model v0.6.1 // indirect
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
github.com/prometheus/common v0.63.0 // indirect
|
github.com/prometheus/common v0.63.0 // indirect
|
||||||
github.com/prometheus/procfs v0.16.0 // indirect
|
github.com/prometheus/procfs v0.16.0 // indirect
|
||||||
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
|
9
go.sum
9
go.sum
|
@ -21,10 +21,14 @@ github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3
|
||||||
github.com/coreos/go-oidc/v3 v3.13.0 h1:M66zd0pcc5VxvBNM4pB331Wrsanby+QomQYjN8HamW8=
|
github.com/coreos/go-oidc/v3 v3.13.0 h1:M66zd0pcc5VxvBNM4pB331Wrsanby+QomQYjN8HamW8=
|
||||||
github.com/coreos/go-oidc/v3 v3.13.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
github.com/coreos/go-oidc/v3 v3.13.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/cli v28.0.4+incompatible h1:pBJSJeNd9QeIWPjRcV91RVJihd/TXB77q1ef64XEu4A=
|
||||||
|
github.com/docker/cli v28.0.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||||
github.com/docker/docker v28.0.4+incompatible h1:JNNkBctYKurkw6FrHfKqY0nKIDf5nrbxjVBtS+cdcok=
|
github.com/docker/docker v28.0.4+incompatible h1:JNNkBctYKurkw6FrHfKqY0nKIDf5nrbxjVBtS+cdcok=
|
||||||
github.com/docker/docker v28.0.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
github.com/docker/docker v28.0.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
@ -127,6 +131,7 @@ github.com/ovh/go-ovh v1.7.0 h1:V14nF7FwDjQrZt9g7jzcvAAQ3HN6DNShRFRMC3jLoPw=
|
||||||
github.com/ovh/go-ovh v1.7.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
|
github.com/ovh/go-ovh v1.7.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
|
@ -150,6 +155,8 @@ github.com/shirou/gopsutil/v4 v4.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMv
|
||||||
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
|
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||||
|
@ -237,6 +244,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
@ -299,6 +307,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
||||||
|
|
24
internal/api/v1/agents.go
Normal file
24
internal/api/v1/agents.go
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
"github.com/coder/websocket/wsjson"
|
||||||
|
config "github.com/yusing/go-proxy/internal/config/types"
|
||||||
|
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||||
|
"github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket"
|
||||||
|
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ListAgents(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||||
|
if httpheaders.IsWebsocket(r.Header) {
|
||||||
|
gpwebsocket.Periodic(w, r, 10*time.Second, func(conn *websocket.Conn) error {
|
||||||
|
wsjson.Write(r.Context(), conn, cfg.ListAgents())
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
gphttp.RespondJSON(w, r, cfg.ListAgents())
|
||||||
|
}
|
||||||
|
}
|
142
internal/api/v1/new_agent.go
Normal file
142
internal/api/v1/new_agent.go
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
_ "embed"
|
||||||
|
|
||||||
|
"github.com/yusing/go-proxy/agent/pkg/agent"
|
||||||
|
"github.com/yusing/go-proxy/agent/pkg/certs"
|
||||||
|
config "github.com/yusing/go-proxy/internal/config/types"
|
||||||
|
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||||
|
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
name := q.Get("name")
|
||||||
|
if name == "" {
|
||||||
|
gphttp.ClientError(w, gphttp.ErrMissingKey("name"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
host := q.Get("host")
|
||||||
|
if host == "" {
|
||||||
|
gphttp.ClientError(w, gphttp.ErrMissingKey("host"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
portStr := q.Get("port")
|
||||||
|
if portStr == "" {
|
||||||
|
gphttp.ClientError(w, gphttp.ErrMissingKey("port"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
port, err := strconv.Atoi(portStr)
|
||||||
|
if err != nil || port < 1 || port > 65535 {
|
||||||
|
gphttp.ClientError(w, gphttp.ErrInvalidKey("port"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostport := fmt.Sprintf("%s:%d", host, port)
|
||||||
|
if _, ok := config.GetInstance().GetAgent(hostport); ok {
|
||||||
|
gphttp.ClientError(w, gphttp.ErrAlreadyExists("agent", hostport), http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t := q.Get("type")
|
||||||
|
switch t {
|
||||||
|
case "docker", "system":
|
||||||
|
break
|
||||||
|
case "":
|
||||||
|
gphttp.ClientError(w, gphttp.ErrMissingKey("type"))
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
gphttp.ClientError(w, gphttp.ErrInvalidKey("type"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nightly := strutils.ParseBool(q.Get("nightly"))
|
||||||
|
var image string
|
||||||
|
if nightly {
|
||||||
|
image = agent.DockerImageNightly
|
||||||
|
} else {
|
||||||
|
image = agent.DockerImageProduction
|
||||||
|
}
|
||||||
|
|
||||||
|
ca, srv, client, err := agent.NewAgent()
|
||||||
|
if err != nil {
|
||||||
|
gphttp.ServerError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg agent.Generator = &agent.AgentEnvConfig{
|
||||||
|
Name: name,
|
||||||
|
Port: port,
|
||||||
|
CACert: ca.String(),
|
||||||
|
SSLCert: srv.String(),
|
||||||
|
}
|
||||||
|
if t == "docker" {
|
||||||
|
cfg = &agent.AgentComposeConfig{
|
||||||
|
Image: image,
|
||||||
|
AgentEnvConfig: cfg.(*agent.AgentEnvConfig),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
template, err := cfg.Generate()
|
||||||
|
if err != nil {
|
||||||
|
gphttp.ServerError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gphttp.RespondJSON(w, r, map[string]any{
|
||||||
|
"compose": template,
|
||||||
|
"ca": ca,
|
||||||
|
"client": client,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func VerifyNewAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
clientPEMData, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
gphttp.ServerError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var data struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
CA agent.PEMPair `json:"ca"`
|
||||||
|
Client agent.PEMPair `json:"client"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(clientPEMData, &data); err != nil {
|
||||||
|
gphttp.ClientError(w, err, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nRoutesAdded, err := config.GetInstance().VerifyNewAgent(data.Host, data.CA, data.Client)
|
||||||
|
if err != nil {
|
||||||
|
gphttp.ClientError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
zip, err := certs.ZipCert(data.CA.Cert, data.Client.Cert, data.Client.Key)
|
||||||
|
if err != nil {
|
||||||
|
gphttp.ServerError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := certs.AgentCertsFilename(data.Host)
|
||||||
|
if !strutils.IsValidFilename(filename) {
|
||||||
|
gphttp.ClientError(w, gphttp.ErrInvalidKey("host"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filename, zip, 0600); err != nil {
|
||||||
|
gphttp.ServerError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(fmt.Appendf(nil, "Added %d routes", nRoutesAdded))
|
||||||
|
}
|
149
scripts/install-agent.sh
Normal file
149
scripts/install-agent.sh
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
check_pkg() {
|
||||||
|
if ! command -v $1 &>/dev/null; then
|
||||||
|
echo "$1 could not be found, please install it first"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# check if curl and jq are installed
|
||||||
|
check_pkg curl
|
||||||
|
check_pkg jq
|
||||||
|
|
||||||
|
# check if running user is root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "Please run the script as root"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# check if system is using systemd
|
||||||
|
if [ -d "/etc/systemd/system" ]; then
|
||||||
|
echo "System is using systemd"
|
||||||
|
else
|
||||||
|
echo "Unsupported init system, currently only systemd is supported"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# check variables
|
||||||
|
if [ -z "$AGENT_NAME" ]; then
|
||||||
|
echo "AGENT_NAME is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$AGENT_PORT" ]; then
|
||||||
|
echo "AGENT_PORT is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$AGENT_CA_CERT" ]; then
|
||||||
|
echo "AGENT_CA_CERT is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$AGENT_SSL_CERT" ]; then
|
||||||
|
echo "AGENT_SSL_CERT is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# init variables
|
||||||
|
arch=$(uname -m)
|
||||||
|
if [ "$arch" = "x86_64" ]; then
|
||||||
|
filename="godoxy-agent-linux-amd64"
|
||||||
|
elif [ "$arch" = "aarch64" ]; then
|
||||||
|
filename="godoxy-agent-linux-arm64"
|
||||||
|
else
|
||||||
|
echo "Unsupported architecture: $arch, expect x86_64 or aarch64"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
repo="yusing/godoxy"
|
||||||
|
install_path="/usr/local/bin"
|
||||||
|
name="godoxy-agent"
|
||||||
|
bin_path="${install_path}/${name}"
|
||||||
|
env_file="/etc/${name}.env"
|
||||||
|
service_file="/etc/systemd/system/${name}.service"
|
||||||
|
log_path="/var/log/${name}.log"
|
||||||
|
data_path="/var/lib/${name}"
|
||||||
|
|
||||||
|
# check if install path is writable
|
||||||
|
if [ ! -w "$install_path" ]; then
|
||||||
|
echo "Install path is not writable, please check the permissions"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# check if service path is writable
|
||||||
|
if [ ! -w "$(dirname "$service_file")" ]; then
|
||||||
|
echo "Service path is not writable, please check the permissions"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# check if env file is writable
|
||||||
|
if [ ! -w "$(dirname "$env_file")" ]; then
|
||||||
|
echo "Env file is not writable, please check the permissions"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# check if command is uninstall
|
||||||
|
if [ "$1" = "uninstall" ]; then
|
||||||
|
echo "Uninstalling the agent"
|
||||||
|
systemctl disable --now $name
|
||||||
|
rm -f $bin_path
|
||||||
|
rm -f $env_file
|
||||||
|
rm -f $service_file
|
||||||
|
rm -rf $data_path
|
||||||
|
systemctl daemon-reload
|
||||||
|
echo "Agent uninstalled successfully"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Finding the latest agent binary"
|
||||||
|
bin_url=$(curl -H "Accept: application/vnd.github.v3+json" https://api.github.com/repos/$repo/releases/latest | jq -r '.assets[] | select(.name | contains("'$filename'")) | .browser_download_url')
|
||||||
|
|
||||||
|
echo "Downloading the agent binary"
|
||||||
|
curl -L "$bin_url" -o $bin_path
|
||||||
|
|
||||||
|
echo "Making the agent binary executable"
|
||||||
|
chmod +x $bin_path
|
||||||
|
|
||||||
|
echo "Creating the environment file"
|
||||||
|
cat <<EOF >$env_file
|
||||||
|
AGENT_NAME="${AGENT_NAME}"
|
||||||
|
AGENT_PORT="${AGENT_PORT}"
|
||||||
|
AGENT_CA_CERT="${AGENT_CA_CERT}"
|
||||||
|
AGENT_SSL_CERT="${AGENT_SSL_CERT}"
|
||||||
|
EOF
|
||||||
|
chmod 600 $env_file
|
||||||
|
|
||||||
|
echo "Creating the data directory"
|
||||||
|
mkdir -p $data_path
|
||||||
|
|
||||||
|
echo "Registering the agent as a service"
|
||||||
|
cat <<EOF >$service_file
|
||||||
|
[Unit]
|
||||||
|
Description=GoDoxy Agent
|
||||||
|
After=docker.socket
|
||||||
|
|
||||||
|
[Service]]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=${bin_path}
|
||||||
|
EnvironmentFile=${env_file}
|
||||||
|
WorkingDirectory=${data_path}
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=append:${log_path}
|
||||||
|
StandardError=append:${log_path}
|
||||||
|
|
||||||
|
# Security settings
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
# User and group
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now $name
|
||||||
|
echo "Agent installed successfully"
|
Loading…
Add table
Reference in a new issue