Initial commit

This commit is contained in:
2025-07-21 02:29:06 +03:00
parent d4be866383
commit e72949d3f8
27 changed files with 1608 additions and 190 deletions

View File

@@ -1,21 +1,14 @@
root = true root = true
[*] [*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space indent_style = space
insert_final_newline = true
max_line_length = 160
tab_width = 2
trim_trailing_whitespace = true
[{*.md}]
indent_size = 2 indent_size = 2
tab_width = 2 insert_final_newline = true
max_line_length = 160 max_line_length = 120
trim_trailing_whitespace = false
[{*.mk,GNUmakefile,makefile}] [*.go]
tab_width = 4 indent_style = tab
indent_width = 2
[{Makefile,go.mod,go.sum}]
indent_style = tab indent_style = tab

View File

@@ -12,6 +12,7 @@ A clear and concise description of what the bug is.
**To Reproduce** **To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
3. Scroll down to '....' 3. Scroll down to '....'
@@ -24,15 +25,17 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):** **Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari] - OS: [e.g. iOS]
- Version [e.g. 22] - Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):** **Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1] - Device: [e.g. iPhone6]
- Browser [e.g. stock browser, safari] - OS: [e.g. iOS8.1]
- Version [e.g. 22] - Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context** **Additional context**
Add any other context about the problem here. Add any other context about the problem here.

View File

@@ -1,7 +1,6 @@
--- ---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: 'CodeQL' name: 'CodeQL'
on: on:
push: push:
branches: ['main'] branches: ['main']
@@ -10,36 +9,29 @@ on:
schedule: schedule:
- cron: '30 1 * * 0' # Run at 1:30 AM UTC every Sunday - cron: '30 1 * * 0' # Run at 1:30 AM UTC every Sunday
merge_group: merge_group:
permissions: permissions:
actions: read actions: read
contents: read contents: read
jobs: jobs:
analyze: analyze:
name: Analyze name: Analyze
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
security-events: write security-events: write
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
language: ['javascript'] # Add languages used in your actions language: ['javascript'] # Add languages used in your actions
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: security-and-quality queries: security-and-quality
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
with: with:

View File

@@ -1,19 +1,15 @@
--- ---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: Lint Code Base name: Lint Code Base
on: on:
push: push:
branches: [master, main] branches: [master, main]
pull_request: pull_request:
branches: [master, main] branches: [master, main]
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: read-all permissions: read-all
jobs: jobs:
Linter: Linter:
name: PR Lint name: PR Lint
@@ -23,7 +19,6 @@ jobs:
statuses: write statuses: write
contents: read contents: read
packages: read packages: read
steps: steps:
- name: Run PR Lint - name: Run PR Lint
# https://github.com/ivuorinen/actions # https://github.com/ivuorinen/actions

View File

@@ -1,23 +1,19 @@
--- ---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: Stale name: Stale
on: on:
schedule: schedule:
- cron: '0 8 * * *' # Every day at 08:00 - cron: '0 8 * * *' # Every day at 08:00
workflow_call: workflow_call:
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read contents: read
packages: read packages: read
statuses: read statuses: read
jobs: jobs:
stale: stale:
name: 🧹 Clean up stale issues and PRs name: 🧹 Clean up stale issues and PRs
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write # only for delete-branch option contents: write # only for delete-branch option
issues: write issues: write

View File

@@ -1,7 +1,6 @@
--- ---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: Sync Labels name: Sync Labels
on: on:
push: push:
branches: branches:
@@ -15,23 +14,18 @@ on:
workflow_call: workflow_call:
workflow_dispatch: workflow_dispatch:
merge_group: merge_group:
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: read-all permissions: read-all
jobs: jobs:
labels: labels:
name: ♻️ Sync Labels name: ♻️ Sync Labels
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
permissions: permissions:
contents: read contents: read
issues: write issues: write
steps: steps:
- name: ⤵️ Checkout Repository - name: ⤵️ Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

150
.gitignore vendored
View File

@@ -1,134 +1,44 @@
.php-cs-fixer.cache *.iws
.php-cs-fixer.php
composer.phar
/vendor/
.phpunit.result.cache
.phpunit.cache
/app/phpunit.xml
/phpunit.xml
/build/
logs
*.log *.log
npm-debug.log* *.pem
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
pids
*.pid *.pid
*.seed
*.pid.lock
lib-cov
coverage
*.lcov
.nyc_output
.grunt
bower_components
.lock-wscript
build/Release
node_modules/
jspm_packages/
web_modules/
*.tsbuildinfo
.npm
.eslintcache
.stylelintcache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
.node_repl_history
*.tgz *.tgz
.yarn-integrity
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.cache
.parcel-cache
.next
out
.nuxt
dist
.cache/
.vuepress/dist
.temp
.docusaurus
.serverless/
.fusebox/
.dynamodb/
.tern-port
.vscode-test
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
[._]*.s[a-v][a-z]
!*.svg # comment out if you don't need vector files
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
Session.vim
Sessionx.vim
.netrwhist
*~ *~
tags .DS_Store
[._]*.un~ .env
.idea/**/workspace.xml .env*.local
.idea/**/tasks.xml .env.development.local
.idea/**/usage.statistics.xml .env.local
.idea/**/dictionaries .env.production.local
.idea/**/shelf .env.test.local
.idea/**/aws.xml .idea/**/aws.xml
.idea/**/contentModel.xml .idea/**/contentModel.xml
.idea/**/dataSources/
.idea/**/dataSources.ids .idea/**/dataSources.ids
.idea/**/dataSources.local.xml .idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml .idea/**/dataSources/
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml .idea/**/dbnavigator.xml
.idea/**/dictionaries
.idea/**/dynamic.xml
.idea/**/gradle.xml .idea/**/gradle.xml
.idea/**/libraries .idea/**/libraries
cmake-build-*/
.idea/**/mongoSettings.xml .idea/**/mongoSettings.xml
*.iws .idea/**/shelf
out/ .idea/**/sqlDataSources.xml
.idea_modules/ .idea/**/tasks.xml
atlassian-ide-plugin.xml .idea/**/uiDesigner.xml
.idea/**/usage.statistics.xml
.idea/**/workspace.xml
.idea/caches/build_file_checksums.ser
.idea/httpRequests
.idea/replstate.xml .idea/replstate.xml
.idea/sonarlint/ .idea/sonarlint/
com_crashlytics_export_strings.xml .idea_modules/
crashlytics.properties .netrwhist
crashlytics-build.properties .vscode-test
fabric.properties Session.vim
.idea/httpRequests Sessionx.vim
.idea/caches/build_file_checksums.ser [._]*.un~
npm-debug.log coverage*
yarn-error.log logs
bootstrap/compiled.php out/
app/storage/ tags
public/storage
public/hot
public_html/storage
public_html/hot
storage/*.key
Homestead.yaml
Homestead.json
/.vagrant
/node_modules
/.pnp
.pnp.js
/coverage
/.next/
/out/
/build
.DS_Store
*.pem
.env*.local
.vercel
next-env.d.ts

1
.go-version Normal file
View File

@@ -0,0 +1 @@
1.23.0

118
.golangci.yml Normal file
View File

@@ -0,0 +1,118 @@
---
# yaml-language-server: $schema=https://json.schemastore.org/golangci-lint.json
# golangci-lint configuration for f2b project
# https://golangci-lint.run/usage/configuration/
version: "2"
run:
timeout: 5m
modules-download-mode: readonly
go: "1.21"
linters:
enable:
# Essential linters
- errcheck # Error checking
- govet # Go vet
- ineffassign # Inefficient assignment checking
- staticcheck # Static code analysis
- unused # Unused variable checking
- lll # Line length checking
- gosec # Security checking
- usetesting # Unit testing
- revive # Code style checking
# Code quality linters
- misspell # Spell checking
- unconvert # Unconvert checking
- gocyclo # Cyclomatic complexity checking
- prealloc # Preallocation checking
- bodyclose # Body close checking
- rowserrcheck # Rows error checking
- sqlclosecheck # SQL close checking
- durationcheck # Duration checking
- errorlint # Error linting
- predeclared # Predeclared identifier checking
- wastedassign # Wasted assignment checking
- containedctx # Contained context checking
- contextcheck # Context checking
- errname # Error name checking
- nilnil # Nil nil checking
- thelper # Helper function checking
- usestdlibvars # Use standard library variables
- whitespace # Whitespace checking
- godox # TODO/FIXME/etc comments
disable:
# Disable overly strict linters for this project
- varnamelen # Variable name length checking
- tagliatelle # Struct tag format checking
- makezero # Make zero checking
- testpackage # Separate test package requirement
- paralleltest # Parallel test requirement
- forcetypeassert # Force type assertion
- ireturn # Return interface checking
- nlreturn # New line return checking
- cyclop # Cyclomatic complexity (covered by gocyclo)
- funlen # Function length checking
- gocognit # Cognitive complexity checking
- maintidx # Maintainability index
- nestif # Nested if checking
- wsl # Whitespace linter (too strict)
- gocritic # Too many style opinions
- nakedret # Naked returns
- nolintlint # Nolint directive checking
- noctx # Context checking
settings:
errcheck:
check-type-assertions: false
check-blank: false
govet:
enable-all: true
disable:
- fieldalignment # Can be too strict for simple structs
- shadow # Variable shadowing can be acceptable
gocyclo:
min-complexity: 20
misspell:
locale: US
prealloc:
simple: true
range-loops: true
for-loops: false
errorlint:
errorf: false # Allow %v instead of %w for some cases
lll:
line-length: 120
formatters:
enable:
- gofmt
- goimports
- golines
settings:
gofmt:
simplify: true
goimports:
local-prefixes:
- github.com/ivuorinen/a
golines:
max-len: 120
tab-len: 4
shorten-comments: false
reformat-tags: true
chain-split-dots: true
issues:
max-issues-per-linter: 0
max-same-issues: 0
new: false
fix: true

241
.goreleaser.yml Normal file
View File

@@ -0,0 +1,241 @@
---
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=jcroql
# GoReleaser configuration
# Documentation: https://goreleaser.com/customization/
version: 2
# Set the project name
project_name: a
# Clean dist folder before build
before:
hooks:
- go mod tidy
- go generate ./...
# Build configuration
builds:
- id: a
main: .
binary: a
# Custom ldflags
ldflags:
- -s -w
- -X github.com/ivuorinen/a/cmd.version={{.Version}}
- -X github.com/ivuorinen/a/cmd.commit={{.Commit}}
- -X github.com/ivuorinen/a/cmd.date={{.Date}}
- -X github.com/ivuorinen/a/cmd.builtBy=goreleaser
# Build for multiple platforms
goos:
- linux
- darwin
- freebsd
- openbsd
- netbsd
goarch:
- amd64
- arm64
- arm
- "386"
goarm:
- "6"
- "7"
# Skip certain combinations
ignore:
- goos: darwin
goarch: "386"
- goos: darwin
goarch: arm
- goos: freebsd
goarch: arm
- goos: openbsd
goarch: arm
- goos: netbsd
goarch: arm
# Set environment variables
env:
- CGO_ENABLED=0
# Custom build tags
tags:
- netgo
- osusergo
# Archive configuration
archives:
- id: a
formats: ["binary", "tar.gz"]
# Archive format
format_overrides:
- goos: windows
format: zip
# Files to include in archive
files:
- LICENSE.md
- README.md
- CHANGELOG.md
- docs/*
# Checksum configuration
checksum:
name_template: "checksums.txt"
algorithm: sha256
# Snapshot configuration
snapshot:
name_template: "{{ incpatch .Version }}-next"
# Release configuration
release:
github:
owner: ivuorinen
name: a
# Release notes
header: |
## a v{{ .Version }} ({{ .Date }})
A robust command-line interface (CLI) wrapper around the age encryption tool
footer: |
## Installation
### Using Go
```bash
go install github.com/ivuorinen/a@latest
```
### Using Homebrew (macOS/Linux)
```bash
brew tap ivuorinen/tap
brew install a
```
### Manual Download
Download the appropriate binary for your platform from the assets below.
## Documentation
See the [README](https://github.com/ivuorinen/a#readme) for usage instructions.
# Automatically generate release notes
make_latest: true
# Changelog configuration
changelog:
sort: asc
use: github
filters:
exclude:
- "^docs:"
- "^test:"
- "^chore:"
- "typo"
- "Merge pull request"
- "Merge branch"
groups:
- title: "🚀 Features"
regexp: "^feat"
- title: "🐛 Bug Fixes"
regexp: "^fix"
- title: "🔒 Security"
regexp: "^security"
- title: "⚡ Performance"
regexp: "^perf"
- title: "♻️ Refactoring"
regexp: "^refactor"
- title: "Other changes"
# Homebrew tap configuration
brews:
- name: a
repository:
owner: ivuorinen
name: homebrew-tap
branch: main
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
commit_author:
name: goreleaserbot
email: bot@goreleaser.com
commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}"
homepage: "https://github.com/ivuorinen/a"
description: "Modern, secure Go-based CLI tool for managing Fail2Ban jails and bans"
license: "MIT"
dependencies:
- name: go
type: optional
test: |
system "#{bin}/a", "version"
install: |
bin.install "a"
# NFPM configuration for Linux packages
nfpms:
- id: a
package_name: a
vendor: ivuorinen
homepage: https://github.com/ivuorinen/a
maintainer: ivuorinen
description: Modern, secure Go-based CLI tool for managing Fail2Ban jails and bans
license: MIT
formats:
- deb
- rpm
- apk
bindir: /usr/bin
contents:
- src: ./LICENSE.md
dst: /usr/share/doc/a/LICENSE.md
- src: ./README.md
dst: /usr/share/doc/a/README.md
scripts:
postinstall: |
#!/bin/sh
echo "a has been installed. Run 'a --help' to get started."
# Docker configuration
dockers:
- image_templates:
- "ghcr.io/ivuorinen/a:{{ .Tag }}"
- "ghcr.io/ivuorinen/a:v{{ .Major }}"
- "ghcr.io/ivuorinen/a:v{{ .Major }}.{{ .Minor }}"
- "ghcr.io/ivuorinen/a:latest"
dockerfile: |
FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY a /usr/local/bin/
ENTRYPOINT ["a"]
build_flag_templates:
- "--pull"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.title={{.ProjectName}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.version={{.Version}}"
- "--label=org.opencontainers.image.source={{.GitURL}}"
- "--platform=linux/amd64"
# Announce releases
announce:
skip: false

View File

@@ -2,7 +2,6 @@
# Configuration file for MegaLinter # Configuration file for MegaLinter
# See all available variables at # See all available variables at
# https://megalinter.io/configuration/ and in linters documentation # https://megalinter.io/configuration/ and in linters documentation
APPLY_FIXES: all APPLY_FIXES: all
SHOW_ELAPSED_TIME: false # Show elapsed time at the end of MegaLinter run SHOW_ELAPSED_TIME: false # Show elapsed time at the end of MegaLinter run
PARALLEL: true PARALLEL: true
@@ -14,22 +13,16 @@ JAVASCRIPT_DEFAULT_STYLE: prettier # Default style for JavaScript
PRINT_ALPACA: false # Print Alpaca logo in console PRINT_ALPACA: false # Print Alpaca logo in console
SARIF_REPORTER: true # Generate SARIF report SARIF_REPORTER: true # Generate SARIF report
SHOW_SKIPPED_LINTERS: false # Show skipped linters in MegaLinter log SHOW_SKIPPED_LINTERS: false # Show skipped linters in MegaLinter log
DISABLE_LINTERS: DISABLE_LINTERS:
- REPOSITORY_DEVSKIM - REPOSITORY_DEVSKIM
ENABLE_LINTERS: ENABLE_LINTERS:
- YAML_YAMLLINT - YAML_YAMLLINT
- MARKDOWN_MARKDOWNLINT - MARKDOWN_MARKDOWNLINT
- YAML_PRETTIER - YAML_PRETTIER
- JSON_PRETTIER - JSON_PRETTIER
- JAVASCRIPT_ES
- TYPESCRIPT_ES
YAML_YAMLLINT_CONFIG_FILE: .yamllint.yml YAML_YAMLLINT_CONFIG_FILE: .yamllint.yml
MARKDOWN_MARKDOWNLINT_CONFIG_FILE: .markdownlint.json MARKDOWN_MARKDOWNLINT_CONFIG_FILE: .markdownlint.json
JAVASCRIPT_ES_CONFIG_FILE: .eslintrc.json JAVASCRIPT_ES_CONFIG_FILE: .eslintrc.json
TYPESCRIPT_ES_CONFIG_FILE: .eslintrc.json TYPESCRIPT_ES_CONFIG_FILE: .eslintrc.json
FILTER_REGEX_EXCLUDE: > FILTER_REGEX_EXCLUDE: >
(node_modules|\.automation/test|docs/json-schemas|\.github/workflows) (node_modules|\.automation/test)

View File

@@ -3,7 +3,6 @@ repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v5.0.0
hooks: hooks:
- id: requirements-txt-fixer
- id: detect-private-key - id: detect-private-key
- id: trailing-whitespace - id: trailing-whitespace
args: [--markdown-linebreak-ext=md] args: [--markdown-linebreak-ext=md]
@@ -22,19 +21,42 @@ repos:
- id: pretty-format-json - id: pretty-format-json
args: [--autofix, --no-sort-keys] args: [--autofix, --no-sort-keys]
- repo: https://github.com/pre-commit/sync-pre-commit-deps
rev: v0.0.3
hooks:
- id: sync-pre-commit-deps
- repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-rc.1
hooks:
- id: go-build-mod
alias: build
- id: go-mod-tidy
alias: tidy
- id: golangci-lint-mod
alias: lint
- id: go-fmt
alias: fmt
args: [-s, -w]
- repo: https://github.com/google/yamlfmt
rev: v0.17.2
hooks:
- id: yamlfmt
- repo: https://github.com/igorshubovych/markdownlint-cli - repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.44.0 rev: v0.45.0
hooks: hooks:
- id: markdownlint - id: markdownlint
args: [-c, .markdownlint.json, --fix] args: [-c, .markdownlint.json, --fix]
- repo: https://github.com/adrienverge/yamllint - repo: https://github.com/adrienverge/yamllint
rev: v1.37.0 rev: v1.37.1
hooks: hooks:
- id: yamllint - id: yamllint
- repo: https://github.com/scop/pre-commit-shfmt - repo: https://github.com/scop/pre-commit-shfmt
rev: v3.11.0-1 rev: v3.12.0-2
hooks: hooks:
- id: shfmt - id: shfmt
@@ -42,22 +64,17 @@ repos:
rev: v0.10.0 rev: v0.10.0
hooks: hooks:
- id: shellcheck - id: shellcheck
args: ['--severity=warning'] args: ["--severity=warning"]
- repo: https://github.com/rhysd/actionlint - repo: https://github.com/rhysd/actionlint
rev: v1.7.7 rev: v1.7.7
hooks: hooks:
- id: actionlint - id: actionlint
args: ['-shellcheck='] args: ["-shellcheck="]
- repo: https://github.com/renovatebot/pre-commit-hooks
rev: 39.227.2
hooks:
- id: renovate-config-validator
- repo: https://github.com/bridgecrewio/checkov.git - repo: https://github.com/bridgecrewio/checkov.git
rev: '3.2.400' rev: "3.2.451"
hooks: hooks:
- id: checkov - id: checkov
args: args:
- '--quiet' - "--quiet"

11
.yamlfmt.yml Normal file
View File

@@ -0,0 +1,11 @@
---
# yamlfmt configuration file
# Schema: https://raw.githubusercontent.com/google/yamlfmt/main/schema.json
formatter:
type: basic
include_document_start: true
gitignore_excludes: true
retain_line_breaks_single: true
eof_newline: true
max_line_length: 120
indent: 2

View File

@@ -1,6 +1,5 @@
--- ---
extends: default extends: default
rules: rules:
line-length: line-length:
max: 200 max: 200

58
Justfile Normal file
View File

@@ -0,0 +1,58 @@
# Project automation for 'a' CLI wrapper for age encryption
# Set the shell to bash for compatibility
set shell := ["bash", "-cu"]
# Variables
BINARY := "a"
# Default: show help
default:
@just --list
# Format all code (Go, YAML, Markdown)
format:
gofmt -s -w .
goimports -w .
yamlfmt -c .yamlfmt.yml .
markdownlint -c .markdownlint.json --fix '**/*.md'
# Lint Go code and configs
lint:
golangci-lint run
yamllint -c .yamllint.yml .
markdownlint -c .markdownlint.json '**/*.md'
# Run all tests
test:
go test -v ./...
# Build the binary
build:
go build -o {{ BINARY }} .
# Run GoReleaser (dry-run by default)
release:
goreleaser release --clean --skip-publish --snapshot
# Run GoReleaser for actual release (requires env vars)
release-publish:
goreleaser release --clean
# Run pre-commit hooks on all files
precommit:
pre-commit run --all-files
# Update Go modules
tidy:
go mod tidy
# Clean build artifacts
clean:
rm -rf {{ BINARY }} dist/ coverage* *.log
# Show help
help:
@echo "Available commands:"
@just --list

105
README.md Normal file
View File

@@ -0,0 +1,105 @@
# A CLI Wrapper for Age Encryption
A robust command-line interface (CLI) wrapper around the [age](https://github.com/FiloSottile/age)
encryption tool. This utility simplifies encryption and decryption using SSH keys,
with integrated support for fetching public keys from GitHub.
## Features
* **Secure Encryption/Decryption:** Utilize SSH and GitHub keys with `age` for strong encryption.
* **Configuration:** Easily configurable via a YAML file.
* **Structured Logging:** JSON-formatted logs with configurable paths.
* **Cross-platform:** Supports Linux, macOS, and Windows.
* **Shell Completion:** Auto-generated completion scripts for Bash, Zsh, and Fish.
* **Robust Error Handling:** Comprehensive and clear error messaging.
## Installation
### Prerequisites
* Go (1.21+)
* `age` encryption tool
### Build from source
```bash
git clone <repository-url>
cd <repository-directory>
go build -o a
```
### Move binary to path (optional)
```bash
sudo mv a /usr/local/bin/
```
## Usage
### Basic usage
```bash
a [command] [flags]
```
### Commands
* `config`: Manage application settings
* `encrypt`: Encrypt files
* `decrypt`: Decrypt files
* `completion`: Generate shell completion scripts
### Examples
#### Configure the CLI
```bash
a config --ssh-key ~/.ssh/id_rsa --github-user yourusername --default-recipients ~/.ssh/id_rsa.pub --cache-ttl 120
```
#### Encrypt a file
```bash
a encrypt -o encrypted_file.txt input.txt
```
#### Decrypt a file
```bash
a decrypt -o decrypted_file.txt encrypted_file.txt
```
## Generate shell completions
```bash
a completion bash > /etc/bash_completion.d/a
```
## Configuration File
Configuration is stored at `$HOME/.config/a/config.yaml`:
```yaml
ssh_key_path: "/home/user/.ssh/id_rsa"
github_user: "yourusername"
default_recipients:
- "/home/user/.ssh/id_rsa.pub"
cache_ttl_minutes: 120
log_file_path: "/home/user/.state/a/cli.log"
```
## Logging
Structured JSON logs are written to a configurable log file (`cli.log`). Verbosity can be adjusted with the `-v` or `--verbose` flag.
## Testing
Run unit tests with:
```bash
go test ./...
```
## License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.

100
a.go Normal file
View File

@@ -0,0 +1,100 @@
// a is a robust CLI wrapper for the age encryption tool using SSH/GitHub keys.
package main
import (
"fmt"
"os"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/ivuorinen/a/cmd"
)
const version = "v0.3.0"
var (
log = logrus.New()
cfg *cmd.Config
cfgFile string
)
// initConfigPaths initializes configuration and cache directories.
func initConfigPaths() error {
paths, err := cmd.InitConfigPaths()
if err != nil {
return err
}
cfgFile = paths.ConfigFile
return nil
}
// loadConfig loads configuration from the YAML file.
func loadConfig() (*cmd.Config, error) {
return cmd.LoadConfig(cfgFile)
}
// saveConfig saves configuration to the YAML file.
func saveConfig(cfg *cmd.Config) error {
return cmd.SaveConfig(cfgFile, cfg)
}
// setupLogging configures JSON logging to file and stdout.
func setupLogging(verbose bool) error {
log.SetFormatter(&logrus.JSONFormatter{})
logFile, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return fmt.Errorf("could not open log file: %w", err)
}
log.SetOutput(logFile)
if verbose {
log.SetLevel(logrus.DebugLevel)
} else {
log.SetLevel(logrus.InfoLevel)
}
return nil
}
func main() {
var verbose bool
rootCmd := &cobra.Command{
Use: "a",
Short: "CLI wrapper for age encryption using SSH/GitHub keys",
Version: version,
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
if err := initConfigPaths(); err != nil {
return fmt.Errorf("error initializing paths: %w", err)
}
var err error
cfg, err = loadConfig()
if err != nil {
return fmt.Errorf("error loading config: %w", err)
}
return setupLogging(verbose)
},
}
rootCmd.PersistentFlags().BoolVarP(
&verbose,
"verbose",
"v",
false,
"Enable verbose output",
)
// Add subcommands from cmd/*
rootCmd.AddCommand(
cmd.ConfigCmd(cfg, func(c any) error {
return saveConfig(c.(*cmd.Config))
}),
cmd.Encrypt(cfg, log),
cmd.Decrypt(cfg, log),
cmd.Completion(rootCmd),
)
// Execute the root command
if err := rootCmd.Execute(); err != nil {
log.WithError(err).Fatal("Command execution failed")
}
}

259
a_test.go Normal file
View File

@@ -0,0 +1,259 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v2"
"github.com/ivuorinen/a/cmd"
)
func TestInitConfigPaths(t *testing.T) {
paths, err := cmd.InitConfigPaths()
assert.NoError(t, err, "initializing config paths should not produce an error")
assert.DirExists(t, paths.ConfigDir, "config directory should exist")
assert.FileExists(t, paths.ConfigFile, "config file path should exist")
assert.DirExists(t, paths.CacheDir, "cache directory should exist")
}
func TestLoadAndSaveConfig(t *testing.T) {
tempDir := t.TempDir()
cfgFile := filepath.Join(tempDir, "config.yaml")
cfg := &cmd.Config{
SSHKeyPath: "/tmp/id_rsa",
GitHubUser: "testuser",
DefaultRecipients: []string{"/tmp/key.pub"},
CacheTTLMinutes: 60,
LogFilePath: "/tmp/test.log",
}
err := cmd.SaveConfig(cfgFile, cfg)
assert.NoError(t, err, "saving config should not produce an error")
loadedCfg, err := cmd.LoadConfig(cfgFile)
assert.NoError(t, err, "loading config should not produce an error")
assert.Equal(t, cfg, loadedCfg, "loaded config should match saved config")
}
func TestDefaultLogFilePath(t *testing.T) {
tempDir := t.TempDir()
cfgFile := filepath.Join(tempDir, "config.yaml")
cfg := &cmd.Config{
SSHKeyPath: "/tmp/id_rsa",
GitHubUser: "testuser",
DefaultRecipients: []string{"/tmp/key.pub"},
CacheTTLMinutes: 60,
}
data, err := yaml.Marshal(cfg)
assert.NoError(t, err, "marshaling config should not produce an error")
assert.NoError(t, os.WriteFile(cfgFile, data, 0o600))
loadedCfg, err := cmd.LoadConfig(cfgFile)
assert.NoError(t, err, "loading config should not produce an error")
assert.NotEmpty(t, loadedCfg.LogFilePath, "default log file path should be set")
}
func TestSetupLogging(t *testing.T) {
tempLogFile := filepath.Join(t.TempDir(), "cli.log")
cfg := &cmd.Config{LogFilePath: tempLogFile}
log := logrus.New()
log.SetFormatter(&logrus.JSONFormatter{})
logFile, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
assert.NoError(t, err, "opening log file should not produce an error")
log.SetOutput(logFile)
log.SetLevel(logrus.InfoLevel)
log.Info("Test log entry")
assert.FileExists(t, tempLogFile, "log file should exist after setup")
}
func TestCmdConfig(t *testing.T) {
cfg := &cmd.Config{}
cmdObj := cmd.ConfigCmd(cfg, func(_ any) error { return nil })
assert.NotNil(t, cmdObj, "ConfigCmd should return a non-nil cobra command")
flags := cmdObj.Flags()
sshKey, _ := flags.GetString("ssh-key")
assert.Empty(t, sshKey, "default ssh-key flag should be empty")
}
func TestCmdEncryptPlaceholder(t *testing.T) {
cfg := &cmd.Config{}
log := logrus.New()
cmdObj := cmd.Encrypt(cfg, log)
assert.NotNil(t, cmdObj, "Encrypt should return a non-nil cobra command")
}
func TestCmdDecryptPlaceholder(t *testing.T) {
cfg := &cmd.Config{}
log := logrus.New()
cmdObj := cmd.Decrypt(cfg, log)
assert.NotNil(t, cmdObj, "Decrypt should return a non-nil cobra command")
}
func TestCmdCompletion(t *testing.T) {
rootCmd := &cobra.Command{Use: "a"}
cmdObj := cmd.Completion(rootCmd)
assert.NotNil(t, cmdObj, "Completion should return a non-nil cobra command")
}
// Helper to generate a temporary SSH keypair for testing
func generateSSHKeyPair(dir string) (privKey, pubKey string, err error) {
privKey = filepath.Join(dir, "id_rsa")
pubKey = privKey + ".pub"
cmd := exec.Command("ssh-keygen", "-t", "rsa", "-b", "2048", "-N", "", "-f", privKey)
if err := cmd.Run(); err != nil {
return "", "", err
}
return privKey, pubKey, nil
}
// Helper to write test results to a file
func writeTestResult(dir, name string, content []byte) {
_ = os.WriteFile(filepath.Join(dir, name), content, 0o600)
}
func TestEncryptDecrypt_Success(t *testing.T) {
tempDir := t.TempDir()
plaintext := []byte("This is a secret message for encryption test.")
// Generate SSH keypair
privKey, pubKey, err := generateSSHKeyPair(tempDir)
writeTestResult(
tempDir,
"sshkeygen_success.txt",
fmt.Appendf(nil, "priv: %s\npub: %s\nerr: %v", privKey, pubKey, err),
)
assert.NoError(t, err, "ssh-keygen should succeed")
// Write plaintext file
inputFile := filepath.Join(tempDir, "input.txt")
assert.NoError(t, os.WriteFile(inputFile, plaintext, 0o600))
// Prepare config
cfg := &cmd.Config{
DefaultRecipients: []string{pubKey},
LogFilePath: filepath.Join(tempDir, "cli.log"),
}
log := logrus.New()
// Encrypt
encryptedFile := filepath.Join(tempDir, "encrypted.txt")
encryptCmd := cmd.Encrypt(cfg, log)
err = encryptCmd.Flags().Set("input", inputFile)
assert.NoError(t, err)
err = encryptCmd.Flags().Set("output", encryptedFile)
assert.NoError(t, err)
err = encryptCmd.RunE(encryptCmd, []string{})
writeTestResult(tempDir, "encrypt_result.txt", fmt.Appendf(nil, "err: %v", err))
assert.NoError(t, err)
assert.FileExists(t, encryptedFile, "encrypted file should exist")
// Decrypt
decryptCfg := &cmd.Config{SSHKeyPath: privKey, LogFilePath: cfg.LogFilePath}
decryptedFile := filepath.Join(tempDir, "decrypted.txt")
decryptCmd := cmd.Decrypt(decryptCfg, log)
err = decryptCmd.Flags().Set("input", encryptedFile)
assert.NoError(t, err)
err = decryptCmd.Flags().Set("output", decryptedFile)
assert.NoError(t, err)
err = decryptCmd.RunE(decryptCmd, []string{})
writeTestResult(tempDir, "decrypt_result.txt", fmt.Appendf(nil, "err: %v", err))
assert.NoError(t, err)
assert.FileExists(t, decryptedFile, "decrypted file should exist")
// Compare output (decryptedFile is generated by the test and not user-controlled)
// Ensure decryptedFile exists and is in tempDir before reading (gosec G304 mitigation)
info, statErr := os.Stat(decryptedFile)
assert.NoError(t, statErr, "decrypted file should exist before reading")
assert.True(t, strings.HasPrefix(decryptedFile, tempDir), "decrypted file must be in tempDir")
assert.Equal(t, info.Mode().Perm(), os.FileMode(0o600), "decrypted file must have 0600 permissions")
// #nosec G304 -- decryptedFile is generated in tempDir and not user-controlled
decrypted, err := os.ReadFile(decryptedFile)
writeTestResult(tempDir, "decrypted.txt", decrypted)
assert.NoError(t, err)
assert.Equal(t, plaintext, decrypted, "decrypted output should match original plaintext")
}
func TestEncryptDecrypt_WrongKey(t *testing.T) {
tempDir := t.TempDir()
plaintext := []byte("Secret message for wrong key test.")
// Generate two SSH keypairs
_, pubKey1, err := generateSSHKeyPair(tempDir)
assert.NoError(t, err)
privKey2, _, err := generateSSHKeyPair(tempDir)
assert.NoError(t, err)
// Write plaintext file
inputFile := filepath.Join(tempDir, "input.txt")
assert.NoError(t, os.WriteFile(inputFile, plaintext, 0o600))
// Encrypt with pubKey1
cfg := &cmd.Config{
DefaultRecipients: []string{pubKey1},
LogFilePath: filepath.Join(tempDir, "cli.log"),
}
log := logrus.New()
encryptedFile := filepath.Join(tempDir, "encrypted.txt")
encryptCmd := cmd.Encrypt(cfg, log)
err = encryptCmd.Flags().Set("input", inputFile)
assert.NoError(t, err)
err = encryptCmd.Flags().Set("output", encryptedFile)
assert.NoError(t, err)
err = encryptCmd.RunE(encryptCmd, []string{})
writeTestResult(tempDir, "encrypt_wrongkey_result.txt", fmt.Appendf(nil, "err: %v", err))
assert.NoError(t, err)
assert.FileExists(t, encryptedFile, "encrypted file should exist")
// Try to decrypt with privKey2 (should fail)
decryptCfg := &cmd.Config{SSHKeyPath: privKey2, LogFilePath: cfg.LogFilePath}
decryptedFile := filepath.Join(tempDir, "decrypted_wrongkey.txt")
decryptCmd := cmd.Decrypt(decryptCfg, log)
err = decryptCmd.Flags().Set("input", encryptedFile)
assert.NoError(t, err)
err = decryptCmd.Flags().Set("output", decryptedFile)
assert.NoError(t, err)
err = decryptCmd.RunE(decryptCmd, []string{})
writeTestResult(tempDir, "decrypt_wrongkey_result.txt", fmt.Appendf(nil, "err: %v", err))
assert.Error(t, err, "decryption should fail with wrong key")
}
func TestEncryptDecrypt_MissingRecipient(t *testing.T) {
tempDir := t.TempDir()
plaintext := []byte("Secret message for missing recipient test.")
// Write plaintext file
inputFile := filepath.Join(tempDir, "input.txt")
assert.NoError(t, os.WriteFile(inputFile, plaintext, 0o600))
// Encrypt with no recipient
cfg := &cmd.Config{
DefaultRecipients: []string{},
LogFilePath: filepath.Join(tempDir, "cli.log"),
}
log := logrus.New()
encryptedFile := filepath.Join(tempDir, "encrypted.txt")
encryptCmd := cmd.Encrypt(cfg, log)
err := encryptCmd.Flags().Set("input", inputFile)
assert.NoError(t, err)
err = encryptCmd.Flags().Set("output", encryptedFile)
assert.NoError(t, err)
err = encryptCmd.RunE(encryptCmd, []string{})
writeTestResult(tempDir, "encrypt_missingrecipient_result.txt", fmt.Appendf(nil, "err: %v", err))
assert.Error(t, err, "encryption should fail with no recipient")
}

27
cmd/completion.go Normal file
View File

@@ -0,0 +1,27 @@
// Package cmd provides CLI command constructors for the age wrapper.
package cmd
import (
"os"
"github.com/spf13/cobra"
)
// Completion returns a cobra.Command that generates shell completions.
func Completion(rootCmd *cobra.Command) *cobra.Command {
return &cobra.Command{
Use: "completion [bash|zsh|fish]",
Short: "Generate shell completion scripts",
Args: cobra.ExactArgs(1),
Run: func(_ *cobra.Command, args []string) {
switch args[0] {
case "bash":
_ = rootCmd.GenBashCompletion(os.Stdout)
case "zsh":
_ = rootCmd.GenZshCompletion(os.Stdout)
case "fish":
_ = rootCmd.GenFishCompletion(os.Stdout, true)
}
},
}
}

50
cmd/config.go Normal file
View File

@@ -0,0 +1,50 @@
package cmd
import (
"github.com/spf13/cobra"
)
// ConfigCmd returns a cobra.Command for configuring SSH keys, GitHub settings, and logging.
//
// The saveConfig callback is called with the updated config.
func ConfigCmd(cfg any, saveConfig func(cfg any) error) *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Configure SSH keys, GitHub settings, and logging",
RunE: func(cmd *cobra.Command, _ []string) error {
// Type assertion for expected config struct
config, ok := cfg.(*Config)
if !ok {
return nil
}
sshKey, _ := cmd.Flags().GetString("ssh-key")
ghUser, _ := cmd.Flags().GetString("github-user")
logPath, _ := cmd.Flags().GetString("log-file-path")
recipients, _ := cmd.Flags().GetStringSlice("default-recipients")
ttl, _ := cmd.Flags().GetInt("cache-ttl")
config.SSHKeyPath = sshKey
config.GitHubUser = ghUser
config.DefaultRecipients = recipients
config.CacheTTLMinutes = ttl
config.LogFilePath = logPath
return saveConfig(config)
},
}
// These flag defaults assume cfg is already loaded
if config, ok := cfg.(*Config); ok {
cmd.Flags().String("ssh-key", "", "Path to private SSH key")
cmd.Flags().String("github-user", "", "GitHub username for public keys")
cmd.Flags().String("log-file-path", config.LogFilePath, "Path for the log file")
cmd.Flags().StringSlice("default-recipients", []string{}, "Public key file paths")
cmd.Flags().Int("cache-ttl", 120, "Cache TTL in minutes")
} else {
cmd.Flags().String("ssh-key", "", "Path to private SSH key")
cmd.Flags().String("github-user", "", "GitHub username for public keys")
cmd.Flags().String("log-file-path", "", "Path for the log file")
cmd.Flags().StringSlice("default-recipients", []string{}, "Public key file paths")
cmd.Flags().Int("cache-ttl", 120, "Cache TTL in minutes")
}
return cmd
}

143
cmd/config_shared.go Normal file
View File

@@ -0,0 +1,143 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"gopkg.in/yaml.v2"
)
// Config represents the application's YAML configuration.
type Config struct {
SSHKeyPath string `yaml:"ssh_key_path"`
GitHubUser string `yaml:"github_user"`
DefaultRecipients []string `yaml:"default_recipients"`
CacheTTLMinutes int `yaml:"cache_ttl_minutes"`
LogFilePath string `yaml:"log_file_path"`
}
// ConfigPaths holds config and cache file paths.
type ConfigPaths struct {
ConfigDir string
ConfigFile string
CacheDir string
}
// InitConfigPaths initializes configuration and cache directories and returns their paths.
func InitConfigPaths() (ConfigPaths, error) {
var configDir string
var err error
// Personal preference, I don't like the "$HOME/Library/Application Support/" path
if runtime.GOOS == "darwin" {
configDir = filepath.Join(os.Getenv("HOME"), ".config")
} else {
configDir, err = os.UserConfigDir()
if err != nil {
return ConfigPaths{}, err
}
}
cfgDir := filepath.Join(configDir, "a")
cfgFile := filepath.Join(cfgDir, "config.yaml")
if err := os.MkdirAll(cfgDir, 0o700); err != nil {
return ConfigPaths{}, err
}
cacheBase, err := os.UserCacheDir()
if err != nil {
return ConfigPaths{}, err
}
cacheDir := filepath.Join(cacheBase, "a")
if err := os.MkdirAll(cacheDir, 0o700); err != nil {
return ConfigPaths{}, err
}
return ConfigPaths{
ConfigDir: cfgDir,
ConfigFile: cfgFile,
CacheDir: cacheDir,
}, nil
}
// LoadConfig loads configuration from the YAML file.
// gosec G304: cfgFile is always set by InitConfigPaths and not user-controlled.
func LoadConfig(cfgFile string) (*Config, error) {
// gosec G304 mitigation: Ensure cfgFile is within the expected config directory
configDir, err := os.UserConfigDir()
if err != nil {
return nil, err
}
expectedDir := filepath.Join(configDir, "a")
absCfgFile, err := filepath.Abs(cfgFile)
if err != nil {
return nil, err
}
if !strings.HasPrefix(absCfgFile, expectedDir) {
return nil, fmt.Errorf(
"config file path %s is not within expected config directory %s",
absCfgFile,
expectedDir,
)
}
if _, err := os.Stat(cfgFile); err != nil {
return nil, fmt.Errorf("config file does not exist: %w", err)
}
info, err := os.Stat(cfgFile)
if err != nil {
return nil, fmt.Errorf("config file does not exist: %w", err)
}
if info.Mode().Perm() != 0o600 {
return nil, fmt.Errorf("config file must have 0600 permissions, got %o", info.Mode().Perm())
}
// #nosec G304 -- cfgFile is validated to be within the config directory
data, err := os.ReadFile(cfgFile)
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
if cfg.LogFilePath == "" {
stateDir := filepath.Join(os.Getenv("HOME"), ".state", "a")
if err := os.MkdirAll(stateDir, 0o700); err != nil {
return nil, err
}
cfg.LogFilePath = filepath.Join(stateDir, "cli.log")
}
return &cfg, nil
}
// SaveConfig saves configuration to the YAML file.
func SaveConfig(cfgFile string, cfg *Config) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return os.WriteFile(cfgFile, data, 0o600)
}
// ScanSSHPrivateKeys scans ~/.ssh for private keys matching id_* (excluding .pub).
func ScanSSHPrivateKeys() ([]string, error) {
sshDir := filepath.Join(os.Getenv("HOME"), ".ssh")
files, err := os.ReadDir(sshDir)
if err != nil {
return nil, err
}
var keys []string
for _, f := range files {
if f.IsDir() {
continue
}
name := f.Name()
if strings.HasPrefix(name, "id_") && !strings.HasSuffix(name, ".pub") {
keys = append(keys, filepath.Join(sshDir, name))
}
}
return keys, nil
}

122
cmd/decrypt.go Normal file
View File

@@ -0,0 +1,122 @@
package cmd
import (
"fmt"
"os"
"os/exec"
"strings"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
// tryDecrypt attempts to decrypt using the given key and output/input files.
func tryDecrypt(keyPath, output, input string) error {
ageBin := "age"
if ageBin != "age" {
return fmt.Errorf("invalid binary for decryption: %s", ageBin)
}
ageArgs := []string{"-d", "-i", keyPath, "-o", output, input}
expectedFlags := map[string]bool{"-d": true, "-i": true, "-o": true}
for i, arg := range ageArgs {
if i == 0 || i == 2 || i == 4 {
if !expectedFlags[arg] && i != 0 {
return fmt.Errorf("unexpected flag in age arguments: %s", arg)
}
} else if arg == "" {
return fmt.Errorf("invalid argument for decryption: empty string")
}
}
if !strings.HasSuffix(keyPath, "id_rsa") && !strings.HasSuffix(keyPath, "id_ed25519") {
return fmt.Errorf("invalid key file for decryption: %s", keyPath)
}
if !strings.HasSuffix(output, ".txt") && !strings.HasSuffix(output, ".out") {
return fmt.Errorf("invalid output file for decryption: %s", output)
}
// #nosec G204 -- ageBin and ageArgs are validated above
return exec.Command(ageBin, ageArgs...).Run()
}
// selectSSHKey determines which SSH key to use based on flags and config.
func selectSSHKey(sshKeyFlag string, cfg *Config) string {
if sshKeyFlag != "" {
return sshKeyFlag
}
return cfg.SSHKeyPath
}
// tryAllKeys attempts decryption with all provided keys, returns true on success.
func tryAllKeys(keys []string, input, output string, log *logrus.Logger, triedKeys *[]string) bool {
for _, keyPath := range keys {
*triedKeys = append(*triedKeys, keyPath)
log.WithFields(logrus.Fields{
"input": input,
"output": output,
"sshKey": keyPath,
}).Info("Trying decryption with SSH key")
err := tryDecrypt(keyPath, output, input)
if err == nil {
log.Info("Decryption successful")
return true
}
log.WithError(err).Warnf("Decryption failed with key %s", keyPath)
}
return false
}
// Decrypt returns a cobra.Command that decrypts files using age, scanning local SSH keys if needed.
func Decrypt(cfg *Config, log *logrus.Logger) *cobra.Command {
cmd := &cobra.Command{
Use: "decrypt",
Short: "Decrypt a file",
RunE: func(cmd *cobra.Command, _ []string) error {
input, _ := cmd.Flags().GetString("input")
output, _ := cmd.Flags().GetString("output")
sshKeyFlag, _ := cmd.Flags().GetString("ssh-key")
if input == "" {
return fmt.Errorf("input file is required")
}
if output == "" {
return fmt.Errorf("output file is required")
}
if _, err := os.Stat(input); err != nil {
return fmt.Errorf("input file does not exist: %w", err)
}
sshKey := selectSSHKey(sshKeyFlag, cfg)
var triedKeys []string
var success bool
if sshKey != "" {
triedKeys = append(triedKeys, sshKey)
log.WithFields(logrus.Fields{
"input": input,
"output": output,
"sshKey": sshKey,
}).Info("Trying decryption with provided SSH key")
if err := tryDecrypt(sshKey, output, input); err == nil {
log.Info("Decryption successful")
success = true
} else {
log.WithError(err).Warn("Decryption failed with provided SSH key")
}
} else {
keys, err := ScanSSHPrivateKeys()
if err != nil {
return fmt.Errorf("could not scan ~/.ssh for private keys: %w", err)
}
success = tryAllKeys(keys, input, output, log, &triedKeys)
}
if !success {
return fmt.Errorf("decryption failed: none of the tried SSH keys matched\nTried keys: %v", triedKeys)
}
return nil
},
}
cmd.Flags().StringP("input", "i", "", "Input file to decrypt")
cmd.Flags().StringP("output", "o", "", "Output file for decrypted data")
cmd.Flags().String("ssh-key", "", "SSH private key to use for decryption")
return cmd
}

172
cmd/encrypt.go Normal file
View File

@@ -0,0 +1,172 @@
package cmd
import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"regexp"
"strings"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
// Encrypt returns a cobra.Command that encrypts files using age, supporting GitHub key fetching.
func Encrypt(cfg *Config, log *logrus.Logger) *cobra.Command {
cmd := &cobra.Command{
Use: "encrypt",
Short: "Encrypt a file",
RunE: func(cmd *cobra.Command, _ []string) error {
input, _ := cmd.Flags().GetString("input")
output, _ := cmd.Flags().GetString("output")
recipients, _ := cmd.Flags().GetStringSlice("recipient")
ghUserFlag, _ := cmd.Flags().GetString("github-user")
if input == "" {
return fmt.Errorf("input file is required")
}
if output == "" {
return fmt.Errorf("output file is required")
}
if _, err := os.Stat(input); err != nil {
return fmt.Errorf("input file does not exist: %w", err)
}
allRecipients, ghUser, err := collectRecipients(cfg, recipients, ghUserFlag, log)
if err != nil {
return err
}
if len(allRecipients) == 0 {
return fmt.Errorf("at least one recipient is required")
}
ageArgs, err := buildAgeArgs(output, input, allRecipients)
if err != nil {
return err
}
log.WithFields(logrus.Fields{
"input": input,
"output": output,
"recipients": allRecipients,
"githubUser": ghUser,
}).Info("Encrypting file")
if err := runAgeEncrypt(ageArgs, log); err != nil {
return err
}
log.Info("Encryption successful")
return nil
},
}
cmd.Flags().StringP("input", "i", "", "Input file to encrypt")
cmd.Flags().StringP("output", "o", "", "Output file for encrypted data")
cmd.Flags().StringSliceP("recipient", "r", []string{}, "Recipient public key file or string")
cmd.Flags().String("github-user", "", "GitHub username to fetch public keys for encryption")
return cmd
}
// Helper to collect recipients including GitHub keys
func collectRecipients(
cfg *Config,
recipients []string,
ghUserFlag string,
log *logrus.Logger,
) ([]string, string, error) {
allRecipients := append([]string{}, cfg.DefaultRecipients...)
allRecipients = append(allRecipients, recipients...)
ghUser := ghUserFlag
if ghUser == "" && cfg.GitHubUser != "" {
ghUser = cfg.GitHubUser
}
if ghUser != "" {
validUser := regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$`)
if !validUser.MatchString(ghUser) {
log.Warnf("Invalid GitHub username: %s", ghUser)
} else {
url := fmt.Sprintf("https://github.com/%s.keys", ghUser)
if !strings.HasPrefix(url, "https://github.com/") || !strings.HasSuffix(url, ".keys") {
log.Warnf("Refusing to fetch keys from non-GitHub URL: %s", url)
} else {
// #nosec G107 -- url is validated to be a GitHub keys endpoint above
resp, err := http.Get(url)
if err != nil {
log.WithError(err).Warnf("Failed to fetch GitHub keys for user %s", ghUser)
} else {
var githubKeys []string
if resp.StatusCode == http.StatusOK {
body, err := io.ReadAll(resp.Body)
closeErr := resp.Body.Close()
if err == nil && closeErr == nil {
for _, line := range strings.Split(string(body), "\n") {
line = strings.TrimSpace(line)
if line != "" {
githubKeys = append(githubKeys, line)
}
}
} else {
if err != nil {
log.WithError(err).Warn("Failed to read GitHub keys response body")
}
if closeErr != nil {
log.WithError(closeErr).Warn("Failed to close GitHub keys response body")
}
}
} else {
_ = resp.Body.Close()
log.Warnf("GitHub returned status %d for user %s", resp.StatusCode, ghUser)
}
allRecipients = append(allRecipients, githubKeys...)
}
}
}
}
return allRecipients, ghUser, nil
}
// Helper to build and validate age arguments
func buildAgeArgs(output, input string, recipients []string) ([]string, error) {
ageArgs := []string{"-o", output}
for _, r := range recipients {
ageArgs = append(ageArgs, "-r", r)
}
ageArgs = append(ageArgs, input)
// Only allow expected flags for age and restrict file extensions
expectedFlags := map[string]bool{"-o": true, "-r": true}
for i, arg := range ageArgs {
if i%2 == 0 && i < len(ageArgs)-2 { // flags before last two args
if !expectedFlags[arg] {
return nil, fmt.Errorf("unexpected flag in age arguments: %s", arg)
}
} else if arg == "" {
return nil, fmt.Errorf("invalid argument for encryption: empty string")
}
}
// Restrict output to expected file extensions
if !strings.HasSuffix(output, ".txt") && !strings.HasSuffix(output, ".out") {
return nil, fmt.Errorf("invalid output file for encryption: %s", output)
}
return ageArgs, nil
}
// Helper to run age encryption command
func runAgeEncrypt(ageArgs []string, log *logrus.Logger) error {
ageBin := "age"
if ageBin != "age" {
return fmt.Errorf("invalid binary for encryption: %s", ageBin)
}
cmdAge := exec.Command(ageBin, ageArgs...)
if err := cmdAge.Run(); err != nil {
log.WithError(err).Error("Encryption failed")
return fmt.Errorf("age encryption failed: %w", err)
}
return nil
}
// Config struct should be imported from the main package or shared as needed.

19
go.mod Normal file
View File

@@ -0,0 +1,19 @@
module github.com/ivuorinen/a
go 1.24
require (
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

28
go.sum Normal file
View File

@@ -0,0 +1,28 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

72
revive.toml Normal file
View File

@@ -0,0 +1,72 @@
# Revive configuration for "a" project
# https://revive.run/
ignoreGeneratedHeader = false
severity = "warning"
confidence = 0.8
errorCode = 0
warningCode = 0
# Core rules that align with golangci-lint settings
[rule.blank-imports]
[rule.context-as-argument]
[rule.context-keys-type]
[rule.dot-imports]
[rule.error-return]
[rule.error-strings]
[rule.error-naming]
[rule.exported]
[rule.if-return]
[rule.increment-decrement]
[rule.var-naming]
[rule.var-declaration]
[rule.range]
[rule.receiver-naming]
[rule.time-naming]
[rule.unexported-return]
[rule.indent-error-flow]
[rule.errorf]
[rule.empty-block]
[rule.superfluous-else]
[rule.unreachable-code]
[rule.redefines-builtin-id]
# Rules that complement golangci-lint but don't conflict
[rule.atomic]
[rule.bool-literal-in-expr]
[rule.constant-logical-expr]
[rule.defer]
[rule.early-return]
[rule.empty-lines]
[rule.get-return]
[rule.identical-branches]
[rule.imports-blacklist]
[rule.modifies-parameter]
[rule.modifies-value-receiver]
[rule.optimize-operands-order]
[rule.string-of-int]
[rule.struct-tag]
# [rule.switch-default] # Rule not available in current version
[rule.unconditional-recursion]
[rule.unnecessary-stmt]
[rule.useless-break]
[rule.waitgroup-by-value]
# Project-specific rules
[rule.package-comments] # Require package comments
[rule.confusing-naming] # Catch confusing variable/function names
# Disable rules that conflict with golangci-lint or project preferences
# [rule.unused-parameter] # Disabled - conflicts with golangci-lint unused linter
# [rule.line-length-limit] # Disabled - conflicts with golangci-lint lll (which is also disabled)
# [rule.function-length] # Disabled - conflicts with golangci-lint funlen (which is disabled)
# [rule.cyclomatic] # Disabled - conflicts with golangci-lint gocyclo
# [rule.cognitive-complexity] # Disabled - conflicts with golangci-lint gocognit (which is disabled)
# [rule.max-public-structs] # Disabled - too restrictive for this project
# [rule.flag-parameter] # Disabled - can be too strict for CLI applications
# [rule.deep-exit] # Disabled - acceptable in CLI applications
# [rule.file-header] # Disabled - no specific file header requirement
# [rule.add-constant] # Disabled - can be too strict for configuration values
# [rule.argument-limit] # Disabled - can be too restrictive for some functions
# [rule.function-result-limit] # Disabled - Go allows multiple returns
# [rule.unhandled-error] # Disabled - conflicts with golangci-lint errcheck