Introduces a static-binary build and Debian package (amd64/arm64) with version/commit/date stamped via -ldflags. Ships section-1 manpages for ctool, ctfetch, and ctail. Adds a `version` subcommand reachable as `ctool version`, `ctool -version`, `ctool --version`, `ctool fetch version`, `ctool tail version`, and via the ctfetch/ctail symlinks. Adds tests covering the dispatcher, fetch/tail argument parsing, and the formatter/helper functions. Adds a retrofit design document modelled on the vpp-maglev one, with FRs and NFRs for each tool and the dispatcher. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
179 lines
4.5 KiB
Go
179 lines
4.5 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"filippo.io/sunlight"
|
|
"git.ipng.ch/certificate-transparency/ctfetch/internal/utils"
|
|
"golang.org/x/mod/sumdb/tlog"
|
|
)
|
|
|
|
func runFetch(args []string) {
|
|
if len(args) > 0 && args[0] == "version" {
|
|
printVersion()
|
|
return
|
|
}
|
|
fs := flag.NewFlagSet("fetch", flag.ContinueOnError)
|
|
logsListURL := fs.String("logs-list-url", "https://www.gstatic.com/ct/log_list/v3/all_logs_list.json", "URL of the CT log list JSON")
|
|
monitoringURL := fs.String("monitoring-url", "", "log root URL for issuer lookups when input is a file")
|
|
fs.Usage = func() {
|
|
n := cmdName("fetch")
|
|
fmt.Fprintf(os.Stderr, "Usage:\n")
|
|
fmt.Fprintf(os.Stderr, " %s [flags] <log-url> <leaf-index> [+sct] [+issuer] [+ctlog] [+all] fetch one entry\n", n)
|
|
fmt.Fprintf(os.Stderr, " %s [flags] <tile-url-or-file> [+sct] [+issuer] [+ctlog] [+all] dump all entries in a tile\n", n)
|
|
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
|
fmt.Fprintf(os.Stderr, " %s https://halloumi2026h1.mon.ct.ipng.ch 629794635 +all\n", n)
|
|
fmt.Fprintf(os.Stderr, " %s https://halloumi2026h1.mon.ct.ipng.ch/tile/data/x002/x460/135 +sct\n", n)
|
|
fmt.Fprintf(os.Stderr, "\nFlags:\n")
|
|
fs.PrintDefaults()
|
|
}
|
|
|
|
if err := fs.Parse(args); err != nil {
|
|
if err == flag.ErrHelp {
|
|
os.Exit(0)
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
|
|
if fs.NArg() < 1 {
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Determine mode: if second positional arg parses as an integer → leaf-index mode.
|
|
_, secondIsInt := func() (int64, bool) {
|
|
if fs.NArg() < 2 {
|
|
return 0, false
|
|
}
|
|
v, err := strconv.ParseInt(fs.Arg(1), 10, 64)
|
|
return v, err == nil
|
|
}()
|
|
|
|
var modifiers []string
|
|
if secondIsInt {
|
|
modifiers = fs.Args()[2:]
|
|
} else {
|
|
modifiers = fs.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
|
|
case "+all":
|
|
opts.ShowSCT = true
|
|
opts.ShowIssuer = true
|
|
opts.ShowCTLog = true
|
|
default:
|
|
fatal("unknown argument %q (expected +sct, +issuer, +ctlog, or +all)", 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(fs.Arg(0), fs.Arg(1), opts)
|
|
} else {
|
|
runTileDump(fs.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://") {
|
|
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 {
|
|
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))
|
|
}
|