1
0
This commit is contained in:
brent saner
2025-12-16 21:45:08 -05:00
commit d94a46af0b
17 changed files with 1298 additions and 0 deletions

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
*.7z
*.bak
*.deb
*.jar
*.rar
*.run
*.sig
*.tar
*.tar.bz2
*.tar.gz
*.tar.xz
*.tbz
*.tbz2
*.tgz
*.txz
*.zip
.*.swp
.editix
# https://github.com/github/gitignore/blob/master/Go.gitignore
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
#*.test
test.sh
/test/
# Built binaries
bin/*
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Example configs.
_exampledata/
# Don't include rendered doc
/README.html
# Example code.
_demo/

118
build.sh Executable file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/env bash
set -e
# This is not portable. It has bashisms.
# Like this (bash >= 4.x)
declare -A os_sfx=(
["linux"]='bin'
["windows"]='exe'
["darwin"]='app'
)
BUILD_TIME="$(date '+%s')"
BUILD_USER="$(whoami)"
BUILD_SUDO_USER="${SUDO_USER}"
BUILD_HOST="$(hostname)"
# Check to make sure git is available.
if ! command -v git &> /dev/null; then
echo "Git is not available; automatic version handling unsupported."
echo "You must build by calling 'go build' directly in the respective directories."
exit 0
fi
# Check git directory/repository.
if ! git rev-parse --is-inside-work-tree &> /dev/null; then
echo "Not running inside a git work tree; automatic version handling unsupported/build script unsupported."
echo "You must build by calling 'go build' directly in the respective directories instead."
exit 0
fi
# If it has a tag in the path of the current HEAD that matches a version string...
# I wish git describe supported regex. It does not; only globs. Gross.
# If there's a bug anywhere, it's here.
if git describe --tags --abbrev=0 --match "v[0-9]*" HEAD &> /dev/null; then
# It has a tag we can use.
CURRENT_VER="$(git describe --tags --abbrev=0 --match "v[0-9]*" HEAD)"
COMMITS_SINCE="$(git rev-list --count ${CURRENT_VER}..HEAD)"
else
# No tag available.
CURRENT_VER=""
COMMITS_SINCE=""
fi
# If it's dirty (staged but not committed or unstaged files)...
if ! git diff-index --quiet HEAD; then
# It's dirty.
IS_DIRTY="1"
else
# It's clean.
IS_DIRTY="0"
fi
# Get the commit hash of the *most recent* commit in the path of current HEAD...
CURRENT_HASH="$(git rev-parse --verify HEAD)"
# The same as above, but abbreviated.
CURRENT_SHORT="$(git rev-parse --verify --short HEAD)"
# Get the module name.
MODPATH="$(sed -n -re 's@^\s*module\s+(.*)(//.*)?$@\1@p' go.mod)"
# Build the ldflags string.
# BEHOLD! BASH WITCHCRAFT.
LDFLAGS_STR="\
-X '${MODPATH}/version.version=${CURRENT_VER}' \
-X '${MODPATH}/version.commitHash=${CURRENT_HASH}' \
-X '${MODPATH}/version.commitShort=${CURRENT_SHORT}' \
-X '${MODPATH}/version.numCommitsAfterTag=${COMMITS_SINCE}' \
-X '${MODPATH}/version.isDirty=${IS_DIRTY}' \
-X '${MODPATH}/version.buildTime=${BUILD_TIME}' \
-X '${MODPATH}/version.buildUser=${BUILD_USER}' \
-X '${MODPATH}/version.buildSudoUser=${BUILD_SUDO_USER}' \
-X '${MODPATH}/version.buildHost=${BUILD_HOST}'"
set -u
# And finally build.
mkdir -p ./bin/
export CGO_ENABLED=0
_origdir="$(pwd)"
_pfx=''
#for cmd in 'discord' 'irc' 'msteams' 'slack' 'xmpp'; do
for cmd_dir in cmd/*; do
cmd="$(basename "${cmd_dir}")"
echo "${cmd}..."
if [ ! -f "${cmd_dir}/main.go" ]; then
continue
fi
cd "${cmd_dir}"
_bin="${_pfx}${cmd}"
for ga in 'amd64' 'arm64'; do
as="${ga}"
if [[ "${ga}" == 'amd64' ]]; then
as='x86_64'
fi
echo -e "\t${as}..."
for osnm in "${!os_sfx[@]}"; do
echo -e "\t\t${osnm}: "
_sfx="${os_sfx[$osnm]}"
bin="${_origdir}/bin/${_bin}-${as}-${CURRENT_VER:-dev}.${_sfx}"
export GOOS="${osnm}"
export GOARCH="${ga}"
echo -e -n "\t\tBuilding '${bin}'..."
go build \
-o "${bin}" \
-ldflags \
"${LDFLAGS_STR}"
# "${LDFLAGS_STR}" \
# *.go
echo " Done."
done
echo -e "\tDone."
done
echo "Done."
done
echo -e "\nBuild complete."

View File

@@ -0,0 +1,70 @@
package main
import (
`fmt`
`log`
`os`
`update_vault_totp/internal`
"golang.org/x/term"
"r00t2.io/sysutils/envs"
)
func getToken() (tok string, err error) {
var p1 []byte
var oldState *term.State
if envs.HasEnv(internal.VaultTokEnv) {
tok = os.Getenv(internal.VaultTokEnv)
return
}
// Prompt for it instead
fmt.Println("Vault token needed.\nVault token (will not be echoed back):")
if oldState, err = term.GetState(internal.TermFd); err != nil {
return
}
defer func() {
if err = term.Restore(internal.TermFd, oldState); err != nil {
log.Println("restore failed:", err)
}
}()
if p1, err = term.ReadPassword(internal.TermFd); err != nil {
return
}
tok = string(p1)
return
}
func main() {
var err error
var errs []error
var tok string
var c *internal.Client
if tok, err = getToken(); err != nil {
log.Panicln(err)
}
if c, err = internal.New(tok, internal.DefAddr, internal.TgtMnt, internal.CollNm); err != nil {
log.Panicln(err)
}
c.DeleteAllVaultKeys()
if err = c.Close(); err != nil {
log.Println(err)
}
if errs = c.Errors(); len(errs) > 0 {
for _, err = range errs {
log.Println(err)
}
}
log.Println("Done.")
}

View File

@@ -0,0 +1,70 @@
package main
import (
`fmt`
`log`
`os`
`update_vault_totp/internal`
"golang.org/x/term"
"r00t2.io/sysutils/envs"
)
func getToken() (tok string, err error) {
var p1 []byte
var oldState *term.State
if envs.HasEnv(internal.VaultTokEnv) {
tok = os.Getenv(internal.VaultTokEnv)
return
}
// Prompt for it instead
fmt.Println("Vault token needed.\nVault token (will not be echoed back):")
if oldState, err = term.GetState(internal.TermFd); err != nil {
return
}
defer func() {
if err = term.Restore(internal.TermFd, oldState); err != nil {
log.Println("restore failed:", err)
}
}()
if p1, err = term.ReadPassword(internal.TermFd); err != nil {
return
}
tok = string(p1)
return
}
func main() {
var err error
var errs []error
var tok string
var c *internal.Client
if tok, err = getToken(); err != nil {
log.Panicln(err)
}
if c, err = internal.New(tok, internal.DefAddr, internal.TgtMnt, internal.CollNm); err != nil {
log.Panicln(err)
}
c.Sync()
if err = c.Close(); err != nil {
log.Println(err)
}
if errs = c.Errors(); len(errs) > 0 {
for _, err = range errs {
log.Println(err)
}
}
log.Println("Done.")
}

View File

@@ -0,0 +1,27 @@
package main
import (
"log"
`update_vault_totp/internal`
sysdUtil "github.com/coreos/go-systemd/v22/util"
`github.com/jessevdk/go-flags`
`r00t2.io/goutils/logging`
)
var (
isSystemd bool = sysdUtil.IsRunningSystemd()
)
const (
logFlags int = log.LstdFlags | log.Lmsgprefix
logFlagsDebug int = logFlags | log.Llongfile
)
var (
logger *logging.MultiLogger
args *internal.Args = new(internal.Args)
logFlagsRuntime int = logFlags
parser *flags.Parser = flags.NewParser(args, flags.Default)
)

View File

@@ -0,0 +1,72 @@
package main
import (
`errors`
`fmt`
`log`
`os`
`update_vault_totp/version`
`github.com/davecgh/go-spew/spew`
`github.com/jessevdk/go-flags`
`r00t2.io/goutils/logging`
)
func main() {
var err error
log.SetOutput(os.Stderr)
parser.EnvNamespaceDelimiter = ""
if _, err = parser.Parse(); err != nil {
var flagsErr *flags.Error
switch {
case errors.As(err, &flagsErr):
switch {
case errors.Is(flagsErr.Type, flags.ErrHelp), errors.Is(flagsErr.Type, flags.ErrCommandRequired), errors.Is(flagsErr.Type, flags.ErrRequired): // These print their relevant messages by themselves.
return
default:
log.Panicln(err)
}
default:
log.Panicln(err)
}
}
if version.Ver, err = version.Version(); err != nil {
log.Panicln(err)
}
// If args.Version or args.DetailVersion are true, just print them and exit.
if args.DetailVersion || args.Version {
if args.Version {
fmt.Println(version.Ver.Short())
return
} else if args.DetailVersion {
fmt.Println(version.Ver.Detail())
return
}
}
// We want to set up logging before anything else.
if args.DoDebug {
logFlagsRuntime = logFlagsDebug
}
logger = logging.GetMultiLogger(args.DoDebug, "ZorkBot")
if err = logger.AddDefaultLogger(
"default",
logFlagsRuntime,
"/var/log/zorkbot/zorkbot.log", "~/logs/zorkbot.log",
); err != nil {
log.Panicln(err)
}
if err = logger.Setup(); err != nil {
log.Panicln(err)
}
defer logger.Shutdown()
logger.Info("main: ZorkBot version %v", version.Ver.Short())
logger.Debug("main: ZorkBot version (extended):\n%v", version.Ver.Detail())
logger.Debug("main: Invocation args:\n%s", spew.Sdump(args))
}

31
go.mod Normal file
View File

@@ -0,0 +1,31 @@
module update_vault_totp
go 1.25.5
require (
github.com/coreos/go-systemd/v22 v22.5.0
github.com/creachadair/otp v0.5.2
github.com/hashicorp/vault-client-go v0.4.3
github.com/jessevdk/go-flags v1.6.1
golang.org/x/mod v0.23.0
golang.org/x/term v0.38.0
r00t2.io/gosecret v1.1.5
r00t2.io/goutils v1.13.0
r00t2.io/sysutils v1.15.0
)
require (
github.com/creachadair/wirepb v0.0.0-20251201055919-954ef89c4c71 // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/godbus/dbus/v5 v5.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/time v0.14.0 // indirect
)

66
go.sum Normal file
View File

@@ -0,0 +1,66 @@
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creachadair/mds v0.25.2 h1:xc0S0AfDq5GX9KUR5sLvi5XjA61/P6S5e0xFs1vA18Q=
github.com/creachadair/mds v0.25.2/go.mod h1:+s4CFteFRj4eq2KcGHW8Wei3u9NyzSPzNV32EvjyK/Q=
github.com/creachadair/otp v0.5.2 h1:3krOkIHA8X1YAnv9lGZi2NlyCZepcsAQkZwKGK5L+9c=
github.com/creachadair/otp v0.5.2/go.mod h1:0cUM4kl9cQmePbIEy1J1O+esh+S3zDM9s2Foo71KzKA=
github.com/creachadair/wirepb v0.0.0-20251201055919-954ef89c4c71 h1:H3EV2OFbtKgFAcxGJiLTNar9krJ9yfsgpE37/QY9vN8=
github.com/creachadair/wirepb v0.0.0-20251201055919-954ef89c4c71/go.mod h1:dHvCVZSsk3Y/fhY0mUex0U2XBZszomOe7PzlghOtHnQ=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
github.com/hashicorp/vault-client-go v0.4.3 h1:zG7STGVgn/VK6rnZc0k8PGbfv2x/sJExRKHSUg3ljWc=
github.com/hashicorp/vault-client-go v0.4.3/go.mod h1:4tDw7Uhq5XOxS1fO+oMtotHL7j4sB9cp0T7U6m4FzDY=
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
r00t2.io/gosecret v1.1.5 h1:pmlsCR8VKeI9+BN7uvUqThP/BtgoSch580XOWin+qH4=
r00t2.io/gosecret v1.1.5/go.mod h1:24jo8x5lOmWAJZJZ1J3FxhoT9+KOXSZ5pQWWzFdvdtM=
r00t2.io/goutils v1.13.0 h1:103QR2eUu42TNF7h9r6YgahbSgRAJ8VtFxSzw9rMaXI=
r00t2.io/goutils v1.13.0/go.mod h1:/0M+6fW2VAgdtAvwGE9oNV259MoEZeJ84rLCZsxe8uI=
r00t2.io/sysutils v1.15.0 h1:FSnREfbXDhBQEO7LMpnRQeKlPshozxk9XHw3YgWRgRg=
r00t2.io/sysutils v1.15.0/go.mod h1:28qB0074EIRQ8Sy/ybaA5jC3qA32iW2aYLkMCRhyAFM=

26
internal/args.go Normal file
View File

@@ -0,0 +1,26 @@
package internal
type (
Args struct {
Version bool `short:"v" long:"version" description:"Print the version and exit."`
DetailVersion bool `short:"V" long:"detail" description:"Print detailed version info and exit."`
DoDebug bool `env:"VTOTP_DEBUG" short:"d" long:"debug" description:"If specified, enable debug logging. This may log potentially sensitive information, so be careful."`
VaultToken string `env:"VAULT_TOKEN" short:"t" long:"vault-token" description:"Vault token to use. If empty/not defined, you will be securely prompted."`
VaultAddr string `env:"VAULT_ADDR" short:"a" long:"vault-addr" default:"https://clandestine.r00t2.io/" description:"Vault address to use."`
QrImgPath string `env:"VTOTP_QR" short:"q" long:"qr-img" description:"Path to QR image to extract OTPAuth URLs from. Either -q/--qr-img, -f/--otp-file, -u/--otp-url, and/or -x/--explicit must be specified." validate:"required_without=OtpFile OtpUrl OtpExplicit,filepath"`
OtpFile string `env:"VTOTP_FILE" short:"f" long:"otp-file" description:"Path to file containing OTPAuth URLs in plaintext, one per line. Either -q/--qr-img, -f/--otp-file, -u/--otp-url, and/or -x/--explicit must be specified." validate:"required_without=QrImgPath OtpUrl OtpExplicit,filepath"`
OtpUrl string `env:"VTOTP_URL" short:"u" long:"otp-url" description:"Explicit OTPAuth URL. Either -q/--qr-img, -f/--otp-file, -u/--otp-url, and/or -x/--explicit must be specified." validate:"required_without=QrImgPath OtpFile OtpExplicit,url"`
OtpExplicit bool `short:"x" long:"otp-explicit" description:"If specified, use the explicit OTP specification under the EXPLICIT TOTP group."`
ExplicitOtp ExplicitOtp `group:"EXPLICIT TOTP" env-namespace:"VTOTP_X"`
}
ExplicitOtp struct {
Type string `env:"TYP" short:"y" long:"type" choice:"totp" choice:"hotp" default:"totp" hidden:"true" description:"The OTP type." validate:"required_with=OtpExplicit,oneof=totp hotp"`
Counter uint64 `env:CTR" short:"c" long:"counter" hidden:"true" description:"The initial counter value (if -y/--type='hotp')." validate:"required_with=OtpExplicit,required_if=Type hotp"`
Account string `env:"ACCT" short:"n" long:"name" description:"Name of the TOTP account (should be just the username)." validate:"required_with=OtpExplicit"`
Issuer string `env:"ISS" short:"i" long:"issuer" description:"Issuer of the TOTP (this is generally the service name you're authing to)." validate:"required_with=OtpExplicit"`
Secret string `env:"SSKEY" short:"s" long:"shared-secret" description:"The shared secret key in Base32 string format (with no padding)." validate:"required_with=OtpExplicit,base32"`
Algorithm string `env:"ALGO" short:"g" long:"algo" choice:"md5" choice:"sha1" choice:"sha256" choice:"sha512" description:"The hashing/checksum algorithm." validate:"required_with=OtpExplicit,oneof=md5 sha1 sha256 sha512"`
Digits int `env:"DIG" short:"l" long:"digits" choice:"6" choice:"8" description:"Number of digits for the generated code." validate:"required_with=OtpExplicit,oneof=6 8"`
Period time.Duration `env:TIME" short:"p" long:"period" default:"30s" description:"The period that a generated code is valid for." validate:"required_with=OtpExplicit,required_if=Type totp"`
}
)

32
internal/consts.go Normal file
View File

@@ -0,0 +1,32 @@
package internal
import (
`os`
)
const (
DefAddr string = "https://clandestine.r00t2.io/"
VaultTokEnv string = "VAULT_TOKEN"
CollNm string = "OTP"
TgtMnt string = "totp_bts.work"
// These attrs need to be added to new SecretService TOTP secrets
// ~/.local/share/gnome-shell/extensions/totp@dkosmari.github.com/schemas/org.gnome.shell.extensions.totp.gschema.xml
SsSchemaName string = "xdg:schema"
SsSchemaVal string = "org.gnome.shell.extensions.totp" // also the gosecret.ItemType
)
var (
TermFd int = int(os.Stdin.Fd())
)
var (
/*
These map from one form to the other using introspection.
ext: The GNOME Shell extension, ~/.local/share/gnome-shell/extensions/totp@dkosmari.github.com/secretUtils.js (makeSchema())
Also found at https://github.com/dkosmari/gnome-shell-extension-totp/blob/master/secretUtils.js#L33-L45
vault: A https://pkg.go.dev/github.com/hashicorp/vault-client-go@/schema#TotpCreateKeyRequest
(reading a key returns a map[string]interface{} for ...some reason)
url: A https://pkg.go.dev/github.com/creachadair/otp/otpauth#URL
*/
// TODO?
)

103
internal/funcs.go Normal file
View File

@@ -0,0 +1,103 @@
package internal
import (
`context`
`sync`
`github.com/hashicorp/vault-client-go`
`r00t2.io/gosecret`
`r00t2.io/goutils/multierr`
)
func New(vaultTok, vaultAddr, vaultMnt, collNm string) (c *Client, err error) {
c = &Client{
// lastIdx: 0,
vtok: vaultTok,
vaddr: vaultAddr,
scollNm: collNm,
vmnt: vaultMnt,
errsDone: make(chan bool, 1),
errChan: make(chan error),
// vc: nil,
wg: sync.WaitGroup{},
ctx: context.Background(),
// ssvc: nil,
// scoll: nil,
mErr: multierr.NewMultiError(nil),
// inSS: nil,
// inVault: nil,
}
if c.vc, err = vault.New(vault.WithAddress(c.vaddr)); err != nil {
return
}
if err = c.vc.SetToken(c.vtok); err != nil {
return
}
if c.ssvc, err = gosecret.NewService(); err != nil {
return
}
if c.scoll, err = c.ssvc.GetCollection(collNm); err != nil {
return
}
go c.readErrs()
c.wg.Add(2)
go c.getSS()
go c.getVault()
c.wg.Wait()
if !c.mErr.IsEmpty() {
err = c.mErr
return
}
return
}
func normalizeVaultNm(nm string) (normalized string) {
var c rune
var idx int
var last rune
var repl rune = '_'
var reduced []rune = make([]rune, 0)
var norm []rune = make([]rune, 0, len(nm))
for _, c = range nm {
// If it's "safe" chars, it's fine
if (c == '-' || c == '.') || // 0x2d, 0x2e
(c >= '0' && c <= '9') || // 0x30 to 0x39
(c == '@') || // 0x40
(c >= 'A' && c <= 'Z') || // 0x41 to 0x5a
(c == '_') || // 0x5f
(c >= 'a' && c <= 'z') { // 0x61 to 0x7a
norm = append(norm, c)
continue
}
// Otherwise normalize it to a safe char
norm = append(norm, repl)
}
// And remove repeating sequential replacers.
for idx, c = range norm[:] {
if idx == 0 {
last = c
reduced = append(reduced, c)
continue
}
if c == last && last == repl {
continue
}
reduced = append(reduced, c)
last = c
}
normalized = string(reduced)
return
}

269
internal/funcs_client.go Normal file
View File

@@ -0,0 +1,269 @@
package internal
import (
`errors`
`fmt`
`math`
`net/http`
`strconv`
`strings`
`github.com/creachadair/otp/otpauth`
`github.com/hashicorp/vault-client-go`
`github.com/hashicorp/vault-client-go/schema`
`r00t2.io/gosecret`
`r00t2.io/goutils/multierr`
)
func (c *Client) Close() (err error) {
if err = c.ssvc.Close(); err != nil {
c.errChan <- err
}
close(c.errChan)
<-c.errsDone
return
}
func (c *Client) DeleteAllVaultKeys() {
var name string
for name, _ = range c.inVault {
c.wg.Add(1)
go func(nm string) {
var vErr error
var resp *vault.Response[map[string]interface{}]
defer c.wg.Done()
if resp, vErr = c.vc.Secrets.TotpDeleteKey(
c.ctx,
nm,
vault.WithMountPath(c.vmnt),
); vErr != nil {
c.errChan <- vErr
return
}
_ = resp
}(name)
}
c.wg.Wait()
}
func (c *Client) Errors() (errs []error) {
errs = make([]error, 0)
if c.mErr == nil || c.mErr.IsEmpty() {
return
}
errs = c.mErr.Errors
c.mErr = multierr.NewMultiError(nil)
return
}
// currently it doesn't update out-of-sync keys and only syncs from SecretService to Vault. TODO.
func (c *Client) Sync() {
c.addVault()
c.wg.Wait()
}
func (c *Client) addVault() {
var name string
var wsec wrappedSsSecret
for name, wsec = range c.inSS {
c.wg.Add(1)
go func(nm string, sec wrappedSsSecret) {
var vErr error
var i int
var ok bool
var digSize int
var numSeconds int
// var digSize int32
var otpUrl *otpauth.URL
var req schema.TotpCreateKeyRequest
var resp *vault.Response[map[string]interface{}]
defer c.wg.Done()
if _, ok = c.inVault[nm]; ok {
return
}
if i, vErr = strconv.Atoi(sec.secret.Attrs["digits"]); vErr != nil {
c.errChan <- vErr
return
}
if i > math.MaxInt32 {
c.errChan <- fmt.Errorf("digits too large (%d > %d)", i, math.MaxInt32)
return
}
// digSize = int32(i)
digSize = i
if i, vErr = strconv.Atoi(sec.secret.Attrs["period"]); vErr != nil {
c.errChan <- vErr
return
}
if i > math.MaxInt32 {
c.errChan <- fmt.Errorf("period too large (%d > %d)", i, math.MaxInt32)
return
}
numSeconds = i
otpUrl = &otpauth.URL{
Type: "TOTP", // TODO: https://github.com/dkosmari/gnome-shell-extension-totp/issues/15
Issuer: sec.secret.Attrs["issuer"],
Account: sec.secret.Attrs["name"],
RawSecret: string(sec.secret.Secret.Value),
Algorithm: strings.ToUpper(strings.ReplaceAll(sec.secret.Attrs["algorithm"], "-", "")),
Digits: digSize,
Period: numSeconds,
Counter: 0,
}
fmt.Printf("Adding '%s' to '%s'\n", nm, c.vmnt)
// https://developer.hashicorp.com/vault/api-docs/secret/totp#create-key
// https://pkg.go.dev/github.com/hashicorp/vault-client-go/schema#TotpCreateKeyRequest
req = schema.TotpCreateKeyRequest{
// AccountName: sec.secret.Attrs["name"],
// Algorithm: strings.ToUpper(strings.ReplaceAll(sec.secret.Attrs["algorithm"], "-", "")),
// Digits: digSize,
// Exported: false,
// Generate: false,
// Issuer: sec.secret.Attrs["issuer"],
// Key: string(sec.secret.Secret.Value),
// // KeySize: 0,
// Period: sec.secret.Attrs["period"],
// // QrSize: 0,
// // Skew: 0,
Url: otpUrl.String(),
}
if resp, vErr = c.vc.Secrets.TotpCreateKey(
c.ctx,
nm,
req,
vault.WithMountPath(c.vmnt),
); vErr != nil {
c.errChan <- fmt.Errorf("error adding '%s': %v", nm, vErr)
return
}
_ = resp
}(name, wsec)
}
}
func (c *Client) getSS() {
var err error
var idx int
var nm string
var spl []string
var item *gosecret.Item
var secrets []*gosecret.Item
defer c.wg.Done()
if secrets, err = c.scoll.Items(); err != nil {
c.errChan <- err
return
}
c.inSS = make(map[string]wrappedSsSecret)
for _, item = range secrets {
// Unlike KVv2 names, Vault is *very* picky about key names. Boo.
// nm = fmt.Sprintf("%s/%s", item.Attrs["issuer"], item.Attrs["name"])
nm = fmt.Sprintf(
"%s"+
"."+
"%s",
normalizeVaultNm(item.Attrs["issuer"]),
normalizeVaultNm(item.Attrs["name"]),
)
fmt.Printf("'%s' from '%s' => '%s'\n", item.Attrs["name"], item.Attrs["issuer"], nm)
spl = strings.SplitN(item.LabelName, ":", 2)
if idx, err = strconv.Atoi(spl[0]); err != nil {
c.errChan <- err
continue
}
c.inSS[nm] = wrappedSsSecret{
id: idx,
strippedNm: nm,
nm: item.LabelName,
secret: item,
}
if idx > c.lastIdx {
c.lastIdx = idx
}
}
return
}
func (c *Client) getVault() {
var err error
var nm string
var nms []string
var vErr *vault.ResponseError
var totpKey *vault.Response[map[string]interface{}]
defer c.wg.Done()
var resp *vault.Response[schema.StandardListResponse]
resp, err = c.vc.Secrets.TotpListKeys(
c.ctx,
vault.WithMountPath(c.vmnt),
)
if err != nil {
// no TOTP yet
if errors.As(err, &vErr) && vErr.StatusCode == http.StatusNotFound {
c.inVault = make(map[string]map[string]interface{})
err = nil
return
}
c.errChan <- err
return
}
nms = resp.Data.Keys
c.inVault = make(map[string]map[string]interface{})
for _, nm = range nms {
if totpKey, err = c.vc.Secrets.TotpReadKey(
c.ctx,
nm,
vault.WithMountPath(c.vmnt),
); err != nil {
c.errChan <- err
continue
}
c.inVault[nm] = totpKey.Data
}
return
}
func (c *Client) readErrs() {
var err error
for err = range c.errChan {
if err != nil {
c.mErr.AddError(err)
}
}
c.errsDone <- true
}

36
internal/types.go Normal file
View File

@@ -0,0 +1,36 @@
package internal
import (
`context`
`sync`
`github.com/hashicorp/vault-client-go`
`r00t2.io/gosecret`
`r00t2.io/goutils/multierr`
)
type (
Client struct {
lastIdx int
vtok string
vaddr string
scollNm string
vmnt string
errsDone chan bool
errChan chan error
vc *vault.Client
wg sync.WaitGroup
ctx context.Context
ssvc *gosecret.Service
scoll *gosecret.Collection
mErr *multierr.MultiError
inSS map[string]wrappedSsSecret
inVault map[string]map[string]interface{}
}
wrappedSsSecret struct {
id int
strippedNm string
nm string
secret *gosecret.Item
}
)

32
version/consts.go Normal file
View File

@@ -0,0 +1,32 @@
package version
import (
"regexp"
)
/*
These variables are automatically handled by the build script.
DO NOT MODIFY THESE VARIABLES.
Refer to /build.sh for how these are generated at build time and populated.
*/
var (
sourceControl string = "git"
version string = "(unknown)"
commitHash string
commitShort string
numCommitsAfterTag string
isDirty string
buildTime string
buildUser string
buildSudoUser string
buildHost string
)
var (
patchRe *regexp.Regexp = regexp.MustCompile(`^(?P<patch>[0-9+])(?P<pre>-[0-9A-Za-z.-]+)?(?P<build>\+[0-9A-Za-z.-]+)?$`)
patchReIsolated *regexp.Regexp = regexp.MustCompile(`^([0-9]+)(?:[-+](.*)?)?$`)
)
// Ver is populated by main() from the build script and used in other places.
var Ver *BuildInfo

144
version/funcs.go Normal file
View File

@@ -0,0 +1,144 @@
package version
import (
"fmt"
"strconv"
"strings"
"time"
"golang.org/x/mod/semver"
)
// Version returns the build information. See build.sh.
func Version() (b *BuildInfo, err error) {
var n int
var s string
var sb strings.Builder
var ok bool
var canonical string
var build strings.Builder
// Why a map?
// I forget but I had a reason for it once upon a time.
var raw map[string]string = map[string]string{
"sourceControl": sourceControl,
"tag": version,
"hash": commitHash,
"shortHash": commitShort,
"postTagCommits": numCommitsAfterTag,
"dirty": isDirty,
"time": buildTime,
"user": buildUser,
"sudoUser": buildSudoUser,
"host": buildHost,
}
var i BuildInfo = BuildInfo{
SourceControl: raw["sourceControl"],
TagVersion: raw["tag"],
// PostTagCommits: 0,
CommitHash: raw["hash"],
CommitId: raw["shortHash"],
BuildUser: raw["user"],
RealBuildUser: raw["sudoUser"],
// BuildTime: time.Time{},
BuildHost: raw["host"],
Dirty: false,
isDefined: false,
raw: raw,
}
if s, ok = raw["postTagCommits"]; ok && strings.TrimSpace(s) != "" {
if n, err = strconv.Atoi(s); err == nil {
i.PostTagCommits = uint(n)
}
}
if s, ok = raw["time"]; ok && strings.TrimSpace(s) != "" {
if n, err = strconv.Atoi(s); err == nil {
i.BuildTime = time.Unix(int64(n), 0).UTC()
}
}
switch strings.ToLower(raw["dirty"]) {
case "1":
i.Dirty = true
case "0", "":
i.Dirty = false
}
// Build the short form. We use this for both BuildInfo.short and BuildInfo.verSplit.
if i.TagVersion == "" {
sb.WriteString(i.SourceControl)
} else {
sb.WriteString(i.TagVersion)
}
/*
Now the mess. In order to conform to SemVer 2.0 (the spec this code targets):
1.) MAJOR.
2.) MINOR.
3.) PATCH
4.) -PRERELEASE (OPTIONAL)
(git commit, if building against a commit made past 1-3. Always included if untagged.)
5.) +BUILDINFO (OPTIONAL)
("+x[.y]", where x is # of commits past 4, or tag commit if 4 is empty. 0 is valid.
y is optional, and is the string "dirty" if it is a "dirty" build - that is, uncommitted/unstaged changes.
if x and y would be 0 and empty, respectively, then 5 is not included.)
1-3 are already written, or the source control software used if not.
Technically 4 and 5 are only included if 3 is present. We force patch to 0 if it's a tagged release and patch isn't present --
so this is not relevant.
*/
// PRERELEASE
if i.TagVersion == "" || i.PostTagCommits > 0 {
// We use the full commit hash for git versions, short identifier for tagged releases.
if i.TagVersion == "" {
i.Pre = i.CommitHash
} else {
i.Pre = i.CommitId
}
sb.WriteString(fmt.Sprintf("-%v", i.Pre))
}
// BUILD
if i.PostTagCommits > 0 || i.Dirty {
build.WriteString(strconv.Itoa(int(i.PostTagCommits)))
if i.Dirty {
build.WriteString(".dirty")
}
i.Build = build.String()
sb.WriteString(fmt.Sprintf("+%v", i.Build))
}
i.short = sb.String()
if semver.IsValid(i.short) {
// DON'T DO THIS. It strips the prerelease and build info.
// i.short = semver.Canonical(i.short)
// Do this instead.
canonical = semver.Canonical(i.short)
// Numeric versions...
if n, err = strconv.Atoi(strings.TrimPrefix(semver.Major(canonical), "v")); err != nil {
err = nil
} else {
i.Major = uint(n)
}
if n, err = strconv.Atoi(strings.Split(semver.MajorMinor(canonical), ".")[1]); err != nil {
err = nil
} else {
i.Minor = uint(n)
}
if n, err = strconv.Atoi(patchReIsolated.FindStringSubmatch(strings.Split(canonical, ".")[2])[1]); err != nil {
err = nil
} else {
i.Patch = uint(n)
}
// The other tag assignments were performed above.
}
// The default is 0 for the numerics, so no big deal.
i.isDefined = true
b = &i
return
}

103
version/funcs_buildinfo.go Normal file
View File

@@ -0,0 +1,103 @@
package version
import (
"fmt"
"strings"
"golang.org/x/mod/semver"
)
// Detail returns a multiline string containing every possible piece of information we collect.
func (b *BuildInfo) Detail() (ver string) {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%v\n\n", b.short))
sb.WriteString(fmt.Sprintf("====\nSource Control: %v\n", b.SourceControl))
if b.TagVersion != "" {
if b.PostTagCommits > 0 {
sb.WriteString(fmt.Sprintf("Version Base: %v\nCommit Hash: %v\n", b.TagVersion, b.CommitHash))
} else {
sb.WriteString(fmt.Sprintf("Version: %v\n", b.TagVersion))
}
} else {
sb.WriteString(fmt.Sprintf("Version: (Unversioned)\nCommit Hash: %v\n", b.CommitHash))
}
// Post-commits
if b.TagVersion != "" {
sb.WriteString(fmt.Sprintf("# of Commits Since %v: %v\n", b.TagVersion, b.PostTagCommits))
} else {
sb.WriteString(fmt.Sprintf("# of Commits Since %v: %v\n", b.CommitId, b.PostTagCommits))
}
sb.WriteString("Uncommitted/Unstaged Changes: ")
if b.Dirty {
sb.WriteString("yes (dirty/monkeypatched build)\n")
} else {
sb.WriteString("no (clean build)\n")
}
if b.TagVersion != "" {
sb.WriteString(
fmt.Sprintf(
"====\nMajor: %v\nMinor: %v\nPatch: %v\n",
b.Major, b.Minor, b.Patch,
),
)
}
sb.WriteString("====\n")
sb.WriteString(b.Meta())
ver = sb.String()
return
}
// Short returns a uniquely identifiable version string.
func (b *BuildInfo) Short() (ver string) {
ver = b.short
return
}
// Meta returns the build/compile-time info.
func (b *BuildInfo) Meta() (meta string) {
var sb strings.Builder
if b.RealBuildUser != b.BuildUser && b.RealBuildUser != "" {
sb.WriteString(fmt.Sprintf("Real Build User: %v\n", b.RealBuildUser))
sb.WriteString(fmt.Sprintf("Sudo Build User: %v\n", b.BuildUser))
} else {
sb.WriteString(fmt.Sprintf("Build User: %v\n", b.BuildUser))
}
sb.WriteString(fmt.Sprintf("Build Time: %v\nBuild Host: %v\n", b.BuildTime, b.BuildHost))
meta = sb.String()
return
}
// getReMap gets a regex map of map[pattern]match.
func (b *BuildInfo) getReMap() (matches map[string]string) {
var s string = b.Short()
var sections []string
if !semver.IsValid(s) {
return
}
sections = strings.Split(s, ".")
// The split should contain everything in the third element.
// Or, if using a "simplified" semver, the last element.
matches = make(map[string]string)
for idx, str := range patchRe.FindStringSubmatch(sections[len(sections)-1]) {
matches[patchRe.SubexpNames()[idx]] = str
}
return
}

51
version/types.go Normal file
View File

@@ -0,0 +1,51 @@
package version
import (
"time"
)
// BuildInfo contains nativized version information.
type BuildInfo struct {
// TagVersion is the most recent tag name on the current branch.
TagVersion string
// PostTagCommits is the number of commits after BuildInfo.TagVersion's commit on the current branch.
PostTagCommits uint
// CommitHash is the full commit hash.
CommitHash string
// CommitId is the "short" version of BuildInfo.CommitHash.
CommitId string
// BuildUser is the user the program was compiled under.
BuildUser string
// If compiled under sudo, BuildInfo.RealBuildUser is the user that called sudo.
RealBuildUser string
// BuildTime is the time and date of the program's build time.
BuildTime time.Time
// BuildHost is the host the binary was compiled on.
BuildHost string
// Dirty specifies if the source was "dirty" (uncommitted/unstaged etc. files) at the time of compilation.
Dirty bool
// SourceControl is the source control version used. Only relevant if not a "clean" build or untagged.
SourceControl string
// Major is the major version, expressed as an uint per spec.
Major uint
// Minor is the minor version, expressed as an uint per spec.
Minor uint
// Patch is the patch version, expressed as an uint per spec.
Patch uint
// Pre
Pre string
// Build
Build string
// isDefined specifies if this version was retrieved from the built-in values.
isDefined bool
// raw is the raw variable values.
raw map[string]string
/*
verSplit is a slice of []string{Major, Minor, Patch, PreRelease, Build}
If using an actual point release, PreRelease and Build are probably blank.
*/
verSplit [5]string
// short is the condensed version of verSplit.
short string
}