diff --git a/server/embed.go b/server/embed.go new file mode 100644 index 0000000..09a0875 --- /dev/null +++ b/server/embed.go @@ -0,0 +1,6 @@ +package main + +import "embed" + +//go:embed templates/index.html +var templateFS embed.FS diff --git a/server/handlers.go b/server/handlers.go new file mode 100644 index 0000000..39c7c26 --- /dev/null +++ b/server/handlers.go @@ -0,0 +1,133 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "strings" + "time" +) + +// Terraform Registry Protocol Structs +type Discovery struct { + ProvidersV1 string `json:"providers.v1"` +} + +type VersionList struct { + ID string `json:"id"` + Versions []Version `json:"versions"` + Warnings []string `json:"warnings"` +} + +type Version struct { + Version string `json:"version"` + Protocols []string `json:"protocols"` + Platforms []Platform `json:"platforms"` +} + +type Platform struct { + OS string `json:"os"` + Arch string `json:"arch"` +} + +type DownloadResponse struct { + Protocols []string `json:"protocols"` + OS string `json:"os"` + Arch string `json:"arch"` + Filename string `json:"filename"` + DownloadURL string `json:"download_url"` + ShasumsURL string `json:"shasums_url"` + ShasumsSignatureURL string `json:"shasums_signature_url"` + Shasum string `json:"shasum"` + SigningKeys SigningKeys `json:"signing_keys"` +} + +type SigningKeys struct { + GPGPublicKeys []GPGPublicKey `json:"gpg_public_keys"` +} + +type GPGPublicKey struct { + KeyID string `json:"key_id"` + ASCIIArmor string `json:"ascii_armor"` +} + +func discoveryHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Discovery{ProvidersV1: "/v1/providers/"}) +} + +func rootHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl, _ := templateFS.ReadFile("index.html") + html := strings.Replace(string(tmpl), "{{.Version}}", VERSION, 1) + fmt.Fprint(w, html) +} + +func healthzHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} + +func readyzHandler(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + _, err := s3Client.BucketExists(ctx, bucketName) + if err != nil { + msg := fmt.Sprintf("s3 unreachable: %v", err) + log.Printf("readyz: %s", msg) + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte(msg)) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} + +func debugHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + + fmt.Fprintf(w, "=== ENV ===\n") + for _, e := range os.Environ() { + if strings.Contains(e, "S3_") || strings.Contains(e, "REGISTRY") || strings.Contains(e, "PORT") { + fmt.Fprintln(w, e) + } + } + + s3ep := os.Getenv("S3_ENDPOINT") + fmt.Fprintf(w, "\n=== DNS: %s ===\n", s3ep) + addrs, err := net.LookupHost(s3ep) + if err != nil { + fmt.Fprintf(w, "DNS ERROR: %v\n", err) + } else { + for _, a := range addrs { + fmt.Fprintf(w, " %s\n", a) + } + } + + fmt.Fprintf(w, "\n=== TCP dial: %s:443 ===\n", s3ep) + conn, err := net.DialTimeout("tcp", s3ep+":443", 5*time.Second) + if err != nil { + fmt.Fprintf(w, "TCP ERROR: %v\n", err) + } else { + fmt.Fprintf(w, "TCP OK: %s -> %s\n", conn.LocalAddr(), conn.RemoteAddr()) + conn.Close() + } + + fmt.Fprintf(w, "\n=== S3 BucketExists ===\n") + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + _, err = s3Client.BucketExists(ctx, bucketName) + if err != nil { + fmt.Fprintf(w, "S3 ERROR: %v\n", err) + } else { + fmt.Fprintf(w, "S3 OK: bucket %s accessible\n", bucketName) + } +} diff --git a/server/main.go b/server/main.go index 265fe3e..8becc3b 100644 --- a/server/main.go +++ b/server/main.go @@ -1,20 +1,12 @@ package main import ( - "bufio" "context" "embed" - "encoding/json" - "fmt" - "io" "log" - "net" "net/http" - "net/url" "os" "os/signal" - "sort" - "strings" "syscall" "time" @@ -27,62 +19,17 @@ var logoFile embed.FS var ( s3Client *s3.Client - bucketName = "terraform-registry" // Default + bucketName = "terraform-registry" hostname = os.Getenv("REGISTRY_HOSTNAME") - s3Prefix = os.Getenv("S3_PREFIX") // S3 key prefix (may differ from hostname) + s3Prefix = os.Getenv("S3_PREFIX") ) const VERSION = "0.0.2" -// Terraform Registry Protocol Structs -type Discovery struct { - ProvidersV1 string `json:"providers.v1"` -} - -type VersionList struct { - ID string `json:"id"` - Versions []Version `json:"versions"` - Warnings []string `json:"warnings"` -} - -type Version struct { - Version string `json:"version"` - Protocols []string `json:"protocols"` - Platforms []Platform `json:"platforms"` -} - -type Platform struct { - OS string `json:"os"` - Arch string `json:"arch"` -} - -type DownloadResponse struct { - Protocols []string `json:"protocols"` - OS string `json:"os"` - Arch string `json:"arch"` - Filename string `json:"filename"` - DownloadURL string `json:"download_url"` - ShasumsURL string `json:"shasums_url"` - ShasumsSignatureURL string `json:"shasums_signature_url"` - Shasum string `json:"shasum"` - SigningKeys SigningKeys `json:"signing_keys"` -} - -type SigningKeys struct { - GPGPublicKeys []GPGPublicKey `json:"gpg_public_keys"` -} - -type GPGPublicKey struct { - KeyID string `json:"key_id"` - ASCIIArmor string `json:"ascii_armor"` -} - func main() { - // 1. Init S3 Connection if hostname == "" { hostname = "localhost:5000" } - // S3_PREFIX default comes from Dockerfile ENV; override in jsonEnv if needed. if s3Prefix == "" { s3Prefix = hostname } @@ -105,9 +52,7 @@ func main() { if err != nil { log.Fatalf("S3 init failed: %v", err) } - log.Printf("S3 client created, testing BucketExists...") - // 2. HTTP Handlers http.HandleFunc("/.well-known/terraform.json", discoveryHandler) http.HandleFunc("/v1/providers/", router) http.HandleFunc("/v1/proxy", proxyHandler) @@ -144,340 +89,3 @@ func main() { } log.Println("Server stopped") } - -func debugHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - - fmt.Fprintf(w, "=== ENV ===\n") - for _, e := range os.Environ() { - if strings.Contains(e, "S3_") || strings.Contains(e, "REGISTRY") || strings.Contains(e, "PORT") { - fmt.Fprintln(w, e) - } - } - - s3ep := os.Getenv("S3_ENDPOINT") - fmt.Fprintf(w, "\n=== DNS: %s ===\n", s3ep) - addrs, err := net.LookupHost(s3ep) - if err != nil { - fmt.Fprintf(w, "DNS ERROR: %v\n", err) - } else { - for _, a := range addrs { - fmt.Fprintf(w, " %s\n", a) - } - } - - fmt.Fprintf(w, "\n=== TCP dial: %s:443 ===\n", s3ep) - conn, err := net.DialTimeout("tcp", s3ep+":443", 5*time.Second) - if err != nil { - fmt.Fprintf(w, "TCP ERROR: %v\n", err) - } else { - fmt.Fprintf(w, "TCP OK: %s -> %s\n", conn.LocalAddr(), conn.RemoteAddr()) - conn.Close() - } - - fmt.Fprintf(w, "\n=== S3 BucketExists ===\n") - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - _, err = s3Client.BucketExists(ctx, bucketName) - if err != nil { - fmt.Fprintf(w, "S3 ERROR: %v\n", err) - } else { - fmt.Fprintf(w, "S3 OK: bucket %s accessible\n", bucketName) - } -} - -func rootHandler(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, ` - - - - -Terraform Registry · Nubes - - - -
- nubes -
-
-
-
Terraform Provider Registry
-
-
- Статус - ONLINE -
-
- Версия - v`+VERSION+` -
-
-
-
- - -`) -} - -func proxyHandler(w http.ResponseWriter, r *http.Request) { - key := r.URL.Query().Get("key") - - if key == "" { - http.Error(w, "Missing key param", http.StatusBadRequest) - return - } - if !strings.HasPrefix(key, s3Prefix+"/") && !strings.HasPrefix(key, "docs/") { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - - ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) - defer cancel() - - obj, err := s3Client.GetObject(ctx, bucketName, key, s3.GetObjectOptions{}) - if err != nil { - log.Printf("Error getting object %s/%s: %v", bucketName, key, err) - http.Error(w, "File not found", http.StatusNotFound) - return - } - defer obj.Close() - - stat, err := obj.Stat() - if err != nil { - log.Printf("Error stating object %s/%s: %v", bucketName, key, err) - http.Error(w, "File not found or not accessible", http.StatusNotFound) - return - } - - w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size)) - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Last-Modified", stat.LastModified.Format(http.TimeFormat)) - - written, err := io.Copy(w, obj) - if err != nil { - log.Printf("Error streaming object: %v (sent %d/%d bytes)", err, written, stat.Size) - } else { - log.Printf("Proxy: sent %d bytes for %s", written, key) - } -} - -func discoveryHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(Discovery{ProvidersV1: "/v1/providers/"}) -} - -func router(w http.ResponseWriter, r *http.Request) { - path := strings.TrimPrefix(r.URL.Path, "/v1/providers/") - parts := strings.Split(path, "/") - - if len(parts) == 3 && parts[2] == "versions" { - listVersions(w, r, parts[0], parts[1]) - return - } - - if len(parts) == 6 && parts[3] == "download" { - downloadVersion(w, r, parts[0], parts[1], parts[2], parts[4], parts[5]) - return - } - - http.Error(w, "Not Found", http.StatusNotFound) -} - -func listVersions(w http.ResponseWriter, r *http.Request, namespace, pType string) { - prefix := fmt.Sprintf("%s/%s/%s/", s3Prefix, namespace, pType) - ctx := context.Background() - versions := []Version{} - seenVersions := map[string]*Version{} - - objectCh := s3Client.ListObjects(ctx, bucketName, s3.ListObjectsOptions{ - Prefix: prefix, - Recursive: true, - }) - - for object := range objectCh { - if object.Err != nil { - continue - } - parts := strings.Split(object.Key, "/") - if len(parts) < 5 { - continue - } - verStr := parts[3] - fileName := parts[4] - - if _, ok := seenVersions[verStr]; !ok { - seenVersions[verStr] = &Version{ - Version: verStr, - Protocols: []string{"5.0"}, - Platforms: []Platform{}, - } - } - - if strings.Contains(fileName, "_darwin_amd64.zip") { - seenVersions[verStr].Platforms = append(seenVersions[verStr].Platforms, Platform{OS: "darwin", Arch: "amd64"}) - } - if strings.Contains(fileName, "_linux_amd64.zip") { - seenVersions[verStr].Platforms = append(seenVersions[verStr].Platforms, Platform{OS: "linux", Arch: "amd64"}) - } - if strings.Contains(fileName, "_windows_amd64.zip") { - seenVersions[verStr].Platforms = append(seenVersions[verStr].Platforms, Platform{OS: "windows", Arch: "amd64"}) - } - } - - for _, v := range seenVersions { - versions = append(versions, *v) - } - - sort.Slice(versions, func(i, j int) bool { - return versions[i].Version < versions[j].Version - }) - - resp := VersionList{ - ID: fmt.Sprintf("%s/%s", namespace, pType), - Versions: versions, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - -func downloadVersion(w http.ResponseWriter, r *http.Request, namespace, pType, version, osType, arch string) { - basePath := fmt.Sprintf("%s/%s/%s/%s", s3Prefix, namespace, pType, version) - filename := fmt.Sprintf("terraform-provider-%s_%s_%s_%s.zip", pType, version, osType, arch) - fullKey := fmt.Sprintf("%s/%s", basePath, filename) - shasumsKey := fmt.Sprintf("%s/terraform-provider-%s_%s_SHA256SUMS", basePath, pType, version) - sigKey := fmt.Sprintf("%s/terraform-provider-%s_%s_SHA256SUMS.sig", basePath, pType, version) - - var shasumValue string - shasumsObj, err := s3Client.GetObject(context.Background(), bucketName, shasumsKey, s3.GetObjectOptions{}) - if err == nil { - defer shasumsObj.Close() - scanner := bufio.NewScanner(shasumsObj) - for scanner.Scan() { - line := scanner.Text() - if strings.Contains(line, filename) { - fields := strings.Fields(line) - if len(fields) >= 1 { - shasumValue = fields[0] - } - break - } - } - } - - baseURL := "https://" + hostname - downloadLink := fmt.Sprintf("%s/v1/proxy?bucket=%s&key=%s", baseURL, bucketName, url.QueryEscape(fullKey)) - shasumsLink := fmt.Sprintf("%s/v1/proxy?bucket=%s&key=%s", baseURL, bucketName, url.QueryEscape(shasumsKey)) - sigLink := fmt.Sprintf("%s/v1/proxy?bucket=%s&key=%s", baseURL, bucketName, url.QueryEscape(sigKey)) - - resp := DownloadResponse{ - Protocols: []string{"5.0"}, - OS: osType, - Arch: arch, - Filename: filename, - DownloadURL: downloadLink, - ShasumsURL: shasumsLink, - ShasumsSignatureURL: sigLink, - Shasum: shasumValue, - SigningKeys: SigningKeys{ - GPGPublicKeys: []GPGPublicKey{gpgPrimaryKey, gpgLegacyKey}, - }, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - -func healthzHandler(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("ok")) -} - -func readyzHandler(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) - defer cancel() - _, err := s3Client.BucketExists(ctx, bucketName) - if err != nil { - msg := fmt.Sprintf("s3 unreachable: %v", err) - log.Printf("readyz: %s", msg) - w.WriteHeader(http.StatusServiceUnavailable) - w.Write([]byte(msg)) - return - } - w.WriteHeader(http.StatusOK) - w.Write([]byte("ok")) -} diff --git a/server/proxy.go b/server/proxy.go new file mode 100644 index 0000000..82bf0b6 --- /dev/null +++ b/server/proxy.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + s3 "github.com/minio/minio-go/v7" +) + +func proxyHandler(w http.ResponseWriter, r *http.Request) { + key := r.URL.Query().Get("key") + + if key == "" { + http.Error(w, "Missing key param", http.StatusBadRequest) + return + } + if !strings.HasPrefix(key, s3Prefix+"/") && !strings.HasPrefix(key, "docs/") { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + obj, err := s3Client.GetObject(ctx, bucketName, key, s3.GetObjectOptions{}) + if err != nil { + log.Printf("Error getting object %s/%s: %v", bucketName, key, err) + http.Error(w, "File not found", http.StatusNotFound) + return + } + defer obj.Close() + + stat, err := obj.Stat() + if err != nil { + log.Printf("Error stating object %s/%s: %v", bucketName, key, err) + http.Error(w, "File not found or not accessible", http.StatusNotFound) + return + } + + w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size)) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Last-Modified", stat.LastModified.Format(http.TimeFormat)) + + written, err := io.Copy(w, obj) + if err != nil { + log.Printf("Error streaming object: %v (sent %d/%d bytes)", err, written, stat.Size) + } else { + log.Printf("Proxy: sent %d bytes for %s", written, key) + } +} diff --git a/server/router_versions.go b/server/router_versions.go new file mode 100644 index 0000000..29b6a33 --- /dev/null +++ b/server/router_versions.go @@ -0,0 +1,135 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + + s3 "github.com/minio/minio-go/v7" +) + +func router(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/v1/providers/") + parts := strings.Split(path, "/") + + if len(parts) == 3 && parts[2] == "versions" { + listVersions(w, parts[0], parts[1]) + return + } + + if len(parts) == 6 && parts[3] == "download" { + downloadVersion(w, parts[0], parts[1], parts[2], parts[4], parts[5]) + return + } + + http.Error(w, "Not Found", http.StatusNotFound) +} + +func listVersions(w http.ResponseWriter, namespace, pType string) { + prefix := fmt.Sprintf("%s/%s/%s/", s3Prefix, namespace, pType) + versions := []Version{} + seenVersions := map[string]*Version{} + + objectCh := s3Client.ListObjects(context.Background(), bucketName, s3.ListObjectsOptions{ + Prefix: prefix, + Recursive: true, + }) + + for object := range objectCh { + if object.Err != nil { + continue + } + parts := strings.Split(object.Key, "/") + if len(parts) < 5 { + continue + } + verStr := parts[3] + fileName := parts[4] + + if _, ok := seenVersions[verStr]; !ok { + seenVersions[verStr] = &Version{ + Version: verStr, + Protocols: []string{"5.0"}, + Platforms: []Platform{}, + } + } + + if strings.Contains(fileName, "_darwin_amd64.zip") { + seenVersions[verStr].Platforms = append(seenVersions[verStr].Platforms, Platform{OS: "darwin", Arch: "amd64"}) + } + if strings.Contains(fileName, "_linux_amd64.zip") { + seenVersions[verStr].Platforms = append(seenVersions[verStr].Platforms, Platform{OS: "linux", Arch: "amd64"}) + } + if strings.Contains(fileName, "_windows_amd64.zip") { + seenVersions[verStr].Platforms = append(seenVersions[verStr].Platforms, Platform{OS: "windows", Arch: "amd64"}) + } + } + + for _, v := range seenVersions { + versions = append(versions, *v) + } + + sort.Slice(versions, func(i, j int) bool { + return versions[i].Version < versions[j].Version + }) + + resp := VersionList{ + ID: fmt.Sprintf("%s/%s", namespace, pType), + Versions: versions, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func downloadVersion(w http.ResponseWriter, namespace, pType, version, osType, arch string) { + basePath := fmt.Sprintf("%s/%s/%s/%s", s3Prefix, namespace, pType, version) + filename := fmt.Sprintf("terraform-provider-%s_%s_%s_%s.zip", pType, version, osType, arch) + fullKey := fmt.Sprintf("%s/%s", basePath, filename) + shasumsKey := fmt.Sprintf("%s/terraform-provider-%s_%s_SHA256SUMS", basePath, pType, version) + sigKey := fmt.Sprintf("%s/terraform-provider-%s_%s_SHA256SUMS.sig", basePath, pType, version) + + var shasumValue string + shasumsObj, err := s3Client.GetObject(context.Background(), bucketName, shasumsKey, s3.GetObjectOptions{}) + if err == nil { + defer shasumsObj.Close() + scanner := bufio.NewScanner(shasumsObj) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, filename) { + fields := strings.Fields(line) + if len(fields) >= 1 { + shasumValue = fields[0] + } + break + } + } + } + + baseURL := "https://" + hostname + downloadLink := fmt.Sprintf("%s/v1/proxy?bucket=%s&key=%s", baseURL, bucketName, url.QueryEscape(fullKey)) + shasumsLink := fmt.Sprintf("%s/v1/proxy?bucket=%s&key=%s", baseURL, bucketName, url.QueryEscape(shasumsKey)) + sigLink := fmt.Sprintf("%s/v1/proxy?bucket=%s&key=%s", baseURL, bucketName, url.QueryEscape(sigKey)) + + resp := DownloadResponse{ + Protocols: []string{"5.0"}, + OS: osType, + Arch: arch, + Filename: filename, + DownloadURL: downloadLink, + ShasumsURL: shasumsLink, + ShasumsSignatureURL: sigLink, + Shasum: shasumValue, + SigningKeys: SigningKeys{ + GPGPublicKeys: []GPGPublicKey{gpgPrimaryKey, gpgLegacyKey}, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} diff --git a/server/templates/index.html b/server/templates/index.html new file mode 100644 index 0000000..e159592 --- /dev/null +++ b/server/templates/index.html @@ -0,0 +1,99 @@ + + + + + +Terraform Registry · Nubes + + + +
+ nubes +
+
+
+
Terraform Provider Registry
+
+
+ Статус + ONLINE +
+
+ Версия + v{{.Version}} +
+
+
+
+ + +