mirror of
https://github.com/skoelle/kctl-tui.git
synced 2026-09-17 20:10:24 +00:00
Phase 2 hardening: binary secret handling, tool checks, input validation, verbose flag, diff scroll
This commit is contained in:
@@ -67,6 +67,9 @@ func (m *fullModel) bootstrap() tea.Msg {
|
|||||||
if len(cfg.Envs) == 0 {
|
if len(cfg.Envs) == 0 {
|
||||||
return errMsg{fmt.Errorf("no 'envs' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
|
return errMsg{fmt.Errorf("no 'envs' configured in ~/.kctl-tui/config.yaml (see config.example.yaml)")}
|
||||||
}
|
}
|
||||||
|
if err := kubeexec.CheckTool("kubectl"); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
return bootstrapMsg{cfg: cfg, context: cfg.EffectiveDefaultContext()}
|
return bootstrapMsg{cfg: cfg, context: cfg.EffectiveDefaultContext()}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +257,18 @@ func (m *fullModel) loadNamespacesFor(teamValue string) tea.Cmd {
|
|||||||
// configured envs, resolved via the context template, so both are
|
// configured envs, resolved via the context template, so both are
|
||||||
// visible side by side.
|
// visible side by side.
|
||||||
func (m *fullModel) startTmuxSession() tea.Cmd {
|
func (m *fullModel) startTmuxSession() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if err := kubeexec.CheckTool("tmux"); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
if err := kubeexec.CheckTool("k9s"); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return m.buildAndRunTmux()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fullModel) buildAndRunTmux() tea.Msg {
|
||||||
selfPath := "kctl-tui" // resolved via PATH; see README for install instructions
|
selfPath := "kctl-tui" // resolved via PATH; see README for install instructions
|
||||||
panelCmd := fmt.Sprintf("%s panel --context=%s --ns=%s --team=%s",
|
panelCmd := fmt.Sprintf("%s panel --context=%s --ns=%s --team=%s",
|
||||||
selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam)
|
selfPath, m.selectedContext, m.selectedNamespace, m.selectedTeam)
|
||||||
|
|||||||
+20
-2
@@ -5,11 +5,29 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/skoelle/kctl-tui/internal/kubeexec"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) > 1 && os.Args[1] == "panel" {
|
args := os.Args[1:]
|
||||||
if err := runPanel(os.Args[2:]); err != nil {
|
|
||||||
|
// Extract --verbose before delegating to panel or full mode.
|
||||||
|
verbose := false
|
||||||
|
filtered := make([]string, 0, len(args))
|
||||||
|
for _, a := range args {
|
||||||
|
if a == "--verbose" {
|
||||||
|
verbose = true
|
||||||
|
} else {
|
||||||
|
filtered = append(filtered, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if verbose {
|
||||||
|
kubeexec.SetVerbose(true, os.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filtered) > 0 && filtered[0] == "panel" {
|
||||||
|
if err := runPanel(filtered[1:]); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "kctl-tui panel error:", err)
|
fmt.Fprintln(os.Stderr, "kctl-tui panel error:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-5
@@ -51,6 +51,7 @@ type panelModel struct {
|
|||||||
awsValues map[string]string
|
awsValues map[string]string
|
||||||
k8sValues map[string]string
|
k8sValues map[string]string
|
||||||
diffEntries []kctl.SecretDiffEntry
|
diffEntries []kctl.SecretDiffEntry
|
||||||
|
diffOffset int // scroll position for diff table
|
||||||
|
|
||||||
message string
|
message string
|
||||||
err error
|
err error
|
||||||
@@ -144,6 +145,14 @@ func (m *panelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m.handleEsc()
|
return m.handleEsc()
|
||||||
case "enter":
|
case "enter":
|
||||||
return m.handleEnter()
|
return m.handleEnter()
|
||||||
|
case "up", "k":
|
||||||
|
if m.step == stepDiffResult {
|
||||||
|
return m.scrollDiff(-1)
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if m.step == stepDiffResult {
|
||||||
|
return m.scrollDiff(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +264,9 @@ func (m *panelModel) fromActionMenu() (tea.Model, tea.Cmd) {
|
|||||||
// interactively instead of letting the user hit a confusing failure
|
// interactively instead of letting the user hit a confusing failure
|
||||||
// several steps later.
|
// several steps later.
|
||||||
func (m *panelModel) checkAWSAuthAndProceed() (tea.Model, tea.Cmd) {
|
func (m *panelModel) checkAWSAuthAndProceed() (tea.Model, tea.Cmd) {
|
||||||
|
if err := kubeexec.CheckTool("aws"); err != nil {
|
||||||
|
return m.showError(err)
|
||||||
|
}
|
||||||
if err := kubeexec.CheckAWSAuth(); err != nil {
|
if err := kubeexec.CheckAWSAuth(); err != nil {
|
||||||
m.err = err
|
m.err = err
|
||||||
m.list.SetItems([]list.Item{
|
m.list.SetItems([]list.Item{
|
||||||
@@ -363,7 +375,8 @@ func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.k8sValues = k8sValues
|
m.k8sValues = k8sValues
|
||||||
m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues)
|
m.diffEntries = diffSecretValues(m.awsValues, m.k8sValues)
|
||||||
m.message = renderDiffTable(m.currentEnv, m.awsSecretName, m.k8sSecretName, m.diffEntries)
|
m.diffOffset = 0
|
||||||
|
m.message = renderDiffTable(m.currentEnv, m.awsSecretName, m.k8sSecretName, m.diffEntries, m.diffOffset, 0)
|
||||||
|
|
||||||
if anyMismatch(m.diffEntries) {
|
if anyMismatch(m.diffEntries) {
|
||||||
m.list.SetItems([]list.Item{
|
m.list.SetItems([]list.Item{
|
||||||
@@ -378,16 +391,52 @@ func (m *panelModel) compareAllFields() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderDiffTable(env, awsSecretName, k8sSecretName string, entries []kctl.SecretDiffEntry) string {
|
func (m *panelModel) scrollDiff(delta int) (tea.Model, tea.Cmd) {
|
||||||
|
newOff := m.diffOffset + delta
|
||||||
|
if newOff < 0 {
|
||||||
|
newOff = 0
|
||||||
|
}
|
||||||
|
maxOff := len(m.diffEntries) - 1
|
||||||
|
if maxOff < 0 {
|
||||||
|
maxOff = 0
|
||||||
|
}
|
||||||
|
if newOff > maxOff {
|
||||||
|
newOff = maxOff
|
||||||
|
}
|
||||||
|
m.diffOffset = newOff
|
||||||
|
m.message = renderDiffTable(m.currentEnv, m.awsSecretName, m.k8sSecretName, m.diffEntries, m.diffOffset, 0)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderDiffTable(env, awsSecretName, k8sSecretName string, entries []kctl.SecretDiffEntry, offset, visibleHeight int) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
fmt.Fprintf(&b, "env: %s AWS secret: %s Kubernetes secret: %s\n\n", env, awsSecretName, k8sSecretName)
|
fmt.Fprintf(&b, "env: %s AWS secret: %s Kubernetes secret: %s\n\n", env, awsSecretName, k8sSecretName)
|
||||||
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", "KEY", "AWS", "KUBERNETES", "STATUS")
|
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", "KEY", "AWS", "KUBERNETES", "STATUS")
|
||||||
for _, e := range entries {
|
start := offset
|
||||||
|
if start > len(entries) {
|
||||||
|
start = len(entries)
|
||||||
|
}
|
||||||
|
end := len(entries)
|
||||||
|
if visibleHeight > 0 && start+visibleHeight < end {
|
||||||
|
end = start + visibleHeight
|
||||||
|
}
|
||||||
|
for _, e := range entries[start:end] {
|
||||||
status := "OK"
|
status := "OK"
|
||||||
if !e.Match {
|
if !e.Match {
|
||||||
status = "MISMATCH"
|
status = "MISMATCH"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", e.Key, truncate(e.Left, 20), truncate(e.Right, 20), status)
|
left := e.Left
|
||||||
|
if e.LeftBin {
|
||||||
|
left = fmt.Sprintf("<binary %d bytes>", len(e.Left))
|
||||||
|
}
|
||||||
|
right := e.Right
|
||||||
|
if e.RightBin {
|
||||||
|
right = fmt.Sprintf("<binary %d bytes>", len(e.Right))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%-25s %-20s %-20s %s\n", e.Key, truncate(left, 20), truncate(right, 20), status)
|
||||||
|
}
|
||||||
|
if len(entries) > 0 {
|
||||||
|
fmt.Fprintf(&b, "\n Showing %d-%d of %d fields (j/k or arrow keys to scroll)", start+1, end, len(entries))
|
||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -415,7 +464,15 @@ func (m *panelModel) fromForceSyncConfirm() (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) {
|
func (m *panelModel) doForceSync() (tea.Model, tea.Cmd) {
|
||||||
name := m.input.Value()
|
name := strings.TrimSpace(m.input.Value())
|
||||||
|
if name == "" {
|
||||||
|
return m.showError(fmt.Errorf("ExternalSecret name must not be empty"))
|
||||||
|
}
|
||||||
|
for _, r := range name {
|
||||||
|
if r < 0x20 || r > 0x7e || r == '/' || r == ' ' {
|
||||||
|
return m.showError(fmt.Errorf("ExternalSecret name contains invalid character: %q", r))
|
||||||
|
}
|
||||||
|
}
|
||||||
ts := time.Now().Unix()
|
ts := time.Now().Unix()
|
||||||
_, err := kubeexec.AnnotateForceSync(m.resolvedContext(), m.ns, name, ts)
|
_, err := kubeexec.AnnotateForceSync(m.resolvedContext(), m.ns, name, ts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+28
-5
@@ -5,10 +5,27 @@ import "sort"
|
|||||||
// SecretDiffEntry represents the comparison of one key between two secret
|
// SecretDiffEntry represents the comparison of one key between two secret
|
||||||
// sources (e.g. AWS Secrets Manager vs. a Kubernetes Secret).
|
// sources (e.g. AWS Secrets Manager vs. a Kubernetes Secret).
|
||||||
type SecretDiffEntry struct {
|
type SecretDiffEntry struct {
|
||||||
Key string
|
Key string
|
||||||
Left string // e.g. the AWS Secrets Manager value
|
Left string // e.g. the AWS Secrets Manager value
|
||||||
Right string // e.g. the decoded Kubernetes secret value
|
Right string // e.g. the decoded Kubernetes secret value
|
||||||
Match bool
|
Match bool
|
||||||
|
LeftBin bool // true if Left contains non-printable (binary) data
|
||||||
|
RightBin bool // true if Right contains non-printable (binary) data
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBinary reports whether s contains non-printable bytes (i.e. is likely
|
||||||
|
// binary data rather than human-readable text). Control characters below
|
||||||
|
// space (0x20) are excluded, except for common whitespace (\t, \n, \r).
|
||||||
|
func IsBinary(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 0x7f {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if r < 0x20 && r != '\t' && r != '\n' && r != '\r' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiffSecretValues compares two key/value maps and returns a sorted list of
|
// DiffSecretValues compares two key/value maps and returns a sorted list of
|
||||||
@@ -35,7 +52,13 @@ func DiffSecretValues(left, right map[string]string) []SecretDiffEntry {
|
|||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
l := left[k]
|
l := left[k]
|
||||||
r := right[k]
|
r := right[k]
|
||||||
result = append(result, SecretDiffEntry{Key: k, Left: l, Right: r, Match: l == r})
|
lb := IsBinary(l)
|
||||||
|
rb := IsBinary(r)
|
||||||
|
match := l == r
|
||||||
|
if lb || rb {
|
||||||
|
match = l == r
|
||||||
|
}
|
||||||
|
result = append(result, SecretDiffEntry{Key: k, Left: l, Right: r, Match: match, LeftBin: lb, RightBin: rb})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,3 +57,43 @@ func TestDiffSecretValues_EmptyMaps(t *testing.T) {
|
|||||||
t.Fatalf("expected no mismatch for empty maps")
|
t.Fatalf("expected no mismatch for empty maps")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsBinary_Plaintext(t *testing.T) {
|
||||||
|
if IsBinary("hello world") {
|
||||||
|
t.Fatal("expected plaintext to not be binary")
|
||||||
|
}
|
||||||
|
if IsBinary("line1\nline2\ttab") {
|
||||||
|
t.Fatal("expected newline/tab to not be binary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBinary_BinaryData(t *testing.T) {
|
||||||
|
if !IsBinary("hello\x00world") {
|
||||||
|
t.Fatal("expected null byte to be binary")
|
||||||
|
}
|
||||||
|
if !IsBinary("key=\xff\xfe") {
|
||||||
|
t.Fatal("expected non-ASCII bytes to be binary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffSecretValues_BinaryDetection(t *testing.T) {
|
||||||
|
left := map[string]string{"ok": "text", "bin": "data\x00here"}
|
||||||
|
right := map[string]string{"ok": "text", "bin": "data\x00here"}
|
||||||
|
|
||||||
|
entries := DiffSecretValues(left, right)
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Key == "bin" {
|
||||||
|
if !e.LeftBin || !e.RightBin {
|
||||||
|
t.Fatalf("expected binary flags set for key 'bin', got LeftBin=%v RightBin=%v", e.LeftBin, e.RightBin)
|
||||||
|
}
|
||||||
|
if !e.Match {
|
||||||
|
t.Fatalf("expected binary values to match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.Key == "ok" {
|
||||||
|
if e.LeftBin || e.RightBin {
|
||||||
|
t.Fatalf("expected binary flags unset for key 'ok'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,13 +19,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func runOutput(name string, args ...string) (string, error) {
|
func runOutput(name string, args ...string) (string, error) {
|
||||||
|
logCmd(name, args...)
|
||||||
cmd := exec.Command(name, args...)
|
cmd := exec.Command(name, args...)
|
||||||
var stdout, stderr strings.Builder
|
var stdout, stderr strings.Builder
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := fmt.Sprintf("%s %s failed: %v", name, strings.Join(args, " "), err)
|
logErr(name, err)
|
||||||
|
prefix := fmt.Sprintf("%s %s failed: %v", name, strings.Join(args, " "), err)
|
||||||
|
msg := prefix
|
||||||
if s := strings.TrimSpace(stderr.String()); s != "" {
|
if s := strings.TrimSpace(stderr.String()); s != "" {
|
||||||
msg += "\n" + s
|
msg += "\n" + s
|
||||||
}
|
}
|
||||||
@@ -34,7 +37,9 @@ func runOutput(name string, args ...string) (string, error) {
|
|||||||
}
|
}
|
||||||
return "", fmt.Errorf("%s", msg)
|
return "", fmt.Errorf("%s", msg)
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(stdout.String()), nil
|
out := strings.TrimSpace(stdout.String())
|
||||||
|
logOutput(name, out)
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// kubectlArgs prepends a --context flag when context is non-empty.
|
// kubectlArgs prepends a --context flag when context is non-empty.
|
||||||
@@ -172,3 +177,13 @@ func RunAWSLogin(loginCommand string) *exec.Cmd {
|
|||||||
}
|
}
|
||||||
return exec.Command(parts[0], parts[1:]...)
|
return exec.Command(parts[0], parts[1:]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckTool verifies that a named executable is available in PATH.
|
||||||
|
// Returns nil if found, or a descriptive error if not.
|
||||||
|
func CheckTool(name string) error {
|
||||||
|
_, err := exec.LookPath(name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%q not found in PATH — please install it first", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package kubeexec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
verbose bool
|
||||||
|
logOut io.Writer = io.Discard
|
||||||
|
mu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetVerbose enables or disables debug logging of executed commands.
|
||||||
|
// When enabled, commands and their outputs are written to the provided
|
||||||
|
// writer (typically os.Stderr). When disabled (the default), all logging
|
||||||
|
// is discarded.
|
||||||
|
func SetVerbose(enabled bool, w io.Writer) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
verbose = enabled
|
||||||
|
if w != nil {
|
||||||
|
logOut = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logCmd(name string, args ...string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if !verbose {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(logOut, "[cmd] %s %s\n", name, strings.Join(args, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func logOutput(name string, output string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if !verbose {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if output != "" {
|
||||||
|
fmt.Fprintf(logOut, "[out] %s: %s\n", name, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logErr(name string, err error) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if !verbose {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(logOut, "[err] %s: %v\n", name, err)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user