Compare commits
29 Commits
a12eccfc1d
...
checkconf
Author | SHA1 | Date | |
---|---|---|---|
61097dc961 | |||
9db46db7ca | |||
d2c564a000 | |||
|
6bc0071bdb | ||
|
a044cb86bd | ||
|
6ecc5d7784 | ||
|
ef0970044b | ||
|
38fe915b37 | ||
|
c9c1e81619 | ||
|
92e3f6baac | ||
|
0ef1a18331 | ||
|
849dacdc30 | ||
|
20607b54d5 | ||
|
cbfa97d480 | ||
|
dc95d8d3bb | ||
|
efa92a73bd | ||
|
ca6797c1f6 | ||
|
be833e189a | ||
|
0b12cbca62 | ||
|
a3d3c4c643 | ||
|
8003270329 | ||
|
a508beefba | ||
|
4638cce236 | ||
|
9b1dd06acf | ||
|
f26322e56b | ||
|
0a534c62bd | ||
|
b6e458d012 | ||
|
0e97b2d872 | ||
|
ebfa490a49 |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
tesseract-genconf
|
||||
roots.pem
|
68
README.md
68
README.md
@@ -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
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module cheese
|
||||
|
||||
go 1.24.4
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
4
go.sum
Normal file
4
go.sum
Normal 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
6380
production-roots.pem
Normal file
File diff suppressed because it is too large
Load Diff
84
tesseract/genconf/env.go
Normal file
84
tesseract/genconf/env.go
Normal 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
232
tesseract/genconf/html.go
Normal 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
57
tesseract/genconf/key.go
Normal 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 for log %s: %s (skipped)\n", logEntry.ShortName, 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 log %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 log %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 for log %s: %v", logEntry.Secret, logEntry.ShortName, err)
|
||||
}
|
||||
|
||||
// Write key to file
|
||||
err = os.WriteFile(logEntry.Secret, privKeyPEM, 0600)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to write key file %s for log %s: %v", logEntry.Secret, logEntry.ShortName, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Generated %s\n", logEntry.Secret)
|
||||
}
|
||||
}
|
200
tesseract/genconf/main.go
Normal file
200
tesseract/genconf/main.go
Normal file
@@ -0,0 +1,200 @@
|
||||
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"}
|
||||
}
|
||||
|
||||
// Checkpoints & Roots are not used in-code, not checking for being set/valid
|
||||
|
||||
// Ensure there are logs configured
|
||||
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 {
|
||||
config.Logs[i].Period = 200
|
||||
}
|
||||
|
||||
if config.Logs[i].PoolSize == 0 {
|
||||
config.Logs[i].PoolSize = 750
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
164
tesseract/genconf/nginx.go
Normal 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
116
tesseract/genconf/roots.go
Normal 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
18
tesseract@.service
Normal 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
|
||||
|
Reference in New Issue
Block a user