3 Commits

9 changed files with 174 additions and 51 deletions

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 gen-key ./tesseract-genconf -c config.yaml --write 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 gen-env ./tesseract-genconf -c config.yaml --write gen-env
``` ```
5. **Generate HTML and JSON files:** 5. **Generate HTML and JSON files:**
```bash ```bash
./tesseract-genconf -c config.yaml gen-html ./tesseract-genconf -c config.yaml --write gen-html
``` ```
6. **Generate nginx configuration files:** 6. **Generate nginx configuration files:**
```bash ```bash
./tesseract-genconf -c config.yaml gen-nginx ./tesseract-genconf -c config.yaml --write 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,3 +66,25 @@ 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,3 +3,8 @@ 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,3 +1,7 @@
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=

View File

@@ -8,7 +8,7 @@ import (
"strings" "strings"
) )
func generateEnv(yamlFile string) { func generateEnv(yamlFile string, wantDiff bool, allowWrite bool, useColor bool) {
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) {
// 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) err := createCombinedRootsPemWithStatus(config.Roots, logEntry.ExtraRoots, rootsPemPath, wantDiff, allowWrite, useColor)
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,7 +32,7 @@ func generateEnv(yamlFile string) {
// 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.%s", logEntry.ShortName, logEntry.Domain), fmt.Sprintf("--origin=%s", logEntry.Origin),
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),
} }
@@ -53,14 +53,14 @@ func generateEnv(yamlFile string) {
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)) err = writeFileWithStatus(envPath, []byte(envContent), wantDiff, allowWrite, useColor)
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) error { func createCombinedRootsPemWithStatus(rootsFile, extraRootsFile, outputPath string, wantDiff bool, allowWrite bool, useColor bool) error {
// Read main roots file // Read main roots file
var combinedContent []byte var combinedContent []byte
if rootsFile != "" { if rootsFile != "" {
@@ -80,5 +80,5 @@ func createCombinedRootsPemWithStatus(rootsFile, extraRootsFile, outputPath stri
combinedContent = append(combinedContent, extraRootsData...) combinedContent = append(combinedContent, extraRootsData...)
} }
return writeFileWithStatus(outputPath, combinedContent) return writeFileWithStatus(outputPath, combinedContent, wantDiff, allowWrite, useColor)
} }

View File

@@ -74,7 +74,7 @@ const htmlTemplate = `<!DOCTYPE html>
{{range .Logs}} {{range .Logs}}
<h2>{{.ShortName}}.{{.Domain}}</h2> <h2>{{.Origin}}</h2>
<p> <p>
Log ID: <code>{{.LogID}}</code><br> Log ID: <code>{{.LogID}}</code><br>
@@ -110,7 +110,7 @@ type TemporalInterval struct {
EndExclusive string `json:"end_exclusive"` EndExclusive string `json:"end_exclusive"`
} }
func generateHTML(yamlFile string) { func generateHTML(yamlFile string, wantDiff bool, allowWrite bool, useColor bool) {
config := loadConfig(yamlFile) config := loadConfig(yamlFile)
// Check that all local directories exist // Check that all local directories exist
@@ -145,14 +145,14 @@ func generateHTML(yamlFile string) {
} }
// Write file with status // Write file with status
err = writeFileWithStatus(indexPath, buf.Bytes()) err = writeFileWithStatus(indexPath, buf.Bytes(), wantDiff, allowWrite, useColor)
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) err = generateLogJSONWithStatus(logEntry, jsonPath, wantDiff, allowWrite, useColor)
if err != nil { if err != nil {
log.Fatalf("Failed to generate %s: %v", jsonPath, err) log.Fatalf("Failed to generate %s: %v", jsonPath, err)
} }
@@ -209,9 +209,9 @@ func computeKeyInfo(logEntry *Log) error {
return nil return nil
} }
func generateLogJSONWithStatus(logEntry Log, outputPath string) error { func generateLogJSONWithStatus(logEntry Log, outputPath string, wantDiff bool, allowWrite bool, useColor bool) error {
logJSON := LogV3JSON{ logJSON := LogV3JSON{
Description: fmt.Sprintf("%s.%s", logEntry.ShortName, logEntry.Domain), Description: logEntry.Origin,
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{
@@ -228,5 +228,5 @@ func generateLogJSONWithStatus(logEntry Log, outputPath string) error {
return fmt.Errorf("failed to marshal JSON: %v", err) return fmt.Errorf("failed to marshal JSON: %v", err)
} }
return writeFileWithStatus(outputPath, jsonData) return writeFileWithStatus(outputPath, jsonData, wantDiff, allowWrite, useColor)
} }

View File

@@ -12,7 +12,12 @@ import (
"path/filepath" "path/filepath"
) )
func generateKeys(yamlFile string) { func generateKeys(yamlFile string, wantDiff bool, allowWrite bool, useColor bool) {
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

View File

@@ -5,8 +5,12 @@ 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"
) )
@@ -19,7 +23,6 @@ type Config struct {
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"`
@@ -38,10 +41,14 @@ 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()
@@ -52,15 +59,15 @@ func main() {
switch args[0] { switch args[0] {
case "gen-html": case "gen-html":
generateHTML(*configFile) generateHTML(*configFile, *wantDiff, *allowWrite, !*noColor)
case "gen-env": case "gen-env":
generateEnv(*configFile) generateEnv(*configFile, *wantDiff, *allowWrite, !*noColor)
case "gen-key": case "gen-key":
generateKeys(*configFile) generateKeys(*configFile, *wantDiff, *allowWrite, !*noColor)
case "gen-nginx": case "gen-nginx":
generateNginx(*configFile) generateNginx(*configFile, *wantDiff, *allowWrite, !*noColor)
case "gen-roots": case "gen-roots":
generateRoots(args[1:]) generateRoots(args[1:], *wantDiff, *allowWrite, !*noColor)
default: default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", args[0]) fmt.Fprintf(os.Stderr, "Unknown command: %s\n", args[0])
showHelp() showHelp()
@@ -85,41 +92,116 @@ func loadConfig(yamlFile string) Config {
config.Listen = []string{":8080"} config.Listen = []string{":8080"}
} }
// Set defaults for log entries and check for empty/missing values // Set defaults for log entries
for i := range config.Logs { for i := range config.Logs {
// Checks are in order of fields of the Log struct if config.Logs[i].PoolSize == 0 {
config.Logs[i].PoolSize = 750
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)
}
if config.Logs[i].Period == 0 { if config.Logs[i].Period == 0 {
config.Logs[i].Period = 200 config.Logs[i].Period = 200
} }
if config.Logs[i].PoolSize == 0 { // Extract hostname from SubmissionPrefix to set Origin
config.Logs[i].PoolSize = 750 if config.Logs[i].SubmissionPrefix != "" {
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
} }
} }
return config return config
} }
func writeFileWithStatus(filename string, content []byte) error { // ANSI color codes
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)
if os.IsNotExist(err) { isNew := 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)
return nil isUnchanged = true
} else { } else {
if allowWrite {
fmt.Printf("Updating %s\n", filename) 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
} }
err = os.WriteFile(filename, content, 0644) err = os.WriteFile(filename, content, 0644)
@@ -131,8 +213,17 @@ func writeFileWithStatus(filename string, content []byte) error {
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\n") fmt.Printf(" -c <file> Path to YAML configuration file (default: ./tesseract-staging.yaml)\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

@@ -76,7 +76,7 @@ type NginxTemplateData struct {
ListenPort string ListenPort string
} }
func generateNginx(yamlFile string) { func generateNginx(yamlFile string, wantDiff bool, allowWrite bool, useColor bool) {
config := loadConfig(yamlFile) config := loadConfig(yamlFile)
// Extract port from first listen address // Extract port from first listen address
@@ -123,7 +123,7 @@ func generateNginx(yamlFile string) {
} }
// Write file with status // Write file with status
err = writeFileWithStatus(outputPath, buf.Bytes()) err = writeFileWithStatus(outputPath, buf.Bytes(), wantDiff, allowWrite, useColor)
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
@@ -151,14 +151,10 @@ 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.Hostname(), nil return parsedURL.Host, nil
} }

View File

@@ -16,7 +16,7 @@ type CTLogRootsResponse struct {
Certificates []string `json:"certificates"` Certificates []string `json:"certificates"`
} }
func generateRoots(args []string) { func generateRoots(args []string, wantDiff bool, allowWrite bool, useColor bool) {
sourceURL := "https://rennet2027h2.log.ct.ipng.ch/" sourceURL := "https://rennet2027h2.log.ct.ipng.ch/"
outputFile := "roots.pem" outputFile := "roots.pem"
@@ -107,7 +107,7 @@ func generateRoots(args []string) {
} }
// Write all certificates to file with status // Write all certificates to file with status
err = writeFileWithStatus(outputFile, pemBuffer.Bytes()) err = writeFileWithStatus(outputFile, pemBuffer.Bytes(), wantDiff, allowWrite, useColor)
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)
} }