Files
ctool/cmd/ctfetch/main.go
2026-04-05 22:54:29 +02:00

183 lines
4.9 KiB
Go

// Command ctfetch fetches and dumps entries from a Static CT log.
//
// Two modes:
//
// ctfetch [flags] <log-url> <leaf-index> [+sct] [+issuer] [+ctlog] fetch one entry by leaf index
// ctfetch [flags] <tile-url-or-file> [+sct] [+issuer] [+ctlog] dump all entries in a tile
//
// (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"strconv"
"strings"
"ctfetch/internal/utils"
"filippo.io/sunlight"
"golang.org/x/mod/sumdb/tlog"
)
func main() {
logsListURL := flag.String("logs-list-url", "https://www.gstatic.com/ct/log_list/v3/all_logs_list.json", "URL of the CT log list JSON")
monitoringURL := flag.String("monitoring-url", "", "log root URL for issuer lookups when input is a file")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage:\n")
fmt.Fprintf(os.Stderr, " %s [flags] <log-url> <leaf-index> [+sct] [+issuer] [+ctlog] fetch one entry\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s [flags] <tile-url-or-file> [+sct] [+issuer] [+ctlog] dump all entries in a tile\n", os.Args[0])
fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " %s https://halloumi2026h1.mon.ct.ipng.ch 457683896 +sct +issuer +ctlog\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s https://halloumi2026h1.mon.ct.ipng.ch/tile/data/x002/x460/135 +sct\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s --monitoring-url https://halloumi2026h1.mon.ct.ipng.ch tile.bin +issuer\n", os.Args[0])
fmt.Fprintf(os.Stderr, "\nFlags:\n")
flag.PrintDefaults()
}
flag.Parse()
if flag.NArg() < 1 {
flag.Usage()
os.Exit(1)
}
// Determine mode: if second positional arg parses as an integer → leaf-index mode.
_, secondIsInt := func() (int64, bool) {
if flag.NArg() < 2 {
return 0, false
}
v, err := strconv.ParseInt(flag.Arg(1), 10, 64)
return v, err == nil
}()
var modifiers []string
if secondIsInt {
modifiers = flag.Args()[2:]
} else {
modifiers = flag.Args()[1:]
}
opts := utils.Options{}
for _, arg := range modifiers {
switch arg {
case "+sct":
opts.ShowSCT = true
case "+issuer":
opts.ShowIssuer = true
case "+ctlog":
opts.ShowCTLog = true
default:
fatal("unknown argument %q (expected +sct, +issuer, or +ctlog)", arg)
}
}
if opts.ShowCTLog && *logsListURL != "" {
ctlogs, err := utils.FetchCTLogList(*logsListURL)
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: could not fetch CT log list: %v\n", err)
} else {
opts.CTLogs = ctlogs
}
}
if secondIsInt {
runLeafIndex(flag.Arg(0), flag.Arg(1), opts)
} else {
runTileDump(flag.Arg(0), *monitoringURL, opts)
}
}
func runLeafIndex(logURL, indexStr string, opts utils.Options) {
logURL = strings.TrimSuffix(logURL, "/")
opts.LogURL = logURL
leafIndex, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
fatal("invalid leaf index: %v", err)
}
tile := tlog.TileForIndex(sunlight.TileHeight, tlog.StoredHashIndex(0, leafIndex))
tile.L = -1
partialPath := sunlight.TilePath(tile)
positionInTile := leafIndex % sunlight.TileWidth
tileData, err := utils.FetchTile(logURL + "/" + partialPath)
if err != nil {
fatal("failed to fetch tile: %v", err)
}
tileData, err = utils.Decompress(tileData)
if err != nil {
fatal("failed to decompress tile: %v", err)
}
entry, err := utils.DumpEntryAtPosition(tileData, int(positionInTile), leafIndex, opts)
if err != nil {
fatal("%v", err)
}
printJSON(entry)
}
func runTileDump(arg, monitoringURL string, opts utils.Options) {
var tileData []byte
var err error
if strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://") {
// Derive log root from tile URL for issuer lookups.
if opts.ShowIssuer {
if idx := strings.Index(arg, "/tile/"); idx != -1 {
opts.LogURL = strings.TrimSuffix(arg[:idx], "/")
} else if monitoringURL != "" {
opts.LogURL = strings.TrimSuffix(monitoringURL, "/")
} else {
fatal("+issuer requires a log root URL; none could be derived from %q and --monitoring-url is not set", arg)
}
}
tileData, err = utils.FetchTile(arg)
if err != nil {
fatal("failed to fetch tile: %v", err)
}
} else {
// File input.
if opts.ShowIssuer {
if monitoringURL != "" {
opts.LogURL = strings.TrimSuffix(monitoringURL, "/")
} else {
fatal("+issuer requires --monitoring-url when input is a file")
}
}
tileData, err = os.ReadFile(arg)
if err != nil {
fatal("failed to read file: %v", err)
}
}
tileData, err = utils.Decompress(tileData)
if err != nil {
fatal("failed to decompress tile: %v", err)
}
result, err := utils.DumpAllEntries(tileData, opts)
if err != nil {
fatal("%v", err)
}
printJSON(result)
}
func printJSON(v interface{}) {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
fatal("failed to marshal JSON: %v", err)
}
fmt.Println(string(data))
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...)
os.Exit(1)
}