Compare commits

...

55 Commits

Author SHA1 Message Date
f3b4838cf6 Overhauling certlib.
LICENSE to Apache 2.0.
2025-11-15 22:00:29 -08:00
8ed30e9960 certlib: linter autofixes 2025-11-15 21:10:09 -08:00
c7de3919b0 log: linting fixes 2025-11-15 21:06:16 -08:00
840066004a logging: linter fixes 2025-11-15 21:02:19 -08:00
9fb93a3802 mwc: linter fixes 2025-11-15 20:39:21 -08:00
ecc7e5ab1e rand: remove unused package 2025-11-15 20:37:02 -08:00
a934c42aa1 temp fix before removing 2025-11-15 20:36:14 -08:00
948986ba60 testutil: remove unused code
It was probably a WIP for something else; it was started in
2016 and not touched since.
2025-11-15 20:25:37 -08:00
3be86573aa testio: linting fixes 2025-11-15 20:24:00 -08:00
e3a6355edb tee: add tests; linter fixes.
Additionally, disable reassign in testing files.
2025-11-15 20:18:09 -08:00
66d16acebc seekbuf: linter fixes 2025-11-15 19:58:41 -08:00
fdff2e0afe sbuf: linter fixes 2025-11-15 19:53:18 -08:00
3d9625b40b Fix calls to die.With. 2025-11-15 16:10:14 -08:00
547a0d8f32 disable linting until cleanups are finished 2025-11-15 16:00:58 -08:00
876a0a2c2b fileutil: linter fixes. 2025-11-15 15:58:51 -08:00
a37d28e3d7 die: linter feedback fixes. 2025-11-15 15:55:17 -08:00
ddf26e00af dbg: linter feedback updates. 2025-11-15 15:53:57 -08:00
e4db163efe Cleaning up. 2025-11-15 15:48:18 -08:00
571443c282 config: apply linting feedback. 2025-11-15 15:47:29 -08:00
aba5e519a4 First round of linter cleanups. 2025-11-15 15:11:07 -08:00
5fcba0e814 Trying a different config. 2025-11-15 13:34:18 -08:00
928c643d8d Fix linter config. 2025-11-15 13:16:30 -08:00
fd9f9f6d66 Fix linting. 2025-11-15 13:08:38 -08:00
a5b7727c8f Add linting stage. 2025-11-15 13:05:00 -08:00
3135c18d95 ignore goland directory 2025-11-15 01:53:40 -08:00
1d32a64dc0 add cert-revcheck 2025-11-14 22:56:10 -08:00
d70ca5ee87 adding golangci-lint 2025-11-14 22:54:06 -08:00
eca3a229a4 config: golangci-lint cleanups. 2025-11-14 22:53:02 -08:00
4c1eb03671 Cleaning up golangci-lint warnings. 2025-11-14 22:49:54 -08:00
f463eeed88 minor updates 2025-11-14 22:01:12 -08:00
289c9d2343 cmd: add cert-bundler
Builds certificate bundles.
2025-11-14 21:59:03 -08:00
e375963243 cmd: add tlsinfo. 2025-11-14 15:21:38 -08:00
31baa10b3b Update README. 2025-11-14 14:55:16 -08:00
0556c7c56d ca-signed: note self-signed certs. 2025-11-14 14:49:46 -08:00
83c95d9db8 cmd: add ca-signed tool.
Verify certificates are signed by a CA.
2025-11-14 14:48:34 -08:00
beccb551e2 add minmax 2025-04-10 01:17:11 -07:00
c761d98b82 additional debugging for basic constraints 2024-08-22 18:06:09 -07:00
e68d22337b cmd: add die roller 2024-06-14 20:27:00 -07:00
4cb6f5b6f0 fix minor issues in Print calls. 2024-05-19 20:51:35 -07:00
6d5708800f circleci: Some days I really hate computers. 2024-05-19 20:48:59 -07:00
fa3eb821e6 circleci: Update go container. 2024-05-19 20:48:25 -07:00
dd5ed403b9 circleci: Update go version. 2024-05-19 20:46:28 -07:00
b4fde22c31 circleci: don't use bazel 2024-05-19 20:45:32 -07:00
9715293773 Add zsearch and dumpbytes. 2024-05-19 20:41:50 -07:00
f6d227946b Get rid of bazel.
Good riddance. More of a headache than it's worth.
2024-05-19 20:24:38 -07:00
6f7a8fa4d4 Add host tool. 2024-04-26 14:15:08 -07:00
622f6a2638 seekbuf: add Bytes method. 2023-08-27 20:56:47 -07:00
e3162b6164 log: remove extraneous print 2023-05-26 22:37:56 +00:00
9d1e3ab2f0 backoff: new package. 2023-05-26 17:28:49 +00:00
dd98356479 cmd/data_sync: update README 2023-05-11 19:42:31 -07:00
9307f44601 README: add data_sync 2023-05-11 19:41:33 -07:00
b9f69e4aa1 data_sync: sync homedir to external storage. 2023-05-11 19:18:29 -07:00
7a4e7977c3 log: fixups, add FatalError
- Support suppressing console output.
- DefaultDebugOptions sets the correct tag now.
- FatalError(error, string) calls log.Fatal(message, err) if err != nil.
2023-05-11 19:03:18 -07:00
72fdc255e7 db: clean out dependency on stretchr 2023-05-11 17:14:19 -07:00
63957ff22a remove unused dependencies 2023-05-07 11:57:38 -07:00
148 changed files with 4977 additions and 2426 deletions

View File

@@ -2,36 +2,43 @@
# See: https://circleci.com/docs/2.0/configuration-reference
version: 2.1
commands:
setup-bazel:
description: |
Setup the Bazel build system used for building the repo
steps:
- run:
name: Add Bazel Apt repository
command: |
sudo apt install curl gnupg
curl -fsSL https://bazel.build/bazel-release.pub.gpg | gpg --dearmor > bazel.gpg
sudo mv bazel.gpg /etc/apt/trusted.gpg.d/
echo "deb [arch=amd64] https://storage.googleapis.com/bazel-apt stable jdk1.8" | sudo tee /etc/apt/sources.list.d/bazel.list
- run:
name: Install Bazel from Apt
command: sudo apt update && sudo apt install bazel
# Define a job to be invoked later in a workflow.
# See: https://circleci.com/docs/2.0/configuration-reference/#jobs
jobs:
lint:
working_directory: ~/repo
docker:
- image: cimg/go:1.22.2
steps:
- checkout
- restore_cache:
keys:
- go-mod-v4-{{ checksum "go.sum" }}
- run:
name: Install Dependencies
command: go mod download
- save_cache:
key: go-mod-v4-{{ checksum "go.sum" }}
paths:
- "/go/pkg/mod"
- run:
name: Install golangci-lint
command: |
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin
- run:
name: Run golangci-lint
command: golangci-lint run --timeout=5m
testbuild:
working_directory: ~/repo
# Specify the execution environment. You can specify an image from Dockerhub or use one of our Convenience Images from CircleCI's Developer Hub.
# See: https://circleci.com/docs/2.0/configuration-reference/#docker-machine-macos-windows-executor
docker:
- image: circleci/golang:1.15.8
- image: cimg/go:1.22.2
# Add steps to the job
# See: https://circleci.com/docs/2.0/configuration-reference/#steps
steps:
- checkout
- setup-bazel
- restore_cache:
keys:
- go-mod-v4-{{ checksum "go.sum" }}
@@ -44,16 +51,17 @@ jobs:
- "/go/pkg/mod"
- run:
name: Run tests
command: bazel test //...
command: go test -race ./...
- run:
name: Run build
command: bazel build //...
command: go build ./...
- store_test_results:
path: /tmp/test-reports
# Invoke jobs via workflows
# See: https://circleci.com/docs/2.0/configuration-reference/#workflows
# Linting is disabled while cleanups are ongoing.
workflows:
testbuild:
jobs:
- testbuild
# - lint

5
.gitignore vendored
View File

@@ -1,4 +1 @@
bazel-bin
bazel-goutils
bazel-out
bazel-testlogs
.idea

475
.golangci.yml Normal file
View File

@@ -0,0 +1,475 @@
# This file is licensed under the terms of the MIT license https://opensource.org/license/mit
# Copyright (c) 2021-2025 Marat Reymers
## Golden config for golangci-lint v2.6.2
#
# This is the best config for golangci-lint based on my experience and opinion.
# It is very strict, but not extremely strict.
# Feel free to adapt it to suit your needs.
# If this config helps you, please consider keeping a link to this file (see the next comment).
# Based on https://gist.github.com/maratori/47a4d00457a92aa426dbd48a18776322
version: "2"
issues:
# Maximum count of issues with the same text.
# Set to 0 to disable.
# Default: 3
max-same-issues: 50
formatters:
enable:
- goimports # checks if the code and import statements are formatted according to the 'goimports' command
- golines # checks if code is formatted, and fixes long lines
## you may want to enable
#- gci # checks if code and import statements are formatted, with additional rules
#- gofmt # checks if the code is formatted according to 'gofmt' command
#- gofumpt # enforces a stricter format than 'gofmt', while being backwards compatible
#- swaggo # formats swaggo comments
# All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml
settings:
goimports:
# A list of prefixes, which, if set, checks import paths
# with the given prefixes are grouped after 3rd-party packages.
# Default: []
local-prefixes:
- github.com/my/project
golines:
# Target maximum line length.
# Default: 100
max-len: 120
linters:
enable:
- asasalint # checks for pass []any as any in variadic func(...any)
- asciicheck # checks that your code does not contain non-ASCII identifiers
- bidichk # checks for dangerous unicode character sequences
- bodyclose # checks whether HTTP response body is closed successfully
- canonicalheader # checks whether net/http.Header uses canonical header
- copyloopvar # detects places where loop variables are copied (Go 1.22+)
- cyclop # checks function and package cyclomatic complexity
- depguard # checks if package imports are in a list of acceptable packages
- dupl # tool for code clone detection
- durationcheck # checks for two durations multiplied together
- errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases
- errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error
- errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13
- exhaustive # checks exhaustiveness of enum switch statements
- exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions
- fatcontext # detects nested contexts in loops
- forbidigo # forbids identifiers
- funcorder # checks the order of functions, methods, and constructors
- funlen # tool for detection of long functions
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
- gochecksumtype # checks exhaustiveness on Go "sum types"
- gocognit # computes and checks the cognitive complexity of functions
- goconst # finds repeated strings that could be replaced by a constant
- gocritic # provides diagnostics that check for bugs, performance and style issues
- gocyclo # computes and checks the cyclomatic complexity of functions
- godoclint # checks Golang's documentation practice
- godot # checks if comments end in a period
- gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod
- goprintffuncname # checks that printf-like functions are named with f at the end
- gosec # inspects source code for security problems
- govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string
- iface # checks the incorrect use of interfaces, helping developers avoid interface pollution
- ineffassign # detects when assignments to existing variables are not used
- intrange # finds places where for loops could make use of an integer range
- iotamixing # checks if iotas are being used in const blocks with other non-iota declarations
- loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap)
- makezero # finds slice declarations with non-zero initial length
- mirror # reports wrong mirror patterns of bytes/strings usage
- mnd # detects magic numbers
- modernize # suggests simplifications to Go code, using modern language and library features
- musttag # enforces field tags in (un)marshaled structs
- nakedret # finds naked returns in functions greater than a specified function length
- nestif # reports deeply nested if statements
- nilerr # finds the code that returns nil even if it checks that the error is not nil
- nilnesserr # reports that it checks for err != nil, but it returns a different nil value error (powered by nilness and nilerr)
- nilnil # checks that there is no simultaneous return of nil error and an invalid value
- noctx # finds sending http request without context.Context
- nolintlint # reports ill-formed or insufficient nolint directives
- nonamedreturns # reports all named returns
- nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL
- perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative
- predeclared # finds code that shadows one of Go's predeclared identifiers
- promlinter # checks Prometheus metrics naming via promlint
- protogetter # reports direct reads from proto message fields when getters should be used
- reassign # checks that package variables are not reassigned
- recvcheck # checks for receiver type consistency
- revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint
- rowserrcheck # checks whether Err of rows is checked successfully
- sloglint # ensure consistent code style when using log/slog
- spancheck # checks for mistakes with OpenTelemetry/Census spans
- sqlclosecheck # checks that sql.Rows and sql.Stmt are closed
- staticcheck # is a go vet on steroids, applying a ton of static analysis checks
- testableexamples # checks if examples are testable (have an expected output)
- testifylint # checks usage of github.com/stretchr/testify
- testpackage # makes you use a separate _test package
- tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes
- unconvert # removes unnecessary type conversions
- unparam # reports unused function parameters
- unqueryvet # detects SELECT * in SQL queries and SQL builders, encouraging explicit column selection
- unused # checks for unused constants, variables, functions and types
- usestdlibvars # detects the possibility to use variables/constants from the Go standard library
- usetesting # reports uses of functions with replacement inside the testing package
- wastedassign # finds wasted assignment statements
- whitespace # detects leading and trailing whitespace
## you may want to enable
#- arangolint # opinionated best practices for arangodb client
#- decorder # checks declaration order and count of types, constants, variables and functions
#- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized
#- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega
#- godox # detects usage of FIXME, TODO and other keywords inside comments
#- goheader # checks is file header matches to pattern
#- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters
#- interfacebloat # checks the number of methods inside an interface
#- ireturn # accept interfaces, return concrete types
#- noinlineerr # disallows inline error handling `if err := ...; err != nil {`
#- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated
#- tagalign # checks that struct tags are well aligned
#- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope
#- wrapcheck # checks that errors returned from external packages are wrapped
#- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event
## disabled
#- containedctx # detects struct contained context.Context field
#- contextcheck # [too many false positives] checks the function whether use a non-inherited context
#- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f())
#- dupword # [useless without config] checks for duplicate words in the source code
#- err113 # [too strict] checks the errors handling expressions
#- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted
#- forcetypeassert # [replaced by errcheck] finds forced type assertions
#- gomodguard # [use more powerful depguard] allow and block lists linter for direct Go module dependencies
#- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase
#- grouper # analyzes expression groups
#- importas # enforces consistent import aliases
#- lll # [replaced by golines] reports long lines
#- maintidx # measures the maintainability index of each function
#- misspell # [useless] finds commonly misspelled English words in comments
#- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity
#- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test
#- tagliatelle # checks the struct tags
#- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers
#- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines
#- wsl_v5 # [too strict and mostly code is not more readable] add or remove empty lines
# All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml
settings:
cyclop:
# The maximal code complexity to report.
# Default: 10
max-complexity: 30
# The maximal average package complexity.
# If it's higher than 0.0 (float) the check is enabled.
# Default: 0.0
package-average: 10.0
depguard:
# Rules to apply.
#
# Variables:
# - File Variables
# Use an exclamation mark `!` to negate a variable.
# Example: `!$test` matches any file that is not a go test file.
#
# `$all` - matches all go files
# `$test` - matches all go test files
#
# - Package Variables
#
# `$gostd` - matches all of go's standard library (Pulled from `GOROOT`)
#
# Default (applies if no custom rules are defined): Only allow $gostd in all files.
rules:
"deprecated":
# List of file globs that will match this list of settings to compare against.
# By default, if a path is relative, it is relative to the directory where the golangci-lint command is executed.
# The placeholder '${base-path}' is substituted with a path relative to the mode defined with `run.relative-path-mode`.
# The placeholder '${config-path}' is substituted with a path relative to the configuration file.
# Default: $all
files:
- "$all"
# List of packages that are not allowed.
# Entries can be a variable (starting with $), a string prefix, or an exact match (if ending with $).
# Default: []
deny:
- pkg: github.com/golang/protobuf
desc: Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules
- pkg: github.com/satori/go.uuid
desc: Use github.com/google/uuid instead, satori's package is not maintained
- pkg: github.com/gofrs/uuid$
desc: Use github.com/gofrs/uuid/v5 or later, it was not a go module before v5
"non-test files":
files:
- "!$test"
deny:
- pkg: math/rand$
desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2
"non-main files":
files:
- "!**/main.go"
deny:
- pkg: log$
desc: Use log/slog instead, see https://go.dev/blog/slog
embeddedstructfieldcheck:
# Checks that sync.Mutex and sync.RWMutex are not used as embedded fields.
# Default: false
forbid-mutex: true
errcheck:
# Report about not checking of errors in type assertions: `a := b.(MyStruct)`.
# Such cases aren't reported by default.
# Default: false
check-type-assertions: true
exclude-functions:
- (*git.wntrmute.dev/kyle/goutils/sbuf.Buffer).Write
exhaustive:
# Program elements to check for exhaustiveness.
# Default: [ switch ]
check:
- switch
- map
exhaustruct:
# List of regular expressions to match type names that should be excluded from processing.
# Anonymous structs can be matched by '<anonymous>' alias.
# Has precedence over `include`.
# Each regular expression must match the full type name, including package path.
# For example, to match type `net/http.Cookie` regular expression should be `.*/http\.Cookie`,
# but not `http\.Cookie`.
# Default: []
exclude:
# std libs
- ^net/http.Client$
- ^net/http.Cookie$
- ^net/http.Request$
- ^net/http.Response$
- ^net/http.Server$
- ^net/http.Transport$
- ^net/url.URL$
- ^os/exec.Cmd$
- ^reflect.StructField$
# public libs
- ^github.com/Shopify/sarama.Config$
- ^github.com/Shopify/sarama.ProducerMessage$
- ^github.com/mitchellh/mapstructure.DecoderConfig$
- ^github.com/prometheus/client_golang/.+Opts$
- ^github.com/spf13/cobra.Command$
- ^github.com/spf13/cobra.CompletionOptions$
- ^github.com/stretchr/testify/mock.Mock$
- ^github.com/testcontainers/testcontainers-go.+Request$
- ^github.com/testcontainers/testcontainers-go.FromDockerfile$
- ^golang.org/x/tools/go/analysis.Analyzer$
- ^google.golang.org/protobuf/.+Options$
- ^gopkg.in/yaml.v3.Node$
# Allows empty structures in return statements.
# Default: false
allow-empty-returns: true
funcorder:
# Checks if the exported methods of a structure are placed before the non-exported ones.
# Default: true
struct-method: false
funlen:
# Checks the number of lines in a function.
# If lower than 0, disable the check.
# Default: 60
lines: 100
# Checks the number of statements in a function.
# If lower than 0, disable the check.
# Default: 40
statements: 50
gochecksumtype:
# Presence of `default` case in switch statements satisfies exhaustiveness, if all members are not listed.
# Default: true
default-signifies-exhaustive: false
gocognit:
# Minimal code complexity to report.
# Default: 30 (but we recommend 10-20)
min-complexity: 20
gocritic:
# Settings passed to gocritic.
# The settings key is the name of a supported gocritic checker.
# The list of supported checkers can be found at https://go-critic.com/overview.
settings:
captLocal:
# Whether to restrict checker to params only.
# Default: true
paramsOnly: false
underef:
# Whether to skip (*x).method() calls where x is a pointer receiver.
# Default: true
skipRecvDeref: false
godoclint:
# List of rules to enable in addition to the default set.
# Default: empty
enable:
# Assert no unused link in godocs.
# https://github.com/godoc-lint/godoc-lint?tab=readme-ov-file#no-unused-link
- no-unused-link
govet:
# Enable all analyzers.
# Default: false
enable-all: true
# Disable analyzers by name.
# Run `GL_DEBUG=govet golangci-lint run --enable=govet` to see default, all available analyzers, and enabled analyzers.
# Default: []
disable:
- fieldalignment # too strict
# Settings per analyzer.
settings:
shadow:
# Whether to be strict about shadowing; can be noisy.
# Default: false
strict: true
inamedparam:
# Skips check for interface methods with only a single parameter.
# Default: false
skip-single-param: true
mnd:
ignored-functions:
- args.Error
- flag.Arg
- flag.Duration.*
- flag.Float.*
- flag.Int.*
- flag.Uint.*
- os.Chmod
- os.Mkdir.*
- os.OpenFile
- os.WriteFile
- prometheus.ExponentialBuckets.*
- prometheus.LinearBuckets
nakedret:
# Make an issue if func has more lines of code than this setting, and it has naked returns.
# Default: 30
max-func-lines: 0
nolintlint:
# Exclude the following linters from requiring an explanation.
# Default: []
allow-no-explanation: [ funlen, gocognit, golines ]
# Enable to require an explanation of nonzero length after each nolint directive.
# Default: false
require-explanation: true
# Enable to require nolint directives to mention the specific linter being suppressed.
# Default: false
require-specific: true
perfsprint:
# Optimizes into strings concatenation.
# Default: true
strconcat: false
reassign:
# Patterns for global variable names that are checked for reassignment.
# See https://github.com/curioswitch/go-reassign#usage
# Default: ["EOF", "Err.*"]
patterns:
- ".*"
rowserrcheck:
# database/sql is always checked.
# Default: []
packages:
- github.com/jmoiron/sqlx
sloglint:
# Enforce not using global loggers.
# Values:
# - "": disabled
# - "all": report all global loggers
# - "default": report only the default slog logger
# https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global
# Default: ""
no-global: all
# Enforce using methods that accept a context.
# Values:
# - "": disabled
# - "all": report all contextless calls
# - "scope": report only if a context exists in the scope of the outermost function
# https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only
# Default: ""
context: scope
staticcheck:
# SAxxxx checks in https://staticcheck.dev/docs/configuration/options/#checks
# Example (to disable some checks): [ "all", "-SA1000", "-SA1001"]
# Default: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022"]
checks:
- all
# Incorrect or missing package comment.
# https://staticcheck.dev/docs/checks/#ST1000
- -ST1000
# Use consistent method receiver names.
# https://staticcheck.dev/docs/checks/#ST1016
- -ST1016
# Omit embedded fields from selector expression.
# https://staticcheck.dev/docs/checks/#QF1008
- -QF1008
usetesting:
# Enable/disable `os.TempDir()` detections.
# Default: false
os-temp-dir: true
exclusions:
# Log a warning if an exclusion rule is unused.
# Default: false
warn-unused: true
# Predefined exclusion rules.
# Default: []
presets:
- std-error-handling
- common-false-positives
rules:
- path: 'ahash/ahash.go'
linters: [ staticcheck, gosec ]
- path: 'backoff/backoff_test.go'
linters: [ testpackage ]
- path: 'dbg/dbg_test.go'
linters: [ testpackage ]
- path: 'log/logger.go'
linters: [ forbidigo ]
- path: 'logging/example_test.go'
linters: [ testableexamples ]
- source: 'TODO'
linters: [ godot ]
- text: 'should have a package comment'
linters: [ revive ]
- text: 'exported \S+ \S+ should have comment( \(or a comment on this block\))? or be unexported'
linters: [ revive ]
- text: 'package comment should be of the form ".+"'
source: '// ?(nolint|TODO)'
linters: [ revive ]
- text: 'comment on exported \S+ \S+ should be of the form ".+"'
source: '// ?(nolint|TODO)'
linters: [ revive, staticcheck ]
- path: '_test\.go'
linters:
- bodyclose
- dupl
- errcheck
- funlen
- goconst
- gosec
- noctx
- reassign
- wrapcheck

View File

@@ -1,26 +0,0 @@
arch:
- amd64
- ppc64le
sudo: false
language: go
go:
- tip
- 1.9
jobs:
exclude:
- go: 1.9
arch: amd64
- go: 1.9
arch: ppc64le
script:
- go get golang.org/x/lint/golint
- go get golang.org/x/tools/cmd/cover
- go get github.com/kisom/goutils/...
- go test -cover github.com/kisom/goutils/...
- golint github.com/kisom/goutils/...
notifications:
email:
recipients:
- coder@kyleisom.net
on_success: change
on_failure: change

View File

@@ -1,22 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
load("@bazel_gazelle//:def.bzl", "gazelle")
# gazelle:prefix git.wntrmute.dev/kyle/goutils
gazelle(name = "gazelle")
go_library(
name = "goutils",
srcs = ["doc.go"],
importpath = "git.wntrmute.dev/kyle/goutils",
visibility = ["//visibility:public"],
)
gazelle(
name = "gazelle-update-repos",
args = [
"-from_file=go.mod",
"-to_macro=deps.bzl%go_dependencies",
"-prune",
],
command = "update-repos",
)

View File

@@ -1,3 +1,21 @@
Unreleased - 2025-11-15
"Error handling modernization" (in progress)
- Introduced typed, wrapped errors via certlib/certerr.Error (Source, Kind, Op, Err) with Unwrap.
- Standardized helper constructors: DecodeError, ParsingError, VerifyError, LoadingError.
- Preserved sentinel errors (e.g., ErrEncryptedPrivateKey, ErrInvalidPEMType, ErrEmptyCertificate) for errors.Is.
- Refactored certlib to use certerr in key paths (CSR parsing/verification, PEM cert pool, certificate read/load).
- Migrated logging/file.go and cmd/kgz away from github.com/pkg/errors to stdlib wrapping.
- Removed dependency on github.com/pkg/errors; ran go mod tidy.
- Added package docs for certerr and a README section on error handling and matching.
- Added unit tests for certerr (Is/As and message formatting).
Planned next steps:
- Continue refactoring remaining error paths for consistent wrapping.
- Add focused tests for key flows (encrypted private key, CSR invalid PEM types, etc.).
- Run golangci-lint (errorlint, errcheck) and address findings.
Release 1.2.1 - 2018-09-15
+ Add missing format argument to Errorf call in kgz.

228
LICENSE
View File

@@ -1,13 +1,219 @@
Copyright (c) 2015-2023 Kyle Isom <kyle@tyrfingr.is>
Copyright 2025 K. Isom <kyle@imap.cc>
Permission to use, copy, modify, and distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
=======================================================================
The backoff package (written during my time at Cloudflare) is released
under the following license:
Copyright (c) 2016 CloudFlare Inc.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

View File

@@ -12,6 +12,7 @@ Contents:
ahash/ Provides hashes from string algorithm specifiers.
assert/ Error handling, assertion-style.
backoff/ Implementation of an intelligent backoff strategy.
cmd/
atping/ Automated TCP ping, meant for putting in cronjobs.
certchain/ Display the certificate chain from a
@@ -27,6 +28,7 @@ Contents:
cruntar/ Untar an archive with hard links, copying instead of
linking.
csrpubdump/ Dump the public key from an X.509 certificate request.
data_sync/ Sync the user's homedir to external storage.
diskimg/ Write a disk image to a device.
eig/ EEPROM image generator.
fragment/ Print a fragment of a file.
@@ -76,3 +78,43 @@ Each program should have a small README in the directory with more
information.
All code here is licensed under the ISC license.
Error handling
--------------
This repo standardizes on Go 1.13+ error wrapping and matching. Libraries and
CLIs should:
- Wrap causes with context using `fmt.Errorf("context: %w", err)`.
- Use typed, structured errors from `certlib/certerr` for certificate-related
operations. These include a typed `*certerr.Error` with `Source` and `Kind`.
- Match errors programmatically:
- `errors.Is(err, certerr.ErrEncryptedPrivateKey)` to detect sentinel states.
- `errors.As(err, &e)` (where `var e *certerr.Error`) to inspect
`e.Source`/`e.Kind`.
Examples:
```
cert, err := certlib.LoadCertificate(path)
if err != nil {
// sentinel match
if errors.Is(err, certerr.ErrEmptyCertificate) {
// handle empty input
}
// typed error match
var ce *certerr.Error
if errors.As(err, &ce) {
switch ce.Kind {
case certerr.KindParse:
// parse error handling
case certerr.KindLoad:
// file loading error handling
}
}
}
```
Avoid including sensitive data (keys, passwords, tokens) in error messages.

View File

@@ -1,32 +0,0 @@
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
### Go tooling, including Gazelle to generate and maintain BUILD files.
http_archive(
name = "io_bazel_rules_go",
sha256 = "6b65cb7917b4d1709f9410ffe00ecf3e160edf674b78c54a894471320862184f",
urls = [
"https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.39.0/rules_go-v0.39.0.zip",
"https://github.com/bazelbuild/rules_go/releases/download/v0.39.0/rules_go-v0.39.0.zip",
],
)
http_archive(
name = "bazel_gazelle",
sha256 = "ecba0f04f96b4960a5b250c8e8eeec42281035970aa8852dda73098274d14a1d",
urls = [
"https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.29.0/bazel-gazelle-v0.29.0.tar.gz",
"https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.29.0/bazel-gazelle-v0.29.0.tar.gz",
],
)
load("@io_bazel_rules_go//go:deps.bzl", "go_register_toolchains", "go_rules_dependencies")
load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies")
load("//:deps.bzl", "go_dependencies")
# gazelle:repository_macro deps.bzl%go_dependencies
go_dependencies()
go_rules_dependencies()
go_register_toolchains(version = "1.20.4")
gazelle_dependencies()

View File

@@ -1,24 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "ahash",
srcs = ["ahash.go"],
importpath = "git.wntrmute.dev/kyle/goutils/ahash",
visibility = ["//visibility:public"],
deps = [
"//assert",
"@org_golang_x_crypto//blake2b",
"@org_golang_x_crypto//blake2s",
"@org_golang_x_crypto//md4",
"@org_golang_x_crypto//ripemd160",
"@org_golang_x_crypto//sha3",
],
)
go_test(
name = "ahash_test",
size = "small",
srcs = ["ahash_test.go"],
embed = [":ahash"],
deps = ["//assert"],
)

View File

@@ -4,8 +4,8 @@
package ahash
import (
"crypto/md5"
"crypto/sha1"
"crypto/md5" // #nosec G505
"crypto/sha1" // #nosec G501
"crypto/sha256"
"crypto/sha512"
"errors"
@@ -17,34 +17,15 @@ import (
"io"
"sort"
"git.wntrmute.dev/kyle/goutils/assert"
"golang.org/x/crypto/blake2b"
"golang.org/x/crypto/blake2s"
"golang.org/x/crypto/md4"
"golang.org/x/crypto/ripemd160"
"golang.org/x/crypto/md4" // #nosec G506
"golang.org/x/crypto/ripemd160" // #nosec G507
"golang.org/x/crypto/sha3"
"git.wntrmute.dev/kyle/goutils/assert"
)
func sha224Slicer(bs []byte) []byte {
sum := sha256.Sum224(bs)
return sum[:]
}
func sha256Slicer(bs []byte) []byte {
sum := sha256.Sum256(bs)
return sum[:]
}
func sha384Slicer(bs []byte) []byte {
sum := sha512.Sum384(bs)
return sum[:]
}
func sha512Slicer(bs []byte) []byte {
sum := sha512.Sum512(bs)
return sum[:]
}
// Hash represents a generic hash function that may or may not be secure. It
// satisfies the hash.Hash interface.
type Hash struct {
@@ -247,17 +228,17 @@ func init() {
// HashList returns a sorted list of all the hash algorithms supported by the
// package.
func HashList() []string {
return hashList[:]
return hashList
}
// SecureHashList returns a sorted list of all the secure (cryptographic) hash
// algorithms supported by the package.
func SecureHashList() []string {
return secureHashList[:]
return secureHashList
}
// InsecureHashList returns a sorted list of all the insecure hash algorithms
// supported by the package.
func InsecureHashList() []string {
return insecureHashList[:]
return insecureHashList
}

View File

@@ -1,16 +1,18 @@
package ahash
package ahash_test
import (
"bytes"
"encoding/hex"
"fmt"
"testing"
"git.wntrmute.dev/kyle/goutils/ahash"
"git.wntrmute.dev/kyle/goutils/assert"
)
func TestSecureHash(t *testing.T) {
algo := "sha256"
h, err := New(algo)
h, err := ahash.New(algo)
assert.NoErrorT(t, err)
assert.BoolT(t, h.IsSecure(), algo+" should be a secure hash")
assert.BoolT(t, h.HashAlgo() == algo, "hash returned the wrong HashAlgo")
@@ -19,28 +21,28 @@ func TestSecureHash(t *testing.T) {
var data []byte
var expected = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
sum, err := Sum(algo, data)
sum, err := ahash.Sum(algo, data)
assert.NoErrorT(t, err)
assert.BoolT(t, fmt.Sprintf("%x", sum) == expected, fmt.Sprintf("expected hash %s but have %x", expected, sum))
assert.BoolT(t, hex.EncodeToString(sum) == expected, fmt.Sprintf("expected hash %s but have %x", expected, sum))
data = []byte("hello, world")
buf := bytes.NewBuffer(data)
expected = "09ca7e4eaa6e8ae9c7d261167129184883644d07dfba7cbfbc4c8a2e08360d5b"
sum, err = SumReader(algo, buf)
sum, err = ahash.SumReader(algo, buf)
assert.NoErrorT(t, err)
assert.BoolT(t, fmt.Sprintf("%x", sum) == expected, fmt.Sprintf("expected hash %s but have %x", expected, sum))
assert.BoolT(t, hex.EncodeToString(sum) == expected, fmt.Sprintf("expected hash %s but have %x", expected, sum))
data = []byte("hello world")
_, err = h.Write(data)
assert.NoErrorT(t, err)
unExpected := "09ca7e4eaa6e8ae9c7d261167129184883644d07dfba7cbfbc4c8a2e08360d5b"
sum = h.Sum(nil)
assert.BoolT(t, fmt.Sprintf("%x", sum) != unExpected, fmt.Sprintf("hash shouldn't have returned %x", unExpected))
assert.BoolT(t, hex.EncodeToString(sum) != unExpected, fmt.Sprintf("hash shouldn't have returned %x", unExpected))
}
func TestInsecureHash(t *testing.T) {
algo := "md5"
h, err := New(algo)
h, err := ahash.New(algo)
assert.NoErrorT(t, err)
assert.BoolT(t, !h.IsSecure(), algo+" shouldn't be a secure hash")
assert.BoolT(t, h.HashAlgo() == algo, "hash returned the wrong HashAlgo")
@@ -49,28 +51,28 @@ func TestInsecureHash(t *testing.T) {
var data []byte
var expected = "d41d8cd98f00b204e9800998ecf8427e"
sum, err := Sum(algo, data)
sum, err := ahash.Sum(algo, data)
assert.NoErrorT(t, err)
assert.BoolT(t, fmt.Sprintf("%x", sum) == expected, fmt.Sprintf("expected hash %s but have %x", expected, sum))
assert.BoolT(t, hex.EncodeToString(sum) == expected, fmt.Sprintf("expected hash %s but have %x", expected, sum))
data = []byte("hello, world")
buf := bytes.NewBuffer(data)
expected = "e4d7f1b4ed2e42d15898f4b27b019da4"
sum, err = SumReader(algo, buf)
sum, err = ahash.SumReader(algo, buf)
assert.NoErrorT(t, err)
assert.BoolT(t, fmt.Sprintf("%x", sum) == expected, fmt.Sprintf("expected hash %s but have %x", expected, sum))
assert.BoolT(t, hex.EncodeToString(sum) == expected, fmt.Sprintf("expected hash %s but have %x", expected, sum))
data = []byte("hello world")
_, err = h.Write(data)
assert.NoErrorT(t, err)
unExpected := "e4d7f1b4ed2e42d15898f4b27b019da4"
sum = h.Sum(nil)
assert.BoolT(t, fmt.Sprintf("%x", sum) != unExpected, fmt.Sprintf("hash shouldn't have returned %x", unExpected))
assert.BoolT(t, hex.EncodeToString(sum) != unExpected, fmt.Sprintf("hash shouldn't have returned %x", unExpected))
}
func TestHash32(t *testing.T) {
algo := "crc32-ieee"
h, err := New(algo)
h, err := ahash.New(algo)
assert.NoErrorT(t, err)
assert.BoolT(t, !h.IsSecure(), algo+" shouldn't be a secure hash")
assert.BoolT(t, h.HashAlgo() == algo, "hash returned the wrong HashAlgo")
@@ -102,7 +104,7 @@ func TestHash32(t *testing.T) {
func TestHash64(t *testing.T) {
algo := "crc64"
h, err := New(algo)
h, err := ahash.New(algo)
assert.NoErrorT(t, err)
assert.BoolT(t, !h.IsSecure(), algo+" shouldn't be a secure hash")
assert.BoolT(t, h.HashAlgo() == algo, "hash returned the wrong HashAlgo")
@@ -133,9 +135,9 @@ func TestHash64(t *testing.T) {
}
func TestListLengthSanity(t *testing.T) {
all := HashList()
secure := SecureHashList()
insecure := InsecureHashList()
all := ahash.HashList()
secure := ahash.SecureHashList()
insecure := ahash.InsecureHashList()
assert.BoolT(t, len(all) == len(secure)+len(insecure))
}
@@ -146,11 +148,11 @@ func TestSumLimitedReader(t *testing.T) {
extendedData := bytes.NewBufferString("hello, world! this is an extended message")
expected := "09ca7e4eaa6e8ae9c7d261167129184883644d07dfba7cbfbc4c8a2e08360d5b"
hash, err := SumReader("sha256", data)
hash, err := ahash.SumReader("sha256", data)
assert.NoErrorT(t, err)
assert.BoolT(t, fmt.Sprintf("%x", hash) == expected, fmt.Sprintf("have hash %x, want %s", hash, expected))
assert.BoolT(t, hex.EncodeToString(hash) == expected, fmt.Sprintf("have hash %x, want %s", hash, expected))
extendedHash, err := SumLimitedReader("sha256", extendedData, int64(dataLen))
extendedHash, err := ahash.SumLimitedReader("sha256", extendedData, int64(dataLen))
assert.NoErrorT(t, err)
assert.BoolT(t, bytes.Equal(hash, extendedHash), fmt.Sprintf("have hash %x, want %x", extendedHash, hash))

View File

@@ -1,8 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
go_library(
name = "assert",
srcs = ["assert.go"],
importpath = "git.wntrmute.dev/kyle/goutils/assert",
visibility = ["//visibility:public"],
)

View File

@@ -9,6 +9,7 @@
package assert
import (
"errors"
"fmt"
"os"
"runtime"
@@ -16,11 +17,13 @@ import (
"testing"
)
const callerSkip = 2
// NoDebug can be set to true to cause all asserts to be ignored.
var NoDebug bool
func die(what string, a ...string) {
_, file, line, ok := runtime.Caller(2)
_, file, line, ok := runtime.Caller(callerSkip)
if !ok {
panic(what)
}
@@ -31,30 +34,32 @@ func die(what string, a ...string) {
s = ": " + s
}
panic(what + s)
} else {
fmt.Fprintf(os.Stderr, "%s", what)
if len(a) > 0 {
s := strings.Join(a, ", ")
fmt.Fprintln(os.Stderr, ": "+s)
} else {
fmt.Fprintf(os.Stderr, "\n")
}
fmt.Fprintf(os.Stderr, "\t%s line %d\n", file, line)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "%s", what)
if len(a) > 0 {
s := strings.Join(a, ", ")
fmt.Fprintln(os.Stderr, ": "+s)
} else {
fmt.Fprintf(os.Stderr, "\n")
}
fmt.Fprintf(os.Stderr, "\t%s line %d\n", file, line)
os.Exit(1)
}
// Bool asserts that cond is false.
//
// For example, this would replace
// if x < 0 {
// log.Fatal("x is subzero")
// }
//
// if x < 0 {
// log.Fatal("x is subzero")
// }
//
// The same assertion would be
// assert.Bool(x, "x is subzero")
//
// assert.Bool(x, "x is subzero")
func Bool(cond bool, s ...string) {
if NoDebug {
return
@@ -68,11 +73,12 @@ func Bool(cond bool, s ...string) {
// Error asserts that err is not nil, e.g. that an error has occurred.
//
// For example,
// if err == nil {
// log.Fatal("call to <something> should have failed")
// }
// // becomes
// assert.Error(err, "call to <something> should have failed")
//
// if err == nil {
// log.Fatal("call to <something> should have failed")
// }
// // becomes
// assert.Error(err, "call to <something> should have failed")
func Error(err error, s ...string) {
if NoDebug {
return
@@ -100,7 +106,7 @@ func NoError(err error, s ...string) {
// ErrorEq asserts that the actual error is the expected error.
func ErrorEq(expected, actual error) {
if NoDebug || (expected == actual) {
if NoDebug || (errors.Is(expected, actual)) {
return
}
@@ -155,7 +161,7 @@ func NoErrorT(t *testing.T, err error) {
// ErrorEqT compares a pair of errors, calling Fatal on it if they
// don't match.
func ErrorEqT(t *testing.T, expected, actual error) {
if NoDebug || (expected == actual) {
if NoDebug || (errors.Is(expected, actual)) {
return
}

24
backoff/LICENSE Normal file
View File

@@ -0,0 +1,24 @@
Copyright (c) 2016 CloudFlare Inc.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

83
backoff/README.md Normal file
View File

@@ -0,0 +1,83 @@
# backoff
## Go implementation of "Exponential Backoff And Jitter"
This package implements the backoff strategy described in the AWS
Architecture Blog article
["Exponential Backoff And Jitter"](http://www.awsarchitectureblog.com/2015/03/backoff.html). Essentially,
the backoff has an interval `time.Duration`; the *n<sup>th</sup>* call
to backoff will return an a `time.Duration` that is *2 <sup>n</sup> *
interval*. If jitter is enabled (which is the default behaviour), the
duration is a random value between 0 and *2 <sup>n</sup> * interval*.
The backoff is configured with a maximum duration that will not be
exceeded; e.g., by default, the longest duration returned is
`backoff.DefaultMaxDuration`.
## Usage
A `Backoff` is initialised with a call to `New`. Using zero values
causes it to use `DefaultMaxDuration` and `DefaultInterval` as the
maximum duration and interval.
```
package something
import "github.com/cloudflare/backoff"
func retryable() {
b := backoff.New(0, 0)
for {
err := someOperation()
if err == nil {
break
}
log.Printf("error in someOperation: %v", err)
<-time.After(b.Duration())
}
log.Printf("succeeded after %d tries", b.Tries()+1)
b.Reset()
}
```
It can also be used to rate limit code that should retry infinitely, but which does not
use `Backoff` itself.
```
package something
import (
"time"
"github.com/cloudflare/backoff"
)
func retryable() {
b := backoff.New(0, 0)
b.SetDecay(30 * time.Second)
for {
// b will reset if someOperation returns later than
// the last call to b.Duration() + 30s.
err := someOperation()
if err == nil {
break
}
log.Printf("error in someOperation: %v", err)
<-time.After(b.Duration())
}
}
```
## Tunables
* `NewWithoutJitter` creates a Backoff that doesn't use jitter.
The default behaviour is controlled by two variables:
* `DefaultInterval` sets the base interval for backoffs created with
the zero `time.Duration` value in the `Interval` field.
* `DefaultMaxDuration` sets the maximum duration for backoffs created
with the zero `time.Duration` value in the `MaxDuration` field.

184
backoff/backoff.go Normal file
View File

@@ -0,0 +1,184 @@
// Package backoff contains an implementation of an intelligent backoff
// strategy. It is based on the approach in the AWS architecture blog
// article titled "Exponential Backoff And Jitter", which is found at
// http://www.awsarchitectureblog.com/2015/03/backoff.html.
//
// Essentially, the backoff has an interval `time.Duration`; the nth
// call to backoff will return a `time.Duration` that is 2^n *
// interval. If jitter is enabled (which is the default behaviour),
// the duration is a random value between 0 and 2^n * interval. The
// backoff is configured with a maximum duration that will not be
// exceeded.
//
// This package uses math/rand/v2 for jitter, which is automatically
// seeded from a cryptographically secure source.
package backoff
import (
"math"
"math/rand/v2"
"time"
)
// DefaultInterval is used when a Backoff is initialised with a
// zero-value Interval.
var DefaultInterval = 5 * time.Minute
// DefaultMaxDuration is the maximum amount of time that the backoff will
// delay for.
var DefaultMaxDuration = 6 * time.Hour
// A Backoff contains the information needed to intelligently backoff
// and retry operations using an exponential backoff algorithm. It should
// be initialised with a call to `New`.
//
// Only use a Backoff from a single goroutine, it is not safe for concurrent
// access.
type Backoff struct {
// maxDuration is the largest possible duration that can be
// returned from a call to Duration.
maxDuration time.Duration
// interval controls the time step for backing off.
interval time.Duration
// noJitter controls whether to use the "Full Jitter" improvement to attempt
// to smooth out spikes in a high-contention scenario. If noJitter is set to
// true, no jitter will be introduced.
noJitter bool
// decay controls the decay of n. If it is non-zero, n is
// reset if more than the last backoff + decay has elapsed since
// the last try.
decay time.Duration
n uint64
lastTry time.Time
}
// New creates a new backoff with the specified maxDuration duration and
// interval. Zero values may be used to use the default values.
//
// Panics if either dMax or interval is negative.
func New(dMax time.Duration, interval time.Duration) *Backoff {
if dMax < 0 || interval < 0 {
panic("backoff: dMax or interval is negative")
}
b := &Backoff{
maxDuration: dMax,
interval: interval,
}
b.setup()
return b
}
// NewWithoutJitter works similarly to New, except that the created
// Backoff will not use jitter.
func NewWithoutJitter(dMax time.Duration, interval time.Duration) *Backoff {
b := New(dMax, interval)
b.noJitter = true
return b
}
func (b *Backoff) setup() {
if b.interval == 0 {
b.interval = DefaultInterval
}
if b.maxDuration == 0 {
b.maxDuration = DefaultMaxDuration
}
}
// Duration returns a time.Duration appropriate for the backoff,
// incrementing the attempt counter.
func (b *Backoff) Duration() time.Duration {
b.setup()
b.decayN()
d := b.duration(b.n)
if b.n < math.MaxUint64 {
b.n++
}
if !b.noJitter {
d = time.Duration(rand.Int64N(int64(d))) // #nosec G404
}
return d
}
const maxN uint64 = 63
// requires b to be locked.
func (b *Backoff) duration(n uint64) time.Duration {
// Use left shift on the underlying integer representation to avoid
// multiplying time.Duration by time.Duration (which is semantically
// incorrect and flagged by linters).
if n >= maxN {
// Saturate when n would overflow a 64-bit shift or exceed maxDuration.
return b.maxDuration
}
// Calculate 2^n * interval using a shift. Detect overflow by checking
// for sign change or monotonicity loss and clamp to maxDuration.
shifted := b.interval << n
if shifted < 0 || shifted < b.interval {
// Overflow occurred during the shift; clamp to maxDuration.
return b.maxDuration
}
if shifted > b.maxDuration {
return b.maxDuration
}
return shifted
}
// Reset resets the attempt counter of a backoff.
//
// It should be called when the rate-limited action succeeds.
func (b *Backoff) Reset() {
b.lastTry = time.Time{}
b.n = 0
}
// SetDecay sets the duration after which the try counter will be reset.
// Panics if decay is smaller than 0.
//
// The decay only kicks in if at least the last backoff + decay has elapsed
// since the last try.
func (b *Backoff) SetDecay(decay time.Duration) {
if decay < 0 {
panic("backoff: decay < 0")
}
b.decay = decay
}
// requires b to be locked.
func (b *Backoff) decayN() {
if b.decay == 0 {
return
}
if b.lastTry.IsZero() {
b.lastTry = time.Now()
return
}
lastDuration := b.duration(b.n - 1)
// Reset when the elapsed time is at least the previous backoff plus decay.
// Using ">=" avoids boundary flakiness in tests and real usage.
decayed := time.Since(b.lastTry) >= lastDuration+b.decay
b.lastTry = time.Now()
if !decayed {
return
}
b.n = 0
}

179
backoff/backoff_test.go Normal file
View File

@@ -0,0 +1,179 @@
package backoff
import (
"fmt"
"math"
"testing"
"time"
)
// If given New with 0's and no jitter, ensure that certain invariants are met:
//
// - the default maxDuration duration and interval should be used
// - noJitter should be true
// - the RNG should not be initialised
// - the first duration should be equal to the default interval
func TestDefaults(t *testing.T) {
b := NewWithoutJitter(0, 0)
if b.maxDuration != DefaultMaxDuration {
t.Fatalf(
"expected new backoff to use the default maxDuration duration (%s), but have %s",
DefaultMaxDuration,
b.maxDuration,
)
}
if b.interval != DefaultInterval {
t.Fatalf("exepcted new backoff to use the default interval (%s), but have %s", DefaultInterval, b.interval)
}
if b.noJitter != true {
t.Fatal("backoff should have been initialised without jitter")
}
dur := b.Duration()
if dur != DefaultInterval {
t.Fatalf("expected first duration to be %s, have %s", DefaultInterval, dur)
}
}
// Given a zero-value initialised Backoff, it should be transparently
// setup.
func TestSetup(t *testing.T) {
b := new(Backoff)
dur := b.Duration()
if dur < 0 || dur > (5*time.Minute) {
t.Fatalf("want duration between 0 and 5 minutes, have %s", dur)
}
}
// Ensure that tries increments as expected.
func TestTries(t *testing.T) {
b := NewWithoutJitter(5, 1)
for i := range uint64(3) {
if b.n != i {
t.Fatalf("want tries=%d, have tries=%d", i, b.n)
}
pow := 1 << i
expected := time.Duration(pow)
dur := b.Duration()
if dur != expected {
t.Fatalf("want duration=%d, have duration=%d at i=%d", expected, dur, i)
}
}
for i := uint(3); i < 5; i++ {
dur := b.Duration()
if dur != 5 {
t.Fatalf("want duration=5, have %d at i=%d", dur, i)
}
}
}
// Ensure that a call to Reset will actually reset the Backoff.
func TestReset(t *testing.T) {
const iter = 10
b := New(1000, 1)
for range iter {
_ = b.Duration()
}
if b.n != iter {
t.Fatalf("expected tries=%d, have tries=%d", iter, b.n)
}
b.Reset()
if b.n != 0 {
t.Fatalf("expected tries=0 after reset, have tries=%d", b.n)
}
}
const decay = 5 * time.Millisecond
const maxDuration = 10 * time.Millisecond
const interval = time.Millisecond
func TestDecay(t *testing.T) {
const iter = 10
b := NewWithoutJitter(maxDuration, 1)
b.SetDecay(decay)
var backoff time.Duration
for range iter {
backoff = b.Duration()
}
if b.n != iter {
t.Fatalf("expected tries=%d, have tries=%d", iter, b.n)
}
// Don't decay below backoff
b.lastTry = time.Now().Add(-backoff + 1)
backoff = b.Duration()
if b.n != iter+1 {
t.Fatalf("expected tries=%d, have tries=%d", iter+1, b.n)
}
// Reset after backoff + decay
b.lastTry = time.Now().Add(-backoff - decay)
b.Duration()
if b.n != 1 {
t.Fatalf("expected tries=%d, have tries=%d", 1, b.n)
}
}
// Ensure that decay works even if the retry counter is saturated.
func TestDecaySaturation(t *testing.T) {
b := NewWithoutJitter(1<<2, 1)
b.SetDecay(decay)
var duration time.Duration
for range 3 {
duration = b.Duration()
}
if duration != 1<<2 {
t.Fatalf("expected duration=%v, have duration=%v", 1<<2, duration)
}
b.lastTry = time.Now().Add(-duration - decay)
b.n = math.MaxUint64
duration = b.Duration()
if duration != 1 {
t.Errorf("expected duration=%v, have duration=%v", 1, duration)
}
}
func ExampleBackoff_SetDecay() {
b := NewWithoutJitter(maxDuration, interval)
b.SetDecay(decay)
// try 0
fmt.Println(b.Duration())
// try 1
fmt.Println(b.Duration())
// try 2
duration := b.Duration()
fmt.Println(duration)
// try 3, below decay
time.Sleep(duration)
duration = b.Duration()
fmt.Println(duration)
// try 4, resets
time.Sleep(duration + decay)
fmt.Println(b.Duration())
// Output: 1ms
// 2ms
// 4ms
// 8ms
// 1ms
}

View File

@@ -1,30 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "certlib",
srcs = [
"certlib.go",
"der_helpers.go",
"ed25519.go",
"helpers.go",
],
importpath = "git.wntrmute.dev/kyle/goutils/certlib",
visibility = ["//visibility:public"],
deps = [
"//certlib/certerr",
"//certlib/pkcs7",
"@com_github_google_certificate_transparency_go//:certificate-transparency-go",
"@com_github_google_certificate_transparency_go//tls",
"@com_github_google_certificate_transparency_go//x509",
"@org_golang_x_crypto//ocsp",
"@org_golang_x_crypto//pkcs12",
],
)
go_test(
name = "certlib_test",
size = "small",
srcs = ["certlib_test.go"],
embed = [":certlib"],
deps = ["//assert"],
)

View File

@@ -1,8 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
go_library(
name = "certerr",
srcs = ["errors.go"],
importpath = "git.wntrmute.dev/kyle/goutils/certlib/certerr",
visibility = ["//visibility:public"],
)

33
certlib/certerr/doc.go Normal file
View File

@@ -0,0 +1,33 @@
// Package certerr provides typed errors and helpers for certificate-related
// operations across the repository. It standardizes error construction and
// matching so callers can reliably branch on error source/kind using the
// Go 1.13+ `errors.Is` and `errors.As` helpers.
//
// Guidelines
// - Always wrap underlying causes using the helper constructors or with
// fmt.Errorf("context: %w", err).
// - Do not include sensitive data (keys, passwords, tokens) in error
// messages; add only non-sensitive, actionable context.
// - Prefer programmatic checks via errors.Is (for sentinel errors) and
// errors.As (to retrieve *certerr.Error) rather than relying on error
// string contents.
//
// Typical usage
//
// if err := doParse(); err != nil {
// return certerr.ParsingError(certerr.ErrorSourceCertificate, err)
// }
//
// Callers may branch on error kinds and sources:
//
// var e *certerr.Error
// if errors.As(err, &e) {
// switch e.Kind {
// case certerr.KindParse:
// // handle parse error
// }
// }
//
// Sentinel errors are provided for common conditions like
// `certerr.ErrEncryptedPrivateKey` and can be matched with `errors.Is`.
package certerr

View File

@@ -37,6 +37,48 @@ const (
ErrorSourceKeypair ErrorSourceType = 5
)
// ErrorKind is a broad classification describing what went wrong.
type ErrorKind uint8
const (
KindParse ErrorKind = iota + 1
KindDecode
KindVerify
KindLoad
)
func (k ErrorKind) String() string {
switch k {
case KindParse:
return "parse"
case KindDecode:
return "decode"
case KindVerify:
return "verify"
case KindLoad:
return "load"
default:
return "unknown"
}
}
// Error is a typed, wrapped error with structured context for programmatic checks.
// It implements error and supports errors.Is/As via Unwrap.
type Error struct {
Source ErrorSourceType // which domain produced the error (certificate, private key, etc.)
Kind ErrorKind // operation category (parse, decode, verify, load)
Op string // optional operation or function name
Err error // wrapped cause
}
func (e *Error) Error() string {
// Keep message format consistent with existing helpers: "failed to <kind> <source>: <err>"
// Do not include Op by default to preserve existing output expectations.
return fmt.Sprintf("failed to %s %s: %v", e.Kind.String(), e.Source.String(), e.Err)
}
func (e *Error) Unwrap() error { return e.Err }
// InvalidPEMType is used to indicate that we were expecting one type of PEM
// file, but saw another.
type InvalidPEMType struct {
@@ -61,19 +103,19 @@ func ErrInvalidPEMType(have string, want ...string) error {
}
func LoadingError(t ErrorSourceType, err error) error {
return fmt.Errorf("failed to load %s from disk: %w", t, err)
return &Error{Source: t, Kind: KindLoad, Err: err}
}
func ParsingError(t ErrorSourceType, err error) error {
return fmt.Errorf("failed to parse %s: %w", t, err)
return &Error{Source: t, Kind: KindParse, Err: err}
}
func DecodeError(t ErrorSourceType, err error) error {
return fmt.Errorf("failed to decode %s: %w", t, err)
return &Error{Source: t, Kind: KindDecode, Err: err}
}
func VerifyError(t ErrorSourceType, err error) error {
return fmt.Errorf("failed to verify %s: %w", t, err)
return &Error{Source: t, Kind: KindVerify, Err: err}
}
var ErrEncryptedPrivateKey = errors.New("private key is encrypted")

View File

@@ -0,0 +1,55 @@
package certerr
import (
"errors"
"strings"
"testing"
)
func TestTypedErrorWrappingAndFormatting(t *testing.T) {
cause := errors.New("bad data")
err := DecodeError(ErrorSourceCertificate, cause)
// Ensure we can retrieve the typed error
var e *Error
if !errors.As(err, &e) {
t.Fatalf("expected errors.As to retrieve *certerr.Error, got %T", err)
}
if e.Kind != KindDecode {
t.Fatalf("unexpected kind: %v", e.Kind)
}
if e.Source != ErrorSourceCertificate {
t.Fatalf("unexpected source: %v", e.Source)
}
// Check message format (no trailing punctuation enforced by content)
msg := e.Error()
if !strings.Contains(msg, "failed to decode certificate") || !strings.Contains(msg, "bad data") {
t.Fatalf("unexpected error message: %q", msg)
}
}
func TestErrorsIsOnWrappedSentinel(t *testing.T) {
err := DecodeError(ErrorSourcePrivateKey, ErrEncryptedPrivateKey)
if !errors.Is(err, ErrEncryptedPrivateKey) {
t.Fatalf("expected errors.Is to match ErrEncryptedPrivateKey")
}
}
func TestInvalidPEMTypeMessageSingle(t *testing.T) {
err := ErrInvalidPEMType("FOO", "CERTIFICATE")
want := "invalid PEM type: have FOO, expected CERTIFICATE"
if err.Error() != want {
t.Fatalf("unexpected error message: got %q, want %q", err.Error(), want)
}
}
func TestInvalidPEMTypeMessageMultiple(t *testing.T) {
err := ErrInvalidPEMType("FOO", "CERTIFICATE", "NEW CERTIFICATE REQUEST")
if !strings.Contains(
err.Error(),
"invalid PEM type: have FOO, expected one of CERTIFICATE, NEW CERTIFICATE REQUEST",
) {
t.Fatalf("unexpected error message: %q", err.Error())
}
}

View File

@@ -4,7 +4,7 @@ import (
"crypto/x509"
"encoding/pem"
"errors"
"io/ioutil"
"os"
"git.wntrmute.dev/kyle/goutils/certlib/certerr"
)
@@ -13,28 +13,31 @@ import (
// byte slice.
func ReadCertificate(in []byte) (cert *x509.Certificate, rest []byte, err error) {
if len(in) == 0 {
err = certerr.ErrEmptyCertificate
return
return nil, nil, certerr.ParsingError(certerr.ErrorSourceCertificate, certerr.ErrEmptyCertificate)
}
if in[0] == '-' {
p, remaining := pem.Decode(in)
if p == nil {
err = errors.New("certlib: invalid PEM file")
return
return nil, nil, certerr.ParsingError(certerr.ErrorSourceCertificate, errors.New("invalid PEM file"))
}
rest = remaining
if p.Type != "CERTIFICATE" {
err = certerr.ErrInvalidPEMType(p.Type, "CERTIFICATE")
return
return nil, rest, certerr.ParsingError(
certerr.ErrorSourceCertificate,
certerr.ErrInvalidPEMType(p.Type, "CERTIFICATE"),
)
}
in = p.Bytes
}
cert, err = x509.ParseCertificate(in)
return
if err != nil {
return nil, rest, certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
return cert, rest, nil
}
// ReadCertificates tries to read all the certificates in a
@@ -64,9 +67,9 @@ func ReadCertificates(in []byte) (certs []*x509.Certificate, err error) {
// the file contains multiple certificates (e.g. a chain), only the
// first certificate is returned.
func LoadCertificate(path string) (*x509.Certificate, error) {
in, err := ioutil.ReadFile(path)
in, err := os.ReadFile(path)
if err != nil {
return nil, err
return nil, certerr.LoadingError(certerr.ErrorSourceCertificate, err)
}
cert, _, err := ReadCertificate(in)
@@ -76,9 +79,9 @@ func LoadCertificate(path string) (*x509.Certificate, error) {
// LoadCertificates tries to read all the certificates in a file,
// returning them in the order that it found them in the file.
func LoadCertificates(path string) ([]*x509.Certificate, error) {
in, err := ioutil.ReadFile(path)
in, err := os.ReadFile(path)
if err != nil {
return nil, err
return nil, certerr.LoadingError(certerr.ErrorSourceCertificate, err)
}
return ReadCertificates(in)

View File

@@ -65,10 +65,10 @@ const OneYear = 8760 * time.Hour
// OneDay is a time.Duration representing a day's worth of seconds.
const OneDay = 24 * time.Hour
// DelegationUsage is the OID for the DelegationUseage extensions
// DelegationUsage is the OID for the DelegationUseage extensions.
var DelegationUsage = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 44363, 44}
// DelegationExtension
// DelegationExtension.
var DelegationExtension = pkix.Extension{
Id: DelegationUsage,
Critical: false,
@@ -89,24 +89,28 @@ var Jul2012 = InclusiveDate(2012, time.July, 01)
// issuing certificates valid for more than 39 months.
var Apr2015 = InclusiveDate(2015, time.April, 01)
// KeyLength returns the bit size of ECDSA or RSA PublicKey
func KeyLength(key interface{}) int {
if key == nil {
// KeyLength returns the bit size of ECDSA or RSA PublicKey.
func KeyLength(key any) int {
switch k := key.(type) {
case *ecdsa.PublicKey:
if k == nil {
return 0
}
return k.Curve.Params().BitSize
case *rsa.PublicKey:
if k == nil {
return 0
}
return k.N.BitLen()
default:
return 0
}
if ecdsaKey, ok := key.(*ecdsa.PublicKey); ok {
return ecdsaKey.Curve.Params().BitSize
} else if rsaKey, ok := key.(*rsa.PublicKey); ok {
return rsaKey.N.BitLen()
}
return 0
}
// ExpiryTime returns the time when the certificate chain is expired.
func ExpiryTime(chain []*x509.Certificate) (notAfter time.Time) {
if len(chain) == 0 {
return
return notAfter
}
notAfter = chain[0].NotAfter
@@ -115,7 +119,7 @@ func ExpiryTime(chain []*x509.Certificate) (notAfter time.Time) {
notAfter = cert.NotAfter
}
}
return
return notAfter
}
// MonthsValid returns the number of months for which a certificate is valid.
@@ -144,109 +148,99 @@ func ValidExpiry(c *x509.Certificate) bool {
maxMonths = 39
case issued.After(Jul2012):
maxMonths = 60
case issued.Before(Jul2012):
default:
maxMonths = 120
}
if MonthsValid(c) > maxMonths {
return false
}
return true
return MonthsValid(c) <= maxMonths
}
// SignatureString returns the TLS signature string corresponding to
// an X509 signature algorithm.
var signatureString = map[x509.SignatureAlgorithm]string{
x509.MD2WithRSA: "MD2WithRSA",
x509.MD5WithRSA: "MD5WithRSA",
x509.SHA1WithRSA: "SHA1WithRSA",
x509.SHA256WithRSA: "SHA256WithRSA",
x509.SHA384WithRSA: "SHA384WithRSA",
x509.SHA512WithRSA: "SHA512WithRSA",
x509.DSAWithSHA1: "DSAWithSHA1",
x509.DSAWithSHA256: "DSAWithSHA256",
x509.ECDSAWithSHA1: "ECDSAWithSHA1",
x509.ECDSAWithSHA256: "ECDSAWithSHA256",
x509.ECDSAWithSHA384: "ECDSAWithSHA384",
x509.ECDSAWithSHA512: "ECDSAWithSHA512",
}
// SignatureString returns the TLS signature string corresponding to
// an X509 signature algorithm.
func SignatureString(alg x509.SignatureAlgorithm) string {
switch alg {
case x509.MD2WithRSA:
return "MD2WithRSA"
case x509.MD5WithRSA:
return "MD5WithRSA"
case x509.SHA1WithRSA:
return "SHA1WithRSA"
case x509.SHA256WithRSA:
return "SHA256WithRSA"
case x509.SHA384WithRSA:
return "SHA384WithRSA"
case x509.SHA512WithRSA:
return "SHA512WithRSA"
case x509.DSAWithSHA1:
return "DSAWithSHA1"
case x509.DSAWithSHA256:
return "DSAWithSHA256"
case x509.ECDSAWithSHA1:
return "ECDSAWithSHA1"
case x509.ECDSAWithSHA256:
return "ECDSAWithSHA256"
case x509.ECDSAWithSHA384:
return "ECDSAWithSHA384"
case x509.ECDSAWithSHA512:
return "ECDSAWithSHA512"
default:
return "Unknown Signature"
if s, ok := signatureString[alg]; ok {
return s
}
return "Unknown Signature"
}
// HashAlgoString returns the hash algorithm name contains in the signature
// method.
var hashAlgoString = map[x509.SignatureAlgorithm]string{
x509.MD2WithRSA: "MD2",
x509.MD5WithRSA: "MD5",
x509.SHA1WithRSA: "SHA1",
x509.SHA256WithRSA: "SHA256",
x509.SHA384WithRSA: "SHA384",
x509.SHA512WithRSA: "SHA512",
x509.DSAWithSHA1: "SHA1",
x509.DSAWithSHA256: "SHA256",
x509.ECDSAWithSHA1: "SHA1",
x509.ECDSAWithSHA256: "SHA256",
x509.ECDSAWithSHA384: "SHA384",
x509.ECDSAWithSHA512: "SHA512",
}
// HashAlgoString returns the hash algorithm name contains in the signature
// method.
func HashAlgoString(alg x509.SignatureAlgorithm) string {
switch alg {
case x509.MD2WithRSA:
return "MD2"
case x509.MD5WithRSA:
return "MD5"
case x509.SHA1WithRSA:
return "SHA1"
case x509.SHA256WithRSA:
return "SHA256"
case x509.SHA384WithRSA:
return "SHA384"
case x509.SHA512WithRSA:
return "SHA512"
case x509.DSAWithSHA1:
return "SHA1"
case x509.DSAWithSHA256:
return "SHA256"
case x509.ECDSAWithSHA1:
return "SHA1"
case x509.ECDSAWithSHA256:
return "SHA256"
case x509.ECDSAWithSHA384:
return "SHA384"
case x509.ECDSAWithSHA512:
return "SHA512"
default:
return "Unknown Hash Algorithm"
if s, ok := hashAlgoString[alg]; ok {
return s
}
return "Unknown Hash Algorithm"
}
// StringTLSVersion returns underlying enum values from human names for TLS
// versions, defaults to current golang default of TLS 1.0
// versions, defaults to current golang default of TLS 1.0.
func StringTLSVersion(version string) uint16 {
switch version {
case "1.3":
return tls.VersionTLS13
case "1.2":
return tls.VersionTLS12
case "1.1":
return tls.VersionTLS11
case "1.0":
return tls.VersionTLS10
default:
// Default to Go's historical default of TLS 1.0 for unknown values
return tls.VersionTLS10
}
}
// EncodeCertificatesPEM encodes a number of x509 certificates to PEM
// EncodeCertificatesPEM encodes a number of x509 certificates to PEM.
func EncodeCertificatesPEM(certs []*x509.Certificate) []byte {
var buffer bytes.Buffer
for _, cert := range certs {
pem.Encode(&buffer, &pem.Block{
if err := pem.Encode(&buffer, &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
})
}); err != nil {
return nil
}
}
return buffer.Bytes()
}
// EncodeCertificatePEM encodes a single x509 certificates to PEM
// EncodeCertificatePEM encodes a single x509 certificates to PEM.
func EncodeCertificatePEM(cert *x509.Certificate) []byte {
return EncodeCertificatesPEM([]*x509.Certificate{cert})
}
@@ -269,7 +263,10 @@ func ParseCertificatesPEM(certsPEM []byte) ([]*x509.Certificate, error) {
certs = append(certs, cert...)
}
if len(certsPEM) > 0 {
return nil, certerr.DecodeError(certerr.ErrorSourceCertificate, errors.New("trailing data at end of certificate"))
return nil, certerr.DecodeError(
certerr.ErrorSourceCertificate,
errors.New("trailing data at end of certificate"),
)
}
return certs, nil
}
@@ -278,29 +275,40 @@ func ParseCertificatesPEM(certsPEM []byte) ([]*x509.Certificate, error) {
// either PKCS #7, PKCS #12, or raw x509.
func ParseCertificatesDER(certsDER []byte, password string) (certs []*x509.Certificate, key crypto.Signer, err error) {
certsDER = bytes.TrimSpace(certsDER)
pkcs7data, err := pkcs7.ParsePKCS7(certsDER)
if err != nil {
var pkcs12data interface{}
certs = make([]*x509.Certificate, 1)
pkcs12data, certs[0], err = pkcs12.Decode(certsDER, password)
if err != nil {
certs, err = x509.ParseCertificates(certsDER)
if err != nil {
return nil, nil, certerr.DecodeError(certerr.ErrorSourceCertificate, err)
}
} else {
key = pkcs12data.(crypto.Signer)
}
} else {
// First, try PKCS #7
if pkcs7data, err7 := pkcs7.ParsePKCS7(certsDER); err7 == nil {
if pkcs7data.ContentInfo != "SignedData" {
return nil, nil, certerr.DecodeError(certerr.ErrorSourceCertificate, errors.New("can only extract certificates from signed data content info"))
return nil, nil, certerr.DecodeError(
certerr.ErrorSourceCertificate,
errors.New("can only extract certificates from signed data content info"),
)
}
certs = pkcs7data.Content.SignedData.Certificates
if certs == nil {
return nil, nil, certerr.DecodeError(certerr.ErrorSourceCertificate, errors.New("no certificates decoded"))
}
return certs, nil, nil
}
if certs == nil {
return nil, key, certerr.DecodeError(certerr.ErrorSourceCertificate, errors.New("no certificates decoded"))
// Next, try PKCS #12
if pkcs12data, cert, err12 := pkcs12.Decode(certsDER, password); err12 == nil {
signer, ok := pkcs12data.(crypto.Signer)
if !ok {
return nil, nil, certerr.DecodeError(
certerr.ErrorSourcePrivateKey,
errors.New("PKCS12 data does not contain a private key"),
)
}
return []*x509.Certificate{cert}, signer, nil
}
return certs, key, nil
// Finally, attempt to parse raw X.509 certificates
certs, err = x509.ParseCertificates(certsDER)
if err != nil {
return nil, nil, certerr.DecodeError(certerr.ErrorSourceCertificate, err)
}
return certs, nil, nil
}
// ParseSelfSignedCertificatePEM parses a PEM-encoded certificate and check if it is self-signed.
@@ -320,17 +328,26 @@ func ParseSelfSignedCertificatePEM(certPEM []byte) (*x509.Certificate, error) {
// can handle PEM encoded PKCS #7 structures.
func ParseCertificatePEM(certPEM []byte) (*x509.Certificate, error) {
certPEM = bytes.TrimSpace(certPEM)
cert, rest, err := ParseOneCertificateFromPEM(certPEM)
certs, rest, err := ParseOneCertificateFromPEM(certPEM)
if err != nil {
return nil, certerr.ParsingError(certerr.ErrorSourceCertificate, err)
} else if cert == nil {
return nil, certerr.DecodeError(certerr.ErrorSourceCertificate, errors.New("no certificate decoded"))
} else if len(rest) > 0 {
return nil, certerr.ParsingError(certerr.ErrorSourceCertificate, errors.New("the PEM file should contain only one object"))
} else if len(cert) > 1 {
return nil, certerr.ParsingError(certerr.ErrorSourceCertificate, errors.New("the PKCS7 object in the PEM file should contain only one certificate"))
}
return cert[0], nil
if certs == nil {
return nil, certerr.DecodeError(certerr.ErrorSourceCertificate, errors.New("no certificate decoded"))
}
if len(rest) > 0 {
return nil, certerr.ParsingError(
certerr.ErrorSourceCertificate,
errors.New("the PEM file should contain only one object"),
)
}
if len(certs) > 1 {
return nil, certerr.ParsingError(
certerr.ErrorSourceCertificate,
errors.New("the PKCS7 object in the PEM file should contain only one certificate"),
)
}
return certs[0], nil
}
// ParseOneCertificateFromPEM attempts to parse one PEM encoded certificate object,
@@ -338,7 +355,6 @@ func ParseCertificatePEM(certPEM []byte) (*x509.Certificate, error) {
// multiple certificates, from the top of certsPEM, which itself may
// contain multiple PEM encoded certificate objects.
func ParseOneCertificateFromPEM(certsPEM []byte) ([]*x509.Certificate, []byte, error) {
block, rest := pem.Decode(certsPEM)
if block == nil {
return nil, rest, nil
@@ -384,7 +400,7 @@ func PEMToCertPool(pemCerts []byte) (*x509.CertPool, error) {
certPool := x509.NewCertPool()
if !certPool.AppendCertsFromPEM(pemCerts) {
return nil, errors.New("failed to load cert pool")
return nil, certerr.LoadingError(certerr.ErrorSourceCertificate, errors.New("failed to load cert pool"))
}
return certPool, nil
@@ -441,7 +457,10 @@ func ParseCSR(in []byte) (csr *x509.CertificateRequest, rest []byte, err error)
p, rest := pem.Decode(in)
if p != nil {
if p.Type != "NEW CERTIFICATE REQUEST" && p.Type != "CERTIFICATE REQUEST" {
return nil, rest, certerr.ParsingError(certerr.ErrorSourceCSR, certerr.ErrInvalidPEMType(p.Type, "NEW CERTIFICATE REQUEST", "CERTIFICATE REQUEST"))
return nil, rest, certerr.ParsingError(
certerr.ErrorSourceCSR,
certerr.ErrInvalidPEMType(p.Type, "NEW CERTIFICATE REQUEST", "CERTIFICATE REQUEST"),
)
}
csr, err = x509.ParseCertificateRequest(p.Bytes)
@@ -450,12 +469,12 @@ func ParseCSR(in []byte) (csr *x509.CertificateRequest, rest []byte, err error)
}
if err != nil {
return nil, rest, err
return nil, rest, certerr.ParsingError(certerr.ErrorSourceCSR, err)
}
err = csr.CheckSignature()
if err != nil {
return nil, rest, err
return nil, rest, certerr.VerifyError(certerr.ErrorSourceCSR, err)
}
return csr, rest, nil
@@ -472,7 +491,7 @@ func ParseCSRPEM(csrPEM []byte) (*x509.CertificateRequest, error) {
csrObject, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
return nil, err
return nil, certerr.ParsingError(certerr.ErrorSourceCSR, err)
}
return csrObject, nil
@@ -509,7 +528,7 @@ func SignerAlgo(priv crypto.Signer) x509.SignatureAlgorithm {
}
}
// LoadClientCertificate load key/certificate from pem files
// LoadClientCertificate load key/certificate from pem files.
func LoadClientCertificate(certFile string, keyFile string) (*tls.Certificate, error) {
if certFile != "" && keyFile != "" {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
@@ -521,7 +540,7 @@ func LoadClientCertificate(certFile string, keyFile string) (*tls.Certificate, e
return nil, nil
}
// CreateTLSConfig creates a tls.Config object from certs and roots
// CreateTLSConfig creates a tls.Config object from certs and roots.
func CreateTLSConfig(remoteCAs *x509.CertPool, cert *tls.Certificate) *tls.Config {
var certs []tls.Certificate
if cert != nil {
@@ -554,7 +573,10 @@ func DeserializeSCTList(serializedSCTList []byte) ([]ct.SignedCertificateTimesta
return nil, err
}
if len(rest) != 0 {
return nil, certerr.ParsingError(certerr.ErrorSourceSCTList, errors.New("serialized SCT list contained trailing garbage"))
return nil, certerr.ParsingError(
certerr.ErrorSourceSCTList,
errors.New("serialized SCT list contained trailing garbage"),
)
}
list := make([]ct.SignedCertificateTimestamp, len(sctList.SCTList))
@@ -565,7 +587,10 @@ func DeserializeSCTList(serializedSCTList []byte) ([]ct.SignedCertificateTimesta
return nil, err
}
if len(rest) != 0 {
return nil, certerr.ParsingError(certerr.ErrorSourceSCTList, errors.New("serialized SCT list contained trailing garbage"))
return nil, certerr.ParsingError(
certerr.ErrorSourceSCTList,
errors.New("serialized SCT list contained trailing garbage"),
)
}
list[i] = sct
}
@@ -611,20 +636,16 @@ func SCTListFromOCSPResponse(response *ocsp.Response) ([]ct.SignedCertificateTim
// the subsequent file. If no prefix is provided, valFile is assumed to be a
// file path.
func ReadBytes(valFile string) ([]byte, error) {
switch splitVal := strings.SplitN(valFile, ":", 2); len(splitVal) {
case 1:
prefix, rest, found := strings.Cut(valFile, ":")
if !found {
return os.ReadFile(valFile)
case 2:
switch splitVal[0] {
case "env":
return []byte(os.Getenv(splitVal[1])), nil
case "file":
return os.ReadFile(splitVal[1])
default:
return nil, fmt.Errorf("unknown prefix: %s", splitVal[0])
}
}
switch prefix {
case "env":
return []byte(os.Getenv(rest)), nil
case "file":
return os.ReadFile(rest)
default:
return nil, fmt.Errorf("multiple prefixes: %s",
strings.Join(splitVal[:len(splitVal)-1], ", "))
return nil, fmt.Errorf("unknown prefix: %s", prefix)
}
}

82
certlib/hosts/hosts.go Normal file
View File

@@ -0,0 +1,82 @@
package hosts
import (
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
)
type Target struct {
Host string
Port int
}
func (t *Target) String() string {
return fmt.Sprintf("%s:%d", t.Host, t.Port)
}
func parseURL(host string) (string, int, error) {
url, err := url.Parse(host)
if err != nil {
return "", 0, fmt.Errorf("certlib/hosts: invalid host: %s", host)
}
if strings.ToLower(url.Scheme) != "https" {
return "", 0, errors.New("certlib/hosts: only https scheme supported")
}
if url.Port() == "" {
return url.Hostname(), 443, nil
}
port, err := strconv.ParseInt(url.Port(), 10, 16)
if err != nil {
return "", 0, fmt.Errorf("certlib/hosts: invalid port: %s", url.Port())
}
return url.Hostname(), int(port), nil
}
func parseHostPort(host string) (string, int, error) {
host, sport, err := net.SplitHostPort(host)
if err == nil {
port, err := strconv.ParseInt(sport, 10, 16)
if err != nil {
return "", 0, fmt.Errorf("certlib/hosts: invalid port: %s", sport)
}
return host, int(port), nil
}
return host, 443, nil
}
func ParseHost(host string) (*Target, error) {
host, port, err := parseURL(host)
if err == nil {
return &Target{Host: host, Port: port}, nil
}
host, port, err = parseHostPort(host)
if err == nil {
return &Target{Host: host, Port: port}, nil
}
return nil, fmt.Errorf("certlib/hosts: invalid host: %s", host)
}
func ParseHosts(hosts ...string) ([]*Target, error) {
targets := make([]*Target, 0, len(hosts))
for _, host := range hosts {
target, err := ParseHost(host)
if err != nil {
return nil, err
}
targets = append(targets, target)
}
return targets, nil
}

View File

@@ -1,9 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
go_library(
name = "pkcs7",
srcs = ["pkcs7.go"],
importpath = "git.wntrmute.dev/kyle/goutils/certlib/pkcs7",
visibility = ["//visibility:public"],
deps = ["//certlib/certerr"],
)

View File

@@ -93,7 +93,7 @@ type signedData struct {
Version int
DigestAlgorithms asn1.RawValue
ContentInfo asn1.RawValue
Certificates asn1.RawValue `asn1:"optional" asn1:"tag:0"`
Certificates asn1.RawValue `asn1:"optional"`
Crls asn1.RawValue `asn1:"optional"`
SignerInfos asn1.RawValue
}
@@ -158,63 +158,95 @@ type EncryptedContentInfo struct {
EncryptedContent []byte `asn1:"tag:0,optional"`
}
func unmarshalInit(raw []byte) (init initPKCS7, err error) {
_, err = asn1.Unmarshal(raw, &init)
if err != nil {
return initPKCS7{}, certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
return init, nil
}
func populateData(msg *PKCS7, content asn1.RawValue) error {
msg.ContentInfo = "Data"
_, err := asn1.Unmarshal(content.Bytes, &msg.Content.Data)
if err != nil {
return certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
return nil
}
func populateSignedData(msg *PKCS7, contentBytes []byte) error {
msg.ContentInfo = "SignedData"
var sd signedData
if _, err := asn1.Unmarshal(contentBytes, &sd); err != nil {
return certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
if len(sd.Certificates.Bytes) != 0 {
certs, err := x509.ParseCertificates(sd.Certificates.Bytes)
if err != nil {
return certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
msg.Content.SignedData.Certificates = certs
}
if len(sd.Crls.Bytes) != 0 {
crl, err := x509.ParseRevocationList(sd.Crls.Bytes)
if err != nil {
return certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
msg.Content.SignedData.Crl = crl
}
msg.Content.SignedData.Version = sd.Version
msg.Content.SignedData.Raw = contentBytes
return nil
}
func populateEncryptedData(msg *PKCS7, contentBytes []byte) error {
msg.ContentInfo = "EncryptedData"
var ed EncryptedData
if _, err := asn1.Unmarshal(contentBytes, &ed); err != nil {
return certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
if ed.Version != 0 {
return certerr.ParsingError(
certerr.ErrorSourceCertificate,
errors.New("only PKCS #7 encryptedData version 0 is supported"),
)
}
msg.Content.EncryptedData = ed
return nil
}
// ParsePKCS7 attempts to parse the DER encoded bytes of a
// PKCS7 structure.
func ParsePKCS7(raw []byte) (msg *PKCS7, err error) {
var pkcs7 initPKCS7
_, err = asn1.Unmarshal(raw, &pkcs7)
pkcs7, err := unmarshalInit(raw)
if err != nil {
return nil, certerr.ParsingError(certerr.ErrorSourceCertificate, err)
return nil, err
}
msg = new(PKCS7)
msg.Raw = pkcs7.Raw
msg.ContentInfo = pkcs7.ContentType.String()
switch {
case msg.ContentInfo == ObjIDData:
msg.ContentInfo = "Data"
_, err = asn1.Unmarshal(pkcs7.Content.Bytes, &msg.Content.Data)
if err != nil {
return nil, certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
case msg.ContentInfo == ObjIDSignedData:
msg.ContentInfo = "SignedData"
var signedData signedData
_, err = asn1.Unmarshal(pkcs7.Content.Bytes, &signedData)
if err != nil {
return nil, certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
if len(signedData.Certificates.Bytes) != 0 {
msg.Content.SignedData.Certificates, err = x509.ParseCertificates(signedData.Certificates.Bytes)
if err != nil {
return nil, certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
}
if len(signedData.Crls.Bytes) != 0 {
msg.Content.SignedData.Crl, err = x509.ParseRevocationList(signedData.Crls.Bytes)
if err != nil {
return nil, certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
}
msg.Content.SignedData.Version = signedData.Version
msg.Content.SignedData.Raw = pkcs7.Content.Bytes
case msg.ContentInfo == ObjIDEncryptedData:
msg.ContentInfo = "EncryptedData"
var encryptedData EncryptedData
_, err = asn1.Unmarshal(pkcs7.Content.Bytes, &encryptedData)
if err != nil {
return nil, certerr.ParsingError(certerr.ErrorSourceCertificate, err)
}
if encryptedData.Version != 0 {
return nil, certerr.ParsingError(certerr.ErrorSourceCertificate, errors.New("only PKCS #7 encryptedData version 0 is supported"))
}
msg.Content.EncryptedData = encryptedData
switch msg.ContentInfo {
case ObjIDData:
if err := populateData(msg, pkcs7.Content); err != nil {
return nil, err
}
case ObjIDSignedData:
if err := populateSignedData(msg, pkcs7.Content.Bytes); err != nil {
return nil, err
}
case ObjIDEncryptedData:
if err := populateEncryptedData(msg, pkcs7.Content.Bytes); err != nil {
return nil, err
}
default:
return nil, certerr.ParsingError(certerr.ErrorSourceCertificate, errors.New("only PKCS# 7 content of type data, signed data or encrypted data can be parsed"))
return nil, certerr.ParsingError(
certerr.ErrorSourceCertificate,
errors.New("only PKCS# 7 content of type data, signed data or encrypted data can be parsed"),
)
}
return msg, nil
}

View File

@@ -1,19 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "revoke",
srcs = ["revoke.go"],
importpath = "git.wntrmute.dev/kyle/goutils/certlib/revoke",
visibility = ["//visibility:public"],
deps = [
"//certlib",
"//log",
"@org_golang_x_crypto//ocsp",
],
)
go_test(
name = "revoke_test",
srcs = ["revoke_test.go"],
embed = [":revoke"],
)

View File

@@ -89,7 +89,7 @@ func ldapURL(url string) bool {
// - false, false: an error was encountered while checking revocations.
// - false, true: the certificate was checked successfully, and it is not revoked.
// - true, true: the certificate was checked successfully, and it is revoked.
// - true, false: failure to check revocation status causes verification to fail
// - true, false: failure to check revocation status causes verification to fail.
func revCheck(cert *x509.Certificate) (revoked, ok bool, err error) {
for _, url := range cert.CRLDistributionPoints {
if ldapURL(url) {
@@ -154,7 +154,6 @@ func getIssuer(cert *x509.Certificate) *x509.Certificate {
}
return issuer
}
// check a cert against a specific CRL. Returns the same bool pair
@@ -222,11 +221,11 @@ func VerifyCertificateError(cert *x509.Certificate) (revoked, ok bool, err error
if !time.Now().Before(cert.NotAfter) {
msg := fmt.Sprintf("Certificate expired %s\n", cert.NotAfter)
log.Info(msg)
return true, true, fmt.Errorf(msg)
return true, true, errors.New(msg)
} else if !time.Now().After(cert.NotBefore) {
msg := fmt.Sprintf("Certificate isn't valid until %s\n", cert.NotBefore)
log.Info(msg)
return true, true, fmt.Errorf(msg)
return true, true, errors.New(msg)
}
return revCheck(cert)
}
@@ -343,21 +342,21 @@ func sendOCSPRequest(server string, req []byte, leaf, issuer *x509.Certificate)
var crlRead = io.ReadAll
// SetCRLFetcher sets the function to use to read from the http response body
// SetCRLFetcher sets the function to use to read from the http response body.
func SetCRLFetcher(fn func(io.Reader) ([]byte, error)) {
crlRead = fn
}
var remoteRead = io.ReadAll
// SetRemoteFetcher sets the function to use to read from the http response body
// SetRemoteFetcher sets the function to use to read from the http response body.
func SetRemoteFetcher(fn func(io.Reader) ([]byte, error)) {
remoteRead = fn
}
var ocspRead = io.ReadAll
// SetOCSPFetcher sets the function to use to read from the http response body
// SetOCSPFetcher sets the function to use to read from the http response body.
func SetOCSPFetcher(fn func(io.Reader) ([]byte, error)) {
ocspRead = fn
}

View File

@@ -50,7 +50,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
// to indicate that this is the case.
// 2014/05/22 14:18:17 Certificate expired 2014-04-04 14:14:20 +0000 UTC
// 2014/05/22 14:18:17 Revoked certificate: misc/intermediate_ca/ActalisServerAuthenticationCA.crt
// 2014/05/22 14:18:17 Revoked certificate: misc/intermediate_ca/ActalisServerAuthenticationCA.crt.
var expiredCert = mustParse(`-----BEGIN CERTIFICATE-----
MIIEXTCCA8agAwIBAgIEBycURTANBgkqhkiG9w0BAQUFADB1MQswCQYDVQQGEwJV
UzEYMBYGA1UEChMPR1RFIENvcnBvcmF0aW9uMScwJQYDVQQLEx5HVEUgQ3liZXJU
@@ -80,7 +80,7 @@ sESPRwHkcMUNdAp37FLweUw=
// 2014/05/22 14:18:31 Serial number match: intermediate is revoked.
// 2014/05/22 14:18:31 certificate is revoked via CRL
// 2014/05/22 14:18:31 Revoked certificate: misc/intermediate_ca/MobileArmorEnterpriseCA.crt
// 2014/05/22 14:18:31 Revoked certificate: misc/intermediate_ca/MobileArmorEnterpriseCA.crt.
var revokedCert = mustParse(`-----BEGIN CERTIFICATE-----
MIIEEzCCAvugAwIBAgILBAAAAAABGMGjftYwDQYJKoZIhvcNAQEFBQAwcTEoMCYG
A1UEAxMfR2xvYmFsU2lnbiBSb290U2lnbiBQYXJ0bmVycyBDQTEdMBsGA1UECxMU
@@ -106,7 +106,7 @@ Kz5vh+5tmytUPKA8hUgmLWe94lMb7Uqq2wgZKsqun5DAWleKu81w7wEcOrjiiB+x
jeBHq7OnpWm+ccTOPCE6H4ZN4wWVS7biEBUdop/8HgXBPQHWAdjL
-----END CERTIFICATE-----`)
// A Comodo intermediate CA certificate with issuer url, CRL url and OCSP url
// A Comodo intermediate CA certificate with issuer url, CRL url and OCSP url.
var goodComodoCA = (`-----BEGIN CERTIFICATE-----
MIIGCDCCA/CgAwIBAgIQKy5u6tl1NmwUim7bo3yMBzANBgkqhkiG9w0BAQwFADCB
hTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G
@@ -182,7 +182,6 @@ func TestGood(t *testing.T) {
} else if revoked {
t.Fatalf("good certificate should not have been marked as revoked")
}
}
func TestLdap(t *testing.T) {
@@ -230,7 +229,6 @@ func TestBadCRLSet(t *testing.T) {
t.Fatalf("key emptystring should be deleted from CRLSet")
}
delete(CRLSet, "")
}
func TestCachedCRLSet(t *testing.T) {
@@ -241,13 +239,11 @@ func TestCachedCRLSet(t *testing.T) {
}
func TestRemoteFetchError(t *testing.T) {
badurl := ":"
if _, err := fetchRemote(badurl); err == nil {
t.Fatalf("fetching bad url should result in non-nil error")
}
}
func TestNoOCSPServers(t *testing.T) {

View File

@@ -1,14 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "atping_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/atping",
visibility = ["//visibility:private"],
)
go_binary(
name = "atping",
embed = [":atping_lib"],
visibility = ["//visibility:public"],
)

40
cmd/ca-signed/README.txt Normal file
View File

@@ -0,0 +1,40 @@
ca-signed: verify certificates against a CA
-------------------------------------------
Description
ca-signed verifies whether one or more certificates are signed by a given
Certificate Authority (CA). It prints a concise status per input certificate
along with the certificates expiration date when validation succeeds.
Usage
ca-signed CA.pem cert1.pem [cert2.pem ...]
- CA.pem: A file containing one or more CA certificates in PEM, DER, or PKCS#7/PKCS#12 formats.
- certN.pem: A file containing the end-entity (leaf) certificate to verify. If the file contains a chain,
the first certificate is treated as the leaf and the remaining ones are used as intermediates.
Output format
For each input certificate file, one line is printed:
<filename>: OK (expires YYYY-MM-DD)
<filename>: INVALID
Special self-test mode
ca-signed selftest
Runs a built-in test suite using embedded certificates. This mode requires no
external files or network access. The program exits with code 0 if all tests
pass, or a non-zero exit code if any test fails. Example output lines include
whether validation succeeds and the leafs expiration when applicable.
Examples
# Verify a server certificate against a root CA
ca-signed isrg-root-x1.pem le-e7.pem
# Run the embedded self-test suite
ca-signed selftest
Notes
- The tool attempts to parse certificates in PEM first, then falls back to
DER/PKCS#7/PKCS#12 (with an empty password) where applicable.
- Expiration is shown for the leaf certificate only.
- In selftest mode, test certificates are compiled into the binary using go:embed.

287
cmd/ca-signed/main.go Normal file
View File

@@ -0,0 +1,287 @@
package main
import (
"crypto/x509"
"embed"
"fmt"
"os"
"path/filepath"
"time"
"git.wntrmute.dev/kyle/goutils/certlib"
)
// loadCertsFromFile attempts to parse certificates from a file that may be in
// PEM or DER/PKCS#7 format. Returns the parsed certificates or an error.
func loadCertsFromFile(path string) ([]*x509.Certificate, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
// Try PEM first
if certs, err := certlib.ParseCertificatesPEM(data); err == nil {
return certs, nil
}
// Try DER/PKCS7/PKCS12 (with no password)
if certs, _, err := certlib.ParseCertificatesDER(data, ""); err == nil {
return certs, nil
} else {
return nil, err
}
}
func makePoolFromFile(path string) (*x509.CertPool, error) {
// Try PEM via helper (it builds a pool)
if pool, err := certlib.LoadPEMCertPool(path); err == nil && pool != nil {
return pool, nil
}
// Fallback: read as DER(s), add to a new pool
certs, err := loadCertsFromFile(path)
if err != nil || len(certs) == 0 {
return nil, fmt.Errorf("failed to load CA certificates from %s", path)
}
pool := x509.NewCertPool()
for _, c := range certs {
pool.AddCert(c)
}
return pool, nil
}
//go:embed testdata/*.pem
var embeddedTestdata embed.FS
// loadCertsFromBytes attempts to parse certificates from bytes that may be in
// PEM or DER/PKCS#7 format.
func loadCertsFromBytes(data []byte) ([]*x509.Certificate, error) {
// Try PEM first
if certs, err := certlib.ParseCertificatesPEM(data); err == nil {
return certs, nil
}
// Try DER/PKCS7/PKCS12 (with no password)
if certs, _, err := certlib.ParseCertificatesDER(data, ""); err == nil {
return certs, nil
} else {
return nil, err
}
}
func makePoolFromBytes(data []byte) (*x509.CertPool, error) {
certs, err := loadCertsFromBytes(data)
if err != nil || len(certs) == 0 {
return nil, fmt.Errorf("failed to load CA certificates from embedded bytes")
}
pool := x509.NewCertPool()
for _, c := range certs {
pool.AddCert(c)
}
return pool, nil
}
// isSelfSigned returns true if the given certificate is self-signed.
// It checks that the subject and issuer match and that the certificate's
// signature verifies against its own public key.
func isSelfSigned(cert *x509.Certificate) bool {
if cert == nil {
return false
}
// Quick check: subject and issuer match
if cert.Subject.String() != cert.Issuer.String() {
return false
}
// Cryptographic check: the certificate is signed by itself
if err := cert.CheckSignatureFrom(cert); err != nil {
return false
}
return true
}
func verifyAgainstCA(caPool *x509.CertPool, path string) (ok bool, expiry string) {
certs, err := loadCertsFromFile(path)
if err != nil || len(certs) == 0 {
return false, ""
}
leaf := certs[0]
ints := x509.NewCertPool()
if len(certs) > 1 {
for _, ic := range certs[1:] {
ints.AddCert(ic)
}
}
opts := x509.VerifyOptions{
Roots: caPool,
Intermediates: ints,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}
if _, err := leaf.Verify(opts); err != nil {
return false, ""
}
return true, leaf.NotAfter.Format("2006-01-02")
}
func verifyAgainstCABytes(caPool *x509.CertPool, certData []byte) (ok bool, expiry string) {
certs, err := loadCertsFromBytes(certData)
if err != nil || len(certs) == 0 {
return false, ""
}
leaf := certs[0]
ints := x509.NewCertPool()
if len(certs) > 1 {
for _, ic := range certs[1:] {
ints.AddCert(ic)
}
}
opts := x509.VerifyOptions{
Roots: caPool,
Intermediates: ints,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}
if _, err := leaf.Verify(opts); err != nil {
return false, ""
}
return true, leaf.NotAfter.Format("2006-01-02")
}
// selftest runs built-in validation using embedded certificates.
func selftest() int {
type testCase struct {
name string
caFile string
certFile string
expectOK bool
}
cases := []testCase{
{name: "ISRG Root X1 validates LE E7", caFile: "testdata/isrg-root-x1.pem", certFile: "testdata/le-e7.pem", expectOK: true},
{name: "ISRG Root X1 does NOT validate Google WR2", caFile: "testdata/isrg-root-x1.pem", certFile: "testdata/goog-wr2.pem", expectOK: false},
{name: "GTS R1 validates Google WR2", caFile: "testdata/gts-r1.pem", certFile: "testdata/goog-wr2.pem", expectOK: true},
{name: "GTS R1 does NOT validate LE E7", caFile: "testdata/gts-r1.pem", certFile: "testdata/le-e7.pem", expectOK: false},
}
failures := 0
for _, tc := range cases {
caBytes, err := embeddedTestdata.ReadFile(tc.caFile)
if err != nil {
fmt.Fprintf(os.Stderr, "selftest: failed to read embedded %s: %v\n", tc.caFile, err)
failures++
continue
}
certBytes, err := embeddedTestdata.ReadFile(tc.certFile)
if err != nil {
fmt.Fprintf(os.Stderr, "selftest: failed to read embedded %s: %v\n", tc.certFile, err)
failures++
continue
}
pool, err := makePoolFromBytes(caBytes)
if err != nil || pool == nil {
fmt.Fprintf(os.Stderr, "selftest: failed to build CA pool for %s: %v\n", tc.caFile, err)
failures++
continue
}
ok, exp := verifyAgainstCABytes(pool, certBytes)
if ok != tc.expectOK {
fmt.Printf("%s: unexpected result: got %v, want %v\n", tc.name, ok, tc.expectOK)
failures++
} else {
if ok {
fmt.Printf("%s: OK (expires %s)\n", tc.name, exp)
} else {
fmt.Printf("%s: INVALID (as expected)\n", tc.name)
}
}
}
// Verify that both embedded root CAs are detected as self-signed
roots := []string{"testdata/gts-r1.pem", "testdata/isrg-root-x1.pem"}
for _, root := range roots {
b, err := embeddedTestdata.ReadFile(root)
if err != nil {
fmt.Fprintf(os.Stderr, "selftest: failed to read embedded %s: %v\n", root, err)
failures++
continue
}
certs, err := loadCertsFromBytes(b)
if err != nil || len(certs) == 0 {
fmt.Fprintf(os.Stderr, "selftest: failed to parse cert(s) from %s: %v\n", root, err)
failures++
continue
}
leaf := certs[0]
if isSelfSigned(leaf) {
fmt.Printf("%s: SELF-SIGNED (as expected)\n", root)
} else {
fmt.Printf("%s: expected SELF-SIGNED, but was not detected as such\n", root)
failures++
}
}
if failures == 0 {
fmt.Println("selftest: PASS")
return 0
}
fmt.Fprintf(os.Stderr, "selftest: FAIL (%d failure(s))\n", failures)
return 1
}
func main() {
// Special selftest mode: single argument "selftest"
if len(os.Args) == 2 && os.Args[1] == "selftest" {
os.Exit(selftest())
}
if len(os.Args) < 3 {
prog := filepath.Base(os.Args[0])
fmt.Fprintf(os.Stderr, "Usage:\n %s ca.pem cert1.pem cert2.pem ...\n %s selftest\n", prog, prog)
os.Exit(2)
}
caPath := os.Args[1]
caPool, err := makePoolFromFile(caPath)
if err != nil || caPool == nil {
fmt.Fprintf(os.Stderr, "failed to load CA certificate(s): %v\n", err)
os.Exit(1)
}
for _, certPath := range os.Args[2:] {
ok, exp := verifyAgainstCA(caPool, certPath)
name := filepath.Base(certPath)
// Load the leaf once for self-signed detection and potential expiry fallback
var leaf *x509.Certificate
if certs, err := loadCertsFromFile(certPath); err == nil && len(certs) > 0 {
leaf = certs[0]
}
// If the certificate is self-signed, prefer the SELF-SIGNED label
if isSelfSigned(leaf) {
fmt.Printf("%s: SELF-SIGNED\n", name)
continue
}
if ok {
// Display with the requested format
// Example: file: OK (expires 2031-01-01)
// Ensure deterministic date formatting
// Note: no timezone displayed; date only as per example
// If exp ended up empty for some reason, recompute safely
if exp == "" {
if leaf != nil {
exp = leaf.NotAfter.Format("2006-01-02")
} else {
// fallback to the current date to avoid empty; though shouldn't happen
exp = time.Now().Format("2006-01-02")
}
}
fmt.Printf("%s: OK (expires %s)\n", name, exp)
} else {
fmt.Printf("%s: INVALID\n", name)
}
}
}

29
cmd/ca-signed/testdata/goog-wr2.pem vendored Normal file
View File

@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIFCzCCAvOgAwIBAgIQf/AFoHxM3tEArZ1mpRB7mDANBgkqhkiG9w0BAQsFADBH
MQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExM
QzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMjMxMjEzMDkwMDAwWhcNMjkwMjIw
MTQwMDAwWjA7MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNl
cnZpY2VzMQwwCgYDVQQDEwNXUjIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQCp/5x/RR5wqFOfytnlDd5GV1d9vI+aWqxG8YSau5HbyfsvAfuSCQAWXqAc
+MGr+XgvSszYhaLYWTwO0xj7sfUkDSbutltkdnwUxy96zqhMt/TZCPzfhyM1IKji
aeKMTj+xWfpgoh6zySBTGYLKNlNtYE3pAJH8do1cCA8Kwtzxc2vFE24KT3rC8gIc
LrRjg9ox9i11MLL7q8Ju26nADrn5Z9TDJVd06wW06Y613ijNzHoU5HEDy01hLmFX
xRmpC5iEGuh5KdmyjS//V2pm4M6rlagplmNwEmceOuHbsCFx13ye/aoXbv4r+zgX
FNFmp6+atXDMyGOBOozAKql2N87jAgMBAAGjgf4wgfswDgYDVR0PAQH/BAQDAgGG
MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/
AgEAMB0GA1UdDgQWBBTeGx7teRXUPjckwyG77DQ5bUKyMDAfBgNVHSMEGDAWgBTk
rysmcRorSCeFL1JmLO/wiRNxPjA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAKG
GGh0dHA6Ly9pLnBraS5nb29nL3IxLmNydDArBgNVHR8EJDAiMCCgHqAchhpodHRw
Oi8vYy5wa2kuZ29vZy9yL3IxLmNybDATBgNVHSAEDDAKMAgGBmeBDAECATANBgkq
hkiG9w0BAQsFAAOCAgEARXWL5R87RBOWGqtY8TXJbz3S0DNKhjO6V1FP7sQ02hYS
TL8Tnw3UVOlIecAwPJQl8hr0ujKUtjNyC4XuCRElNJThb0Lbgpt7fyqaqf9/qdLe
SiDLs/sDA7j4BwXaWZIvGEaYzq9yviQmsR4ATb0IrZNBRAq7x9UBhb+TV+PfdBJT
DhEl05vc3ssnbrPCuTNiOcLgNeFbpwkuGcuRKnZc8d/KI4RApW//mkHgte8y0YWu
ryUJ8GLFbsLIbjL9uNrizkqRSvOFVU6xddZIMy9vhNkSXJ/UcZhjJY1pXAprffJB
vei7j+Qi151lRehMCofa6WBmiA4fx+FOVsV2/7R6V2nyAiIJJkEd2nSi5SnzxJrl
Xdaqev3htytmOPvoKWa676ATL/hzfvDaQBEcXd2Ppvy+275W+DKcH0FBbX62xevG
iza3F4ydzxl6NJ8hk8R+dDXSqv1MbRT1ybB5W0k8878XSOjvmiYTDIfyc9acxVJr
Y/cykHipa+te1pOhv7wYPYtZ9orGBV5SGOJm4NrB3K1aJar0RfzxC3ikr7Dyc6Qw
qDTBU39CluVIQeuQRgwG3MuSxl7zRERDRilGoKb8uY45JzmxWuKxrfwT/478JuHU
/oTxUFqOl2stKnn7QGTq8z29W+GgBLCXSBxC9epaHM0myFH/FJlniXJfHeytWt0=
-----END CERTIFICATE-----

31
cmd/ca-signed/testdata/gts-r1.pem vendored Normal file
View File

@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw
MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp
Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA
A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo
27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w
Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw
TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl
qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH
szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8
Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk
MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92
wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p
aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN
VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID
AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb
C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe
QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy
h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4
7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J
ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef
MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/
Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT
6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ
0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm
2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb
bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c
-----END CERTIFICATE-----

31
cmd/ca-signed/testdata/isrg-root-x1.pem vendored Normal file
View File

@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----

26
cmd/ca-signed/testdata/le-e7.pem vendored Normal file
View File

@@ -0,0 +1,26 @@
-----BEGIN CERTIFICATE-----
MIIEVzCCAj+gAwIBAgIRAKp18eYrjwoiCWbTi7/UuqEwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
RW5jcnlwdDELMAkGA1UEAxMCRTcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARB6AST
CFh/vjcwDMCgQer+VtqEkz7JANurZxLP+U9TCeioL6sp5Z8VRvRbYk4P1INBmbef
QHJFHCxcSjKmwtvGBWpl/9ra8HW0QDsUaJW2qOJqceJ0ZVFT3hbUHifBM/2jgfgw
gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSuSJ7chx1EoG/aouVgdAR4
wpwAgDAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB
AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g
BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu
Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAjx66fDdLk5ywFn3CzA1w1qfylHUD
aEf0QZpXcJseddJGSfbUUOvbNR9N/QQ16K1lXl4VFyhmGXDT5Kdfcr0RvIIVrNxF
h4lqHtRRCP6RBRstqbZ2zURgqakn/Xip0iaQL0IdfHBZr396FgknniRYFckKORPG
yM3QKnd66gtMst8I5nkRQlAg/Jb+Gc3egIvuGKWboE1G89NTsN9LTDD3PLj0dUMr
OIuqVjLB8pEC6yk9enrlrqjXQgkLEYhXzq7dLafv5Vkig6Gl0nuuqjqfp0Q1bi1o
yVNAlXe6aUXw92CcghC9bNsKEO1+M52YY5+ofIXlS/SEQbvVYYBLZ5yeiglV6t3S
M6H+vTG0aP9YHzLn/KVOHzGQfXDP7qM5tkf+7diZe7o2fw6O7IvN6fsQXEQQj8TJ
UXJxv2/uJhcuy/tSDgXwHM8Uk34WNbRT7zGTGkQRX0gsbjAea/jYAoWv0ZvQRwpq
Pe79D/i7Cep8qWnA+7AE/3B3S/3dEEYmc0lpe1366A/6GEgk3ktr9PEoQrLChs6I
tu3wnNLB2euC8IKGLQFpGtOO/2/hiAKjyajaBP25w1jF0Wl8Bbqne3uZ2q1GyPFJ
YRmT7/OXpmOH/FVLtwS+8ng1cAmpCujPwteJZNcDG0sF2n/sc0+SQf49fdyUK0ty
+VUwFj9tmWxyR/M=
-----END CERTIFICATE-----

148
cmd/cert-bundler/README.txt Normal file
View File

@@ -0,0 +1,148 @@
cert-bundler: create certificate chain archives
------------------------------------------------
Description
cert-bundler creates archives of certificate chains from a YAML configuration
file. It validates certificates, checks expiration dates, and generates
archives in multiple formats (zip, tar.gz) with optional manifest files
containing SHA256 checksums.
Usage
cert-bundler [options]
Options:
-c <file> Path to YAML configuration file (default: bundle.yaml)
-o <dir> Output directory for archives (default: pkg)
YAML Configuration Format
The configuration file uses the following structure:
config:
hashes: <filename>
expiry: <duration>
chains:
<group_name>:
certs:
- root: <path>
intermediates:
- <path>
- <path>
- root: <path>
intermediates:
- <path>
outputs:
include_single: <bool>
include_individual: <bool>
manifest: <bool>
encoding: <encoding>
formats:
- <format>
- <format>
Configuration Fields
config:
hashes: (optional) Name of the file to write SHA256 checksums of all
generated archives. If omitted, no hash file is created.
expiry: (optional) Expiration warning threshold. Certificates expiring
within this period will trigger a warning. Supports formats like
"1y" (year), "6m" (month), "30d" (day). Default: 1y
chains:
Each key under "chains" defines a named certificate group. All certificates
in a group are bundled together into archives with the group name.
certs:
List of certificate chains. Each chain has:
root: Path to root CA certificate (PEM or DER format)
intermediates: List of paths to intermediate certificates
All intermediates are validated against their root CA. An error is
reported if signature verification fails.
outputs:
Defines output formats and content for the group's archives:
include_single: (bool) If true, all certificates in the group are
concatenated into a single file named "bundle.pem"
(or "bundle.crt" for DER encoding).
include_individual: (bool) If true, each certificate is included as
a separate file in the archive, named after the
original file (e.g., "int/cca2.pem" becomes
"cca2.pem").
manifest: (bool) If true, a MANIFEST file is included containing
SHA256 checksums of all files in the archive.
encoding: Specifies certificate encoding in the archive:
- "pem": PEM format with .pem extension (default)
- "der": DER format with .crt extension
- "both": Both PEM and DER versions are included
formats: List of archive formats to generate:
- "zip": Creates a .zip archive
- "tgz": Creates a .tar.gz archive
Output Files
For each group and format combination, an archive is created:
<group_name>.zip or <group_name>.tar.gz
If config.hashes is specified, a hash file is created in the output directory
containing SHA256 checksums of all generated archives.
Example Configuration
config:
hashes: bundle.sha256
expiry: 1y
chains:
core_certs:
certs:
- root: roots/core-ca.pem
intermediates:
- int/cca1.pem
- int/cca2.pem
- int/cca3.pem
- root: roots/ssh-ca.pem
intermediates:
- ssh/ssh_dmz1.pem
- ssh/ssh_internal.pem
outputs:
include_single: true
include_individual: true
manifest: true
encoding: pem
formats:
- zip
- tgz
This configuration:
- Creates core_certs.zip and core_certs.tar.gz in the output directory
- Each archive contains bundle.pem (all certificates concatenated)
- Each archive contains individual certificates (core-ca.pem, cca1.pem, etc.)
- Each archive includes a MANIFEST file with SHA256 checksums
- Creates bundle.sha256 with checksums of the two archives
- Warns if any certificate expires within 1 year
Examples
# Create bundles using default configuration (bundle.yaml -> pkg/)
cert-bundler
# Use custom configuration and output directory
cert-bundler -c myconfig.yaml -o output
# Create bundles from testdata configuration
cert-bundler -c testdata/bundle.yaml -o testdata/pkg
Notes
- Certificate paths in the YAML are relative to the current working directory
- All intermediates must be properly signed by their specified root CA
- Certificates are checked for expiration; warnings are printed to stderr
- Expired certificates do not prevent archive creation but generate warnings
- Both PEM and DER certificate formats are supported as input
- Archive filenames use the group name, not individual chain names
- If both include_single and include_individual are true, archives contain both

528
cmd/cert-bundler/main.go Normal file
View File

@@ -0,0 +1,528 @@
package main
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"crypto/x509"
_ "embed"
"encoding/pem"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.wntrmute.dev/kyle/goutils/certlib"
"gopkg.in/yaml.v2"
)
// Config represents the top-level YAML configuration
type Config struct {
Config struct {
Hashes string `yaml:"hashes"`
Expiry string `yaml:"expiry"`
} `yaml:"config"`
Chains map[string]ChainGroup `yaml:"chains"`
}
// ChainGroup represents a named group of certificate chains
type ChainGroup struct {
Certs []CertChain `yaml:"certs"`
Outputs Outputs `yaml:"outputs"`
}
// CertChain represents a root certificate and its intermediates
type CertChain struct {
Root string `yaml:"root"`
Intermediates []string `yaml:"intermediates"`
}
// Outputs defines output format options
type Outputs struct {
IncludeSingle bool `yaml:"include_single"`
IncludeIndividual bool `yaml:"include_individual"`
Manifest bool `yaml:"manifest"`
Formats []string `yaml:"formats"`
Encoding string `yaml:"encoding"`
}
var (
configFile string
outputDir string
)
var formatExtensions = map[string]string{
"zip": ".zip",
"tgz": ".tar.gz",
}
//go:embed README.txt
var readmeContent string
func usage() {
fmt.Fprint(os.Stderr, readmeContent)
}
func main() {
flag.Usage = usage
flag.StringVar(&configFile, "c", "bundle.yaml", "path to YAML configuration file")
flag.StringVar(&outputDir, "o", "pkg", "output directory for archives")
flag.Parse()
if configFile == "" {
fmt.Fprintf(os.Stderr, "Error: configuration file required (-c flag)\n")
os.Exit(1)
}
// Load and parse configuration
cfg, err := loadConfig(configFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
// Parse expiry duration (default 1 year)
expiryDuration := 365 * 24 * time.Hour
if cfg.Config.Expiry != "" {
expiryDuration, err = parseDuration(cfg.Config.Expiry)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing expiry: %v\n", err)
os.Exit(1)
}
}
// Create output directory if it doesn't exist
if err := os.MkdirAll(outputDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err)
os.Exit(1)
}
// Process each chain group
// Pre-allocate createdFiles based on total number of formats across all groups
totalFormats := 0
for _, group := range cfg.Chains {
totalFormats += len(group.Outputs.Formats)
}
createdFiles := make([]string, 0, totalFormats)
for groupName, group := range cfg.Chains {
files, err := processChainGroup(groupName, group, expiryDuration)
if err != nil {
fmt.Fprintf(os.Stderr, "Error processing chain group %s: %v\n", groupName, err)
os.Exit(1)
}
createdFiles = append(createdFiles, files...)
}
// Generate hash file for all created archives
if cfg.Config.Hashes != "" {
hashFile := filepath.Join(outputDir, cfg.Config.Hashes)
if err := generateHashFile(hashFile, createdFiles); err != nil {
fmt.Fprintf(os.Stderr, "Error generating hash file: %v\n", err)
os.Exit(1)
}
}
fmt.Println("Certificate bundling completed successfully")
}
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func parseDuration(s string) (time.Duration, error) {
// Support simple formats like "1y", "6m", "30d"
if len(s) < 2 {
return 0, fmt.Errorf("invalid duration format: %s", s)
}
unit := s[len(s)-1]
value := s[:len(s)-1]
var multiplier time.Duration
switch unit {
case 'y', 'Y':
multiplier = 365 * 24 * time.Hour
case 'm', 'M':
multiplier = 30 * 24 * time.Hour
case 'd', 'D':
multiplier = 24 * time.Hour
default:
return time.ParseDuration(s)
}
var num int
_, err := fmt.Sscanf(value, "%d", &num)
if err != nil {
return 0, fmt.Errorf("invalid duration value: %s", s)
}
return time.Duration(num) * multiplier, nil
}
func processChainGroup(groupName string, group ChainGroup, expiryDuration time.Duration) ([]string, error) {
// Default encoding to "pem" if not specified
encoding := group.Outputs.Encoding
if encoding == "" {
encoding = "pem"
}
// Collect certificates from all chains in the group
singleFileCerts, individualCerts, err := loadAndCollectCerts(group.Certs, group.Outputs, expiryDuration)
if err != nil {
return nil, err
}
// Prepare files for inclusion in archives
archiveFiles, err := prepareArchiveFiles(singleFileCerts, individualCerts, group.Outputs, encoding)
if err != nil {
return nil, err
}
// Create archives for the entire group
createdFiles, err := createArchiveFiles(groupName, group.Outputs.Formats, archiveFiles)
if err != nil {
return nil, err
}
return createdFiles, nil
}
// loadAndCollectCerts loads all certificates from chains and collects them for processing
func loadAndCollectCerts(chains []CertChain, outputs Outputs, expiryDuration time.Duration) ([]*x509.Certificate, []certWithPath, error) {
var singleFileCerts []*x509.Certificate
var individualCerts []certWithPath
for _, chain := range chains {
// Load root certificate
rootCert, err := certlib.LoadCertificate(chain.Root)
if err != nil {
return nil, nil, fmt.Errorf("failed to load root certificate %s: %v", chain.Root, err)
}
// Check expiry for root
checkExpiry(chain.Root, rootCert, expiryDuration)
// Add root to collections if needed
if outputs.IncludeSingle {
singleFileCerts = append(singleFileCerts, rootCert)
}
if outputs.IncludeIndividual {
individualCerts = append(individualCerts, certWithPath{
cert: rootCert,
path: chain.Root,
})
}
// Load and validate intermediates
for _, intPath := range chain.Intermediates {
intCert, err := certlib.LoadCertificate(intPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to load intermediate certificate %s: %v", intPath, err)
}
// Validate that intermediate is signed by root
if err := intCert.CheckSignatureFrom(rootCert); err != nil {
return nil, nil, fmt.Errorf("intermediate %s is not properly signed by root %s: %v", intPath, chain.Root, err)
}
// Check expiry for intermediate
checkExpiry(intPath, intCert, expiryDuration)
// Add intermediate to collections if needed
if outputs.IncludeSingle {
singleFileCerts = append(singleFileCerts, intCert)
}
if outputs.IncludeIndividual {
individualCerts = append(individualCerts, certWithPath{
cert: intCert,
path: intPath,
})
}
}
}
return singleFileCerts, individualCerts, nil
}
// prepareArchiveFiles prepares all files to be included in archives
func prepareArchiveFiles(singleFileCerts []*x509.Certificate, individualCerts []certWithPath, outputs Outputs, encoding string) ([]fileEntry, error) {
var archiveFiles []fileEntry
// Handle a single bundle file
if outputs.IncludeSingle && len(singleFileCerts) > 0 {
files, err := encodeCertsToFiles(singleFileCerts, "bundle", encoding, true)
if err != nil {
return nil, fmt.Errorf("failed to encode single bundle: %v", err)
}
archiveFiles = append(archiveFiles, files...)
}
// Handle individual files
if outputs.IncludeIndividual {
for _, cp := range individualCerts {
baseName := strings.TrimSuffix(filepath.Base(cp.path), filepath.Ext(cp.path))
files, err := encodeCertsToFiles([]*x509.Certificate{cp.cert}, baseName, encoding, false)
if err != nil {
return nil, fmt.Errorf("failed to encode individual cert %s: %v", cp.path, err)
}
archiveFiles = append(archiveFiles, files...)
}
}
// Generate manifest if requested
if outputs.Manifest {
manifestContent := generateManifest(archiveFiles)
archiveFiles = append(archiveFiles, fileEntry{
name: "MANIFEST",
content: manifestContent,
})
}
return archiveFiles, nil
}
// createArchiveFiles creates archive files in the specified formats
func createArchiveFiles(groupName string, formats []string, archiveFiles []fileEntry) ([]string, error) {
createdFiles := make([]string, 0, len(formats))
for _, format := range formats {
ext, ok := formatExtensions[format]
if !ok {
return nil, fmt.Errorf("unsupported format: %s", format)
}
archivePath := filepath.Join(outputDir, groupName+ext)
switch format {
case "zip":
if err := createZipArchive(archivePath, archiveFiles); err != nil {
return nil, fmt.Errorf("failed to create zip archive: %v", err)
}
case "tgz":
if err := createTarGzArchive(archivePath, archiveFiles); err != nil {
return nil, fmt.Errorf("failed to create tar.gz archive: %v", err)
}
default:
return nil, fmt.Errorf("unsupported format: %s", format)
}
createdFiles = append(createdFiles, archivePath)
}
return createdFiles, nil
}
func checkExpiry(path string, cert *x509.Certificate, expiryDuration time.Duration) {
now := time.Now()
expiryThreshold := now.Add(expiryDuration)
if cert.NotAfter.Before(expiryThreshold) {
daysUntilExpiry := int(cert.NotAfter.Sub(now).Hours() / 24)
if daysUntilExpiry < 0 {
fmt.Fprintf(os.Stderr, "WARNING: Certificate %s has EXPIRED (expired %d days ago)\n", path, -daysUntilExpiry)
} else {
fmt.Fprintf(os.Stderr, "WARNING: Certificate %s will expire in %d days (on %s)\n", path, daysUntilExpiry, cert.NotAfter.Format("2006-01-02"))
}
}
}
type fileEntry struct {
name string
content []byte
}
type certWithPath struct {
cert *x509.Certificate
path string
}
// encodeCertsToFiles converts certificates to file entries based on encoding type
// If isSingle is true, certs are concatenated into a single file; otherwise one cert per file
func encodeCertsToFiles(certs []*x509.Certificate, baseName string, encoding string, isSingle bool) ([]fileEntry, error) {
var files []fileEntry
switch encoding {
case "pem":
pemContent := encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".pem",
content: pemContent,
})
case "der":
if isSingle {
// For single file in DER, concatenate all cert DER bytes
var derContent []byte
for _, cert := range certs {
derContent = append(derContent, cert.Raw...)
}
files = append(files, fileEntry{
name: baseName + ".crt",
content: derContent,
})
} else {
// Individual DER file (should only have one cert)
if len(certs) > 0 {
files = append(files, fileEntry{
name: baseName + ".crt",
content: certs[0].Raw,
})
}
}
case "both":
// Add PEM version
pemContent := encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".pem",
content: pemContent,
})
// Add DER version
if isSingle {
var derContent []byte
for _, cert := range certs {
derContent = append(derContent, cert.Raw...)
}
files = append(files, fileEntry{
name: baseName + ".crt",
content: derContent,
})
} else {
if len(certs) > 0 {
files = append(files, fileEntry{
name: baseName + ".crt",
content: certs[0].Raw,
})
}
}
default:
return nil, fmt.Errorf("unsupported encoding: %s (must be 'pem', 'der', or 'both')", encoding)
}
return files, nil
}
// encodeCertsToPEM encodes certificates to PEM format
func encodeCertsToPEM(certs []*x509.Certificate) []byte {
var pemContent []byte
for _, cert := range certs {
pemBlock := &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}
pemContent = append(pemContent, pem.EncodeToMemory(pemBlock)...)
}
return pemContent
}
func generateManifest(files []fileEntry) []byte {
var manifest strings.Builder
for _, file := range files {
if file.name == "MANIFEST" {
continue
}
hash := sha256.Sum256(file.content)
manifest.WriteString(fmt.Sprintf("%x %s\n", hash, file.name))
}
return []byte(manifest.String())
}
func createZipArchive(path string, files []fileEntry) error {
f, err := os.Create(path)
if err != nil {
return err
}
w := zip.NewWriter(f)
for _, file := range files {
fw, err := w.Create(file.name)
if err != nil {
w.Close()
f.Close()
return err
}
if _, err := fw.Write(file.content); err != nil {
w.Close()
f.Close()
return err
}
}
// Check errors on close operations
if err := w.Close(); err != nil {
f.Close()
return err
}
return f.Close()
}
func createTarGzArchive(path string, files []fileEntry) error {
f, err := os.Create(path)
if err != nil {
return err
}
gw := gzip.NewWriter(f)
tw := tar.NewWriter(gw)
for _, file := range files {
hdr := &tar.Header{
Name: file.name,
Mode: 0644,
Size: int64(len(file.content)),
}
if err := tw.WriteHeader(hdr); err != nil {
tw.Close()
gw.Close()
f.Close()
return err
}
if _, err := tw.Write(file.content); err != nil {
tw.Close()
gw.Close()
f.Close()
return err
}
}
// Check errors on close operations in the correct order
if err := tw.Close(); err != nil {
gw.Close()
f.Close()
return err
}
if err := gw.Close(); err != nil {
f.Close()
return err
}
return f.Close()
}
func generateHashFile(path string, files []string) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
for _, file := range files {
data, err := os.ReadFile(file)
if err != nil {
return err
}
hash := sha256.Sum256(data)
fmt.Fprintf(f, "%x %s\n", hash, filepath.Base(file))
}
return nil
}

197
cmd/cert-bundler/prompt.txt Normal file
View File

@@ -0,0 +1,197 @@
This project is an exploration into the utility of Jetbrains' Junie
to write smaller but tedious programs.
Task: build a certificate bundling tool in cmd/cert-bundler. It
creates archives of certificates chains.
A YAML file for this looks something like:
``` yaml
config:
hashes: bundle.sha256
expiry: 1y
chains:
core_certs:
certs:
- root: roots/core-ca.pem
intermediates:
- int/cca1.pem
- int/cca2.pem
- int/cca3.pem
- root: roots/ssh-ca.pem
intermediates:
- ssh/ssh_dmz1.pem
- ssh/ssh_internal.pem
outputs:
include_single: true
include_individual: true
manifest: true
formats:
- zip
- tgz
```
Some requirements:
1. First, all the certificates should be loaded.
2. For each root, each of the indivudal intermediates should be
checked to make sure they are properly signed by the root CA.
3. The program should optionally take an expiration period (defaulting
to one year), specified in config.expiration, and if any certificate
is within that expiration period, a warning should be printed.
4. If outputs.include_single is true, all certificates under chains
should be concatenated into a single file.
5. If outputs.include_individual is true, all certificates under
chains should be included at the root level (e.g. int/cca2.pem
would be cca2.pem in the archive).
6. If bundle.manifest is true, a "MANIFEST" file is created with
SHA256 sums of each file included in the archive.
7. For each of the formats, create an archive file in the output
directory (specified with `-o`) with that format.
- If zip is included, create a .zip file.
- If tgz is included, create a .tar.gz file with default compression
levels.
- All archive files should include any generated files (single
and/or individual) in the top-level directory.
8. In the output directory, create a file with the same name as
config.hashes that contains the SHA256 sum of all files created.
-----
The outputs.include_single and outputs.include_individual describe
what should go in the final archive. If both are specified, the output
archive should include both a single bundle.pem and each individual
certificate, for example.
-----
As it stands, given the following `bundle.yaml`:
``` yaml
config:
hashes: bundle.sha256
expiry: 1y
chains:
core_certs:
certs:
- root: pems/gts-r1.pem
intermediates:
- pems/goog-wr2.pem
outputs:
include_single: true
include_individual: true
manifest: true
formats:
- zip
- tgz
- root: pems/isrg-root-x1.pem
intermediates:
- pems/le-e7.pem
outputs:
include_single: true
include_individual: false
manifest: true
formats:
- zip
- tgz
google_certs:
certs:
- root: pems/gts-r1.pem
intermediates:
- pems/goog-wr2.pem
outputs:
include_single: true
include_individual: false
manifest: true
formats:
- tgz
lets_encrypt:
certs:
- root: pems/isrg-root-x1.pem
intermediates:
- pems/le-e7.pem
outputs:
include_single: false
include_individual: true
manifest: false
formats:
- zip
```
The program outputs the following files:
- bundle.sha256
- core_certs_0.tgz (contains individual certs)
- core_certs_0.zip (contains individual certs)
- core_certs_1.tgz (contains core_certs.pem)
- core_certs_1.zip (contains core_certs.pem)
- google_certs_0.tgz
- lets_encrypt_0.zip
It should output
- bundle.sha256
- core_certs.tgz
- core_certs.zip
- google_certs.tgz
- lets_encrypt.zip
core_certs.* should contain `bundle.pem` and all the individual
certs. There should be no _$n$ variants of archives.
-----
Add an additional field to outputs: encoding. It should accept one of
`der`, `pem`, or `both`. If `der`, certificates should be output as a
`.crt` file containing a DER-encoded certificate. If `pem`, certificates
should be output as a `.pem` file containing a PEM-encoded certificate.
If both, both the `.crt` and `.pem` certificate should be included.
For example, given the previous config, if `encoding` is der, the
google_certs.tgz archive should contain
- bundle.crt
- MANIFEST
Or with lets_encrypt.zip:
- isrg-root-x1.crt
- le-e7.crt
However, if `encoding` is pem, the lets_encrypt.zip archive should contain:
- isrg-root-x1.pem
- le-e7.pem
And if it `encoding` is both, the lets_encrypt.zip archive should contain:
- isrg-root-x1.crt
- isrg-root-x1.pem
- le-e7.crt
- le-e7.pem
-----
The tgz format should output a `.tar.gz` file instead of a `.tgz` file.
-----
Move the format extensions to a global variable.
-----
Write a README.txt with a description of the bundle.yaml format.
Additionally, update the help text for the program (e.g. with `-h`)
to provide the same detailed information.
-----
It may be easier to embed the README.txt in the program on build.
-----
For the archive (tar.gz and zip) writers, make sure errors are
checked at the end, and don't just defer the close operations.

43
cmd/cert-bundler/testdata/bundle.yaml vendored Normal file
View File

@@ -0,0 +1,43 @@
config:
hashes: bundle.sha256
expiry: 1y
chains:
core_certs:
certs:
- root: pems/gts-r1.pem
intermediates:
- pems/goog-wr2.pem
- root: pems/isrg-root-x1.pem
intermediates:
- pems/le-e7.pem
outputs:
include_single: true
include_individual: true
manifest: true
formats:
- zip
- tgz
google_certs:
certs:
- root: pems/gts-r1.pem
intermediates:
- pems/goog-wr2.pem
outputs:
include_single: true
include_individual: false
manifest: true
encoding: der
formats:
- tgz
lets_encrypt:
certs:
- root: pems/isrg-root-x1.pem
intermediates:
- pems/le-e7.pem
outputs:
include_single: false
include_individual: true
manifest: false
encoding: both
formats:
- zip

View File

@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIFCzCCAvOgAwIBAgIQf/AFoHxM3tEArZ1mpRB7mDANBgkqhkiG9w0BAQsFADBH
MQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExM
QzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMjMxMjEzMDkwMDAwWhcNMjkwMjIw
MTQwMDAwWjA7MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNl
cnZpY2VzMQwwCgYDVQQDEwNXUjIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQCp/5x/RR5wqFOfytnlDd5GV1d9vI+aWqxG8YSau5HbyfsvAfuSCQAWXqAc
+MGr+XgvSszYhaLYWTwO0xj7sfUkDSbutltkdnwUxy96zqhMt/TZCPzfhyM1IKji
aeKMTj+xWfpgoh6zySBTGYLKNlNtYE3pAJH8do1cCA8Kwtzxc2vFE24KT3rC8gIc
LrRjg9ox9i11MLL7q8Ju26nADrn5Z9TDJVd06wW06Y613ijNzHoU5HEDy01hLmFX
xRmpC5iEGuh5KdmyjS//V2pm4M6rlagplmNwEmceOuHbsCFx13ye/aoXbv4r+zgX
FNFmp6+atXDMyGOBOozAKql2N87jAgMBAAGjgf4wgfswDgYDVR0PAQH/BAQDAgGG
MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/
AgEAMB0GA1UdDgQWBBTeGx7teRXUPjckwyG77DQ5bUKyMDAfBgNVHSMEGDAWgBTk
rysmcRorSCeFL1JmLO/wiRNxPjA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAKG
GGh0dHA6Ly9pLnBraS5nb29nL3IxLmNydDArBgNVHR8EJDAiMCCgHqAchhpodHRw
Oi8vYy5wa2kuZ29vZy9yL3IxLmNybDATBgNVHSAEDDAKMAgGBmeBDAECATANBgkq
hkiG9w0BAQsFAAOCAgEARXWL5R87RBOWGqtY8TXJbz3S0DNKhjO6V1FP7sQ02hYS
TL8Tnw3UVOlIecAwPJQl8hr0ujKUtjNyC4XuCRElNJThb0Lbgpt7fyqaqf9/qdLe
SiDLs/sDA7j4BwXaWZIvGEaYzq9yviQmsR4ATb0IrZNBRAq7x9UBhb+TV+PfdBJT
DhEl05vc3ssnbrPCuTNiOcLgNeFbpwkuGcuRKnZc8d/KI4RApW//mkHgte8y0YWu
ryUJ8GLFbsLIbjL9uNrizkqRSvOFVU6xddZIMy9vhNkSXJ/UcZhjJY1pXAprffJB
vei7j+Qi151lRehMCofa6WBmiA4fx+FOVsV2/7R6V2nyAiIJJkEd2nSi5SnzxJrl
Xdaqev3htytmOPvoKWa676ATL/hzfvDaQBEcXd2Ppvy+275W+DKcH0FBbX62xevG
iza3F4ydzxl6NJ8hk8R+dDXSqv1MbRT1ybB5W0k8878XSOjvmiYTDIfyc9acxVJr
Y/cykHipa+te1pOhv7wYPYtZ9orGBV5SGOJm4NrB3K1aJar0RfzxC3ikr7Dyc6Qw
qDTBU39CluVIQeuQRgwG3MuSxl7zRERDRilGoKb8uY45JzmxWuKxrfwT/478JuHU
/oTxUFqOl2stKnn7QGTq8z29W+GgBLCXSBxC9epaHM0myFH/FJlniXJfHeytWt0=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw
MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp
Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA
A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo
27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w
Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw
TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl
qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH
szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8
Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk
MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92
wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p
aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN
VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID
AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb
C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe
QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy
h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4
7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J
ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef
MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/
Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT
6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ
0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm
2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb
bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c
-----END CERTIFICATE-----

View File

@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,26 @@
-----BEGIN CERTIFICATE-----
MIIEVzCCAj+gAwIBAgIRAKp18eYrjwoiCWbTi7/UuqEwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
RW5jcnlwdDELMAkGA1UEAxMCRTcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARB6AST
CFh/vjcwDMCgQer+VtqEkz7JANurZxLP+U9TCeioL6sp5Z8VRvRbYk4P1INBmbef
QHJFHCxcSjKmwtvGBWpl/9ra8HW0QDsUaJW2qOJqceJ0ZVFT3hbUHifBM/2jgfgw
gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSuSJ7chx1EoG/aouVgdAR4
wpwAgDAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB
AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g
BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu
Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAjx66fDdLk5ywFn3CzA1w1qfylHUD
aEf0QZpXcJseddJGSfbUUOvbNR9N/QQ16K1lXl4VFyhmGXDT5Kdfcr0RvIIVrNxF
h4lqHtRRCP6RBRstqbZ2zURgqakn/Xip0iaQL0IdfHBZr396FgknniRYFckKORPG
yM3QKnd66gtMst8I5nkRQlAg/Jb+Gc3egIvuGKWboE1G89NTsN9LTDD3PLj0dUMr
OIuqVjLB8pEC6yk9enrlrqjXQgkLEYhXzq7dLafv5Vkig6Gl0nuuqjqfp0Q1bi1o
yVNAlXe6aUXw92CcghC9bNsKEO1+M52YY5+ofIXlS/SEQbvVYYBLZ5yeiglV6t3S
M6H+vTG0aP9YHzLn/KVOHzGQfXDP7qM5tkf+7diZe7o2fw6O7IvN6fsQXEQQj8TJ
UXJxv2/uJhcuy/tSDgXwHM8Uk34WNbRT7zGTGkQRX0gsbjAea/jYAoWv0ZvQRwpq
Pe79D/i7Cep8qWnA+7AE/3B3S/3dEEYmc0lpe1366A/6GEgk3ktr9PEoQrLChs6I
tu3wnNLB2euC8IKGLQFpGtOO/2/hiAKjyajaBP25w1jF0Wl8Bbqne3uZ2q1GyPFJ
YRmT7/OXpmOH/FVLtwS+8ng1cAmpCujPwteJZNcDG0sF2n/sc0+SQf49fdyUK0ty
+VUwFj9tmWxyR/M=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,4 @@
5ed8bf9ed693045faa8a5cb0edc4a870052e56aef6291ce8b1604565affbc2a4 core_certs.zip
e59eddc590d2f7b790a87c5b56e81697088ab54be382c0e2c51b82034006d308 core_certs.tgz
51b9b63b1335118079e90700a3a5b847c363808e9116e576ca84f301bc433289 google_certs.tgz
3d1910ca8835c3ded1755a8c7d6c48083c2f3ff68b2bfbf932aaf27e29d0a232 lets_encrypt.zip

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,36 @@
cert-revcheck: check certificate expiry and revocation
-----------------------------------------------------
Description
cert-revcheck accepts a list of certificate files (PEM or DER) or
site addresses (host[:port]) and checks whether the leaf certificate
is expired or revoked. Revocation checks use CRL and OCSP via the
certlib/revoke package.
Usage
cert-revcheck [options] <target> [<target>...]
Options
-hardfail treat revocation check failures as fatal (default: false)
-timeout dur HTTP/OCSP/CRL timeout for network operations (default: 10s)
-v verbose output
Targets
- File paths to certificates in PEM or DER format.
- Site addresses in the form host or host:port. If no port is
provided, 443 is assumed.
Examples
# Check a PEM file
cert-revcheck ./server.pem
# Check a DER (single) certificate
cert-revcheck ./server.der
# Check a live site (leaf certificate)
cert-revcheck example.com:443
Notes
- For sites, only the leaf certificate is checked.
- When -hardfail is set, network issues during OCSP/CRL fetch will
cause the check to fail (treated as revoked).

140
cmd/cert-revcheck/main.go Normal file
View File

@@ -0,0 +1,140 @@
package main
import (
"crypto/tls"
"crypto/x509"
"flag"
"errors"
"fmt"
"io/ioutil"
"net"
"os"
"time"
"git.wntrmute.dev/kyle/goutils/certlib"
hosts "git.wntrmute.dev/kyle/goutils/certlib/hosts"
"git.wntrmute.dev/kyle/goutils/certlib/revoke"
"git.wntrmute.dev/kyle/goutils/fileutil"
)
var (
hardfail bool
timeout time.Duration
verbose bool
)
func main() {
flag.BoolVar(&hardfail, "hardfail", false, "treat revocation check failures as fatal")
flag.DurationVar(&timeout, "timeout", 10*time.Second, "network timeout for OCSP/CRL fetches and TLS site connects")
flag.BoolVar(&verbose, "v", false, "verbose output")
flag.Parse()
revoke.HardFail = hardfail
// Set HTTP client timeout for revocation library
revoke.HTTPClient.Timeout = timeout
if flag.NArg() == 0 {
fmt.Fprintf(os.Stderr, "Usage: %s [options] <target> [<target>...]\n", os.Args[0])
os.Exit(2)
}
exitCode := 0
for _, target := range flag.Args() {
status, err := processTarget(target)
switch status {
case "OK":
fmt.Printf("%s: OK\n", target)
case "EXPIRED":
fmt.Printf("%s: EXPIRED: %v\n", target, err)
exitCode = 1
case "REVOKED":
fmt.Printf("%s: REVOKED\n", target)
exitCode = 1
case "UNKNOWN":
fmt.Printf("%s: UNKNOWN: %v\n", target, err)
if hardfail {
// In hardfail, treat unknown as failure
exitCode = 1
}
}
}
os.Exit(exitCode)
}
func processTarget(target string) (string, error) {
if fileutil.FileDoesExist(target) {
return checkFile(target)
}
// Not a file; treat as site
return checkSite(target)
}
func checkFile(path string) (string, error) {
in, err := ioutil.ReadFile(path)
if err != nil {
return "UNKNOWN", err
}
// Try PEM first; if that fails, try single DER cert
certs, err := certlib.ReadCertificates(in)
if err != nil || len(certs) == 0 {
cert, _, derr := certlib.ReadCertificate(in)
if derr != nil || cert == nil {
if err == nil {
err = derr
}
return "UNKNOWN", err
}
return evaluateCert(cert)
}
// Evaluate the first certificate (leaf) by default
return evaluateCert(certs[0])
}
func checkSite(hostport string) (string, error) {
// Use certlib/hosts to parse host/port (supports https URLs and host:port)
target, err := hosts.ParseHost(hostport)
if err != nil {
return "UNKNOWN", err
}
d := &net.Dialer{Timeout: timeout}
conn, err := tls.DialWithDialer(d, "tcp", target.String(), &tls.Config{InsecureSkipVerify: true, ServerName: target.Host})
if err != nil {
return "UNKNOWN", err
}
defer conn.Close()
state := conn.ConnectionState()
if len(state.PeerCertificates) == 0 {
return "UNKNOWN", errors.New("no peer certificates presented")
}
return evaluateCert(state.PeerCertificates[0])
}
func evaluateCert(cert *x509.Certificate) (string, error) {
// Expiry check
now := time.Now()
if !now.Before(cert.NotAfter) {
return "EXPIRED", fmt.Errorf("expired at %s", cert.NotAfter)
}
if !now.After(cert.NotBefore) {
return "EXPIRED", fmt.Errorf("not valid until %s", cert.NotBefore)
}
// Revocation check using certlib/revoke
revoked, ok, err := revoke.VerifyCertificateError(cert)
if revoked {
// If revoked is true, ok will be true per implementation, err may describe why
return "REVOKED", err
}
if !ok {
// Revocation status could not be determined
return "UNKNOWN", err
}
return "OK", nil
}

View File

@@ -1,15 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "certchain_lib",
srcs = ["certchain.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/certchain",
visibility = ["//visibility:private"],
deps = ["//die"],
)
go_binary(
name = "certchain",
embed = [":certchain_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,22 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "certdump_lib",
srcs = [
"certdump.go",
"util.go",
],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/certdump",
visibility = ["//visibility:private"],
deps = [
"//certlib",
"//lib",
"@com_github_kr_text//:text",
],
)
go_binary(
name = "certdump",
embed = [":certdump_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -110,6 +110,14 @@ func showBasicConstraints(cert *x509.Certificate) {
if cert.IsCA {
fmt.Printf(", is a CA certificate")
if !cert.BasicConstraintsValid {
fmt.Printf(" (basic constraint failure)")
}
} else {
fmt.Printf("is not a CA certificate")
if cert.KeyUsage&x509.KeyUsageKeyEncipherment != 0 {
fmt.Printf(" (key encipherment usage enabled!)")
}
}
if (cert.MaxPathLen == 0 && cert.MaxPathLenZero) || (cert.MaxPathLen > 0) {

View File

@@ -1,19 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "certexpiry_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/certexpiry",
visibility = ["//visibility:private"],
deps = [
"//certlib",
"//die",
"//lib",
],
)
go_binary(
name = "certexpiry",
embed = [":certexpiry_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,20 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "certverify_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/certverify",
visibility = ["//visibility:private"],
deps = [
"//certlib",
"//certlib/revoke",
"//die",
"//lib",
],
)
go_binary(
name = "certverify",
embed = [":certverify_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,20 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "clustersh_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/clustersh",
visibility = ["//visibility:private"],
deps = [
"//lib",
"@com_github_pkg_sftp//:sftp",
"@org_golang_x_crypto//ssh",
"@org_golang_x_crypto//ssh/agent",
],
)
go_binary(
name = "clustersh",
embed = [":clustersh_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,18 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "cruntar_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/cruntar",
visibility = ["//visibility:private"],
deps = [
"//die",
"//fileutil",
],
)
go_binary(
name = "cruntar",
embed = [":cruntar_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,15 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "csrpubdump_lib",
srcs = ["pubdump.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/csrpubdump",
visibility = ["//visibility:private"],
deps = ["//die"],
)
go_binary(
name = "csrpubdump",
embed = [":csrpubdump_lib"],
visibility = ["//visibility:public"],
)

32
cmd/data_sync/README Normal file
View File

@@ -0,0 +1,32 @@
data_sync
This is a tool I wrote primarily to sync my home directory to a backup
drive plugged into my laptop. This system is provisioned by Ansible,
and the goal is to be able to just copy my home directory back in the
event of a failure without having lost a great deal of work or to wait
for ansible to finish installing the right backup software. Specifically,
I use a Framework laptop with the 1TB storage module, encrypted with
LUKS, and run this twice daily (timed to correspond with my commute,
though that's not really necessary). It started off as a shell script,
then I decided to just write it as a program.
Usage: data_sync [-d path] [-l level] [-m path] [-nqsv]
[-t path]
-d path path to sync source directory
(default "~")
-l level log level to output (default "INFO"). Valid log
levels are DEBUG, INFO, NOTICE, WARNING, ERR,
CRIT, ALERT, EMERG. The default is INFO.
-m path path to sync mount directory
(default "/media/$USER/$(hostname -s)_data")
-n dry-run mode: only check paths and print files to
exclude
-q suppress console output
-s suppress syslog output
-t path path to sync target directory
(default "/media/$USER/$(hostname -s)_data/$USER")
-v verbose rsync output
data_sync rsyncs the tree at the sync source directory (-d) to the sync target
directory (-t); it checks the mount directory (-m) exists; the sync target
target directory must exist on the mount directory.

230
cmd/data_sync/main.go Normal file
View File

@@ -0,0 +1,230 @@
package main
import (
"flag"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"git.wntrmute.dev/kyle/goutils/config"
"git.wntrmute.dev/kyle/goutils/fileutil"
"git.wntrmute.dev/kyle/goutils/log"
)
func mustHostname() string {
hostname, err := os.Hostname()
log.FatalError(err, "couldn't retrieve hostname")
if hostname == "" {
log.Fatal("no hostname returned")
}
return strings.Split(hostname, ".")[0]
}
var (
defaultDataDir = mustHostname() + "_data"
defaultProgName = defaultDataDir + "_sync"
defaultMountDir = filepath.Join("/media", os.Getenv("USER"), defaultDataDir)
defaultSyncDir = os.Getenv("HOME")
defaultTargetDir = filepath.Join(defaultMountDir, os.Getenv("USER"))
)
func usage(w io.Writer) {
prog := filepath.Base(os.Args[0])
fmt.Fprintf(w, `Usage: %s [-d path] [-l level] [-m path] [-nqsv]
[-t path]
-d path path to sync source directory
(default "%s")
-l level log level to output (default "INFO"). Valid log
levels are DEBUG, INFO, NOTICE, WARNING, ERR,
CRIT, ALERT, EMERG. The default is INFO.
-m path path to sync mount directory
(default "%s")
-n dry-run mode: only check paths and print files to
exclude
-q suppress console output
-s suppress syslog output
-t path path to sync target directory
(default "%s")
-v verbose rsync output
%s rsyncs the tree at the sync source directory (-d) to the sync target
directory (-t); it checks the mount directory (-m) exists; the sync target
target directory must exist on the mount directory.
`, prog, defaultSyncDir, defaultMountDir, defaultTargetDir, prog)
}
func checkPaths(mount, target string, dryRun bool) error {
if !fileutil.DirectoryDoesExist(mount) {
return fmt.Errorf("sync dir %s isn't mounted", mount)
}
if !strings.HasPrefix(target, mount) {
return fmt.Errorf("target dir %s must exist in %s", target, mount)
}
if !fileutil.DirectoryDoesExist(target) {
if dryRun {
log.Infof("would mkdir %s", target)
} else {
log.Infof("mkdir %s", target)
if err := os.Mkdir(target, 0755); err != nil {
return err
}
}
}
return nil
}
func buildExcludes(syncDir string) ([]string, error) {
var excluded []string
walker := func(path string, info fs.FileInfo, err error) error {
if err != nil {
excluded = append(excluded, strings.TrimPrefix(path, syncDir))
if info != nil && info.IsDir() {
return filepath.SkipDir
}
return nil
}
if info.Mode().IsRegular() {
if err = fileutil.Access(path, fileutil.AccessRead); err != nil {
excluded = append(excluded, strings.TrimPrefix(path, syncDir))
}
}
if info.IsDir() {
if err = fileutil.Access(path, fileutil.AccessExec); err != nil {
excluded = append(excluded, strings.TrimPrefix(path, syncDir))
}
}
return nil
}
err := filepath.Walk(syncDir, walker)
return excluded, err
}
func writeExcludes(excluded []string) (string, error) {
if len(excluded) == 0 {
return "", nil
}
excludeFile, err := os.CreateTemp("", defaultProgName)
if err != nil {
return "", err
}
for _, name := range excluded {
fmt.Fprintln(excludeFile, name)
}
defer excludeFile.Close()
return excludeFile.Name(), nil
}
func rsync(syncDir, target, excludeFile string, verboseRsync bool) error {
var args []string
if excludeFile != "" {
args = append(args, "--exclude-from")
args = append(args, excludeFile)
}
if verboseRsync {
args = append(args, "--progress")
args = append(args, "-v")
}
args = append(args, []string{"-au", syncDir + "/", target + "/"}...)
path, err := exec.LookPath("rsync")
if err != nil {
return err
}
cmd := exec.Command(path, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func init() {
flag.Usage = func() { usage(os.Stderr) }
}
func main() {
var logLevel, mountDir, syncDir, target string
var dryRun, quietMode, noSyslog, verboseRsync bool
flag.StringVar(&syncDir, "d", config.GetDefault("sync_dir", defaultSyncDir),
"`path to sync source directory`")
flag.StringVar(&logLevel, "l", config.GetDefault("log_level", "INFO"),
"log level to output")
flag.StringVar(&mountDir, "m", config.GetDefault("mount_dir", defaultMountDir),
"`path` to sync mount directory")
flag.BoolVar(&dryRun, "n", false, "dry-run mode: only check paths and print files to exclude")
flag.BoolVar(&quietMode, "q", quietMode, "suppress console output")
flag.BoolVar(&noSyslog, "s", noSyslog, "suppress syslog output")
flag.StringVar(&target, "t", config.GetDefault("sync_target", defaultTargetDir),
"`path` to sync target directory")
flag.BoolVar(&verboseRsync, "v", false, "verbose rsync output")
flag.Parse()
if quietMode && noSyslog {
fmt.Fprintln(os.Stderr, "both console and syslog output are suppressed")
fmt.Fprintln(os.Stderr, "errors will NOT be reported")
}
logOpts := &log.Options{
Level: logLevel,
Tag: defaultProgName,
Facility: "user",
WriteSyslog: !noSyslog,
WriteConsole: !quietMode,
}
err := log.Setup(logOpts)
log.FatalError(err, "failed to set up logging")
log.Infof("checking paths: mount=%s, target=%s", mountDir, target)
err = checkPaths(mountDir, target, dryRun)
log.FatalError(err, "target dir isn't ready")
log.Infof("checking for files to exclude from %s", syncDir)
excluded, err := buildExcludes(syncDir)
log.FatalError(err, "couldn't build excludes")
if dryRun {
fmt.Println("excluded files:")
for _, path := range excluded {
fmt.Printf("\t%s\n", path)
}
return
}
excludeFile, err := writeExcludes(excluded)
log.FatalError(err, "couldn't write exclude file")
log.Infof("excluding %d files via %s", len(excluded), excludeFile)
if excludeFile != "" {
defer func() {
log.Infof("removing exclude file %s", excludeFile)
if err := os.Remove(excludeFile); err != nil {
log.Warningf("failed to remove temp file %s", excludeFile)
}
}()
}
err = rsync(syncDir, target, excludeFile, verboseRsync)
log.FatalError(err, "couldn't sync data")
}

View File

@@ -1,19 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "diskimg_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/diskimg",
visibility = ["//visibility:private"],
deps = [
"//ahash",
"//dbg",
"//die",
],
)
go_binary(
name = "diskimg",
embed = [":diskimg_lib"],
visibility = ["//visibility:public"],
)

71
cmd/dumpbytes/main.go Normal file
View File

@@ -0,0 +1,71 @@
package main
import (
"flag"
"fmt"
"git.wntrmute.dev/kyle/goutils/die"
"io"
"os"
)
func usage(w io.Writer, exc int) {
fmt.Fprintln(w, `usage: dumpbytes <file>`)
os.Exit(exc)
}
func printBytes(buf []byte) {
fmt.Printf("\t")
for i := 0; i < len(buf); i++ {
fmt.Printf("0x%02x, ", buf[i])
}
fmt.Println()
}
func dumpFile(path string, indentLevel int) error {
indent := ""
for i := 0; i < indentLevel; i++ {
indent += "\t"
}
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
fmt.Printf("%svar buffer = []byte{\n", indent)
for {
buf := make([]byte, 8)
n, err := file.Read(buf)
if err == io.EOF {
if n > 0 {
fmt.Printf("%s", indent)
printBytes(buf[:n])
}
break
}
if err != nil {
return err
}
fmt.Printf("%s", indent)
printBytes(buf[:n])
}
fmt.Printf("%s}\n", indent)
return nil
}
func main() {
indent := 0
flag.Usage = func() { usage(os.Stderr, 0) }
flag.IntVar(&indent, "n", 0, "indent level")
flag.Parse()
for _, file := range flag.Args() {
err := dumpFile(file, indent)
die.If(err)
}
}

View File

@@ -1,15 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "eig_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/eig",
visibility = ["//visibility:private"],
deps = ["//die"],
)
go_binary(
name = "eig",
embed = [":eig_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,15 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "fragment_lib",
srcs = ["fragment.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/fragment",
visibility = ["//visibility:private"],
deps = ["//die"],
)
go_binary(
name = "fragment",
embed = [":fragment_lib"],
visibility = ["//visibility:public"],
)

5
cmd/host/README Normal file
View File

@@ -0,0 +1,5 @@
host
This is a utility to display CNAME records and IPs for a hostname. It
was born of my frustration in trying to figure out how to get the host(1)
tool installed on Fedora.

41
cmd/host/main.go Normal file
View File

@@ -0,0 +1,41 @@
package main
import (
"flag"
"fmt"
"log"
"net"
)
func lookupHost(host string) error {
cname, err := net.LookupCNAME(host)
if err != nil {
return err
}
if cname != host {
fmt.Printf("%s is a CNAME for %s\n", host, cname)
host = cname
}
addrs, err := net.LookupHost(host)
if err != nil {
return err
}
for _, addr := range addrs {
fmt.Printf("\t%s\n", addr)
}
return nil
}
func main() {
flag.Parse()
for _, arg := range flag.Args() {
if err := lookupHost(arg); err != nil {
log.Printf("%s: %s", arg, err)
}
}
}

View File

@@ -1,15 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "jlp_lib",
srcs = ["jlp.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/jlp",
visibility = ["//visibility:private"],
deps = ["//lib"],
)
go_binary(
name = "jlp",
embed = [":jlp_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,15 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "kgz_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/kgz",
visibility = ["//visibility:private"],
deps = ["@com_github_pkg_errors//:errors"],
)
go_binary(
name = "kgz",
embed = [":kgz_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,74 +1,68 @@
package main
import (
"compress/flate"
"compress/gzip"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/pkg/errors"
"compress/flate"
"compress/gzip"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
const gzipExt = ".gz"
func compress(path, target string, level int) error {
sourceFile, err := os.Open(path)
if err != nil {
return errors.Wrap(err, "opening file for read")
}
sourceFile, err := os.Open(path)
if err != nil {
return fmt.Errorf("opening file for read: %w", err)
}
defer sourceFile.Close()
destFile, err := os.Create(target)
if err != nil {
return errors.Wrap(err, "opening file for write")
}
destFile, err := os.Create(target)
if err != nil {
return fmt.Errorf("opening file for write: %w", err)
}
defer destFile.Close()
gzipCompressor, err := gzip.NewWriterLevel(destFile, level)
if err != nil {
return errors.Wrap(err, "invalid compression level")
}
gzipCompressor, err := gzip.NewWriterLevel(destFile, level)
if err != nil {
return fmt.Errorf("invalid compression level: %w", err)
}
defer gzipCompressor.Close()
_, err = io.Copy(gzipCompressor, sourceFile)
if err != nil {
return errors.Wrap(err, "compressing file")
}
if err != nil {
return errors.Wrap(err, "stat(2)ing destination file")
}
_, err = io.Copy(gzipCompressor, sourceFile)
if err != nil {
return fmt.Errorf("compressing file: %w", err)
}
return nil
}
func uncompress(path, target string) error {
sourceFile, err := os.Open(path)
if err != nil {
return errors.Wrap(err, "opening file for read")
}
sourceFile, err := os.Open(path)
if err != nil {
return fmt.Errorf("opening file for read: %w", err)
}
defer sourceFile.Close()
gzipUncompressor, err := gzip.NewReader(sourceFile)
if err != nil {
return errors.Wrap(err, "reading gzip headers")
}
gzipUncompressor, err := gzip.NewReader(sourceFile)
if err != nil {
return fmt.Errorf("reading gzip headers: %w", err)
}
defer gzipUncompressor.Close()
destFile, err := os.Create(target)
if err != nil {
return errors.Wrap(err, "opening file for write")
}
destFile, err := os.Create(target)
if err != nil {
return fmt.Errorf("opening file for write: %w", err)
}
defer destFile.Close()
_, err = io.Copy(destFile, gzipUncompressor)
if err != nil {
return errors.Wrap(err, "uncompressing file")
}
_, err = io.Copy(destFile, gzipUncompressor)
if err != nil {
return fmt.Errorf("uncompressing file: %w", err)
}
return nil
}
@@ -112,9 +106,9 @@ func pathForUncompressing(source, dest string) (string, error) {
}
source = filepath.Base(source)
if !strings.HasSuffix(source, gzipExt) {
return "", errors.Errorf("%s is a not gzip-compressed file", source)
}
if !strings.HasSuffix(source, gzipExt) {
return "", fmt.Errorf("%s is a not gzip-compressed file", source)
}
outFile := source[:len(source)-len(gzipExt)]
outFile = filepath.Join(dest, outFile)
return outFile, nil
@@ -126,9 +120,9 @@ func pathForCompressing(source, dest string) (string, error) {
}
source = filepath.Base(source)
if strings.HasSuffix(source, gzipExt) {
return "", errors.Errorf("%s is a gzip-compressed file", source)
}
if strings.HasSuffix(source, gzipExt) {
return "", fmt.Errorf("%s is a gzip-compressed file", source)
}
dest = filepath.Join(dest, source+gzipExt)
return dest, nil

3
cmd/minmax/README Normal file
View File

@@ -0,0 +1,3 @@
minmax
A quick tool to calculate minmax codes if needed for uLisp.

53
cmd/minmax/minmax.go Normal file
View File

@@ -0,0 +1,53 @@
package main
import (
"flag"
"fmt"
"os"
"strconv"
)
var kinds = map[string]int{
"sym": 0,
"tf": 1,
"fn": 2,
"sp": 3,
}
func dieIf(err error) {
if err == nil {
return
}
fmt.Fprintf(os.Stderr, "[!] %s\n", err)
os.Exit(1)
}
func usage() {
fmt.Fprintf(os.Stderr, "usage: minmax type min max\n")
fmt.Fprintf(os.Stderr, " type is one of fn, sp, sym, tf\n")
os.Exit(1)
}
func main() {
flag.Parse()
if flag.NArg() != 3 {
usage()
}
kind, ok := kinds[flag.Arg(0)]
if !ok {
usage()
}
min, err := strconv.Atoi(flag.Arg(1))
dieIf(err)
max, err := strconv.Atoi(flag.Arg(2))
dieIf(err)
code := kind << 6
code += (min << 3)
code += max
fmt.Printf("%0o\n", code)
}

View File

@@ -1,15 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "parts_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/parts",
visibility = ["//visibility:private"],
deps = ["//die"],
)
go_binary(
name = "parts",
embed = [":parts_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,14 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "pem2bin_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/pem2bin",
visibility = ["//visibility:private"],
)
go_binary(
name = "pem2bin",
embed = [":pem2bin_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,15 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "pembody_lib",
srcs = ["pembody.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/pembody",
visibility = ["//visibility:private"],
deps = ["//lib"],
)
go_binary(
name = "pembody",
embed = [":pembody_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,19 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "pemit_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/pemit",
visibility = ["//visibility:private"],
deps = [
"//assert",
"//die",
"//lib",
],
)
go_binary(
name = "pemit",
embed = [":pemit_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,14 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "readchain_lib",
srcs = ["chain.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/readchain",
visibility = ["//visibility:private"],
)
go_binary(
name = "readchain",
embed = [":readchain_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,18 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "renfnv_lib",
srcs = ["renfnv.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/renfnv",
visibility = ["//visibility:private"],
deps = [
"//fileutil",
"//lib",
],
)
go_binary(
name = "renfnv",
embed = [":renfnv_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,19 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "rhash_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/rhash",
visibility = ["//visibility:private"],
deps = [
"//ahash",
"//die",
"//lib",
],
)
go_binary(
name = "rhash",
embed = [":rhash_lib"],
visibility = ["//visibility:public"],
)

48
cmd/rolldie/main.go Normal file
View File

@@ -0,0 +1,48 @@
package main
import (
"flag"
"fmt"
"math/rand"
"os"
"regexp"
"strconv"
"git.wntrmute.dev/kyle/goutils/die"
)
var dieRollFormat = regexp.MustCompile(`^(\d+)[dD](\d+)$`)
func rollDie(count, sides int) []int {
sum := 0
var rolls []int
for i := 0; i < count; i++ {
roll := rand.Intn(sides) + 1
sum += roll
rolls = append(rolls, roll)
}
rolls = append(rolls, sum)
return rolls
}
func main() {
flag.Parse()
for _, arg := range flag.Args() {
if !dieRollFormat.MatchString(arg) {
fmt.Fprintf(os.Stderr, "invalid die format %s: should be XdY\n", arg)
os.Exit(1)
}
dieRoll := dieRollFormat.FindAllStringSubmatch(arg, -1)
count, err := strconv.Atoi(dieRoll[0][1])
die.If(err)
sides, err := strconv.Atoi(dieRoll[0][2])
die.If(err)
fmt.Println(rollDie(count, sides))
}
}

View File

@@ -1,18 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "showimp_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/showimp",
visibility = ["//visibility:private"],
deps = [
"//dbg",
"//die",
],
)
go_binary(
name = "showimp",
embed = [":showimp_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,18 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "ski_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/ski",
visibility = ["//visibility:private"],
deps = [
"//die",
"//lib",
],
)
go_binary(
name = "ski",
embed = [":ski_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,15 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "sprox_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/sprox",
visibility = ["//visibility:private"],
deps = ["//die"],
)
go_binary(
name = "sprox",
embed = [":sprox_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,15 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "stealchain-server_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/stealchain-server",
visibility = ["//visibility:private"],
deps = ["//die"],
)
go_binary(
name = "stealchain-server",
embed = [":stealchain-server_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,15 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "stealchain_lib",
srcs = ["thief.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/stealchain",
visibility = ["//visibility:private"],
deps = ["//die"],
)
go_binary(
name = "stealchain",
embed = [":stealchain_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,19 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "subjhash_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/subjhash",
visibility = ["//visibility:private"],
deps = [
"//certlib",
"//die",
"//lib",
],
)
go_binary(
name = "subjhash",
embed = [":subjhash_lib"],
visibility = ["//visibility:public"],
)

34
cmd/tlsinfo/README.txt Normal file
View File

@@ -0,0 +1,34 @@
tlsinfo: show TLS version, cipher, and peer certificates
---------------------------------------------------------
Description
tlsinfo connects to a TLS server and prints the negotiated TLS version and
cipher suite, followed by details for each certificate in the servers
presented chain (as provided by the server).
Usage
tlsinfo <hostname:port>
Output
The program prints the negotiated protocol and cipher, then one section per
certificate in the order received from the server. Example fields:
TLS Version: TLS 1.3
Cipher Suite: TLS_AES_128_GCM_SHA256
Certificate 1
Subject: CN=example.com, O=Example Corp, C=US
Issuer: CN=Example Root CA, O=Example Corp, C=US
DNS Names: [example.com www.example.com]
Not Before: 2025-01-01 00:00:00 +0000 UTC
Not After: 2026-01-01 23:59:59 +0000 UTC
Examples
# Inspect a public HTTPS endpoint
tlsinfo example.com:443
Notes
- Verification is intentionally disabled (InsecureSkipVerify=true). The tool
does not validate the server certificate or hostname; it is for inspection
only.
- The SNI/ServerName is inferred from <hostname> when applicable.
- You must specify a port (e.g., 443 for HTTPS).
- The entire certificate chain is printed exactly as presented by the server.

64
cmd/tlsinfo/main.go Normal file
View File

@@ -0,0 +1,64 @@
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Printf("Usage: %s hostname:port>\n", os.Args[0])
os.Exit(1)
}
hostPort := os.Args[1]
conn, err := tls.Dial("tcp", hostPort, &tls.Config{
InsecureSkipVerify: true,
})
if err != nil {
fmt.Printf("Failed to connect to the TLS server: %v\n", err)
os.Exit(1)
}
defer conn.Close()
state := conn.ConnectionState()
printConnectionDetails(state)
}
func printConnectionDetails(state tls.ConnectionState) {
version := tlsVersion(state.Version)
cipherSuite := tls.CipherSuiteName(state.CipherSuite)
fmt.Printf("TLS Version: %s\n", version)
fmt.Printf("Cipher Suite: %s\n", cipherSuite)
printPeerCertificates(state.PeerCertificates)
}
func tlsVersion(version uint16) string {
switch version {
case tls.VersionTLS13:
return "TLS 1.3"
case tls.VersionTLS12:
return "TLS 1.2"
case tls.VersionTLS11:
return "TLS 1.1"
case tls.VersionTLS10:
return "TLS 1.0"
default:
return "Unknown"
}
}
func printPeerCertificates(certificates []*x509.Certificate) {
for i, cert := range certificates {
fmt.Printf("Certificate %d\n", i+1)
fmt.Printf("\tSubject: %s\n", cert.Subject)
fmt.Printf("\tIssuer: %s\n", cert.Issuer)
fmt.Printf("\tDNS Names: %v\n", cert.DNSNames)
fmt.Printf("\tNot Before: %s\n:", cert.NotBefore)
fmt.Printf("\tNot After: %s\n", cert.NotAfter)
}
}

View File

@@ -1,15 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "tlskeypair_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/tlskeypair",
visibility = ["//visibility:private"],
deps = ["//die"],
)
go_binary(
name = "tlskeypair",
embed = [":tlskeypair_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,14 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "utc_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/utc",
visibility = ["//visibility:private"],
)
go_binary(
name = "utc",
embed = [":utc_lib"],
visibility = ["//visibility:public"],
)

View File

@@ -1,15 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "yamll_lib",
srcs = ["main.go"],
importpath = "git.wntrmute.dev/kyle/goutils/cmd/yamll",
visibility = ["//visibility:private"],
deps = ["@in_gopkg_yaml_v2//:yaml_v2"],
)
go_binary(
name = "yamll",
embed = [":yamll_lib"],
visibility = ["//visibility:public"],
)

136
cmd/zsearch/main.go Normal file
View File

@@ -0,0 +1,136 @@
// zsearch is a utility for searching zlib-compressed files for a
// search string. It was really designed for use with the Git object
// store, i.e. to aid in the recovery of files after Git does what Git
// do.
package main
import (
"bufio"
"bytes"
"compress/zlib"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
)
const defaultDirectory = ".git/objects"
func errorf(format string, a ...interface{}) {
fmt.Fprintf(os.Stderr, format, a...)
if format[len(format)-1] != '\n' {
fmt.Fprintf(os.Stderr, "\n")
}
}
func isDir(path string) bool {
fi, err := os.Stat(path)
if err != nil {
return false
}
return fi.IsDir()
}
func loadFile(path string) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
buf := new(bytes.Buffer)
zread, err := zlib.NewReader(file)
if err != nil {
return nil, err
}
defer zread.Close()
_, err = io.Copy(buf, zread)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func showFile(path string) {
fileData, err := loadFile(path)
if err != nil {
errorf("%v", err)
return
}
fmt.Printf("%s\n", fileData)
}
func searchFile(path string, search *regexp.Regexp) error {
file, err := os.Open(path)
if err != nil {
errorf("%v", err)
return err
}
defer file.Close()
zread, err := zlib.NewReader(file)
if err != nil {
errorf("%v", err)
return err
}
defer zread.Close()
zbuf := bufio.NewReader(zread)
if search.MatchReader(zbuf) {
fileData, err := loadFile(path)
if err != nil {
errorf("%v", err)
return err
}
fmt.Printf("%s:\n%s\n", path, fileData)
}
return nil
}
func buildWalker(searchExpr *regexp.Regexp) filepath.WalkFunc {
return func(path string, info os.FileInfo, err error) error {
if info.Mode().IsRegular() {
return searchFile(path, searchExpr)
}
return nil
}
}
func main() {
flSearch := flag.String("s", "", "search string (should be an RE2 regular expression)")
flag.Parse()
if *flSearch == "" {
for _, path := range flag.Args() {
showFile(path)
}
} else {
search, err := regexp.Compile(*flSearch)
if err != nil {
errorf("Bad regexp: %v", err)
return
}
pathList := flag.Args()
if len(pathList) == 0 {
pathList = []string{defaultDirectory}
}
for _, path := range pathList {
if isDir(path) {
err := filepath.Walk(path, buildWalker(search))
if err != nil {
errorf("%v", err)
return
}
} else {
searchFile(path, search)
}
}
}
}

View File

@@ -1,24 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "config",
srcs = [
"config.go",
"path.go",
"path_linux.go",
],
importpath = "git.wntrmute.dev/kyle/goutils/config",
visibility = ["//visibility:public"],
deps = ["//config/iniconf"],
)
go_test(
name = "config_test",
size = "small",
srcs = [
"config_test.go",
"path_test.go",
],
data = glob(["testdata/**"]),
embed = [":config"],
)

View File

@@ -11,7 +11,7 @@ package config
import (
"bufio"
"fmt"
"log"
"maps"
"os"
"sort"
"strings"
@@ -33,14 +33,15 @@ func SetEnvPrefix(pfx string) {
prefix = pfx
}
const keyValueSplitLength = 2
func addLine(line string) {
if strings.HasPrefix(line, "#") || line == "" {
return
}
lineParts := strings.SplitN(line, "=", 2)
if len(lineParts) != 2 {
log.Print("skipping line: ", line)
lineParts := strings.SplitN(line, "=", keyValueSplitLength)
if len(lineParts) != keyValueSplitLength {
return // silently ignore empty keys
}
@@ -49,7 +50,7 @@ func addLine(line string) {
vars[lineParts[0]] = lineParts[1]
}
// LoadFile scans the file at path for key=value pairs and adds them
// LoadFile scans the file at 'path' for key=value pairs and adds them
// to the configuration.
func LoadFile(path string) error {
file, err := os.Open(path)
@@ -64,25 +65,19 @@ func LoadFile(path string) error {
addLine(line)
}
if err = scanner.Err(); err != nil {
return err
}
return nil
return scanner.Err()
}
// LoadFileFor scans the ini file at path, loading the default section
// and overriding any keys found under section. If strict is true, the
// named section must exist (i.e. to catch typos in the section name).
// LoadFileFor scans the ini file at 'path', loading the default section
// and overriding any keys found under 'section'. If strict is true, the
// named section must exist (i.e., to catch typos in the section name).
func LoadFileFor(path, section string, strict bool) error {
cmap, err := iniconf.ParseFile(path)
if err != nil {
return err
}
for key, value := range cmap[iniconf.DefaultSection] {
vars[key] = value
}
maps.Copy(vars, cmap[iniconf.DefaultSection])
smap, ok := cmap[section]
if !ok {
@@ -92,9 +87,7 @@ func LoadFileFor(path, section string, strict bool) error {
return nil
}
for key, value := range smap {
vars[key] = value
}
maps.Copy(vars, smap)
return nil
}
@@ -111,7 +104,7 @@ func Get(key string) string {
// GetDefault retrieves a value from either a configuration file or
// the environment. Note that value from a file will override
// environment variables. If a value isn't found (e.g. Get returns an
// environment variables. If a value isn't found (e.g., Get returns an
// empty string), the default value will be used.
func GetDefault(key, def string) string {
if v := Get(key); v != "" {
@@ -121,8 +114,7 @@ func GetDefault(key, def string) string {
}
// Require retrieves a value from either a configuration file or the
// environment. If the key isn't present, it will call log.Fatal, printing
// the missing key.
// environment. If the key isn't present, it will panic.
func Require(key string) string {
if v, ok := vars[key]; ok {
return v
@@ -135,7 +127,7 @@ func Require(key string) string {
envMessage = " (note: looked for the key " + prefix + key
envMessage += " in the local env)"
}
log.Fatalf("missing required configuration value %s%s", key, envMessage)
panic(fmt.Sprintf("missing required configuration value %s%s", key, envMessage))
}
return v
@@ -143,7 +135,8 @@ func Require(key string) string {
// ListKeys returns a slice of the currently known keys.
func ListKeys() []string {
keyList := []string{}
var keyList []string
for k := range vars {
keyList = append(keyList, k)
}

View File

@@ -1,27 +1,26 @@
package config
package config_test
import (
"os"
"testing"
"git.wntrmute.dev/kyle/goutils/config"
)
const (
testFilePath = "testdata/test.env"
// Keys
// Key constants.
kOrder = "ORDER"
kSpecies = "SPECIES"
kName = "COMMON_NAME"
// Env
eOrder = "corvus"
eSpecies = "corvus corax"
eName = "northern raven"
// File
fOrder = "stringiformes"
fSpecies = "strix aluco"
// Name isn't set in the file to test fall through.
)
func init() {
@@ -31,8 +30,8 @@ func init() {
}
func TestLoadEnvOnly(t *testing.T) {
order := Get(kOrder)
species := Get(kSpecies)
order := config.Get(kOrder)
species := config.Get(kSpecies)
if order != eOrder {
t.Errorf("want %s, have %s", eOrder, order)
}
@@ -43,14 +42,14 @@ func TestLoadEnvOnly(t *testing.T) {
}
func TestLoadFile(t *testing.T) {
err := LoadFile(testFilePath)
err := config.LoadFile(testFilePath)
if err != nil {
t.Fatal(err)
}
order := Get(kOrder)
species := Get(kSpecies)
name := Get(kName)
order := config.Get(kOrder)
species := config.Get(kSpecies)
name := config.Get(kName)
if order != fOrder {
t.Errorf("want %s, have %s", fOrder, order)

View File

@@ -1,16 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "iniconf",
srcs = ["iniconf.go"],
importpath = "git.wntrmute.dev/kyle/goutils/config/iniconf",
visibility = ["//visibility:public"],
)
go_test(
name = "iniconf_test",
size = "small",
srcs = ["iniconf_test.go"],
data = glob(["testdata/**"]),
embed = [":iniconf"],
)

View File

@@ -2,6 +2,7 @@ package iniconf
import (
"bufio"
"errors"
"fmt"
"io"
"os"
@@ -23,86 +24,115 @@ var (
var DefaultSection = "default"
// ParseFile attempts to load the named config file.
func ParseFile(fileName string) (cfg ConfigMap, err error) {
var file *os.File
file, err = os.Open(fileName)
func ParseFile(fileName string) (ConfigMap, error) {
file, err := os.Open(fileName)
if err != nil {
return
return nil, err
}
defer file.Close()
return ParseReader(file)
}
// ParseReader reads a configuration from an io.Reader.
func ParseReader(r io.Reader) (cfg ConfigMap, err error) {
cfg = ConfigMap{}
func ParseReader(r io.Reader) (ConfigMap, error) {
cfg := ConfigMap{}
buf := bufio.NewReader(r)
var (
line string
longLine bool
currentSection string
lineBytes []byte
isPrefix bool
err error
)
for {
err = nil
lineBytes, isPrefix, err = buf.ReadLine()
if io.EOF == err {
line, longLine, err = readConfigLine(buf, line, longLine)
if errors.Is(err, io.EOF) {
err = nil
break
} else if err != nil {
break
} else if isPrefix {
line += string(lineBytes)
longLine = true
continue
} else if longLine {
line += string(lineBytes)
longLine = false
} else {
line = string(lineBytes)
}
if commentLine.MatchString(line) {
if line == "" {
continue
} else if blankLine.MatchString(line) {
continue
} else if configSection.MatchString(line) {
section := configSection.ReplaceAllString(line,
"$1")
if section == "" {
err = fmt.Errorf("invalid structure in file")
break
} else if !cfg.SectionInConfig(section) {
cfg[section] = make(map[string]string, 0)
}
currentSection = section
} else if configLine.MatchString(line) {
regex := configLine
if quotedConfigLine.MatchString(line) {
regex = quotedConfigLine
}
if currentSection == "" {
currentSection = DefaultSection
if !cfg.SectionInConfig(currentSection) {
cfg[currentSection] = map[string]string{}
}
}
key := regex.ReplaceAllString(line, "$1")
val := regex.ReplaceAllString(line, "$2")
if key == "" {
continue
}
cfg[currentSection][key] = val
} else {
err = fmt.Errorf("invalid config file")
}
currentSection, err = processConfigLine(cfg, line, currentSection)
if err != nil {
break
}
}
return
return cfg, err
}
// readConfigLine reads and assembles a complete configuration line, handling long lines.
func readConfigLine(buf *bufio.Reader, currentLine string, longLine bool) (string, bool, error) {
lineBytes, isPrefix, err := buf.ReadLine()
if err != nil {
return "", false, err
}
if isPrefix {
return currentLine + string(lineBytes), true, nil
} else if longLine {
return currentLine + string(lineBytes), false, nil
}
return string(lineBytes), false, nil
}
// processConfigLine processes a single line and updates the configuration map.
func processConfigLine(cfg ConfigMap, line string, currentSection string) (string, error) {
if commentLine.MatchString(line) || blankLine.MatchString(line) {
return currentSection, nil
}
if configSection.MatchString(line) {
return handleSectionLine(cfg, line)
}
if configLine.MatchString(line) {
return handleConfigLine(cfg, line, currentSection)
}
return currentSection, errors.New("invalid config file")
}
// handleSectionLine processes a section header line.
func handleSectionLine(cfg ConfigMap, line string) (string, error) {
section := configSection.ReplaceAllString(line, "$1")
if section == "" {
return "", errors.New("invalid structure in file")
}
if !cfg.SectionInConfig(section) {
cfg[section] = make(map[string]string, 0)
}
return section, nil
}
// handleConfigLine processes a key=value configuration line.
func handleConfigLine(cfg ConfigMap, line string, currentSection string) (string, error) {
regex := configLine
if quotedConfigLine.MatchString(line) {
regex = quotedConfigLine
}
if currentSection == "" {
currentSection = DefaultSection
if !cfg.SectionInConfig(currentSection) {
cfg[currentSection] = map[string]string{}
}
}
key := regex.ReplaceAllString(line, "$1")
val := regex.ReplaceAllString(line, "$2")
if key != "" {
cfg[currentSection][key] = val
}
return currentSection, nil
}
// SectionInConfig determines whether a section is in the configuration.
@@ -112,41 +142,39 @@ func (c ConfigMap) SectionInConfig(section string) bool {
}
// ListSections returns the list of sections in the config map.
func (c ConfigMap) ListSections() (sections []string) {
func (c ConfigMap) ListSections() []string {
sections := make([]string, 0, len(c))
for section := range c {
sections = append(sections, section)
}
return
return sections
}
// WriteFile writes out the configuration to a file.
func (c ConfigMap) WriteFile(filename string) (err error) {
func (c ConfigMap) WriteFile(filename string) error {
file, err := os.Create(filename)
if err != nil {
return
return err
}
defer file.Close()
for _, section := range c.ListSections() {
sName := fmt.Sprintf("[ %s ]\n", section)
_, err = file.Write([]byte(sName))
if err != nil {
return
if _, err = file.WriteString(sName); err != nil {
return err
}
for k, v := range c[section] {
line := fmt.Sprintf("%s = %s\n", k, v)
_, err = file.Write([]byte(line))
if err != nil {
return
if _, err = file.WriteString(line); err != nil {
return err
}
}
_, err = file.Write([]byte{0x0a})
if err != nil {
return
if _, err = file.Write([]byte{0x0a}); err != nil {
return err
}
}
return
return nil
}
// AddSection creates a new section in the config map.
@@ -170,27 +198,26 @@ func (c ConfigMap) AddKeyVal(section, key, val string) {
}
// GetValue retrieves the value from a key map.
func (c ConfigMap) GetValue(section, key string) (val string, present bool) {
func (c ConfigMap) GetValue(section, key string) (string, bool) {
if c == nil {
return
return "", false
}
if section == "" {
section = DefaultSection
}
_, ok := c[section]
if !ok {
return
if _, ok := c[section]; !ok {
return "", false
}
val, present = c[section][key]
return
val, present := c[section][key]
return val, present
}
// GetValueDefault retrieves the value from a key map if present,
// otherwise the default value.
func (c ConfigMap) GetValueDefault(section, key, value string) (val string) {
func (c ConfigMap) GetValueDefault(section, key, value string) string {
kval, ok := c.GetValue(section, key)
if !ok {
return value
@@ -199,7 +226,7 @@ func (c ConfigMap) GetValueDefault(section, key, value string) (val string) {
}
// SectionKeys returns the sections in the config map.
func (c ConfigMap) SectionKeys(section string) (keys []string, present bool) {
func (c ConfigMap) SectionKeys(section string) ([]string, bool) {
if c == nil {
return nil, false
}
@@ -208,13 +235,12 @@ func (c ConfigMap) SectionKeys(section string) (keys []string, present bool) {
section = DefaultSection
}
cm := c
s, ok := cm[section]
s, ok := c[section]
if !ok {
return nil, false
}
keys = make([]string, 0, len(s))
keys := make([]string, 0, len(s))
for key := range s {
keys = append(keys, key)
}

View File

@@ -1,18 +1,19 @@
package iniconf
package iniconf_test
import (
"errors"
"fmt"
"os"
"sort"
"testing"
"git.wntrmute.dev/kyle/goutils/config/iniconf"
)
// FailWithError is a utility for dumping errors and failing the test.
func FailWithError(t *testing.T, err error) {
fmt.Println("failed")
t.Log("failed")
if err != nil {
fmt.Println("[!] ", err.Error())
t.Log("[!] ", err.Error())
}
t.FailNow()
}
@@ -49,47 +50,50 @@ func stringSlicesEqual(slice1, slice2 []string) bool {
func TestGoodConfig(t *testing.T) {
testFile := "testdata/test.conf"
fmt.Printf("[+] validating known-good config... ")
cmap, err := ParseFile(testFile)
t.Logf("[+] validating known-good config... ")
cmap, err := iniconf.ParseFile(testFile)
if err != nil {
FailWithError(t, err)
} else if len(cmap) != 2 {
FailWithError(t, err)
}
fmt.Println("ok")
t.Log("ok")
}
func TestGoodConfig2(t *testing.T) {
testFile := "testdata/test2.conf"
fmt.Printf("[+] validating second known-good config... ")
cmap, err := ParseFile(testFile)
if err != nil {
t.Logf("[+] validating second known-good config... ")
cmap, err := iniconf.ParseFile(testFile)
switch {
case err != nil:
FailWithError(t, err)
} else if len(cmap) != 1 {
case len(cmap) != 1:
FailWithError(t, err)
} else if len(cmap["default"]) != 3 {
case len(cmap["default"]) != 3:
FailWithError(t, err)
default:
// nothing to do here
}
fmt.Println("ok")
t.Log("ok")
}
func TestBadConfig(t *testing.T) {
testFile := "testdata/bad.conf"
fmt.Printf("[+] ensure invalid config file fails... ")
_, err := ParseFile(testFile)
t.Logf("[+] ensure invalid config file fails... ")
_, err := iniconf.ParseFile(testFile)
if err == nil {
err = fmt.Errorf("invalid config file should fail")
err = errors.New("invalid config file should fail")
FailWithError(t, err)
}
fmt.Println("ok")
t.Log("ok")
}
func TestWriteConfigFile(t *testing.T) {
fmt.Printf("[+] ensure config file is written properly... ")
t.Logf("[+] ensure config file is written properly... ")
const testFile = "testdata/test.conf"
const testOut = "testdata/test.out"
cmap, err := ParseFile(testFile)
cmap, err := iniconf.ParseFile(testFile)
if err != nil {
FailWithError(t, err)
}
@@ -100,7 +104,7 @@ func TestWriteConfigFile(t *testing.T) {
FailWithError(t, err)
}
cmap2, err := ParseFile(testOut)
cmap2, err := iniconf.ParseFile(testOut)
if err != nil {
FailWithError(t, err)
}
@@ -110,25 +114,25 @@ func TestWriteConfigFile(t *testing.T) {
sort.Strings(sectionList1)
sort.Strings(sectionList2)
if !stringSlicesEqual(sectionList1, sectionList2) {
err = fmt.Errorf("section lists don't match")
err = errors.New("section lists don't match")
FailWithError(t, err)
}
for _, section := range sectionList1 {
for _, k := range cmap[section] {
if cmap[section][k] != cmap2[section][k] {
err = fmt.Errorf("config key doesn't match")
err = errors.New("config key doesn't match")
FailWithError(t, err)
}
}
}
fmt.Println("ok")
t.Log("ok")
}
func TestQuotedValue(t *testing.T) {
testFile := "testdata/test.conf"
fmt.Printf("[+] validating quoted value... ")
cmap, _ := ParseFile(testFile)
t.Logf("[+] validating quoted value... ")
cmap, _ := iniconf.ParseFile(testFile)
val := cmap["sectionName"]["key4"]
if val != " space at beginning and end " {
FailWithError(t, errors.New("Wrong value in double quotes ["+val+"]"))
@@ -138,5 +142,5 @@ func TestQuotedValue(t *testing.T) {
if val != " is quoted with single quotes " {
FailWithError(t, errors.New("Wrong value in single quotes ["+val+"]"))
}
fmt.Println("ok")
t.Log("ok")
}

Some files were not shown because too many files have changed in this diff Show More