3 Commits

17 changed files with 6502 additions and 1833 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,2 @@
tesseract-genconf tesseract-genconf
*roots.pem roots.pem

View File

@@ -36,23 +36,23 @@ logs:
3. **Generate private keys:** 3. **Generate private keys:**
```bash ```bash
mkdir -p /etc/tesseract/keys mkdir -p /etc/tesseract/keys
./tesseract-genconf -c config.yaml --write gen-key ./tesseract-genconf -c config.yaml gen-key
``` ```
4. **Create directories and generate environment files:** 4. **Create directories and generate environment files:**
```bash ```bash
mkdir -p /var/lib/tesseract/example2025h1/data mkdir -p /var/lib/tesseract/example2025h1/data
./tesseract-genconf -c config.yaml --write gen-env ./tesseract-genconf -c config.yaml gen-env
``` ```
5. **Generate HTML and JSON files:** 5. **Generate HTML and JSON files:**
```bash ```bash
./tesseract-genconf -c config.yaml --write gen-html ./tesseract-genconf -c config.yaml gen-html
``` ```
6. **Generate nginx configuration files:** 6. **Generate nginx configuration files:**
```bash ```bash
./tesseract-genconf -c config.yaml --write gen-nginx ./tesseract-genconf -c config.yaml gen-nginx
``` ```
The port from the main `listen:` field will be used in the NGINX server blocks (in our case The port from the main `listen:` field will be used in the NGINX server blocks (in our case
@@ -66,25 +66,3 @@ The port from the main `listen:` field will be used in the NGINX server blocks (
# For production environment, take the ccadb 'production' roots # For production environment, take the ccadb 'production' roots
./tesseract-genconf gen-roots --source https://gouda2027h2.log.ct.ipng.ch/ --output roots-production.pem ./tesseract-genconf gen-roots --source https://gouda2027h2.log.ct.ipng.ch/ --output roots-production.pem
``` ```
### Safe File Operations with `--diff` and `--write`
The `tesseract-genconf` tool includes safety features to prevent accidental file modifications:
- **`--diff`**: Shows colored unified diffs of what would change without writing files
- **`--write`**: Required flag to actually write files to disk
- **`--no-color`**: Disables colored diff output (useful for redirecting to files)
**Recommended workflow:**
```bash
# 1. First, preview changes with --diff
./tesseract-genconf -c config.yaml --diff gen-html
# 2. Review the colored diff output, then apply changes
./tesseract-genconf -c config.yaml --write gen-html
# 3. Or combine both to see diffs and write files
./tesseract-genconf -c config.yaml --diff --write gen-html
```
**Note:** Flags must come before the command name (e.g., `--diff gen-html`, not `gen-html --diff`).

5
go.mod
View File

@@ -3,8 +3,3 @@ module cheese
go 1.24.4 go 1.24.4
require gopkg.in/yaml.v3 v3.0.1 require gopkg.in/yaml.v3 v3.0.1
require (
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
)

4
go.sum
View File

@@ -1,7 +1,3 @@
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

6380
production-roots.pem Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ import (
"strings" "strings"
) )
func generateEnv(yamlFile string, wantDiff bool, allowWrite bool, useColor bool) { func generateEnv(yamlFile string) {
config := loadConfig(yamlFile) config := loadConfig(yamlFile)
// Check that all local directories exist // Check that all local directories exist
@@ -24,7 +24,7 @@ func generateEnv(yamlFile string, wantDiff bool, allowWrite bool, useColor bool)
// Create combined roots.pem file // Create combined roots.pem file
rootsPemPath := filepath.Join(logEntry.LocalDirectory, "roots.pem") rootsPemPath := filepath.Join(logEntry.LocalDirectory, "roots.pem")
err := createCombinedRootsPemWithStatus(config.Roots, logEntry.ExtraRoots, rootsPemPath, wantDiff, allowWrite, useColor) err := createCombinedRootsPemWithStatus(config.Roots, logEntry.ExtraRoots, rootsPemPath)
if err != nil { if err != nil {
log.Fatalf("Failed to create %s: %v", rootsPemPath, err) log.Fatalf("Failed to create %s: %v", rootsPemPath, err)
} }
@@ -32,13 +32,9 @@ func generateEnv(yamlFile string, wantDiff bool, allowWrite bool, useColor bool)
// Build TESSERACT_ARGS string // Build TESSERACT_ARGS string
args := []string{ args := []string{
fmt.Sprintf("--private_key=%s", logEntry.Secret), fmt.Sprintf("--private_key=%s", logEntry.Secret),
fmt.Sprintf("--origin=%s", logEntry.Origin), fmt.Sprintf("--origin=%s.%s", logEntry.ShortName, logEntry.Domain),
fmt.Sprintf("--storage_dir=%s", logEntry.LocalDirectory), fmt.Sprintf("--storage_dir=%s", logEntry.LocalDirectory),
fmt.Sprintf("--roots_pem_file=%s", rootsPemPath), fmt.Sprintf("--roots_pem_file=%s", rootsPemPath),
"--batch_max_size=512",
"--batch_max_age=500ms",
"--pushback_max_outstanding=4096",
"--pushback_max_dedupe_in_flight=250",
} }
// Add http_endpoint if Listen is specified // Add http_endpoint if Listen is specified
@@ -57,14 +53,14 @@ func generateEnv(yamlFile string, wantDiff bool, allowWrite bool, useColor bool)
tesseractArgs := strings.Join(args, " ") tesseractArgs := strings.Join(args, " ")
envContent := fmt.Sprintf("TESSERACT_ARGS=\"%s\"\nOTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318\n", tesseractArgs) envContent := fmt.Sprintf("TESSERACT_ARGS=\"%s\"\nOTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318\n", tesseractArgs)
err = writeFileWithStatus(envPath, []byte(envContent), wantDiff, allowWrite, useColor) err = writeFileWithStatus(envPath, []byte(envContent))
if err != nil { if err != nil {
log.Fatalf("Failed to write %s: %v", envPath, err) log.Fatalf("Failed to write %s: %v", envPath, err)
} }
} }
} }
func createCombinedRootsPemWithStatus(rootsFile, extraRootsFile, outputPath string, wantDiff bool, allowWrite bool, useColor bool) error { func createCombinedRootsPemWithStatus(rootsFile, extraRootsFile, outputPath string) error {
// Read main roots file // Read main roots file
var combinedContent []byte var combinedContent []byte
if rootsFile != "" { if rootsFile != "" {
@@ -84,5 +80,5 @@ func createCombinedRootsPemWithStatus(rootsFile, extraRootsFile, outputPath stri
combinedContent = append(combinedContent, extraRootsData...) combinedContent = append(combinedContent, extraRootsData...)
} }
return writeFileWithStatus(outputPath, combinedContent, wantDiff, allowWrite, useColor) return writeFileWithStatus(outputPath, combinedContent)
} }

View File

@@ -1,219 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestGenerateEnv(t *testing.T) {
tmpDir := t.TempDir()
// Create test directories
testLogDir := filepath.Join(tmpDir, "test-log")
testLog2Dir := filepath.Join(tmpDir, "test-log-2")
err := os.MkdirAll(testLogDir, 0755)
if err != nil {
t.Fatal(err)
}
err = os.MkdirAll(testLog2Dir, 0755)
if err != nil {
t.Fatal(err)
}
// Create test roots files
rootsFile := filepath.Join(tmpDir, "roots.pem")
extraRootsFile := filepath.Join(tmpDir, "extra-roots.pem")
rootsContent := `-----BEGIN CERTIFICATE-----
MIIBkTCB+wIJAKZJ...
-----END CERTIFICATE-----`
extraRootsContent := `-----BEGIN CERTIFICATE-----
MIIBkTCB+wIJAKZK...
-----END CERTIFICATE-----`
err = os.WriteFile(rootsFile, []byte(rootsContent), 0644)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(extraRootsFile, []byte(extraRootsContent), 0644)
if err != nil {
t.Fatal(err)
}
// Create test config
configContent := `listen:
- ":8080"
roots: "` + rootsFile + `"
logs:
- shortname: "test-log"
submissionprefix: "https://example.com/submit"
monitoringprefix: "https://example.com/monitor"
extraroots: "` + extraRootsFile + `"
secret: "test-data/test-log.key"
localdirectory: "` + testLogDir + `"
listen: ":8081"
notafterstart: "2024-01-01T00:00:00Z"
notafterlimit: "2025-01-01T00:00:00Z"
- shortname: "test-log-2"
submissionprefix: "https://log2.example.com/submit"
monitoringprefix: "https://log2.example.com/monitor"
secret: "test-data/test-log-2.key"
localdirectory: "` + testLog2Dir + `"`
configFile := filepath.Join(tmpDir, "test-config.yaml")
err = os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatal(err)
}
// Run generateEnv
generateEnv(configFile, false, true, false)
// Verify .env file was created for first log
envFile := filepath.Join(testLogDir, ".env")
envContent, err := os.ReadFile(envFile)
if err != nil {
t.Fatalf("Failed to read .env file: %v", err)
}
envStr := string(envContent)
// Check TESSERACT_ARGS contains expected values
if !strings.Contains(envStr, "TESSERACT_ARGS=") {
t.Error("Expected TESSERACT_ARGS in .env file")
}
if !strings.Contains(envStr, "--private_key=test-data/test-log.key") {
t.Error("Expected private_key argument in TESSERACT_ARGS")
}
if !strings.Contains(envStr, "--origin=example.com") {
t.Error("Expected origin argument in TESSERACT_ARGS")
}
if !strings.Contains(envStr, "--storage_dir="+testLogDir) {
t.Error("Expected storage_dir argument in TESSERACT_ARGS")
}
if !strings.Contains(envStr, "--http_endpoint=:8081") {
t.Error("Expected http_endpoint argument in TESSERACT_ARGS")
}
if !strings.Contains(envStr, "--not_after_start=2024-01-01T00:00:00Z") {
t.Error("Expected not_after_start argument in TESSERACT_ARGS")
}
if !strings.Contains(envStr, "--not_after_limit=2025-01-01T00:00:00Z") {
t.Error("Expected not_after_limit argument in TESSERACT_ARGS")
}
if !strings.Contains(envStr, "OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318") {
t.Error("Expected OTEL_EXPORTER_OTLP_ENDPOINT in .env file")
}
// Verify combined roots.pem was created
combinedRootsFile := filepath.Join(testLogDir, "roots.pem")
combinedContent, err := os.ReadFile(combinedRootsFile)
if err != nil {
t.Fatalf("Failed to read combined roots.pem: %v", err)
}
combinedStr := string(combinedContent)
if !strings.Contains(combinedStr, "MIIBkTCB+wIJAKZJ") {
t.Error("Expected content from main roots file")
}
if !strings.Contains(combinedStr, "MIIBkTCB+wIJAKZK") {
t.Error("Expected content from extra roots file")
}
// Verify second log's .env file was created
env2File := filepath.Join(testLog2Dir, ".env")
env2Content, err := os.ReadFile(env2File)
if err != nil {
t.Fatalf("Failed to read second .env file: %v", err)
}
env2Str := string(env2Content)
if !strings.Contains(env2Str, "--origin=log2.example.com") {
t.Error("Expected correct origin for second log")
}
if strings.Contains(env2Str, "--http_endpoint=") {
t.Error("Second log should not have http_endpoint (not specified)")
}
}
func TestCreateCombinedRootsPemWithStatus(t *testing.T) {
tmpDir := t.TempDir()
// Test case 1: Both files exist
rootsFile := filepath.Join(tmpDir, "roots.pem")
extraFile := filepath.Join(tmpDir, "extra.pem")
outputFile := filepath.Join(tmpDir, "combined.pem")
rootsContent := "ROOT CERT CONTENT\n"
extraContent := "EXTRA CERT CONTENT\n"
err := os.WriteFile(rootsFile, []byte(rootsContent), 0644)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(extraFile, []byte(extraContent), 0644)
if err != nil {
t.Fatal(err)
}
err = createCombinedRootsPemWithStatus(rootsFile, extraFile, outputFile, false, true, false)
if err != nil {
t.Errorf("createCombinedRootsPemWithStatus() error = %v", err)
}
combinedContent, err := os.ReadFile(outputFile)
if err != nil {
t.Fatal(err)
}
expected := rootsContent + extraContent
if string(combinedContent) != expected {
t.Errorf("Combined content = %s, want %s", string(combinedContent), expected)
}
// Test case 2: Only roots file exists
outputFile2 := filepath.Join(tmpDir, "combined2.pem")
err = createCombinedRootsPemWithStatus(rootsFile, "", outputFile2, false, true, false)
if err != nil {
t.Errorf("createCombinedRootsPemWithStatus() with empty extra file error = %v", err)
}
combinedContent2, err := os.ReadFile(outputFile2)
if err != nil {
t.Fatal(err)
}
if string(combinedContent2) != rootsContent {
t.Errorf("Combined content = %s, want %s", string(combinedContent2), rootsContent)
}
// Test case 3: Extra file doesn't exist (should error)
outputFile3 := filepath.Join(tmpDir, "combined3.pem")
err = createCombinedRootsPemWithStatus(rootsFile, "nonexistent.pem", outputFile3, false, true, false)
if err == nil {
t.Error("Expected error when extra file doesn't exist")
}
}
func TestGenerateEnvMissingDirectory(t *testing.T) {
tmpDir := t.TempDir()
// Create config with non-existent directory
configContent := `logs:
- shortname: "test-log"
submissionprefix: "https://example.com/submit"
secret: "test.key"
localdirectory: "/nonexistent/directory"`
configFile := filepath.Join(tmpDir, "test-config.yaml")
err := os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatal(err)
}
// Should call log.Fatalf which exits the program
// We can't easily test this without subprocess, so we'll skip it
t.Skip("Cannot easily test log.Fatalf without subprocess")
}

View File

@@ -74,7 +74,7 @@ const htmlTemplate = `<!DOCTYPE html>
{{range .Logs}} {{range .Logs}}
<h2>{{.Origin}}</h2> <h2>{{.ShortName}}.{{.Domain}}</h2>
<p> <p>
Log ID: <code>{{.LogID}}</code><br> Log ID: <code>{{.LogID}}</code><br>
@@ -95,7 +95,6 @@ const htmlTemplate = `<!DOCTYPE html>
</html> </html>
` `
// LogV3JSON represents the Certificate Transparency log v3 JSON metadata format
type LogV3JSON struct { type LogV3JSON struct {
Description string `json:"description"` Description string `json:"description"`
SubmissionURL string `json:"submission_url"` SubmissionURL string `json:"submission_url"`
@@ -106,13 +105,12 @@ type LogV3JSON struct {
MMD int `json:"mmd"` MMD int `json:"mmd"`
} }
// TemporalInterval represents the time range for a Certificate Transparency log
type TemporalInterval struct { type TemporalInterval struct {
StartInclusive string `json:"start_inclusive"` StartInclusive string `json:"start_inclusive"`
EndExclusive string `json:"end_exclusive"` EndExclusive string `json:"end_exclusive"`
} }
func generateHTML(yamlFile string, wantDiff bool, allowWrite bool, useColor bool) { func generateHTML(yamlFile string) {
config := loadConfig(yamlFile) config := loadConfig(yamlFile)
// Check that all local directories exist // Check that all local directories exist
@@ -147,14 +145,14 @@ func generateHTML(yamlFile string, wantDiff bool, allowWrite bool, useColor bool
} }
// Write file with status // Write file with status
err = writeFileWithStatus(indexPath, buf.Bytes(), wantDiff, allowWrite, useColor) err = writeFileWithStatus(indexPath, buf.Bytes())
if err != nil { if err != nil {
log.Fatalf("Failed to write HTML to %s: %v", indexPath, err) log.Fatalf("Failed to write HTML to %s: %v", indexPath, err)
} }
// Generate log.v3.json for this log // Generate log.v3.json for this log
jsonPath := filepath.Join(logEntry.LocalDirectory, "log.v3.json") jsonPath := filepath.Join(logEntry.LocalDirectory, "log.v3.json")
err = generateLogJSONWithStatus(logEntry, jsonPath, wantDiff, allowWrite, useColor) err = generateLogJSONWithStatus(logEntry, jsonPath)
if err != nil { if err != nil {
log.Fatalf("Failed to generate %s: %v", jsonPath, err) log.Fatalf("Failed to generate %s: %v", jsonPath, err)
} }
@@ -211,9 +209,9 @@ func computeKeyInfo(logEntry *Log) error {
return nil return nil
} }
func generateLogJSONWithStatus(logEntry Log, outputPath string, wantDiff bool, allowWrite bool, useColor bool) error { func generateLogJSONWithStatus(logEntry Log, outputPath string) error {
logJSON := LogV3JSON{ logJSON := LogV3JSON{
Description: logEntry.Origin, Description: fmt.Sprintf("%s.%s", logEntry.ShortName, logEntry.Domain),
SubmissionURL: fmt.Sprintf("%s/", logEntry.SubmissionPrefix), SubmissionURL: fmt.Sprintf("%s/", logEntry.SubmissionPrefix),
MonitoringURL: fmt.Sprintf("%s/", logEntry.MonitoringPrefix), MonitoringURL: fmt.Sprintf("%s/", logEntry.MonitoringPrefix),
TemporalInterval: TemporalInterval{ TemporalInterval: TemporalInterval{
@@ -230,5 +228,5 @@ func generateLogJSONWithStatus(logEntry Log, outputPath string, wantDiff bool, a
return fmt.Errorf("failed to marshal JSON: %v", err) return fmt.Errorf("failed to marshal JSON: %v", err)
} }
return writeFileWithStatus(outputPath, jsonData, wantDiff, allowWrite, useColor) return writeFileWithStatus(outputPath, jsonData)
} }

View File

@@ -1,316 +0,0 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestGenerateHTML(t *testing.T) {
tmpDir := t.TempDir()
// Create test directories
testLogDir := filepath.Join(tmpDir, "test-log")
err := os.MkdirAll(testLogDir, 0755)
if err != nil {
t.Fatal(err)
}
// Generate test private key
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
privKeyDER, err := x509.MarshalECPrivateKey(privKey)
if err != nil {
t.Fatal(err)
}
privKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "EC PRIVATE KEY",
Bytes: privKeyDER,
})
keyFile := filepath.Join(tmpDir, "test-key.pem")
err = os.WriteFile(keyFile, privKeyPEM, 0600)
if err != nil {
t.Fatal(err)
}
// Create test config
configContent := `listen:
- ":8080"
logs:
- shortname: "test-log"
submissionprefix: "https://example.com/submit"
monitoringprefix: "https://example.com/monitor"
secret: "` + keyFile + `"
localdirectory: "` + testLogDir + `"
notafterstart: "2024-01-01T00:00:00Z"
notafterlimit: "2025-01-01T00:00:00Z"`
configFile := filepath.Join(tmpDir, "test-config.yaml")
err = os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatal(err)
}
// Run generateHTML
generateHTML(configFile, false, true, false)
// Verify HTML file was created
htmlFile := filepath.Join(testLogDir, "index.html")
htmlContent, err := os.ReadFile(htmlFile)
if err != nil {
t.Fatalf("Failed to read HTML file: %v", err)
}
htmlStr := string(htmlContent)
// Check HTML contains expected elements
if !strings.Contains(htmlStr, "<!DOCTYPE html>") {
t.Error("Expected HTML doctype")
}
if !strings.Contains(htmlStr, "TesseraCT") {
t.Error("Expected TesseraCT title")
}
if !strings.Contains(htmlStr, "example.com") {
t.Error("Expected origin hostname in HTML")
}
if !strings.Contains(htmlStr, "https://example.com/submit/") {
t.Error("Expected submission prefix in HTML")
}
if !strings.Contains(htmlStr, "https://example.com/monitor/") {
t.Error("Expected monitoring prefix in HTML")
}
if !strings.Contains(htmlStr, "2024-01-01T00:00:00Z") {
t.Error("Expected start time in HTML")
}
if !strings.Contains(htmlStr, "2025-01-01T00:00:00Z") {
t.Error("Expected end time in HTML")
}
if !strings.Contains(htmlStr, "-----BEGIN PUBLIC KEY-----") {
t.Error("Expected public key in HTML")
}
// Verify JSON file was created
jsonFile := filepath.Join(testLogDir, "log.v3.json")
jsonContent, err := os.ReadFile(jsonFile)
if err != nil {
t.Fatalf("Failed to read JSON file: %v", err)
}
var logJSON LogV3JSON
err = json.Unmarshal(jsonContent, &logJSON)
if err != nil {
t.Fatalf("Failed to parse JSON: %v", err)
}
// Verify JSON content
if logJSON.Description != "example.com" {
t.Errorf("Expected description 'example.com', got %s", logJSON.Description)
}
if logJSON.SubmissionURL != "https://example.com/submit/" {
t.Errorf("Expected submission URL 'https://example.com/submit/', got %s", logJSON.SubmissionURL)
}
if logJSON.MonitoringURL != "https://example.com/monitor/" {
t.Errorf("Expected monitoring URL 'https://example.com/monitor/', got %s", logJSON.MonitoringURL)
}
if logJSON.TemporalInterval.StartInclusive != "2024-01-01T00:00:00Z" {
t.Errorf("Expected start time '2024-01-01T00:00:00Z', got %s", logJSON.TemporalInterval.StartInclusive)
}
if logJSON.TemporalInterval.EndExclusive != "2025-01-01T00:00:00Z" {
t.Errorf("Expected end time '2025-01-01T00:00:00Z', got %s", logJSON.TemporalInterval.EndExclusive)
}
if logJSON.MMD != 60 {
t.Errorf("Expected MMD 60, got %d", logJSON.MMD)
}
if logJSON.LogID == "" {
t.Error("Expected non-empty LogID")
}
if logJSON.Key == "" {
t.Error("Expected non-empty Key")
}
}
func TestComputeKeyInfo(t *testing.T) {
tmpDir := t.TempDir()
// Generate test private key
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
privKeyDER, err := x509.MarshalECPrivateKey(privKey)
if err != nil {
t.Fatal(err)
}
privKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "EC PRIVATE KEY",
Bytes: privKeyDER,
})
keyFile := filepath.Join(tmpDir, "test-key.pem")
err = os.WriteFile(keyFile, privKeyPEM, 0600)
if err != nil {
t.Fatal(err)
}
// Create log entry
logEntry := Log{
ShortName: "test-log",
Secret: keyFile,
}
// Compute key info
err = computeKeyInfo(&logEntry)
if err != nil {
t.Fatalf("computeKeyInfo() error = %v", err)
}
// Verify computed fields
if logEntry.LogID == "" {
t.Error("Expected non-empty LogID")
}
if logEntry.PublicKeyPEM == "" {
t.Error("Expected non-empty PublicKeyPEM")
}
if !strings.Contains(logEntry.PublicKeyPEM, "-----BEGIN PUBLIC KEY-----") {
t.Error("Expected PEM format public key")
}
if !strings.Contains(logEntry.PublicKeyPEM, "-----END PUBLIC KEY-----") {
t.Error("Expected PEM format public key")
}
if logEntry.PublicKeyDERB64 == "" {
t.Error("Expected non-empty PublicKeyDERB64")
}
if logEntry.PublicKeyBase64 == "" {
t.Error("Expected non-empty PublicKeyBase64")
}
if logEntry.PublicKeyDERB64 != logEntry.PublicKeyBase64 {
t.Error("Expected PublicKeyDERB64 and PublicKeyBase64 to be the same")
}
}
func TestComputeKeyInfoInvalidKey(t *testing.T) {
tmpDir := t.TempDir()
// Create invalid key file
invalidKeyFile := filepath.Join(tmpDir, "invalid-key.pem")
invalidKeyContent := `-----BEGIN EC PRIVATE KEY-----
INVALID KEY DATA
-----END EC PRIVATE KEY-----`
err := os.WriteFile(invalidKeyFile, []byte(invalidKeyContent), 0600)
if err != nil {
t.Fatal(err)
}
logEntry := Log{
Secret: invalidKeyFile,
}
err = computeKeyInfo(&logEntry)
if err == nil {
t.Error("Expected error for invalid key file")
}
}
func TestComputeKeyInfoMissingFile(t *testing.T) {
logEntry := Log{
Secret: "/nonexistent/key.pem",
}
err := computeKeyInfo(&logEntry)
if err == nil {
t.Error("Expected error for missing key file")
}
}
func TestGenerateLogJSONWithStatus(t *testing.T) {
tmpDir := t.TempDir()
outputFile := filepath.Join(tmpDir, "test-log.v3.json")
testTime1, _ := time.Parse("2006-01-02T15:04:05Z", "2024-01-01T00:00:00Z")
testTime2, _ := time.Parse("2006-01-02T15:04:05Z", "2025-01-01T00:00:00Z")
logEntry := Log{
Origin: "test.example.com",
SubmissionPrefix: "https://test.example.com/submit",
MonitoringPrefix: "https://test.example.com/monitor",
NotAfterStart: testTime1,
NotAfterLimit: testTime2,
LogID: "dGVzdC1sb2ctaWQ=", // base64 encoded "test-log-id"
PublicKeyBase64: "dGVzdC1wdWJsaWMta2V5", // base64 encoded "test-public-key"
}
err := generateLogJSONWithStatus(logEntry, outputFile, false, true, false)
if err != nil {
t.Fatalf("generateLogJSONWithStatus() error = %v", err)
}
// Verify JSON was created
jsonContent, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read JSON file: %v", err)
}
var logJSON LogV3JSON
err = json.Unmarshal(jsonContent, &logJSON)
if err != nil {
t.Fatalf("Failed to parse JSON: %v", err)
}
// Verify content
if logJSON.Description != "test.example.com" {
t.Errorf("Expected description 'test.example.com', got %s", logJSON.Description)
}
if logJSON.SubmissionURL != "https://test.example.com/submit/" {
t.Errorf("Expected submission URL to have trailing slash")
}
if logJSON.MonitoringURL != "https://test.example.com/monitor/" {
t.Errorf("Expected monitoring URL to have trailing slash")
}
if logJSON.LogID != "dGVzdC1sb2ctaWQ=" {
t.Errorf("Expected LogID 'dGVzdC1sb2ctaWQ=', got %s", logJSON.LogID)
}
if logJSON.Key != "dGVzdC1wdWJsaWMta2V5" {
t.Errorf("Expected Key 'dGVzdC1wdWJsaWMta2V5', got %s", logJSON.Key)
}
if logJSON.MMD != 60 {
t.Errorf("Expected MMD 60, got %d", logJSON.MMD)
}
}
func TestGenerateHTMLMissingDirectory(t *testing.T) {
tmpDir := t.TempDir()
// Create config with non-existent directory
configContent := `logs:
- shortname: "test-log"
submissionprefix: "https://example.com/submit"
monitoringprefix: "https://example.com/monitor"
secret: "test.key"
localdirectory: "/nonexistent/directory"`
configFile := filepath.Join(tmpDir, "test-config.yaml")
err := os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatal(err)
}
// Should call log.Fatalf which exits the program
// We can't easily test this without subprocess, so we'll skip it
t.Skip("Cannot easily test log.Fatalf without subprocess")
}

View File

@@ -12,32 +12,27 @@ import (
"path/filepath" "path/filepath"
) )
func generateKeys(yamlFile string, wantDiff bool, allowWrite bool, useColor bool) { func generateKeys(yamlFile string) {
if !allowWrite {
fmt.Printf("Key generation requires --write flag\n")
return
}
config := loadConfig(yamlFile) config := loadConfig(yamlFile)
// Generate keys for each log // Generate keys for each log
for _, logEntry := range config.Logs { for _, logEntry := range config.Logs {
// Check if key already exists // Check if key already exists
if _, err := os.Stat(logEntry.Secret); err == nil { if _, err := os.Stat(logEntry.Secret); err == nil {
fmt.Printf("Key already exists: %s (skipped)\n", logEntry.Secret) fmt.Printf("Key already exists for log %s: %s (skipped)\n", logEntry.ShortName, logEntry.Secret)
continue continue
} }
// Generate new prime256v1 key // Generate new prime256v1 key
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil { if err != nil {
log.Fatalf("Failed to generate key for %s: %v", logEntry.ShortName, err) log.Fatalf("Failed to generate key for log %s: %v", logEntry.ShortName, err)
} }
// Marshal private key to DER format // Marshal private key to DER format
privKeyDER, err := x509.MarshalECPrivateKey(privKey) privKeyDER, err := x509.MarshalECPrivateKey(privKey)
if err != nil { if err != nil {
log.Fatalf("Failed to marshal private key for %s: %v", logEntry.ShortName, err) log.Fatalf("Failed to marshal private key for log %s: %v", logEntry.ShortName, err)
} }
// Create PEM block // Create PEM block
@@ -48,13 +43,13 @@ func generateKeys(yamlFile string, wantDiff bool, allowWrite bool, useColor bool
// Ensure directory exists // Ensure directory exists
if err := os.MkdirAll(filepath.Dir(logEntry.Secret), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(logEntry.Secret), 0755); err != nil {
log.Fatalf("Failed to create directory for %s: %v", logEntry.Secret, err) log.Fatalf("Failed to create directory for %s for log %s: %v", logEntry.Secret, logEntry.ShortName, err)
} }
// Write key to file // Write key to file
err = os.WriteFile(logEntry.Secret, privKeyPEM, 0600) err = os.WriteFile(logEntry.Secret, privKeyPEM, 0600)
if err != nil { if err != nil {
log.Fatalf("Failed to write key file %s: %v", logEntry.Secret, err) log.Fatalf("Failed to write key file %s for log %s: %v", logEntry.Secret, logEntry.ShortName, err)
} }
fmt.Printf("Generated %s\n", logEntry.Secret) fmt.Printf("Generated %s\n", logEntry.Secret)

View File

@@ -1,296 +0,0 @@
package main
import (
"crypto/elliptic"
"crypto/x509"
"encoding/pem"
"os"
"path/filepath"
"strings"
"testing"
)
func TestGenerateKeys(t *testing.T) {
tmpDir := t.TempDir()
// Create test directories
keyDir := filepath.Join(tmpDir, "keys")
err := os.MkdirAll(keyDir, 0755)
if err != nil {
t.Fatal(err)
}
// Create test config
key1Path := filepath.Join(keyDir, "test-log-1.key")
key2Path := filepath.Join(keyDir, "test-log-2.key")
configContent := `logs:
- shortname: "test-log-1"
secret: "` + key1Path + `"
- shortname: "test-log-2"
secret: "` + key2Path + `"`
configFile := filepath.Join(tmpDir, "test-config.yaml")
err = os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatal(err)
}
// Run generateKeys
generateKeys(configFile, false, true, false)
// Verify first key was created
if _, err := os.Stat(key1Path); os.IsNotExist(err) {
t.Error("Expected first key file to be created")
}
// Verify second key was created
if _, err := os.Stat(key2Path); os.IsNotExist(err) {
t.Error("Expected second key file to be created")
}
// Verify key format
keyContent, err := os.ReadFile(key1Path)
if err != nil {
t.Fatal(err)
}
keyStr := string(keyContent)
if !strings.Contains(keyStr, "-----BEGIN EC PRIVATE KEY-----") {
t.Error("Expected EC private key PEM header")
}
if !strings.Contains(keyStr, "-----END EC PRIVATE KEY-----") {
t.Error("Expected EC private key PEM footer")
}
// Verify key can be parsed
block, _ := pem.Decode(keyContent)
if block == nil {
t.Error("Failed to decode PEM block")
}
privKey, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
t.Errorf("Failed to parse EC private key: %v", err)
}
// Verify it's a P-256 key
if privKey.Curve != elliptic.P256() {
t.Error("Expected P-256 curve")
}
// Verify file permissions
info, err := os.Stat(key1Path)
if err != nil {
t.Fatal(err)
}
perm := info.Mode().Perm()
expected := os.FileMode(0600)
if perm != expected {
t.Errorf("Expected file mode %o, got %o", expected, perm)
}
}
func TestGenerateKeysExistingKey(t *testing.T) {
tmpDir := t.TempDir()
// Create existing key file
keyPath := filepath.Join(tmpDir, "existing.key")
existingContent := `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIExistingKeyContent
-----END EC PRIVATE KEY-----`
err := os.WriteFile(keyPath, []byte(existingContent), 0600)
if err != nil {
t.Fatal(err)
}
// Create test config
configContent := `logs:
- shortname: "test-log"
secret: "` + keyPath + `"`
configFile := filepath.Join(tmpDir, "test-config.yaml")
err = os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatal(err)
}
// Run generateKeys
generateKeys(configFile, false, true, false)
// Verify existing key was not overwritten
keyContent, err := os.ReadFile(keyPath)
if err != nil {
t.Fatal(err)
}
if string(keyContent) != existingContent {
t.Error("Expected existing key to be preserved")
}
}
func TestGenerateKeysWithoutWriteFlag(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "test.key")
configContent := `logs:
- shortname: "test-log"
secret: "` + keyPath + `"`
configFile := filepath.Join(tmpDir, "test-config.yaml")
err := os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatal(err)
}
// Run generateKeys without write flag
generateKeys(configFile, false, false, false)
// Verify key was not created
if _, err := os.Stat(keyPath); !os.IsNotExist(err) {
t.Error("Expected key file to not be created without --write flag")
}
}
func TestGenerateKeysCreateDirectory(t *testing.T) {
tmpDir := t.TempDir()
// Key path with non-existent directory
keyDir := filepath.Join(tmpDir, "subdir", "keys")
keyPath := filepath.Join(keyDir, "test.key")
configContent := `logs:
- shortname: "test-log"
secret: "` + keyPath + `"`
configFile := filepath.Join(tmpDir, "test-config.yaml")
err := os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatal(err)
}
// Run generateKeys
generateKeys(configFile, false, true, false)
// Verify directory was created
if _, err := os.Stat(keyDir); os.IsNotExist(err) {
t.Error("Expected directory to be created")
}
// Verify key was created
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
t.Error("Expected key file to be created")
}
}
func TestGenerateKeysMultipleRuns(t *testing.T) {
tmpDir := t.TempDir()
key1Path := filepath.Join(tmpDir, "key1.key")
key2Path := filepath.Join(tmpDir, "key2.key")
configContent := `logs:
- shortname: "test-log-1"
secret: "` + key1Path + `"
- shortname: "test-log-2"
secret: "` + key2Path + `"`
configFile := filepath.Join(tmpDir, "test-config.yaml")
err := os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatal(err)
}
// First run - should create both keys
generateKeys(configFile, false, true, false)
// Read first key content
key1Content, err := os.ReadFile(key1Path)
if err != nil {
t.Fatal(err)
}
key2Content, err := os.ReadFile(key2Path)
if err != nil {
t.Fatal(err)
}
// Second run - should not overwrite existing keys
generateKeys(configFile, false, true, false)
// Verify keys are unchanged
key1Content2, err := os.ReadFile(key1Path)
if err != nil {
t.Fatal(err)
}
key2Content2, err := os.ReadFile(key2Path)
if err != nil {
t.Fatal(err)
}
if string(key1Content) != string(key1Content2) {
t.Error("First key should not have been overwritten")
}
if string(key2Content) != string(key2Content2) {
t.Error("Second key should not have been overwritten")
}
}
func TestECKeyGeneration(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "test.key")
configContent := `logs:
- shortname: "test-log"
secret: "` + keyPath + `"`
configFile := filepath.Join(tmpDir, "test-config.yaml")
err := os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatal(err)
}
generateKeys(configFile, false, true, false)
// Read and parse the generated key
keyContent, err := os.ReadFile(keyPath)
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode(keyContent)
if block == nil {
t.Fatal("Failed to decode PEM block")
}
if block.Type != "EC PRIVATE KEY" {
t.Errorf("Expected block type 'EC PRIVATE KEY', got %s", block.Type)
}
privKey, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
t.Fatalf("Failed to parse EC private key: %v", err)
}
// Verify public key can be derived
pubKey := &privKey.PublicKey
if pubKey.X == nil || pubKey.Y == nil {
t.Error("Public key coordinates should not be nil")
}
// Verify key is on the correct curve
if !pubKey.Curve.IsOnCurve(pubKey.X, pubKey.Y) {
t.Error("Public key is not on the curve")
}
// Verify we can marshal the public key (for log ID computation)
_, err = x509.MarshalPKIXPublicKey(pubKey)
if err != nil {
t.Errorf("Failed to marshal public key: %v", err)
}
}

View File

@@ -5,16 +5,11 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"strings"
"time" "time"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// Config represents the main YAML configuration structure for tesseract
type Config struct { type Config struct {
Listen []string `yaml:"listen"` Listen []string `yaml:"listen"`
Checkpoints string `yaml:"checkpoints"` Checkpoints string `yaml:"checkpoints"`
@@ -22,9 +17,9 @@ type Config struct {
Logs []Log `yaml:"logs"` Logs []Log `yaml:"logs"`
} }
// Log represents a single Certificate Transparency log configuration
type Log struct { type Log struct {
ShortName string `yaml:"shortname"` ShortName string `yaml:"shortname"`
Domain string `yaml:"domain"`
Inception string `yaml:"inception"` Inception string `yaml:"inception"`
Period int `yaml:"period"` Period int `yaml:"period"`
PoolSize int `yaml:"poolsize"` PoolSize int `yaml:"poolsize"`
@@ -43,14 +38,10 @@ type Log struct {
PublicKeyPEM string PublicKeyPEM string
PublicKeyDERB64 string PublicKeyDERB64 string
PublicKeyBase64 string PublicKeyBase64 string
Origin string
} }
func main() { func main() {
configFile := flag.String("c", "./tesseract-staging.yaml", "Path to the YAML configuration file") configFile := flag.String("c", "./tesseract-staging.yaml", "Path to the YAML configuration file")
wantDiff := flag.Bool("diff", false, "Show unified diff of changes")
allowWrite := flag.Bool("write", false, "Allow writing files (required for actual file modifications)")
noColor := flag.Bool("no-color", false, "Disable colored diff output")
flag.Parse() flag.Parse()
args := flag.Args() args := flag.Args()
@@ -61,15 +52,15 @@ func main() {
switch args[0] { switch args[0] {
case "gen-html": case "gen-html":
generateHTML(*configFile, *wantDiff, *allowWrite, !*noColor) generateHTML(*configFile)
case "gen-env": case "gen-env":
generateEnv(*configFile, *wantDiff, *allowWrite, !*noColor) generateEnv(*configFile)
case "gen-key": case "gen-key":
generateKeys(*configFile, *wantDiff, *allowWrite, !*noColor) generateKeys(*configFile)
case "gen-nginx": case "gen-nginx":
generateNginx(*configFile, *wantDiff, *allowWrite, !*noColor) generateNginx(*configFile)
case "gen-roots": case "gen-roots":
generateRoots(args[1:], *wantDiff, *allowWrite, !*noColor) generateRoots(args[1:])
default: default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", args[0]) fmt.Fprintf(os.Stderr, "Unknown command: %s\n", args[0])
showHelp() showHelp()
@@ -94,116 +85,91 @@ func loadConfig(yamlFile string) Config {
config.Listen = []string{":8080"} config.Listen = []string{":8080"}
} }
// Set defaults for log entries // Checkpoints & Roots are not used in-code, not checking for being set/valid
for i := range config.Logs {
if config.Logs[i].PoolSize == 0 { // Ensure there are logs configured
config.Logs[i].PoolSize = 750 if len(config.Logs) == 0 {
log.Fatalf("Parsed YAML did not include any 'logs'")
} }
// Set defaults for log entries and check for empty/missing values
for i := range config.Logs {
// Checks are in order of fields of the Log struct
if config.Logs[i].ShortName == "" {
log.Fatalf("Log %d is missing a ShortName", i)
}
if config.Logs[i].Domain == "" {
log.Fatalf("Log %d (%s) is missing a value for Domain", i, config.Logs[i].ShortName)
}
// Inception is not used in-code
if config.Logs[i].Period == 0 { if config.Logs[i].Period == 0 {
config.Logs[i].Period = 200 config.Logs[i].Period = 200
} }
// Extract hostname from SubmissionPrefix to set Origin if config.Logs[i].PoolSize == 0 {
if config.Logs[i].SubmissionPrefix != "" { config.Logs[i].PoolSize = 750
hostname, err := extractHostname(config.Logs[i].SubmissionPrefix)
if err != nil {
log.Fatalf("Failed to parse SubmissionPrefix URL for %s: %v", config.Logs[i].ShortName, err)
} }
config.Logs[i].Origin = hostname
if config.Logs[i].SubmissionPrefix == "" {
log.Fatalf("Log %d (%s) is missing a value for SubmissionPrefix", i, config.Logs[i].ShortName)
}
if config.Logs[i].MonitoringPrefix == "" {
log.Fatalf("Log %d (%s) is missing a value for MonitoringPrefix", i, config.Logs[i].ShortName)
}
// CCadbRoots is not used in-code
// ExtraRoots is optional
if config.Logs[i].Secret == "" {
log.Fatalf("Log %d (%s) is missing a value for Secret", i, config.Logs[i].ShortName)
}
// Cache is not used in-code
if config.Logs[i].LocalDirectory == "" {
log.Fatalf("Log %d (%s) is missing a value for LocalDirectory", i, config.Logs[i].ShortName)
}
// Listen, NotAfterStart and NotAfterLimit are optional
// These fields are exported due to HTML templates
// but should not be provided/filled by the user
if config.Logs[i].LogID != "" {
log.Fatalf("Log %d (%s) has field LogID should not be configured (%s)", i, config.Logs[i].ShortName, config.Logs[i].LogID)
}
if config.Logs[i].PublicKeyPEM != "" {
log.Fatalf("Log %d (%s) has field PublicKeyPEM should not be configured (%s)", i, config.Logs[i].ShortName, config.Logs[i].PublicKeyPEM)
}
if config.Logs[i].PublicKeyDERB64 != "" {
log.Fatalf("Log %d (%s) has field PublicKeyDERB64 should not be configured (%s)", i, config.Logs[i].ShortName, config.Logs[i].PublicKeyDERB64)
}
if config.Logs[i].PublicKeyBase64 != "" {
log.Fatalf("Log %d (%s) has field PublicKeyBase64 should not be configured (%s)", i, config.Logs[i].ShortName, config.Logs[i].PublicKeyBase64)
} }
} }
return config return config
} }
// ANSI color codes func writeFileWithStatus(filename string, content []byte) error {
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorGreen = "\033[32m"
colorYellow = "\033[33m"
colorCyan = "\033[36m"
)
// colorizeUnifiedDiff adds ANSI color codes to unified diff output
func colorizeUnifiedDiff(diff string) string {
lines := strings.Split(diff, "\n")
var colorizedLines []string
for _, line := range lines {
switch {
case strings.HasPrefix(line, "---"):
// File deletion header in cyan
colorizedLines = append(colorizedLines, colorCyan+line+colorReset)
case strings.HasPrefix(line, "+++"):
// File addition header in cyan
colorizedLines = append(colorizedLines, colorCyan+line+colorReset)
case strings.HasPrefix(line, "@@"):
// Hunk header in yellow
colorizedLines = append(colorizedLines, colorYellow+line+colorReset)
case strings.HasPrefix(line, "-"):
// Deleted lines in red
colorizedLines = append(colorizedLines, colorRed+line+colorReset)
case strings.HasPrefix(line, "+"):
// Added lines in green
colorizedLines = append(colorizedLines, colorGreen+line+colorReset)
default:
// Context lines unchanged
colorizedLines = append(colorizedLines, line)
}
}
return strings.Join(colorizedLines, "\n")
}
func writeFileWithStatus(filename string, content []byte, wantDiff bool, allowWrite bool, useColor bool) error {
existingContent, err := os.ReadFile(filename) existingContent, err := os.ReadFile(filename)
isNew := os.IsNotExist(err) if os.IsNotExist(err) {
isUnchanged := false
if isNew {
if allowWrite {
fmt.Printf("Creating %s\n", filename) fmt.Printf("Creating %s\n", filename)
} else {
fmt.Printf("Would create %s\n", filename)
}
} else if err != nil { } else if err != nil {
return fmt.Errorf("failed to read existing file %s: %v", filename, err) return fmt.Errorf("failed to read existing file %s: %v", filename, err)
} else if string(existingContent) == string(content) { } else if string(existingContent) == string(content) {
fmt.Printf("Unchanged %s\n", filename) fmt.Printf("Unchanged %s\n", filename)
isUnchanged = true
} else {
if allowWrite {
fmt.Printf("Updating %s\n", filename)
} else {
fmt.Printf("Would update %s\n", filename)
}
}
if wantDiff && !isUnchanged {
if isNew {
// For new files, show the entire content as added
edits := myers.ComputeEdits(span.URIFromPath(filename), "", string(content))
diff := fmt.Sprint(gotextdiff.ToUnified("/dev/null", filename, "", edits))
if useColor {
fmt.Print(colorizeUnifiedDiff(diff))
} else {
fmt.Print(diff)
}
} else {
// For existing files, show the diff
edits := myers.ComputeEdits(span.URIFromPath(filename), string(existingContent), string(content))
diff := fmt.Sprint(gotextdiff.ToUnified(filename, filename+".new", string(existingContent), edits))
if useColor {
fmt.Print(colorizeUnifiedDiff(diff))
} else {
fmt.Print(diff)
}
}
}
if isUnchanged || !allowWrite {
return nil return nil
} else {
fmt.Printf("Updating %s\n", filename)
} }
err = os.WriteFile(filename, content, 0644) err = os.WriteFile(filename, content, 0644)
@@ -215,17 +181,8 @@ func writeFileWithStatus(filename string, content []byte, wantDiff bool, allowWr
func showHelp() { func showHelp() {
fmt.Printf("Usage: %s [options] <command>\n\n", os.Args[0]) fmt.Printf("Usage: %s [options] <command>\n\n", os.Args[0])
fmt.Printf("Note: Flags must come before the command name.\n\n")
fmt.Printf("Options:\n") fmt.Printf("Options:\n")
fmt.Printf(" -c <file> Path to YAML configuration file (default: ./tesseract-staging.yaml)\n") fmt.Printf(" -c <file> Path to YAML configuration file (default: ./tesseract-staging.yaml)\n\n")
fmt.Printf(" --diff Show unified diff of changes without writing files\n")
fmt.Printf(" --write Allow writing files (required for actual file modifications)\n")
fmt.Printf(" --no-color Disable colored diff output\n\n")
fmt.Printf("Examples:\n")
fmt.Printf(" %s --diff gen-html # Show colored diffs without writing\n", os.Args[0])
fmt.Printf(" %s --diff --no-color gen-html # Show plain diffs without writing\n", os.Args[0])
fmt.Printf(" %s --write gen-html # Write files\n", os.Args[0])
fmt.Printf(" %s --diff --write gen-html # Show colored diffs and write files\n\n", os.Args[0])
fmt.Printf("Commands:\n") fmt.Printf("Commands:\n")
fmt.Printf(" gen-html Generate index.html and log.v3.json files in each log's localdirectory.\n") fmt.Printf(" gen-html Generate index.html and log.v3.json files in each log's localdirectory.\n")
fmt.Printf(" Creates HTML pages with log information and CT log metadata JSON.\n") fmt.Printf(" Creates HTML pages with log information and CT log metadata JSON.\n")

View File

@@ -1,273 +0,0 @@
package main
import (
"os"
"strings"
"testing"
"time"
)
func TestLoadConfig(t *testing.T) {
tests := []struct {
name string
yamlContent string
wantError bool
checkFields func(*testing.T, Config)
}{
{
name: "valid config with all fields",
yamlContent: `listen:
- ":8080"
checkpoints: "checkpoints"
roots: "roots.pem"
logs:
- shortname: "test-log"
inception: "2024-01-01T00:00:00Z"
period: 300
poolsize: 1000
submissionprefix: "https://example.com/submit"
monitoringprefix: "https://example.com/monitor"
secret: "test.key"
localdirectory: "test-dir"
notafterstart: "2024-01-01T00:00:00Z"
notafterlimit: "2025-01-01T00:00:00Z"`,
wantError: false,
checkFields: func(t *testing.T, config Config) {
if len(config.Listen) != 1 || config.Listen[0] != ":8080" {
t.Errorf("Expected Listen [\":8080\"], got %v", config.Listen)
}
if config.Checkpoints != "checkpoints" {
t.Errorf("Expected Checkpoints \"checkpoints\", got %s", config.Checkpoints)
}
if len(config.Logs) != 1 {
t.Errorf("Expected 1 log, got %d", len(config.Logs))
}
log := config.Logs[0]
if log.ShortName != "test-log" {
t.Errorf("Expected ShortName \"test-log\", got %s", log.ShortName)
}
if log.Period != 300 {
t.Errorf("Expected Period 300, got %d", log.Period)
}
if log.PoolSize != 1000 {
t.Errorf("Expected PoolSize 1000, got %d", log.PoolSize)
}
if log.Origin != "example.com" {
t.Errorf("Expected Origin \"example.com\", got %s", log.Origin)
}
},
},
{
name: "config with defaults",
yamlContent: `logs:
- shortname: "minimal-log"
submissionprefix: "https://test.example.com/submit"
secret: "test.key"
localdirectory: "test-dir"`,
wantError: false,
checkFields: func(t *testing.T, config Config) {
if len(config.Listen) != 1 || config.Listen[0] != ":8080" {
t.Errorf("Expected default Listen [\":8080\"], got %v", config.Listen)
}
if len(config.Logs) != 1 {
t.Errorf("Expected 1 log, got %d", len(config.Logs))
}
log := config.Logs[0]
if log.Period != 200 {
t.Errorf("Expected default Period 200, got %d", log.Period)
}
if log.PoolSize != 750 {
t.Errorf("Expected default PoolSize 750, got %d", log.PoolSize)
}
if log.Origin != "test.example.com" {
t.Errorf("Expected Origin \"test.example.com\", got %s", log.Origin)
}
},
},
{
name: "invalid yaml",
yamlContent: `invalid: yaml: content:
- malformed`,
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpfile, err := os.CreateTemp("", "test-config-*.yaml")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpfile.Name())
_, err = tmpfile.WriteString(tt.yamlContent)
if err != nil {
t.Fatal(err)
}
tmpfile.Close()
if tt.wantError {
// Can't easily test log.Fatalf without subprocess
t.Skip("Cannot easily test log.Fatalf without subprocess")
} else {
config := loadConfig(tmpfile.Name())
if tt.checkFields != nil {
tt.checkFields(t, config)
}
}
})
}
}
func TestExtractHostname(t *testing.T) {
tests := []struct {
name string
urlStr string
want string
wantErr bool
}{
{
name: "https URL",
urlStr: "https://example.com/path",
want: "example.com",
wantErr: false,
},
{
name: "http URL",
urlStr: "http://test.example.com:8080/path",
want: "test.example.com:8080",
wantErr: false,
},
{
name: "URL with port",
urlStr: "https://log.example.com:9090/monitor",
want: "log.example.com:9090",
wantErr: false,
},
{
name: "invalid URL",
urlStr: "://invalid-url",
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := extractHostname(tt.urlStr)
if (err != nil) != tt.wantErr {
t.Errorf("extractHostname() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("extractHostname() = %v, want %v", got, tt.want)
}
})
}
}
func TestExtractPort(t *testing.T) {
tests := []struct {
name string
listenAddr string
want string
}{
{
name: "port only",
listenAddr: ":8080",
want: "8080",
},
{
name: "localhost with port",
listenAddr: "localhost:9090",
want: "9090",
},
{
name: "IPv6 with port",
listenAddr: "[::]:8080",
want: "8080",
},
{
name: "no port",
listenAddr: "localhost",
want: "",
},
{
name: "empty string",
listenAddr: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractPort(tt.listenAddr)
if got != tt.want {
t.Errorf("extractPort() = %v, want %v", got, tt.want)
}
})
}
}
func TestColorizeUnifiedDiff(t *testing.T) {
diff := `--- file1.txt
+++ file2.txt
@@ -1,3 +1,3 @@
line1
-old line
+new line
line3`
result := colorizeUnifiedDiff(diff)
if !strings.Contains(result, colorCyan) {
t.Error("Expected diff to contain cyan color codes")
}
if !strings.Contains(result, colorYellow) {
t.Error("Expected diff to contain yellow color codes")
}
if !strings.Contains(result, colorRed) {
t.Error("Expected diff to contain red color codes")
}
if !strings.Contains(result, colorGreen) {
t.Error("Expected diff to contain green color codes")
}
if !strings.Contains(result, colorReset) {
t.Error("Expected diff to contain reset color codes")
}
}
func TestWriteFileWithStatus(t *testing.T) {
tmpDir := t.TempDir()
testFile := tmpDir + "/test.txt"
content := []byte("test content")
err := writeFileWithStatus(testFile, content, false, true, false)
if err != nil {
t.Errorf("writeFileWithStatus() error = %v", err)
}
writtenContent, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("Failed to read written file: %v", err)
}
if string(writtenContent) != string(content) {
t.Errorf("Written content = %s, want %s", string(writtenContent), string(content))
}
err = writeFileWithStatus(testFile, content, false, false, false)
if err != nil {
t.Errorf("writeFileWithStatus() with allowWrite=false should not error: %v", err)
}
}
func TestTimeFormats(t *testing.T) {
testTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
expected := "2024-01-01T12:00:00Z"
formatted := testTime.Format("2006-01-02T15:04:05Z")
if formatted != expected {
t.Errorf("Time format = %s, want %s", formatted, expected)
}
}

View File

@@ -23,61 +23,60 @@ const nginxTemplate = `server {
location = / { location = / {
try_files /index.html =404; try_files /index.html =404;
add_header Content-Type "text/html; charset=utf-8"; add_header Content-Type "text/html; charset=utf-8" always;
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*" always;
} }
# Checkpoint endpoint - no caching # Checkpoint endpoint - no caching
location = /checkpoint { location = /checkpoint {
try_files /checkpoint =404; try_files /checkpoint =404;
add_header Content-Type "text/plain; charset=utf-8"; add_header Content-Type "text/plain; charset=utf-8" always;
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "no-store"; add_header Cache-Control "no-store" always;
} }
# Log info endpoint # Log info endpoint
location = /log.v3.json { location = /log.v3.json {
try_files /log.v3.json =404; try_files /log.v3.json =404;
add_header Content-Type "application/json"; add_header Content-Type "application/json" always;
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=3600, immutable"; add_header Cache-Control "public, max-age=3600, immutable" always;
} }
# Issuer certificate endpoint - long cache # Issuer certificate endpoint - long cache
location ~ ^/issuer/(.+)$ { location ~ ^/issuer/(.+)$ {
try_files /issuer/$1 =404; try_files /issuer/$1 =404;
add_header Content-Type "application/pkix-cert"; add_header Content-Type "application/pkix-cert" always;
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=604800, immutable"; add_header Cache-Control "public, max-age=604800, immutable" always;
} }
# Tile data endpoint - long cache, may have gzip # Tile data endpoint - long cache, may have gzip
location ~ ^/tile/(.+)$ { location ~ ^/tile/(.+)$ {
try_files /tile/$1 =404; try_files /tile/$1 =404;
add_header Content-Type "application/octet-stream"; add_header Content-Type "application/octet-stream" always;
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=604800, immutable"; add_header Cache-Control "public, max-age=604800, immutable" always;
# Gzip encoding for .gz files # Gzip encoding for .gz files
location ~ \.gz$ { location ~ \.gz$ {
add_header Content-Encoding "gzip"; add_header Content-Encoding "gzip" always;
} }
} }
} }
` `
// NginxTemplateData contains the data needed to generate nginx configuration files
type NginxTemplateData struct { type NginxTemplateData struct {
MonitoringHost string MonitoringHost string
LocalDirectory string LocalDirectory string
ListenPort string ListenPort string
} }
func generateNginx(yamlFile string, wantDiff bool, allowWrite bool, useColor bool) { func generateNginx(yamlFile string) {
config := loadConfig(yamlFile) config := loadConfig(yamlFile)
// Extract port from first listen address // Extract port from first listen address
@@ -124,7 +123,7 @@ func generateNginx(yamlFile string, wantDiff bool, allowWrite bool, useColor boo
} }
// Write file with status // Write file with status
err = writeFileWithStatus(outputPath, buf.Bytes(), wantDiff, allowWrite, useColor) err = writeFileWithStatus(outputPath, buf.Bytes())
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to write nginx config file %s: %v\n", outputPath, err) fmt.Fprintf(os.Stderr, "Failed to write nginx config file %s: %v\n", outputPath, err)
continue continue
@@ -152,10 +151,14 @@ func extractPort(listenAddr string) string {
} }
func extractHostname(urlStr string) (string, error) { func extractHostname(urlStr string) (string, error) {
if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") {
urlStr = "https://" + urlStr
}
parsedURL, err := url.Parse(urlStr) parsedURL, err := url.Parse(urlStr)
if err != nil { if err != nil {
return "", err return "", err
} }
return parsedURL.Host, nil return parsedURL.Hostname(), nil
} }

View File

@@ -1,307 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestGenerateNginx(t *testing.T) {
tmpDir := t.TempDir()
// Create test directories
log1Dir := filepath.Join(tmpDir, "log1")
log2Dir := filepath.Join(tmpDir, "log2")
err := os.MkdirAll(log1Dir, 0755)
if err != nil {
t.Fatal(err)
}
err = os.MkdirAll(log2Dir, 0755)
if err != nil {
t.Fatal(err)
}
// Create test config
configContent := `listen:
- ":8080"
logs:
- shortname: "log1"
monitoringprefix: "https://log1.example.com/monitor"
localdirectory: "` + log1Dir + `"
- shortname: "log2"
monitoringprefix: "https://log2.example.com:9090/monitor"
localdirectory: "` + log2Dir + `"`
configFile := filepath.Join(tmpDir, "test-config.yaml")
err = os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatal(err)
}
// Run generateNginx
generateNginx(configFile, false, true, false)
// Verify nginx config for first log
nginxFile1 := filepath.Join(log1Dir, "log1.example.com.conf")
nginxContent1, err := os.ReadFile(nginxFile1)
if err != nil {
t.Fatalf("Failed to read nginx config: %v", err)
}
nginx1Str := string(nginxContent1)
// Check server block basics
if !strings.Contains(nginx1Str, "server {") {
t.Error("Expected server block")
}
if !strings.Contains(nginx1Str, "listen 8080;") {
t.Error("Expected listen directive with port 8080")
}
if !strings.Contains(nginx1Str, "listen [::]:8080;") {
t.Error("Expected IPv6 listen directive")
}
if !strings.Contains(nginx1Str, "server_name log1.example.com;") {
t.Error("Expected correct server name")
}
if !strings.Contains(nginx1Str, "root "+log1Dir+";") {
t.Error("Expected correct document root")
}
// Check location blocks
if !strings.Contains(nginx1Str, "location = / {") {
t.Error("Expected root location block")
}
if !strings.Contains(nginx1Str, "location = /checkpoint {") {
t.Error("Expected checkpoint location block")
}
if !strings.Contains(nginx1Str, "location = /log.v3.json {") {
t.Error("Expected log.v3.json location block")
}
if !strings.Contains(nginx1Str, "location ~ ^/issuer/(.+)$ {") {
t.Error("Expected issuer location block")
}
if !strings.Contains(nginx1Str, "location ~ ^/tile/(.+)$ {") {
t.Error("Expected tile location block")
}
// Check CORS headers
if !strings.Contains(nginx1Str, `add_header Access-Control-Allow-Origin "*" always;`) {
t.Error("Expected CORS headers")
}
// Check cache control headers
if !strings.Contains(nginx1Str, `add_header Cache-Control "no-store" always;`) {
t.Error("Expected no-store cache control for checkpoint")
}
if !strings.Contains(nginx1Str, `add_header Cache-Control "public, max-age=3600, immutable" always;`) {
t.Error("Expected long cache control for log.v3.json")
}
if !strings.Contains(nginx1Str, `add_header Cache-Control "public, max-age=604800, immutable" always;`) {
t.Error("Expected very long cache control for static files")
}
// Check content types
if !strings.Contains(nginx1Str, `add_header Content-Type "text/html; charset=utf-8" always;`) {
t.Error("Expected HTML content type")
}
if !strings.Contains(nginx1Str, `add_header Content-Type "text/plain; charset=utf-8" always;`) {
t.Error("Expected plain text content type for checkpoint")
}
if !strings.Contains(nginx1Str, `add_header Content-Type "application/json" always;`) {
t.Error("Expected JSON content type")
}
if !strings.Contains(nginx1Str, `add_header Content-Type "application/pkix-cert" always;`) {
t.Error("Expected certificate content type")
}
if !strings.Contains(nginx1Str, `add_header Content-Type "application/octet-stream" always;`) {
t.Error("Expected octet-stream content type")
}
// Check gzip handling
if !strings.Contains(nginx1Str, `add_header Content-Encoding "gzip" always;`) {
t.Error("Expected gzip content encoding for .gz files")
}
// Verify nginx config for second log (with port)
nginxFile2 := filepath.Join(log2Dir, "log2.example.com:9090.conf")
nginxContent2, err := os.ReadFile(nginxFile2)
if err != nil {
t.Fatalf("Failed to read second nginx config: %v", err)
}
nginx2Str := string(nginxContent2)
if !strings.Contains(nginx2Str, "server_name log2.example.com:9090;") {
t.Error("Expected correct server name with port")
}
if !strings.Contains(nginx2Str, "root "+log2Dir+";") {
t.Error("Expected correct document root for second log")
}
}
func TestGenerateNginxCustomPort(t *testing.T) {
tmpDir := t.TempDir()
logDir := filepath.Join(tmpDir, "test-log")
err := os.MkdirAll(logDir, 0755)
if err != nil {
t.Fatal(err)
}
// Create config with custom port
configContent := `listen:
- ":9999"
logs:
- shortname: "test-log"
monitoringprefix: "https://test.example.com/monitor"
localdirectory: "` + logDir + `"`
configFile := filepath.Join(tmpDir, "test-config.yaml")
err = os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatal(err)
}
generateNginx(configFile, false, true, false)
nginxFile := filepath.Join(logDir, "test.example.com.conf")
nginxContent, err := os.ReadFile(nginxFile)
if err != nil {
t.Fatalf("Failed to read nginx config: %v", err)
}
nginxStr := string(nginxContent)
if !strings.Contains(nginxStr, "listen 9999;") {
t.Error("Expected listen directive with custom port 9999")
}
if !strings.Contains(nginxStr, "listen [::]:9999;") {
t.Error("Expected IPv6 listen directive with custom port")
}
}
func TestGenerateNginxMultiplePorts(t *testing.T) {
tmpDir := t.TempDir()
logDir := filepath.Join(tmpDir, "test-log")
err := os.MkdirAll(logDir, 0755)
if err != nil {
t.Fatal(err)
}
// Create config with multiple listen addresses (should use first one)
configContent := `listen:
- ":8080"
- ":8081"
logs:
- shortname: "test-log"
monitoringprefix: "https://test.example.com/monitor"
localdirectory: "` + logDir + `"`
configFile := filepath.Join(tmpDir, "test-config.yaml")
err = os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatal(err)
}
generateNginx(configFile, false, true, false)
nginxFile := filepath.Join(logDir, "test.example.com.conf")
nginxContent, err := os.ReadFile(nginxFile)
if err != nil {
t.Fatalf("Failed to read nginx config: %v", err)
}
nginxStr := string(nginxContent)
if !strings.Contains(nginxStr, "listen 8080;") {
t.Error("Expected first port to be used")
}
if strings.Contains(nginxStr, "listen 8081;") {
t.Error("Should not contain second port")
}
}
func TestGenerateNginxInvalidURL(t *testing.T) {
tmpDir := t.TempDir()
logDir := filepath.Join(tmpDir, "test-log")
err := os.MkdirAll(logDir, 0755)
if err != nil {
t.Fatal(err)
}
// Create config with invalid monitoring URL
configContent := `logs:
- shortname: "test-log"
monitoringprefix: "not-a-valid-url"
localdirectory: "` + logDir + `"`
configFile := filepath.Join(tmpDir, "test-config.yaml")
err = os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatal(err)
}
// Should not create nginx config file due to invalid URL
generateNginx(configFile, false, true, false)
// Check that a .conf file was still created (even with invalid hostname extraction)
// The function continues execution and creates a file with empty hostname
files, err := filepath.Glob(filepath.Join(logDir, "*.conf"))
if err != nil {
t.Fatal(err)
}
// The function still creates a file, but with an empty hostname, resulting in ".conf"
if len(files) == 0 {
t.Error("Expected at least one config file to be created despite invalid URL")
}
}
func TestNginxTemplateData(t *testing.T) {
data := NginxTemplateData{
MonitoringHost: "example.com",
LocalDirectory: "/path/to/files",
ListenPort: "8080",
}
if data.MonitoringHost != "example.com" {
t.Errorf("Expected MonitoringHost 'example.com', got %s", data.MonitoringHost)
}
if data.LocalDirectory != "/path/to/files" {
t.Errorf("Expected LocalDirectory '/path/to/files', got %s", data.LocalDirectory)
}
if data.ListenPort != "8080" {
t.Errorf("Expected ListenPort '8080', got %s", data.ListenPort)
}
}
func TestGenerateNginxNoListenConfig(t *testing.T) {
tmpDir := t.TempDir()
logDir := filepath.Join(tmpDir, "test-log")
err := os.MkdirAll(logDir, 0755)
if err != nil {
t.Fatal(err)
}
// Create config without listen directive (should use default 8080)
configContent := `logs:
- shortname: "test-log"
monitoringprefix: "https://test.example.com/monitor"
localdirectory: "` + logDir + `"`
configFile := filepath.Join(tmpDir, "test-config.yaml")
err = os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatal(err)
}
generateNginx(configFile, false, true, false)
nginxFile := filepath.Join(logDir, "test.example.com.conf")
nginxContent, err := os.ReadFile(nginxFile)
if err != nil {
t.Fatalf("Failed to read nginx config: %v", err)
}
nginxStr := string(nginxContent)
if !strings.Contains(nginxStr, "listen 8080;") {
t.Error("Expected default port 8080 when no listen config provided")
}
}

View File

@@ -12,12 +12,11 @@ import (
"strings" "strings"
) )
// CTLogRootsResponse represents the JSON response from a CT log's get-roots endpoint
type CTLogRootsResponse struct { type CTLogRootsResponse struct {
Certificates []string `json:"certificates"` Certificates []string `json:"certificates"`
} }
func generateRoots(args []string, wantDiff bool, allowWrite bool, useColor bool) { func generateRoots(args []string) {
sourceURL := "https://rennet2027h2.log.ct.ipng.ch/" sourceURL := "https://rennet2027h2.log.ct.ipng.ch/"
outputFile := "roots.pem" outputFile := "roots.pem"
@@ -108,7 +107,7 @@ func generateRoots(args []string, wantDiff bool, allowWrite bool, useColor bool)
} }
// Write all certificates to file with status // Write all certificates to file with status
err = writeFileWithStatus(outputFile, pemBuffer.Bytes(), wantDiff, allowWrite, useColor) err = writeFileWithStatus(outputFile, pemBuffer.Bytes())
if err != nil { if err != nil {
log.Fatalf("Failed to write output file %s: %v", outputFile, err) log.Fatalf("Failed to write output file %s: %v", outputFile, err)
} }

View File

@@ -1,217 +0,0 @@
package main
import (
"encoding/json"
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestGenerateRoots(t *testing.T) {
tmpDir := t.TempDir()
outputFile := filepath.Join(tmpDir, "test-roots.pem")
// Create a test HTTP server that returns mock root certificates
mockResponse := CTLogRootsResponse{
Certificates: []string{
// Real certificate (base64 encoded DER from OpenSSL)
"MIIDFzCCAf+gAwIBAgIUbOZ5dIVBuGei8T0VdIKjCpACVgMwDQYJKoZIhvcNAQELBQAwGzEZMBcGA1UEAwwQVGVzdCBDZXJ0aWZpY2F0ZTAeFw0yNTA4MjgxOTA4MjlaFw0yNjA4MjgxOTA4MjlaMBsxGTAXBgNVBAMMEFRlc3QgQ2VydGlmaWNhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDEmF+JcVMD9u0efdi8AkIH4xiKb/8lJveOYu5UKJ/hE7j+QN0HcK+3d0Fh83Hgny7ZgF3A8JlTDnlZrkI1+n8uxLAbvZ/PVNS286FwlnWppnarSBwFMuHp+SoGFuNIQtB5LaxVt943Oxaw4vVaFBqi0iYPFKXj2JxLIwiwKxLiZ2hS15g/JIhRXSycHjMCvukPj1wtRo/cHF83Miydw+ngFqrfXcEzvG/Yx5qvAgowvG/FEKha5zB36ywy35SHq8gX7DHs77eQ52lxd4yvYfPhIwaAzTvnpyACcvfgP/XiETMxsaPoZ3WFzK4y3Smg6zUMLAB1YE4RxUd+4NA+NBmbAgMBAAGjUzBRMB0GA1UdDgQWBBQxgJBg/4o2f1e58aBMOlyEO0svIDAfBgNVHSMEGDAWgBQxgJBg/4o2f1e58aBMOlyEO0svIDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQA1Z1r8pJeYizgo30kA+Q4SICq70jvAO6XPrin2hp8MlvE7nUG2afInzNh6FXjqMI0TWctqV+wF9nnCkPBPu4ybCbm7reiJ1SojPmp/qxv+qoyXXFZxX/ugOhhJv4BbSoHxepdSPBtqQb5uZPL/1q9vYmLfsEdEdh1dI4vEbrvvZR/fV/5+ZjL2uOuWf6zw0BpMqXyC0wcoZIWKHl62yLOhdJwbUVLfKiHuwrhsdIIc0QDI74U6x5pu/eIa1hCPX9e/X8vEK8/0EBV2xGhAeeteu4bY04AjrSs0tESQO5EfACmhQM/1ytlqMx3qsPGb7pIptUttHREvr+RL6qKCRZCK",
// Second certificate (same but different serial for testing)
"MIIDFzCCAf+gAwIBAgIUbOZ5dIVBuGei8T0VdIKjCpACVgQwDQYJKoZIhvcNAQELBQAwGzEZMBcGA1UEAwwQVGVzdCBDZXJ0aWZpY2F0ZTAeFw0yNTA4MjgxOTA4MjlaFw0yNjA4MjgxOTA4MjlaMBsxGTAXBgNVBAMMEFRlc3QgQ2VydGlmaWNhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDEmF+JcVMD9u0efdi8AkIH4xiKb/8lJveOYu5UKJ/hE7j+QN0HcK+3d0Fh83Hgny7ZgF3A8JlTDnlZrkI1+n8uxLAbvZ/PVNS286FwlnWppnarSBwFMuHp+SoGFuNIQtB5LaxVt943Oxaw4vVaFBqi0iYPFKXj2JxLIwiwKxLiZ2hS15g/JIhRXSycHjMCvukPj1wtRo/cHF83Miydw+ngFqrfXcEzvG/Yx5qvAgowvG/FEKha5zB36ywy35SHq8gX7DHs77eQ52lxd4yvYfPhIwaAzTvnpyACcvfgP/XiETMxsaPoZ3WFzK4y3Smg6zUMLAB1YE4RxUd+4NA+NBmbAgMBAAGjUzBRMB0GA1UdDgQWBBQxgJBg/4o2f1e58aBMOlyEO0svIDAfBgNVHSMEGDAWgBQxgJBg/4o2f1e58aBMOlyEO0svIDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQA1Z1r8pJeYizgo30kA+Q4SICq70jvAO6XPrin2hp8MlvE7nUG2afInzNh6FXjqMI0TWctqV+wF9nnCkPBPu4ybCbm7reiJ1SojPmp/qxv+qoyXXFZxX/ugOhhJv4BbSoHxepdSPBtqQb5uZPL/1q9vYmLfsEdEdh1dI4vEbrvvZR/fV/5+ZjL2uOuWf6zw0BpMqXyC0wcoZIWKHl62yLOhdJwbUVLfKiHuwrhsdIIc0QDI74U6x5pu/eIa1hCPX9e/X8vEK8/0EBV2xGhAeeteu4bY04AjrSs0tESQO5EfACmhQM/1ytlqMx3qsPGb7pIptUttHREvr+RL6qKCRZCK",
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/ct/v1/get-roots") {
t.Errorf("Expected path to end with /ct/v1/get-roots, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(mockResponse)
}))
defer server.Close()
// Test with default arguments
args := []string{"--source", server.URL, "--output", outputFile}
generateRoots(args, false, true, false)
// Verify output file was created
if _, err := os.Stat(outputFile); os.IsNotExist(err) {
t.Fatal("Output file was not created")
}
// Verify file contents
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatal(err)
}
contentStr := string(content)
// Should contain PEM headers
if !strings.Contains(contentStr, "-----BEGIN CERTIFICATE-----") {
t.Error("Expected PEM certificate headers")
}
if !strings.Contains(contentStr, "-----END CERTIFICATE-----") {
t.Error("Expected PEM certificate footers")
}
// Count certificates (should have 2)
certCount := strings.Count(contentStr, "-----BEGIN CERTIFICATE-----")
if certCount != 2 {
t.Errorf("Expected 2 certificates, found %d", certCount)
}
}
func TestGenerateRootsWithNegativeSerial(t *testing.T) {
// Should call log.Fatalf due to invalid base64
// Can't easily test this without subprocess, so we'll skip it
t.Skip("Cannot easily test log.Fatalf without subprocess")
}
func TestGenerateRootsHTTPError(t *testing.T) {
// Should call log.Fatalf due to HTTP error
// Can't easily test this without subprocess, so we'll skip it
t.Skip("Cannot easily test log.Fatalf without subprocess")
}
func TestGenerateRootsInvalidJSON(t *testing.T) {
// Should call log.Fatalf due to JSON parse error
// Can't easily test this without subprocess, so we'll skip it
t.Skip("Cannot easily test log.Fatalf without subprocess")
}
func TestGenerateRootsArgumentParsing(t *testing.T) {
tmpDir := t.TempDir()
customOutputFile := filepath.Join(tmpDir, "custom-roots.pem")
mockResponse := CTLogRootsResponse{
Certificates: []string{
"MIIDFzCCAf+gAwIBAgIUbOZ5dIVBuGei8T0VdIKjCpACVgMwDQYJKoZIhvcNAQELBQAwGzEZMBcGA1UEAwwQVGVzdCBDZXJ0aWZpY2F0ZTAeFw0yNTA4MjgxOTA4MjlaFw0yNjA4MjgxOTA4MjlaMBsxGTAXBgNVBAMMEFRlc3QgQ2VydGlmaWNhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDEmF+JcVMD9u0efdi8AkIH4xiKb/8lJveOYu5UKJ/hE7j+QN0HcK+3d0Fh83Hgny7ZgF3A8JlTDnlZrkI1+n8uxLAbvZ/PVNS286FwlnWppnarSBwFMuHp+SoGFuNIQtB5LaxVt943Oxaw4vVaFBqi0iYPFKXj2JxLIwiwKxLiZ2hS15g/JIhRXSycHjMCvukPj1wtRo/cHF83Miydw+ngFqrfXcEzvG/Yx5qvAgowvG/FEKha5zB36ywy35SHq8gX7DHs77eQ52lxd4yvYfPhIwaAzTvnpyACcvfgP/XiETMxsaPoZ3WFzK4y3Smg6zUMLAB1YE4RxUd+4NA+NBmbAgMBAAGjUzBRMB0GA1UdDgQWBBQxgJBg/4o2f1e58aBMOlyEO0svIDAfBgNVHSMEGDAWgBQxgJBg/4o2f1e58aBMOlyEO0svIDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQA1Z1r8pJeYizgo30kA+Q4SICq70jvAO6XPrin2hp8MlvE7nUG2afInzNh6FXjqMI0TWctqV+wF9nnCkPBPu4ybCbm7reiJ1SojPmp/qxv+qoyXXFZxX/ugOhhJv4BbSoHxepdSPBtqQb5uZPL/1q9vYmLfsEdEdh1dI4vEbrvvZR/fV/5+ZjL2uOuWf6zw0BpMqXyC0wcoZIWKHl62yLOhdJwbUVLfKiHuwrhsdIIc0QDI74U6x5pu/eIa1hCPX9e/X8vEK8/0EBV2xGhAeeteu4bY04AjrSs0tESQO5EfACmhQM/1ytlqMx3qsPGb7pIptUttHREvr+RL6qKCRZCK",
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(mockResponse)
}))
defer server.Close()
// Test custom source and output
args := []string{"--source", server.URL, "--output", customOutputFile}
generateRoots(args, false, true, false)
// Verify custom output file was created
if _, err := os.Stat(customOutputFile); os.IsNotExist(err) {
t.Error("Custom output file was not created")
}
}
func TestGenerateRootsMissingSourceArgument(t *testing.T) {
// Should call log.Fatal due to missing source URL argument
// Can't easily test this without subprocess, so we'll skip it
t.Skip("Cannot easily test log.Fatal without subprocess")
}
func TestGenerateRootsMissingOutputArgument(t *testing.T) {
// Should call log.Fatal due to missing output filename argument
// Can't easily test this without subprocess, so we'll skip it
t.Skip("Cannot easily test log.Fatal without subprocess")
}
func TestGenerateRootsUnknownArgument(t *testing.T) {
// Should call log.Fatalf due to unknown argument
// Can't easily test this without subprocess, so we'll skip it
t.Skip("Cannot easily test log.Fatalf without subprocess")
}
func TestGenerateRootsDefaultArguments(t *testing.T) {
// Test that default arguments are used correctly
// This test would normally make a real HTTP request, so we'll skip it
// unless we're doing integration tests
t.Skip("Skipping test that would make real HTTP request")
}
func TestGenerateRootsSourceURLFormatting(t *testing.T) {
tmpDir := t.TempDir()
outputFile := filepath.Join(tmpDir, "test-roots.pem")
mockResponse := CTLogRootsResponse{
Certificates: []string{
"MIIDFzCCAf+gAwIBAgIUbOZ5dIVBuGei8T0VdIKjCpACVgMwDQYJKoZIhvcNAQELBQAwGzEZMBcGA1UEAwwQVGVzdCBDZXJ0aWZpY2F0ZTAeFw0yNTA4MjgxOTA4MjlaFw0yNjA4MjgxOTA4MjlaMBsxGTAXBgNVBAMMEFRlc3QgQ2VydGlmaWNhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDEmF+JcVMD9u0efdi8AkIH4xiKb/8lJveOYu5UKJ/hE7j+QN0HcK+3d0Fh83Hgny7ZgF3A8JlTDnlZrkI1+n8uxLAbvZ/PVNS286FwlnWppnarSBwFMuHp+SoGFuNIQtB5LaxVt943Oxaw4vVaFBqi0iYPFKXj2JxLIwiwKxLiZ2hS15g/JIhRXSycHjMCvukPj1wtRo/cHF83Miydw+ngFqrfXcEzvG/Yx5qvAgowvG/FEKha5zB36ywy35SHq8gX7DHs77eQ52lxd4yvYfPhIwaAzTvnpyACcvfgP/XiETMxsaPoZ3WFzK4y3Smg6zUMLAB1YE4RxUd+4NA+NBmbAgMBAAGjUzBRMB0GA1UdDgQWBBQxgJBg/4o2f1e58aBMOlyEO0svIDAfBgNVHSMEGDAWgBQxgJBg/4o2f1e58aBMOlyEO0svIDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQA1Z1r8pJeYizgo30kA+Q4SICq70jvAO6XPrin2hp8MlvE7nUG2afInzNh6FXjqMI0TWctqV+wF9nnCkPBPu4ybCbm7reiJ1SojPmp/qxv+qoyXXFZxX/ugOhhJv4BbSoHxepdSPBtqQb5uZPL/1q9vYmLfsEdEdh1dI4vEbrvvZR/fV/5+ZjL2uOuWf6zw0BpMqXyC0wcoZIWKHl62yLOhdJwbUVLfKiHuwrhsdIIc0QDI74U6x5pu/eIa1hCPX9e/X8vEK8/0EBV2xGhAeeteu4bY04AjrSs0tESQO5EfACmhQM/1ytlqMx3qsPGb7pIptUttHREvr+RL6qKCRZCK",
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify the path formatting
expectedPath := "/ct/v1/get-roots"
if r.URL.Path != expectedPath {
t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(mockResponse)
}))
defer server.Close()
// Test with URL that doesn't end with /
sourceURL := strings.TrimSuffix(server.URL, "/")
args := []string{"--source", sourceURL, "--output", outputFile}
generateRoots(args, false, true, false)
// Should still work (the function adds trailing slash)
if _, err := os.Stat(outputFile); os.IsNotExist(err) {
t.Error("Output file was not created when source URL lacks trailing slash")
}
}
func TestPEMEncoding(t *testing.T) {
tmpDir := t.TempDir()
outputFile := filepath.Join(tmpDir, "test-roots.pem")
mockResponse := CTLogRootsResponse{
Certificates: []string{
"MIIDFzCCAf+gAwIBAgIUbOZ5dIVBuGei8T0VdIKjCpACVgMwDQYJKoZIhvcNAQELBQAwGzEZMBcGA1UEAwwQVGVzdCBDZXJ0aWZpY2F0ZTAeFw0yNTA4MjgxOTA4MjlaFw0yNjA4MjgxOTA4MjlaMBsxGTAXBgNVBAMMEFRlc3QgQ2VydGlmaWNhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDEmF+JcVMD9u0efdi8AkIH4xiKb/8lJveOYu5UKJ/hE7j+QN0HcK+3d0Fh83Hgny7ZgF3A8JlTDnlZrkI1+n8uxLAbvZ/PVNS286FwlnWppnarSBwFMuHp+SoGFuNIQtB5LaxVt943Oxaw4vVaFBqi0iYPFKXj2JxLIwiwKxLiZ2hS15g/JIhRXSycHjMCvukPj1wtRo/cHF83Miydw+ngFqrfXcEzvG/Yx5qvAgowvG/FEKha5zB36ywy35SHq8gX7DHs77eQ52lxd4yvYfPhIwaAzTvnpyACcvfgP/XiETMxsaPoZ3WFzK4y3Smg6zUMLAB1YE4RxUd+4NA+NBmbAgMBAAGjUzBRMB0GA1UdDgQWBBQxgJBg/4o2f1e58aBMOlyEO0svIDAfBgNVHSMEGDAWgBQxgJBg/4o2f1e58aBMOlyEO0svIDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQA1Z1r8pJeYizgo30kA+Q4SICq70jvAO6XPrin2hp8MlvE7nUG2afInzNh6FXjqMI0TWctqV+wF9nnCkPBPu4ybCbm7reiJ1SojPmp/qxv+qoyXXFZxX/ugOhhJv4BbSoHxepdSPBtqQb5uZPL/1q9vYmLfsEdEdh1dI4vEbrvvZR/fV/5+ZjL2uOuWf6zw0BpMqXyC0wcoZIWKHl62yLOhdJwbUVLfKiHuwrhsdIIc0QDI74U6x5pu/eIa1hCPX9e/X8vEK8/0EBV2xGhAeeteu4bY04AjrSs0tESQO5EfACmhQM/1ytlqMx3qsPGb7pIptUttHREvr+RL6qKCRZCK",
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(mockResponse)
}))
defer server.Close()
args := []string{"--source", server.URL, "--output", outputFile}
generateRoots(args, false, true, false)
// Read and verify PEM structure
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatal(err)
}
// Parse PEM blocks
var certCount int
remaining := content
for len(remaining) > 0 {
block, rest := pem.Decode(remaining)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
t.Errorf("Expected block type 'CERTIFICATE', got %s", block.Type)
}
certCount++
remaining = rest
}
if certCount != 1 {
t.Errorf("Expected 1 certificate, found %d", certCount)
}
}