Last active
December 4, 2025 19:14
-
-
Save maelvls/9dac51852e5208f6d8b16fd02b1e83fb to your computer and use it in GitHub Desktop.
A fake acme server that doesn't ask you to solve challenges
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package main | |
| import ( | |
| "crypto/ecdsa" | |
| "crypto/elliptic" | |
| "crypto/rand" | |
| "crypto/x509" | |
| "crypto/x509/pkix" | |
| "encoding/base64" | |
| "encoding/json" | |
| "flag" | |
| "fmt" | |
| "log" | |
| "math/big" | |
| "net/http" | |
| "strings" | |
| "sync" | |
| "time" | |
| ) | |
| // -------------------------------------------------------------------- | |
| // Types | |
| // -------------------------------------------------------------------- | |
| type Order struct { | |
| ID string `json:"id"` | |
| Status string `json:"status"` | |
| Authorizations []string `json:"authorizations"` | |
| Finalize string `json:"finalize"` | |
| Certificate string `json:"certificate,omitempty"` // URL | |
| cert string // internal only | |
| } | |
| type Account struct { | |
| ID string `json:"id"` | |
| Status string `json:"status"` | |
| Contact []string `json:"contact,omitempty"` | |
| Orders string `json:"orders,omitempty"` | |
| } | |
| // Minimal request shape for new-account (we ignore most fields) | |
| type newAccountRequest struct { | |
| Contact []string `json:"contact"` | |
| TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"` | |
| } | |
| // -------------------------------------------------------------------- | |
| // Globals (super simple, single-order / single-authz server) | |
| // -------------------------------------------------------------------- | |
| var ( | |
| orderID = "1234" | |
| authzID = "5678" | |
| certID = "abcd" | |
| accountID = "acct1" | |
| mu sync.Mutex | |
| orders = map[string]*Order{} | |
| accounts = map[string]*Account{} | |
| certs = map[string]string{} // certID -> PEM string | |
| baseURL string | |
| caCert *x509.Certificate | |
| caKey *ecdsa.PrivateKey | |
| ) | |
| // -------------------------------------------------------------------- | |
| // main | |
| // -------------------------------------------------------------------- | |
| func main() { | |
| port := flag.String("port", "4000", "Port to listen on") | |
| baseurlFlag := flag.String("baseurl", "", "Base URL for ACME server (e.g. http://localhost:4000). If not set, defaults to http://localhost:<port>") | |
| flag.Parse() | |
| if *baseurlFlag != "" { | |
| baseURL = *baseurlFlag | |
| } else { | |
| baseURL = fmt.Sprintf("http://localhost:%s", *port) | |
| } | |
| var err error | |
| caCert, caKey, err = selfSignedCA() | |
| if err != nil { | |
| log.Fatalf("Error creating self-signed CA: %v", err) | |
| } | |
| http.HandleFunc("/directory", handleDirectory) | |
| http.HandleFunc("/acme/new-nonce", handleNewNonce) | |
| http.HandleFunc("/acme/new-account", handleNewAccount) | |
| http.HandleFunc("/acme/new-order", handleNewOrder) | |
| http.HandleFunc("/acme/authz/", handleAuthz) | |
| http.HandleFunc("/acme/order/", handleOrder) | |
| http.HandleFunc("/acme/cert/", handleCert) | |
| addr := ":" + *port | |
| log.Printf("Fake ACME server listening on %s\n", addr) | |
| log.Fatal(http.ListenAndServe(addr, nil)) | |
| } | |
| // -------------------------------------------------------------------- | |
| // /directory (ACME directory) | |
| // -------------------------------------------------------------------- | |
| // | |
| // GET /directory | |
| // | |
| // Returns the URLs for newNonce, newAccount, newOrder. | |
| func handleDirectory(w http.ResponseWriter, r *http.Request) { | |
| if r.Method != http.MethodGet { | |
| http.Error(w, "GET only", http.StatusMethodNotAllowed) | |
| return | |
| } | |
| resp := map[string]any{ | |
| "newNonce": baseURL + "/acme/new-nonce", | |
| "newAccount": baseURL + "/acme/new-account", | |
| "newOrder": baseURL + "/acme/new-order", | |
| "meta": map[string]any{ | |
| "termsOfService": baseURL + "/terms", | |
| }, | |
| } | |
| _ = json.NewEncoder(w).Encode(resp) | |
| } | |
| // -------------------------------------------------------------------- | |
| // /acme/new-nonce (Replay-Nonce endpoint) | |
| // -------------------------------------------------------------------- | |
| // | |
| // HEAD /acme/new-nonce | |
| // | |
| // Returns a Replay-Nonce header. We don't actually validate it. | |
| func handleNewNonce(w http.ResponseWriter, r *http.Request) { | |
| if r.Method != http.MethodHead && r.Method != http.MethodGet { | |
| http.Error(w, "HEAD or GET only", http.StatusMethodNotAllowed) | |
| return | |
| } | |
| nonce := randomNonce() | |
| w.Header().Set("Replay-Nonce", nonce) | |
| w.WriteHeader(http.StatusOK) | |
| } | |
| // -------------------------------------------------------------------- | |
| // /acme/new-account (Create fake account) | |
| // -------------------------------------------------------------------- | |
| // | |
| // POST /acme/new-account | |
| // | |
| // Body: plain JSON, not JWS. In real ACME it's a JWS-wrapped payload. | |
| func handleNewAccount(w http.ResponseWriter, r *http.Request) { | |
| if r.Method != http.MethodPost { | |
| http.Error(w, "POST only", http.StatusMethodNotAllowed) | |
| return | |
| } | |
| var req newAccountRequest | |
| _ = json.NewDecoder(r.Body).Decode(&req) | |
| mu.Lock() | |
| defer mu.Unlock() | |
| // Single account, overwrite if called again. | |
| acct := &Account{ | |
| ID: accountID, | |
| Status: "valid", | |
| Contact: req.Contact, | |
| Orders: baseURL + "/acme/order/" + orderID, | |
| } | |
| accounts[accountID] = acct | |
| w.Header().Set("Location", baseURL+"/acme/account/"+accountID) | |
| w.WriteHeader(http.StatusCreated) | |
| _ = json.NewEncoder(w).Encode(acct) | |
| } | |
| // -------------------------------------------------------------------- | |
| // /acme/new-order (Create fake order) | |
| // -------------------------------------------------------------------- | |
| // | |
| // POST /acme/new-order | |
| // | |
| // Ignores body, always returns a single pending order with one authz. | |
| func handleNewOrder(w http.ResponseWriter, r *http.Request) { | |
| if r.Method != http.MethodPost { | |
| http.Error(w, "POST only", http.StatusMethodNotAllowed) | |
| return | |
| } | |
| mu.Lock() | |
| defer mu.Unlock() | |
| o := &Order{ | |
| ID: orderID, | |
| Status: "pending", | |
| Authorizations: []string{ | |
| baseURL + "/acme/authz/" + authzID, | |
| }, | |
| Finalize: baseURL + "/acme/order/" + orderID + "/finalize", | |
| } | |
| orders[orderID] = o | |
| w.Header().Set("Location", baseURL+"/acme/order/"+orderID) | |
| w.WriteHeader(http.StatusCreated) | |
| _ = json.NewEncoder(w).Encode(o) | |
| } | |
| // -------------------------------------------------------------------- | |
| // /acme/authz/<id> (Authorization always valid, no challenges) | |
| // -------------------------------------------------------------------- | |
| // | |
| // GET /acme/authz/5678 | |
| // | |
| // Returns Example 1: status=valid, challenges: []. | |
| func handleAuthz(w http.ResponseWriter, r *http.Request) { | |
| if r.Method != http.MethodGet && r.Method != http.MethodPost { | |
| http.Error(w, "GET or POST only", http.StatusMethodNotAllowed) | |
| return | |
| } | |
| id := r.URL.Path[len("/acme/authz/"):] | |
| if id != authzID { | |
| http.NotFound(w, r) | |
| return | |
| } | |
| resp := map[string]any{ | |
| "identifier": map[string]string{ | |
| "type": "dns", | |
| "value": "example.com", | |
| }, | |
| "status": "valid", | |
| "expires": "2030-01-01T00:00:00Z", | |
| "challenges": []any{}, | |
| } | |
| _ = json.NewEncoder(w).Encode(resp) | |
| } | |
| // -------------------------------------------------------------------- | |
| // /acme/order/<id>[/finalize] | |
| // -------------------------------------------------------------------- | |
| // | |
| // GET /acme/order/1234 -> returns the order, already valid on purpose | |
| // POST /acme/order/1234/finalize -> sets certificate URL | |
| func handleOrder(w http.ResponseWriter, r *http.Request) { | |
| path := r.URL.Path[len("/acme/order/"):] | |
| if path == "" { | |
| http.NotFound(w, r) | |
| return | |
| } | |
| // finalize endpoint | |
| if path == orderID+"/finalize" { | |
| if r.Method != http.MethodPost { | |
| http.Error(w, "POST only", http.StatusMethodNotAllowed) | |
| return | |
| } | |
| mu.Lock() | |
| defer mu.Unlock() | |
| o := orders[orderID] | |
| if o == nil { | |
| w.WriteHeader(http.StatusNotFound) | |
| w.Write([]byte("finalize: order " + orderID + " not found")) | |
| return | |
| } | |
| var hdr struct { | |
| Payload string `json:"payload"` | |
| } | |
| _ = json.NewDecoder(r.Body).Decode(&hdr) | |
| if hdr.Payload == "" { | |
| http.Error(w, "finalize: missing protected header", http.StatusBadRequest) | |
| return | |
| } | |
| payload, err := base64.RawURLEncoding.DecodeString(hdr.Payload) | |
| if err != nil { | |
| http.Error(w, "finalize: invalid base64 payload: "+err.Error(), http.StatusBadRequest) | |
| return | |
| } | |
| var body struct { | |
| Csr string `json:"csr"` | |
| } | |
| _ = json.NewDecoder(strings.NewReader(string(payload))).Decode(&body) | |
| if body.Csr == "" { | |
| http.Error(w, "finalize: missing csr in payload", http.StatusBadRequest) | |
| return | |
| } | |
| decoded, err := base64.RawURLEncoding.DecodeString(body.Csr) | |
| if err != nil { | |
| http.Error(w, "finalize: invalid base64 csr: "+err.Error(), http.StatusBadRequest) | |
| return | |
| } | |
| csr, err := x509.ParseCertificateRequest(decoded) | |
| if err != nil { | |
| http.Error(w, "finalize: invalid csr: "+err.Error(), http.StatusBadRequest) | |
| return | |
| } | |
| // Sign using the caCert and caKey. | |
| template := &x509.Certificate{ | |
| SerialNumber: big.NewInt(1), | |
| Subject: csr.Subject, | |
| Issuer: caCert.Subject, | |
| SignatureAlgorithm: x509.ECDSAWithSHA256, | |
| NotBefore: time.Now(), | |
| NotAfter: time.Now().Add(365 * 24 * time.Hour), | |
| KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, | |
| ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |
| BasicConstraintsValid: true, | |
| DNSNames: csr.DNSNames, | |
| } | |
| certDER, err := x509.CreateCertificate(rand.Reader, template, caCert, csr.PublicKey, caKey) | |
| if err != nil { | |
| http.Error(w, fmt.Sprintf("error creating self-signed certificate: %v", err), http.StatusInternalServerError) | |
| return | |
| } | |
| certs[certID] = fmt.Sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n", | |
| base64.StdEncoding.EncodeToString(certDER)) | |
| o.Status = "valid" | |
| o.Certificate = baseURL + "/acme/cert/" + certID | |
| _ = json.NewEncoder(w).Encode(o) | |
| return | |
| } | |
| // GET /acme/order/1234 | |
| if path == orderID { | |
| if r.Method != http.MethodGet && r.Method != http.MethodPost { | |
| http.Error(w, "GET or POST only", http.StatusMethodNotAllowed) | |
| return | |
| } | |
| mu.Lock() | |
| defer mu.Unlock() | |
| o := orders[orderID] | |
| if o == nil { | |
| w.WriteHeader(http.StatusNotFound) | |
| w.Write([]byte("order " + orderID + " not found")) | |
| return | |
| } | |
| // No challenges, let's pretend the CA has some out-of-band way to | |
| // authorize the domain. | |
| if o.Status == "pending" { | |
| o.Status = "ready" | |
| } | |
| _ = json.NewEncoder(w).Encode(o) | |
| return | |
| } | |
| http.NotFound(w, r) | |
| } | |
| // -------------------------------------------------------------------- | |
| // /acme/cert/<id> (Return fake PEM certificate) | |
| // -------------------------------------------------------------------- | |
| func handleCert(w http.ResponseWriter, r *http.Request) { | |
| if r.Method != http.MethodGet && r.Method != http.MethodPost { | |
| http.Error(w, "GET and POST-as-GET only", http.StatusMethodNotAllowed) | |
| return | |
| } | |
| id := r.URL.Path[len("/acme/cert/"):] | |
| if id != certID { | |
| http.NotFound(w, r) | |
| return | |
| } | |
| w.Header().Set("Content-Type", "application/pem-certificate-chain") | |
| mu.Lock() | |
| defer mu.Unlock() | |
| c := certs[id] | |
| if c == "" { | |
| w.WriteHeader(http.StatusNotFound) | |
| w.Write([]byte("cert " + id + " not found")) | |
| return | |
| } | |
| fmt.Fprint(w, c) | |
| } | |
| // -------------------------------------------------------------------- | |
| // Helpers | |
| // -------------------------------------------------------------------- | |
| func randomNonce() string { | |
| const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" | |
| b := make([]byte, 24) | |
| for i := range b { | |
| n, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) | |
| if err != nil { | |
| panic(err) | |
| } | |
| b[i] = letters[n.Int64()] | |
| } | |
| return string(b) | |
| } | |
| func selfSignedCA() (*x509.Certificate, *ecdsa.PrivateKey, error) { | |
| template := &x509.Certificate{ | |
| SerialNumber: big.NewInt(1), | |
| Subject: pkix.Name{CommonName: "Fake ACME CA"}, | |
| NotBefore: time.Now(), | |
| NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), | |
| KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, | |
| BasicConstraintsValid: true, | |
| IsCA: true, | |
| MaxPathLen: 0, | |
| } | |
| priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| if err != nil { | |
| return nil, nil, fmt.Errorf("error generating private key: %v", err) | |
| } | |
| certDER, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) | |
| if err != nil { | |
| return nil, nil, fmt.Errorf("error creating self-signed certificate: %v", err) | |
| } | |
| cert, err := x509.ParseCertificate(certDER) | |
| if err != nil { | |
| return nil, nil, fmt.Errorf("error parsing self-signed certificate: %v", err) | |
| } | |
| return cert, priv, nil | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
To test this out:
clone the cert-manager project and run: