mirror of
https://github.com/yusing/godoxy.git
synced 2026-04-10 18:56:55 +02:00
feat: godoxy agent
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user