diff --git a/.github/workflows/agent-binary.yml b/.github/workflows/agent-binary.yml new file mode 100644 index 0000000..cd6693c --- /dev/null +++ b/.github/workflows/agent-binary.yml @@ -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 }} diff --git a/agent/cmd/main.go b/agent/cmd/main.go new file mode 100644 index 0000000..0640b14 --- /dev/null +++ b/agent/cmd/main.go @@ -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) +} diff --git a/agent/pkg/agent/bare_metal.go b/agent/pkg/agent/bare_metal.go new file mode 100644 index 0000000..a7e4e47 --- /dev/null +++ b/agent/pkg/agent/bare_metal.go @@ -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 +} diff --git a/agent/pkg/agent/config.go b/agent/pkg/agent/config.go new file mode 100644 index 0000000..83243a2 --- /dev/null +++ b/agent/pkg/agent/config.go @@ -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, + }) +} diff --git a/agent/pkg/agent/docker_compose.go b/agent/pkg/agent/docker_compose.go new file mode 100644 index 0000000..63b4669 --- /dev/null +++ b/agent/pkg/agent/docker_compose.go @@ -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 +} diff --git a/agent/pkg/agent/env.go b/agent/pkg/agent/env.go new file mode 100644 index 0000000..68bc2da --- /dev/null +++ b/agent/pkg/agent/env.go @@ -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) + } +) diff --git a/agent/pkg/agent/new_agent.go b/agent/pkg/agent/new_agent.go new file mode 100644 index 0000000..7d75328 --- /dev/null +++ b/agent/pkg/agent/new_agent.go @@ -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 +} diff --git a/agent/pkg/agent/new_agent_test.go b/agent/pkg/agent/new_agent_test.go new file mode 100644 index 0000000..92537ed --- /dev/null +++ b/agent/pkg/agent/new_agent_test.go @@ -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) +} diff --git a/agent/pkg/agent/requests.go b/agent/pkg/agent/requests.go new file mode 100644 index 0000000..a50e9f0 --- /dev/null +++ b/agent/pkg/agent/requests.go @@ -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, + }) +} diff --git a/agent/pkg/agent/templates/agent.compose.yml b/agent/pkg/agent/templates/agent.compose.yml new file mode 100644 index 0000000..1397640 --- /dev/null +++ b/agent/pkg/agent/templates/agent.compose.yml @@ -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 diff --git a/agent/pkg/agentproxy/headers.go b/agent/pkg/agentproxy/headers.go new file mode 100644 index 0000000..098b5bc --- /dev/null +++ b/agent/pkg/agentproxy/headers.go @@ -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)) +} diff --git a/agent/pkg/certs/zip.go b/agent/pkg/certs/zip.go new file mode 100644 index 0000000..9349499 --- /dev/null +++ b/agent/pkg/certs/zip.go @@ -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 +} diff --git a/agent/pkg/certs/zip_test.go b/agent/pkg/certs/zip_test.go new file mode 100644 index 0000000..835e820 --- /dev/null +++ b/agent/pkg/certs/zip_test.go @@ -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) +} diff --git a/agent/pkg/env/env.go b/agent/pkg/env/env.go new file mode 100644 index 0000000..5342209 --- /dev/null +++ b/agent/pkg/env/env.go @@ -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", "") +) diff --git a/agent/pkg/handler/check_health.go b/agent/pkg/handler/check_health.go new file mode 100644 index 0000000..f656453 --- /dev/null +++ b/agent/pkg/handler/check_health.go @@ -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) +} diff --git a/agent/pkg/handler/check_health_test.go b/agent/pkg/handler/check_health_test.go new file mode 100644 index 0000000..4633ed6 --- /dev/null +++ b/agent/pkg/handler/check_health_test.go @@ -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) + }) + } +} diff --git a/agent/pkg/handler/docker_socket.go b/agent/pkg/handler/docker_socket.go new file mode 100644 index 0000000..27bedf2 --- /dev/null +++ b/agent/pkg/handler/docker_socket.go @@ -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 +} diff --git a/agent/pkg/handler/handler.go b/agent/pkg/handler/handler.go new file mode 100644 index 0000000..8d661ce --- /dev/null +++ b/agent/pkg/handler/handler.go @@ -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 +} diff --git a/agent/pkg/handler/proxy_http.go b/agent/pkg/handler/proxy_http.go new file mode 100644 index 0000000..712f261 --- /dev/null +++ b/agent/pkg/handler/proxy_http.go @@ -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) +} diff --git a/agent/pkg/server/server.go b/agent/pkg/server/server.go new file mode 100644 index 0000000..9d6c336 --- /dev/null +++ b/agent/pkg/server/server.go @@ -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) + }) +} diff --git a/go.mod b/go.mod index eb0237a..ed04d11 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( ) require ( + github.com/docker/cli v28.0.4+incompatible github.com/docker/go-connections v0.5.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/common v0.63.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/numcpus v0.10.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect diff --git a/go.sum b/go.sum index 44e4452..ac52545 100644 --- a/go.sum +++ b/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/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= 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/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/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/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 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/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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-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-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-20220811171246-fbc7d0a398ab/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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= diff --git a/internal/api/v1/agents.go b/internal/api/v1/agents.go new file mode 100644 index 0000000..d9d2a87 --- /dev/null +++ b/internal/api/v1/agents.go @@ -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()) + } +} diff --git a/internal/api/v1/new_agent.go b/internal/api/v1/new_agent.go new file mode 100644 index 0000000..7c381f1 --- /dev/null +++ b/internal/api/v1/new_agent.go @@ -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)) +} diff --git a/scripts/install-agent.sh b/scripts/install-agent.sh new file mode 100644 index 0000000..6b6d05b --- /dev/null +++ b/scripts/install-agent.sh @@ -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 <$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 <$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"