27 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
Pim van Pelt
efa92a73bd Add gen-nginx for the read path 2025-08-24 14:05:26 +02:00
Pim van Pelt
ca6797c1f6 Add a templatable unit-file for TesseraCT 2025-08-24 13:10:12 +02:00
Pim van Pelt
be833e189a Add per-log listen statement 2025-08-24 12:02:50 +02:00
Pim van Pelt
0b12cbca62 Skip PEM with negative serial number 2025-08-24 12:02:37 +02:00
Pim van Pelt
a3d3c4c643 Refactor each command in its own file 2025-08-24 11:44:28 +02:00
Pim van Pelt
8003270329 Add get-roots 2025-08-24 11:39:03 +02:00
Pim van Pelt
a508beefba Add simple README and .gitignore 2025-08-24 11:17:03 +02:00
Pim van Pelt
4638cce236 Add defaults 2025-08-24 11:16:43 +02:00
Pim van Pelt
9b1dd06acf go fmt 2025-08-24 11:11:25 +02:00
Pim van Pelt
f26322e56b Add gen-key helper 2025-08-24 11:11:08 +02:00
Pim van Pelt
0a534c62bd Add help output 2025-08-24 11:07:04 +02:00
Pim van Pelt
b6e458d012 Add .env and TESSERACT_ARGS generator in gen-env; add as well the roots.pem file 2025-08-24 11:03:36 +02:00
Pim van Pelt
0e97b2d872 Add JSON output in gen-html 2025-08-24 10:51:15 +02:00
Pim van Pelt
ebfa490a49 First stab at tesseract config generator. Generate HTML files in each of the log dirs 2025-08-24 10:46:47 +02:00
12 changed files with 7280 additions and 0 deletions

2
.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,68 @@
# Cheese
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
The `tesseract/genconf` tool generates CT log configuration files and keys from a YAML specification
in a very similar way to Sunlight.
### Usage
1. **Build the tool:**
```bash
go build -o tesseract-genconf ./tesseract/genconf/
```
2. **Create YAML configuration file:**
```yaml
listen:
- "[::]: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
extraroots: /etc/tesseract/extra-roots.pem
secret: /etc/tesseract/keys/example2025h1.pem
localdirectory: /var/lib/tesseract/example2025h1/data
notafterstart: 2025-01-01T00:00:00Z
notafterlimit: 2025-07-01T00:00:00Z
```
3. **Generate private keys:**
```bash
mkdir -p /etc/tesseract/keys
./tesseract-genconf -c config.yaml gen-key
```
4. **Create directories and generate environment files:**
```bash
mkdir -p /var/lib/tesseract/example2025h1/data
./tesseract-genconf -c config.yaml gen-env
```
5. **Generate HTML and JSON files:**
```bash
./tesseract-genconf -c config.yaml gen-html
```
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
```

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module cheese
go 1.24.4
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View File

@@ -0,0 +1,4 @@
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

6380
production-roots.pem Normal file

File diff suppressed because it is too large Load Diff

84
tesseract/genconf/env.go Normal file
View File

@@ -0,0 +1,84 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
)
func generateEnv(yamlFile string) {
config := loadConfig(yamlFile)
// Check that all local directories exist
for _, logEntry := range config.Logs {
if _, err := os.Stat(logEntry.LocalDirectory); os.IsNotExist(err) {
log.Fatalf("User is required to create %s", logEntry.LocalDirectory)
}
}
// Generate .env file for each log
for _, logEntry := range config.Logs {
envPath := filepath.Join(logEntry.LocalDirectory, ".env")
// Create combined roots.pem file
rootsPemPath := filepath.Join(logEntry.LocalDirectory, "roots.pem")
err := createCombinedRootsPemWithStatus(config.Roots, logEntry.ExtraRoots, rootsPemPath)
if err != nil {
log.Fatalf("Failed to create %s: %v", rootsPemPath, err)
}
// Build TESSERACT_ARGS string
args := []string{
fmt.Sprintf("--private_key=%s", logEntry.Secret),
fmt.Sprintf("--origin=%s.%s", logEntry.ShortName, logEntry.Domain),
fmt.Sprintf("--storage_dir=%s", logEntry.LocalDirectory),
fmt.Sprintf("--roots_pem_file=%s", rootsPemPath),
}
// Add http_endpoint if Listen is specified
if logEntry.Listen != "" {
args = append(args, fmt.Sprintf("--http_endpoint=%s", logEntry.Listen))
}
// 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")))
}
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)
}
}
}
func createCombinedRootsPemWithStatus(rootsFile, extraRootsFile, outputPath string) error {
// Read main roots file
var combinedContent []byte
if rootsFile != "" {
rootsData, err := os.ReadFile(rootsFile)
if err != nil {
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.ReadFile(extraRootsFile)
if err != nil {
return fmt.Errorf("failed to read extra roots file %s: %v", extraRootsFile, err)
}
combinedContent = append(combinedContent, extraRootsData...)
}
return writeFileWithStatus(outputPath, combinedContent)
}

232
tesseract/genconf/html.go Normal file
View File

@@ -0,0 +1,232 @@
package main
import (
"bytes"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"log"
"os"
"path/filepath"
"text/template"
)
const htmlTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TesseraCT</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bitter&family=Raleway&family=Source+Code+Pro&display=swap" rel="stylesheet">
<style>
.container {
width: auto;
max-width: 700px;
padding: 0 15px;
margin: 80px auto;
}
body {
font-family: "Raleway", sans-serif;
line-height: 1.4;
}
h1, h2, h3 {
font-family: "Bitter", serif;
}
code {
font-family: "Source Code Pro", monospace;
-webkit-font-smoothing: antialiased;
}
.response {
white-space: wrap;
word-break: break-all;
}
</style>
</head>
<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="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:
{{range .Logs}}
<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>
Links: <a href="{{.MonitoringPrefix}}/checkpoint">checkpoint</a>
<a href="data:application/octet-stream;base64,{{.PublicKeyDERB64}}"
download="{{.ShortName}}.der">key</a>
<a href="{{.SubmissionPrefix}}/ct/v1/get-roots">get-roots</a>
<a href="{{.MonitoringPrefix}}/log.v3.json">json</a><br>
Ratelimit: {{.PoolSize}} req/s
<pre><code>{{.PublicKeyPEM}}</code></pre>
{{end}}
</body>
</html>
`
type LogV3JSON struct {
Description string `json:"description"`
SubmissionURL string `json:"submission_url"`
MonitoringURL string `json:"monitoring_url"`
TemporalInterval TemporalInterval `json:"temporal_interval"`
LogID string `json:"log_id"`
Key string `json:"key"`
MMD int `json:"mmd"`
}
type TemporalInterval struct {
StartInclusive string `json:"start_inclusive"`
EndExclusive string `json:"end_exclusive"`
}
func generateHTML(yamlFile string) {
config := loadConfig(yamlFile)
// Check that all local directories exist
for _, logEntry := range config.Logs {
if _, err := os.Stat(logEntry.LocalDirectory); os.IsNotExist(err) {
log.Fatalf("User is required to create %s", logEntry.LocalDirectory)
}
}
// Compute key information for each log
for i := range config.Logs {
err := computeKeyInfo(&config.Logs[i])
if err != nil {
log.Fatalf("Failed to compute key info for %s: %v", config.Logs[i].ShortName, err)
}
}
tmpl, err := template.New("html").Parse(htmlTemplate)
if err != nil {
log.Fatalf("Failed to parse template: %v", err)
}
// Write HTML file to each log's local directory
for _, logEntry := range config.Logs {
indexPath := fmt.Sprintf("%s/index.html", logEntry.LocalDirectory)
// Execute template to buffer
var buf bytes.Buffer
err := tmpl.Execute(&buf, config)
if err != nil {
log.Fatalf("Failed to execute HTML template for %s: %v", indexPath, err)
}
// Write file with status
err = writeFileWithStatus(indexPath, buf.Bytes())
if err != nil {
log.Fatalf("Failed to write HTML to %s: %v", indexPath, err)
}
// Generate log.v3.json for this log
jsonPath := filepath.Join(logEntry.LocalDirectory, "log.v3.json")
err = generateLogJSONWithStatus(logEntry, jsonPath)
if err != nil {
log.Fatalf("Failed to generate %s: %v", jsonPath, err)
}
}
}
func computeKeyInfo(logEntry *Log) error {
// Read the private key file
keyData, err := os.ReadFile(logEntry.Secret)
if err != nil {
return fmt.Errorf("failed to read key file: %v", err)
}
// Parse PEM block
block, _ := pem.Decode(keyData)
if block == nil {
return fmt.Errorf("failed to decode PEM block")
}
// Parse EC private key
privKey, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return fmt.Errorf("failed to parse EC private key: %v", err)
}
// Extract public key
pubKey := &privKey.PublicKey
// Convert public key to DER format
pubKeyDER, err := x509.MarshalPKIXPublicKey(pubKey)
if err != nil {
return fmt.Errorf("failed to marshal public key: %v", err)
}
// Create PEM format
pubKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: pubKeyDER,
})
// Compute Log ID (SHA-256 of the DER-encoded public key)
logIDBytes := sha256.Sum256(pubKeyDER)
logID := base64.StdEncoding.EncodeToString(logIDBytes[:])
// Base64 encode DER for download link
pubKeyDERB64 := base64.StdEncoding.EncodeToString(pubKeyDER)
// Set computed fields
logEntry.LogID = logID
logEntry.PublicKeyPEM = string(pubKeyPEM)
logEntry.PublicKeyDERB64 = pubKeyDERB64
logEntry.PublicKeyBase64 = pubKeyDERB64 // Same as DER base64 for JSON
return nil
}
func generateLogJSONWithStatus(logEntry Log, outputPath string) error {
logJSON := LogV3JSON{
Description: fmt.Sprintf("%s.%s", logEntry.ShortName, logEntry.Domain),
SubmissionURL: fmt.Sprintf("%s/", logEntry.SubmissionPrefix),
MonitoringURL: fmt.Sprintf("%s/", logEntry.MonitoringPrefix),
TemporalInterval: TemporalInterval{
StartInclusive: logEntry.NotAfterStart.Format("2006-01-02T15:04:05Z"),
EndExclusive: logEntry.NotAfterLimit.Format("2006-01-02T15:04:05Z"),
},
LogID: logEntry.LogID,
Key: logEntry.PublicKeyBase64,
MMD: 60, // Default MMD of 60 seconds
}
jsonData, err := json.MarshalIndent(logJSON, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON: %v", err)
}
return writeFileWithStatus(outputPath, jsonData)
}

57
tesseract/genconf/key.go Normal file
View File

@@ -0,0 +1,57 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
"log"
"os"
"path/filepath"
)
func generateKeys(yamlFile string) {
config := loadConfig(yamlFile)
// Generate keys for each log
for _, logEntry := range config.Logs {
// Check if key already exists
if _, err := os.Stat(logEntry.Secret); err == nil {
fmt.Printf("Key already exists: %s (skipped)\n", logEntry.Secret)
continue
}
// Generate new prime256v1 key
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
log.Fatalf("Failed to generate key for %s: %v", logEntry.ShortName, err)
}
// Marshal private key to DER format
privKeyDER, err := x509.MarshalECPrivateKey(privKey)
if err != nil {
log.Fatalf("Failed to marshal private key for %s: %v", logEntry.ShortName, err)
}
// Create PEM block
privKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "EC PRIVATE KEY",
Bytes: privKeyDER,
})
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(logEntry.Secret), 0755); err != nil {
log.Fatalf("Failed to create directory for %s: %v", logEntry.Secret, err)
}
// Write key to file
err = os.WriteFile(logEntry.Secret, privKeyPEM, 0600)
if err != nil {
log.Fatalf("Failed to write key file %s: %v", logEntry.Secret, err)
}
fmt.Printf("Generated %s\n", logEntry.Secret)
}
}

150
tesseract/genconf/main.go Normal file
View File

@@ -0,0 +1,150 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"time"
"gopkg.in/yaml.v3"
)
type Config struct {
Listen []string `yaml:"listen"`
Checkpoints string `yaml:"checkpoints"`
Roots string `yaml:"roots"`
Logs []Log `yaml:"logs"`
}
type Log struct {
ShortName string `yaml:"shortname"`
Domain string `yaml:"domain"`
Inception string `yaml:"inception"`
Period int `yaml:"period"`
PoolSize int `yaml:"poolsize"`
SubmissionPrefix string `yaml:"submissionprefix"`
MonitoringPrefix string `yaml:"monitoringprefix"`
CCadbRoots string `yaml:"ccadbroots"`
ExtraRoots string `yaml:"extraroots"`
Secret string `yaml:"secret"`
Cache string `yaml:"cache"`
LocalDirectory string `yaml:"localdirectory"`
Listen string `yaml:"listen"`
NotAfterStart time.Time `yaml:"notafterstart"`
NotAfterLimit time.Time `yaml:"notafterlimit"`
// Computed fields
LogID string
PublicKeyPEM string
PublicKeyDERB64 string
PublicKeyBase64 string
}
func main() {
configFile := flag.String("c", "./tesseract-staging.yaml", "Path to the YAML configuration file")
flag.Parse()
args := flag.Args()
if len(args) == 0 {
showHelp()
return
}
switch args[0] {
case "gen-html":
generateHTML(*configFile)
case "gen-env":
generateEnv(*configFile)
case "gen-key":
generateKeys(*configFile)
case "gen-nginx":
generateNginx(*configFile)
case "gen-roots":
generateRoots(args[1:])
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", args[0])
showHelp()
os.Exit(1)
}
}
func loadConfig(yamlFile string) Config {
data, err := os.ReadFile(yamlFile)
if err != nil {
log.Fatalf("Failed to read YAML file: %v", err)
}
var config Config
err = yaml.Unmarshal(data, &config)
if err != nil {
log.Fatalf("Failed to parse YAML: %v", err)
}
// 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")
fmt.Printf(" -c <file> Path to YAML configuration file (default: ./tesseract-staging.yaml)\n\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(" Creates HTML pages with log information and CT log metadata JSON.\n")
fmt.Printf(" Computes LOG_ID and public keys from private keys.\n\n")
fmt.Printf(" gen-env Generate .env files and combined roots.pem in each log's localdirectory.\n")
fmt.Printf(" Creates TESSERACT_ARGS environment variable with command line flags.\n")
fmt.Printf(" Combines global roots and log-specific extraroots into roots.pem.\n\n")
fmt.Printf(" gen-key Generate prime256v1 private keys for each log (only if they don't exist).\n")
fmt.Printf(" Creates EC private key files at the path specified in log.secret.\n\n")
fmt.Printf(" gen-nginx Generate nginx configuration files for each log's monitoring endpoint.\n")
fmt.Printf(" Creates nginx-<hostname>.conf files in each log's localdirectory.\n\n")
fmt.Printf(" gen-roots Download root certificates from a Certificate Transparency log.\n")
fmt.Printf(" Options: --source <url> (default: https://rennet2027h2.log.ct.ipng.ch/)\n")
fmt.Printf(" --output <file> (default: roots.pem)\n\n")
}

164
tesseract/genconf/nginx.go Normal file
View File

@@ -0,0 +1,164 @@
package main
import (
"bytes"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"text/template"
)
const nginxTemplate = `server {
listen {{.ListenPort}};
listen [::]:{{.ListenPort}};
# Replace with your actual domain(s)
server_name {{.MonitoringHost}};
# Document root for static files
root {{.LocalDirectory}};
location = / {
try_files /index.html =404;
add_header Content-Type "text/html; charset=utf-8" always;
add_header Access-Control-Allow-Origin "*" always;
}
# Checkpoint endpoint - no caching
location = /checkpoint {
try_files /checkpoint =404;
add_header Content-Type "text/plain; charset=utf-8" always;
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "no-store" always;
}
# Log info endpoint
location = /log.v3.json {
try_files /log.v3.json =404;
add_header Content-Type "application/json" always;
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=3600, immutable" always;
}
# Issuer certificate endpoint - long cache
location ~ ^/issuer/(.+)$ {
try_files /issuer/$1 =404;
add_header Content-Type "application/pkix-cert" always;
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=604800, immutable" always;
}
# Tile data endpoint - long cache, may have gzip
location ~ ^/tile/(.+)$ {
try_files /tile/$1 =404;
add_header Content-Type "application/octet-stream" always;
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=604800, immutable" always;
# Gzip encoding for .gz files
location ~ \.gz$ {
add_header Content-Encoding "gzip" always;
}
}
}
`
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)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to extract hostname from %s: %v\n", log.MonitoringPrefix, err)
continue
}
// Create template data
data := NginxTemplateData{
MonitoringHost: hostname,
LocalDirectory: log.LocalDirectory,
ListenPort: listenPort,
}
// Parse and execute template
tmpl, err := template.New("nginx").Parse(nginxTemplate)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse nginx template: %v\n", err)
continue
}
// Generate output filename using only hostname part
outputFilename := fmt.Sprintf("%s.conf", hostname)
outputPath := filepath.Join(log.LocalDirectory, outputFilename)
// 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
}
// 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) {
if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") {
urlStr = "https://" + urlStr
}
parsedURL, err := url.Parse(urlStr)
if err != nil {
return "", err
}
return parsedURL.Hostname(), nil
}

116
tesseract/genconf/roots.go Normal file
View File

@@ -0,0 +1,116 @@
package main
import (
"bytes"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"log"
"net/http"
"strings"
)
type CTLogRootsResponse struct {
Certificates []string `json:"certificates"`
}
func generateRoots(args []string) {
sourceURL := "https://rennet2027h2.log.ct.ipng.ch/"
outputFile := "roots.pem"
// Parse command line arguments
for i := 0; i < len(args); i++ {
switch args[i] {
case "--source":
if i+1 >= len(args) {
log.Fatal("--source flag requires a URL argument")
}
sourceURL = args[i+1]
i++ // Skip the next argument since we used it
case "--output":
if i+1 >= len(args) {
log.Fatal("--output flag requires a filename argument")
}
outputFile = args[i+1]
i++ // Skip the next argument since we used it
default:
log.Fatalf("Unknown argument: %s", args[i])
}
}
// Ensure source URL ends with /
if !strings.HasSuffix(sourceURL, "/") {
sourceURL += "/"
}
// Construct the get-roots URL
getRootsURL := sourceURL + "ct/v1/get-roots"
// Fetch roots from CT log
fmt.Printf("Fetching roots from: %s\n", getRootsURL)
resp, err := http.Get(getRootsURL)
if err != nil {
log.Fatalf("Failed to fetch roots: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Fatalf("HTTP request failed with status: %d", resp.StatusCode)
}
// Parse JSON response
var rootsResp CTLogRootsResponse
err = json.NewDecoder(resp.Body).Decode(&rootsResp)
if err != nil {
log.Fatalf("Failed to parse JSON response: %v", err)
}
// 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)
if err != nil {
log.Fatalf("Failed to decode certificate: %v", err)
}
// Parse X.509 certificate to check serial number
cert, err := x509.ParseCertificate(certBytes)
if err != nil {
log.Printf("Warning: Failed to parse certificate, skipping: %v", err)
continue
}
// Check for negative serial number
if cert.SerialNumber.Sign() < 0 {
log.Printf("Warning: Certificate with negative serial number found, skipping (serial: %s)", cert.SerialNumber.String())
continue
}
// Create PEM block
pemBlock := &pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
}
// Write PEM to buffer
err = pem.Encode(&pemBuffer, pemBlock)
if err != nil {
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))
}

18
tesseract@.service Normal file
View File

@@ -0,0 +1,18 @@
[Unit]
Description=Tesseract CT Log service for %i
ConditionFileExists=/ssd-vol0/logs/%i/data/.env
After=network.target
[Service]
# The %i here refers to the instance name, e.g., "lipase2025h2"
# This path should point to where your instance-specific .env files are located
EnvironmentFile=/ssd-vol0/logs/%i/data/.env
ExecStart=/home/ctlog/bin/tesseract-posix $TESSERACT_ARGS
User=ctlog
Group=ctlog
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target