13 Commits

Author SHA1 Message Date
d2c564a000 Add Domain option to Log so that a custom domain can be specified 2025-08-28 11:13:22 +02:00
Pim van Pelt
6bc0071bdb Add start/limit flags 2025-08-26 10:07:53 +02:00
Pim van Pelt
a044cb86bd Clarify port use for nginx 2025-08-25 17:40:03 +02:00
Pim van Pelt
6ecc5d7784 Add build step; add gen-roots step. 2025-08-25 17:38:46 +02:00
Pim van Pelt
ef0970044b Update README.md
Make it clear that this is for TesseraCT and models after Sunlight.
2025-08-25 15:34:22 +00:00
Pim van Pelt
38fe915b37 writeFileWithStatus() which shows 'Creating' for new, 'Updating' for changed and 'Unchanged' for files that won't change 2025-08-25 11:51:41 +02:00
Pim van Pelt
c9c1e81619 Use main listen for nginx 2025-08-25 11:41:16 +02:00
Pim van Pelt
92e3f6baac Change logo size. Add metrics. 2025-08-24 18:39:55 +02:00
Pim van Pelt
0ef1a18331 Avoid unicode 2025-08-24 15:48:46 +02:00
Pim van Pelt
849dacdc30 Add mkdir for key directory 2025-08-24 15:45:41 +02:00
Pim van Pelt
20607b54d5 Add listen port to example.yaml 2025-08-24 15:44:02 +02:00
Pim van Pelt
cbfa97d480 Add otelcol endpoint in .env file 2025-08-24 15:06:21 +02:00
Pim van Pelt
dc95d8d3bb Add gen-nginx to README 2025-08-24 15:06:09 +02:00
7 changed files with 6550 additions and 96 deletions

View File

@@ -1,6 +1,6 @@
# Cheese
A Certificate Transparency log configuration and deployment tool.
A Certificate Transparency log configuration and deployment tool for Google's [[TesseraCT](github.com/transparency-dev/tesseract)] implementation. It tries to look and feel a little like the one provided by [[Sunlight](https://github.com/FiloSottile/sunlight)].
## Configuration Generator
@@ -9,14 +9,20 @@ in a very similar way to Sunlight.
### Usage
1. **Create YAML configuration file:**
1. **Build the tool:**
```bash
go build -o tesseract-genconf ./tesseract/genconf/
```
2. **Create YAML configuration file:**
```yaml
listen:
- "[::]:16420"
- "[::]:8080"
roots: /etc/tesseract/roots.pem
logs:
- shortname: example2025h1
listen: "[::]:16900"
inception: 2025-01-01
submissionprefix: https://example2025h1.log.ct.example.com
monitoringprefix: https://example2025h1.mon.ct.example.com
@@ -27,20 +33,36 @@ logs:
notafterlimit: 2025-07-01T00:00:00Z
```
2. **Generate private keys:**
3. **Generate private keys:**
```bash
go run ./tesseract/genconf/main.go -c config.yaml gen-key
mkdir -p /etc/tesseract/keys
./tesseract-genconf -c config.yaml gen-key
```
3. **Create directories and generate environment files:**
4. **Create directories and generate environment files:**
```bash
mkdir -p /var/lib/tesseract/example2025h1/data
go run ./tesseract/genconf/main.go -c config.yaml gen-env
./tesseract-genconf -c config.yaml gen-env
```
4. **Generate HTML and JSON files:**
5. **Generate HTML and JSON files:**
```bash
go run ./tesseract/genconf/main.go -c config.yaml gen-html
./tesseract-genconf -c config.yaml gen-html
```
This generates `index.html`, `log.v3.json`, `.env`, and `roots.pem` files in each log's directory.
6. **Generate nginx configuration files:**
```bash
./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
`:8080`). You can symlink the generated $monitoringprefix.conf files from `/etc/nginx/sites-enabled/`.
7. **Generate root certificates (optional):**
```bash
# For testing/staging environment, take the ccadb 'testing' roots
./tesseract-genconf gen-roots --source https://rennet2027h2.log.ct.ipng.ch/ --output roots-staging.pem
# For production environment, take the ccadb 'production' roots
./tesseract-genconf gen-roots --source https://gouda2027h2.log.ct.ipng.ch/ --output roots-production.pem
```

6380
production-roots.pem Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@ package main
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
@@ -25,16 +24,15 @@ func generateEnv(yamlFile string) {
// Create combined roots.pem file
rootsPemPath := filepath.Join(logEntry.LocalDirectory, "roots.pem")
err := createCombinedRootsPem(config.Roots, logEntry.ExtraRoots, rootsPemPath)
err := createCombinedRootsPemWithStatus(config.Roots, logEntry.ExtraRoots, rootsPemPath)
if err != nil {
log.Fatalf("Failed to create %s: %v", rootsPemPath, err)
}
fmt.Printf("Generated %s\n", rootsPemPath)
// Build TESSERACT_ARGS string
args := []string{
fmt.Sprintf("--private_key=%s", logEntry.Secret),
fmt.Sprintf("--origin=%s.log.ct.ipng.ch", logEntry.ShortName),
fmt.Sprintf("--origin=%s.%s", logEntry.ShortName, logEntry.Domain),
fmt.Sprintf("--storage_dir=%s", logEntry.LocalDirectory),
fmt.Sprintf("--roots_pem_file=%s", rootsPemPath),
}
@@ -44,53 +42,43 @@ func generateEnv(yamlFile string) {
args = append(args, fmt.Sprintf("--http_endpoint=%s", logEntry.Listen))
}
tesseractArgs := strings.Join(args, " ")
envContent := fmt.Sprintf("TESSERACT_ARGS=\"%s\"\n", tesseractArgs)
// Add not_after flags if specified
if !logEntry.NotAfterStart.IsZero() {
args = append(args, fmt.Sprintf("--not_after_start=%s", logEntry.NotAfterStart.Format("2006-01-02T15:04:05Z")))
}
if !logEntry.NotAfterLimit.IsZero() {
args = append(args, fmt.Sprintf("--not_after_limit=%s", logEntry.NotAfterLimit.Format("2006-01-02T15:04:05Z")))
}
err = os.WriteFile(envPath, []byte(envContent), 0644)
tesseractArgs := strings.Join(args, " ")
envContent := fmt.Sprintf("TESSERACT_ARGS=\"%s\"\nOTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318\n", tesseractArgs)
err = writeFileWithStatus(envPath, []byte(envContent))
if err != nil {
log.Fatalf("Failed to write %s: %v", envPath, err)
}
fmt.Printf("Generated %s\n", envPath)
}
}
func createCombinedRootsPem(rootsFile, extraRootsFile, outputPath string) error {
// Create output file
outputFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %v", err)
}
defer outputFile.Close()
// Copy main roots file
func createCombinedRootsPemWithStatus(rootsFile, extraRootsFile, outputPath string) error {
// Read main roots file
var combinedContent []byte
if rootsFile != "" {
rootsData, err := os.Open(rootsFile)
rootsData, err := os.ReadFile(rootsFile)
if err != nil {
return fmt.Errorf("failed to open roots file %s: %v", rootsFile, err)
}
defer rootsData.Close()
_, err = io.Copy(outputFile, rootsData)
if err != nil {
return fmt.Errorf("failed to copy roots file: %v", err)
return fmt.Errorf("failed to read roots file %s: %v", rootsFile, err)
}
combinedContent = append(combinedContent, rootsData...)
}
// Append extra roots file if it exists
if extraRootsFile != "" {
extraRootsData, err := os.Open(extraRootsFile)
extraRootsData, err := os.ReadFile(extraRootsFile)
if err != nil {
return fmt.Errorf("failed to open extra roots file %s: %v", extraRootsFile, err)
}
defer extraRootsData.Close()
_, err = io.Copy(outputFile, extraRootsData)
if err != nil {
return fmt.Errorf("failed to copy extra roots file: %v", err)
return fmt.Errorf("failed to read extra roots file %s: %v", extraRootsFile, err)
}
combinedContent = append(combinedContent, extraRootsData...)
}
return nil
return writeFileWithStatus(outputPath, combinedContent)
}

View File

@@ -1,6 +1,7 @@
package main
import (
"bytes"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
@@ -58,25 +59,28 @@ const htmlTemplate = `<!DOCTYPE html>
<body>
<div class="container">
<p align="center">
<img alt="A TesseraCT logo, a stylized four-dimensional hypercube with on the left side penstrokes of orange with a padlock, and on the right penstrokes of dark blue with a magnifying glass." width="250" height="278" src="https://ipng.ch/assets/ctlog/tesseract-logo.png"> </p>
<img alt="A TesseraCT logo, a stylized four-dimensional hypercube with on the left side penstrokes of orange with a padlock, and on the right penstrokes of dark blue with a magnifying glass." width="260" height="250" src="https://ipng.ch/assets/ctlog/tesseract-logo.png"> </p>
<p>
This is a <a href="https://github.com/transparency-dev/tesseract">TesseraCT</a> Certificate Transparency log instance.
<p>
<a href="/metrics">Metrics</a> are available.
<hr>
<p>
The following logs are active.
The following logs are active:
{{range .Logs}}
<h2>{{.ShortName}}.log.ct.ipng.ch</h2>
<h2>{{.ShortName}}.{{.Domain}}</h2>
<p>
Log ID: <code>{{.LogID}}</code><br>
Monitoring prefix: <code>{{.MonitoringPrefix}}/</code><br>
Submission prefix: <code>{{.SubmissionPrefix}}/</code><br>
Interval: {{.NotAfterStart.Format "2006-01-02T15:04:05Z"}} {{.NotAfterLimit.Format "2006-01-02T15:04:05Z"}}<br>
Interval: {{.NotAfterStart.Format "2006-01-02T15:04:05Z"}} - {{.NotAfterLimit.Format "2006-01-02T15:04:05Z"}}<br>
Links: <a href="{{.MonitoringPrefix}}/checkpoint">checkpoint</a>
<a href="data:application/octet-stream;base64,{{.PublicKeyDERB64}}"
download="{{.ShortName}}.der">key</a>
@@ -133,27 +137,25 @@ func generateHTML(yamlFile string) {
for _, logEntry := range config.Logs {
indexPath := fmt.Sprintf("%s/index.html", logEntry.LocalDirectory)
file, err := os.Create(indexPath)
// Execute template to buffer
var buf bytes.Buffer
err := tmpl.Execute(&buf, config)
if err != nil {
log.Fatalf("Failed to create %s: %v", indexPath, err)
log.Fatalf("Failed to execute HTML template for %s: %v", indexPath, err)
}
err = tmpl.Execute(file, config)
// Write file with status
err = writeFileWithStatus(indexPath, buf.Bytes())
if err != nil {
file.Close()
log.Fatalf("Failed to write HTML to %s: %v", indexPath, err)
}
file.Close()
fmt.Printf("Generated %s\n", indexPath)
// Generate log.v3.json for this log
jsonPath := filepath.Join(logEntry.LocalDirectory, "log.v3.json")
err = generateLogJSON(logEntry, jsonPath)
err = generateLogJSONWithStatus(logEntry, jsonPath)
if err != nil {
log.Fatalf("Failed to generate %s: %v", jsonPath, err)
}
fmt.Printf("Generated %s\n", jsonPath)
}
}
@@ -207,9 +209,9 @@ func computeKeyInfo(logEntry *Log) error {
return nil
}
func generateLogJSON(logEntry Log, outputPath string) error {
func generateLogJSONWithStatus(logEntry Log, outputPath string) error {
logJSON := LogV3JSON{
Description: fmt.Sprintf("%s.log.ct.ipng.ch", logEntry.ShortName),
Description: fmt.Sprintf("%s.%s", logEntry.ShortName, logEntry.Domain),
SubmissionURL: fmt.Sprintf("%s/", logEntry.SubmissionPrefix),
MonitoringURL: fmt.Sprintf("%s/", logEntry.MonitoringPrefix),
TemporalInterval: TemporalInterval{
@@ -226,10 +228,5 @@ func generateLogJSON(logEntry Log, outputPath string) error {
return fmt.Errorf("failed to marshal JSON: %v", err)
}
err = os.WriteFile(outputPath, jsonData, 0644)
if err != nil {
return fmt.Errorf("failed to write JSON file: %v", err)
}
return nil
return writeFileWithStatus(outputPath, jsonData)
}

View File

@@ -19,6 +19,7 @@ type Config struct {
type Log struct {
ShortName string `yaml:"shortname"`
Domain string `yaml:"domain"`
Inception string `yaml:"inception"`
Period int `yaml:"period"`
PoolSize int `yaml:"poolsize"`
@@ -79,19 +80,55 @@ func loadConfig(yamlFile string) Config {
log.Fatalf("Failed to parse YAML: %v", err)
}
// Set defaults for log entries
for i := range config.Logs {
if config.Logs[i].PoolSize == 0 {
config.Logs[i].PoolSize = 750
// Set default listen port if not configured
if len(config.Listen) == 0 {
config.Listen = []string{":8080"}
}
// 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)
}
if config.Logs[i].Period == 0 {
config.Logs[i].Period = 200
}
if config.Logs[i].PoolSize == 0 {
config.Logs[i].PoolSize = 750
}
}
return config
}
func writeFileWithStatus(filename string, content []byte) error {
existingContent, err := os.ReadFile(filename)
if os.IsNotExist(err) {
fmt.Printf("Creating %s\n", filename)
} else if err != nil {
return fmt.Errorf("failed to read existing file %s: %v", filename, err)
} else if string(existingContent) == string(content) {
fmt.Printf("Unchanged %s\n", filename)
return nil
} else {
fmt.Printf("Updating %s\n", filename)
}
err = os.WriteFile(filename, content, 0644)
if err != nil {
return fmt.Errorf("failed to write file %s: %v", filename, err)
}
return nil
}
func showHelp() {
fmt.Printf("Usage: %s [options] <command>\n\n", os.Args[0])
fmt.Printf("Options:\n")

View File

@@ -1,6 +1,7 @@
package main
import (
"bytes"
"fmt"
"net/url"
"os"
@@ -10,8 +11,8 @@ import (
)
const nginxTemplate = `server {
listen 8080;
listen [::]:8080;
listen {{.ListenPort}};
listen [::]:{{.ListenPort}};
# Replace with your actual domain(s)
server_name {{.MonitoringHost}};
@@ -72,11 +73,21 @@ const nginxTemplate = `server {
type NginxTemplateData struct {
MonitoringHost string
LocalDirectory string
ListenPort string
}
func generateNginx(yamlFile string) {
config := loadConfig(yamlFile)
// Extract port from first listen address
listenPort := "8080" // fallback default
if len(config.Listen) > 0 {
port := extractPort(config.Listen[0])
if port != "" {
listenPort = port
}
}
for _, log := range config.Logs {
// Extract hostname from monitoring prefix
hostname, err := extractHostname(log.MonitoringPrefix)
@@ -89,6 +100,7 @@ func generateNginx(yamlFile string) {
data := NginxTemplateData{
MonitoringHost: hostname,
LocalDirectory: log.LocalDirectory,
ListenPort: listenPort,
}
// Parse and execute template
@@ -102,23 +114,40 @@ func generateNginx(yamlFile string) {
outputFilename := fmt.Sprintf("%s.conf", hostname)
outputPath := filepath.Join(log.LocalDirectory, outputFilename)
// Create output file
file, err := os.Create(outputPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create nginx config file %s: %v\n", outputPath, err)
continue
}
defer file.Close()
// Execute template
err = tmpl.Execute(file, data)
// Execute template to buffer
var buf bytes.Buffer
err = tmpl.Execute(&buf, data)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to execute nginx template for %s: %v\n", outputPath, err)
continue
}
fmt.Printf("Generated nginx config: %s\n", outputPath)
// Write file with status
err = writeFileWithStatus(outputPath, buf.Bytes())
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to write nginx config file %s: %v\n", outputPath, err)
continue
}
}
}
func extractPort(listenAddr string) string {
// Handle common listen address formats:
// ":8080" -> "8080"
// "localhost:8080" -> "8080"
// "[::]:8080" -> "8080"
if strings.HasPrefix(listenAddr, ":") {
return listenAddr[1:] // Remove the leading ":"
}
// For addresses with host:port format
if strings.Contains(listenAddr, ":") {
parts := strings.Split(listenAddr, ":")
return parts[len(parts)-1] // Return the last part (port)
}
return ""
}
func extractHostname(urlStr string) (string, error) {

View File

@@ -1,6 +1,7 @@
package main
import (
"bytes"
"crypto/x509"
"encoding/base64"
"encoding/json"
@@ -8,7 +9,6 @@ import (
"fmt"
"log"
"net/http"
"os"
"strings"
)
@@ -67,15 +67,10 @@ func generateRoots(args []string) {
log.Fatalf("Failed to parse JSON response: %v", err)
}
// Create output file
outFile, err := os.Create(outputFile)
if err != nil {
log.Fatalf("Failed to create output file %s: %v", outputFile, err)
}
defer outFile.Close()
// Write each certificate as PEM
// Collect all valid certificates in a buffer
var pemBuffer bytes.Buffer
validCertCount := 0
for _, certBase64 := range rootsResp.Certificates {
// Decode base64 certificate
certBytes, err := base64.StdEncoding.DecodeString(certBase64)
@@ -102,14 +97,20 @@ func generateRoots(args []string) {
Bytes: certBytes,
}
// Write PEM to file
err = pem.Encode(outFile, pemBlock)
// Write PEM to buffer
err = pem.Encode(&pemBuffer, pemBlock)
if err != nil {
log.Fatalf("Failed to write PEM certificate: %v", err)
log.Fatalf("Failed to encode PEM certificate: %v", err)
}
validCertCount++
}
// Write all certificates to file with status
err = writeFileWithStatus(outputFile, pemBuffer.Bytes())
if err != nil {
log.Fatalf("Failed to write output file %s: %v", outputFile, err)
}
fmt.Printf("Successfully wrote %d certificates to %s (out of %d total)\n", validCertCount, outputFile, len(rootsResp.Certificates))
}