major architecture refactor
This commit is contained in:
parent
4266bfbfc2
commit
f82d06dbf0
11 changed files with 636 additions and 272 deletions
|
|
@ -4,16 +4,16 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.juancwu.dev/juancwu/porkbacon/internal/app"
|
"git.juancwu.dev/juancwu/porkbacon/internal/ui"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
a := app.New()
|
m := ui.New()
|
||||||
|
|
||||||
p := tea.NewProgram(&a)
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
fmt.Println("Error:", err)
|
fmt.Println("Error:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
5
go.mod
5
go.mod
|
|
@ -26,6 +26,7 @@ require (
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sahilm/fuzzy v0.1.1 // indirect
|
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
golang.org/x/text v0.3.8 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
golang.org/x/text v0.33.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
6
go.sum
6
go.sum
|
|
@ -45,11 +45,17 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
|
|
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.juancwu.dev/juancwu/porkbacon/internal/pass"
|
|
||||||
"git.juancwu.dev/juancwu/porkbacon/internal/porkbun"
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
StateInit uint = iota
|
|
||||||
StateLoadingCredentials
|
|
||||||
StateMenu
|
|
||||||
StateExecuting
|
|
||||||
StateResult
|
|
||||||
)
|
|
||||||
|
|
||||||
type CredentialsLoadedMsg struct {
|
|
||||||
APIKey string
|
|
||||||
SecretKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
type PingResultMsg struct {
|
|
||||||
IP string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ErrMsg error
|
|
||||||
|
|
||||||
type item struct {
|
|
||||||
title, desc string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i item) Title() string { return i.title }
|
|
||||||
func (i item) Description() string { return i.desc }
|
|
||||||
func (i item) FilterValue() string { return i.title }
|
|
||||||
|
|
||||||
type App struct {
|
|
||||||
state uint
|
|
||||||
|
|
||||||
apiKeyName string
|
|
||||||
secretApiKeyName string
|
|
||||||
client *porkbun.Client
|
|
||||||
|
|
||||||
textInput textinput.Model
|
|
||||||
spinner spinner.Model
|
|
||||||
list list.Model
|
|
||||||
|
|
||||||
result string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func New() App {
|
|
||||||
ti := textinput.New()
|
|
||||||
ti.Placeholder = "Pass Name"
|
|
||||||
ti.Focus()
|
|
||||||
ti.Width = 20
|
|
||||||
|
|
||||||
s := spinner.New()
|
|
||||||
s.Spinner = spinner.Dot
|
|
||||||
|
|
||||||
items := []list.Item{
|
|
||||||
item{title: "Domain: List All", desc: "List all domains in your account"},
|
|
||||||
item{title: "Domain: Get Details", desc: "Get details for a specific domain"},
|
|
||||||
item{title: "DNS: Retrieve Records", desc: "Retrieve DNS records for a domain"},
|
|
||||||
item{title: "DNS: Create Record", desc: "Create a new DNS record"},
|
|
||||||
item{title: "DNS: Edit Record", desc: "Edit an existing DNS record"},
|
|
||||||
item{title: "DNS: Delete Record", desc: "Delete a DNS record"},
|
|
||||||
item{title: "Util: Ping", desc: "Ping Porkbun"},
|
|
||||||
}
|
|
||||||
|
|
||||||
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
|
|
||||||
l.Title = "Porkbun Actions"
|
|
||||||
|
|
||||||
return App{
|
|
||||||
state: StateInit,
|
|
||||||
textInput: ti,
|
|
||||||
spinner: s,
|
|
||||||
list: l,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a App) Init() tea.Cmd {
|
|
||||||
return tea.Sequence(textinput.Blink, a.spinner.Tick)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
var cmd tea.Cmd
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch msg.String() {
|
|
||||||
case "ctrl+c":
|
|
||||||
return a, tea.Quit
|
|
||||||
case "esc":
|
|
||||||
if a.state == StateResult {
|
|
||||||
a.state = StateMenu
|
|
||||||
a.result = ""
|
|
||||||
a.err = nil
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
case "enter":
|
|
||||||
if a.state == StateInit {
|
|
||||||
return a.handleEnter()
|
|
||||||
} else if a.state == StateMenu {
|
|
||||||
return a.handleMenuEnter()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
a.list.SetWidth(msg.Width)
|
|
||||||
a.list.SetHeight(msg.Height)
|
|
||||||
|
|
||||||
case CredentialsLoadedMsg:
|
|
||||||
a.client = porkbun.New(msg.APIKey, msg.SecretKey)
|
|
||||||
a.state = StateMenu
|
|
||||||
return a, nil
|
|
||||||
|
|
||||||
case PingResultMsg:
|
|
||||||
a.result = fmt.Sprintf("Ping successful!\nYour IP: %s", msg.IP)
|
|
||||||
a.state = StateResult
|
|
||||||
return a, nil
|
|
||||||
|
|
||||||
case ErrMsg:
|
|
||||||
a.err = msg
|
|
||||||
a.state = StateResult // Show error in result view
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.state == StateInit {
|
|
||||||
a.textInput, cmd = a.textInput.Update(msg)
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.state == StateLoadingCredentials || a.state == StateExecuting {
|
|
||||||
a.spinner, cmd = a.spinner.Update(msg)
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.state == StateMenu {
|
|
||||||
a.list, cmd = a.list.Update(msg)
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
return a, tea.Sequence(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) View() string {
|
|
||||||
if a.state == StateResult {
|
|
||||||
content := a.result
|
|
||||||
if a.err != nil {
|
|
||||||
content = fmt.Sprintf("Error: %v", a.err)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s\n\n(Press Esc to go back)", content)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch a.state {
|
|
||||||
case StateInit:
|
|
||||||
return a.renderPorkbunCredentialForm()
|
|
||||||
case StateLoadingCredentials:
|
|
||||||
return fmt.Sprintf("%s Loading credentials...", a.spinner.View())
|
|
||||||
case StateMenu:
|
|
||||||
return a.list.View()
|
|
||||||
case StateExecuting:
|
|
||||||
return fmt.Sprintf("%s Executing...", a.spinner.View())
|
|
||||||
}
|
|
||||||
return "Invalid state"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) renderPorkbunCredentialForm() string {
|
|
||||||
title := "Enter Porkbun API Key Name (pass entry):"
|
|
||||||
if a.apiKeyName != "" {
|
|
||||||
title = "Enter Porkbun Secret API Key Name (pass entry):"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%s\n%s\n\n(Press Enter to confirm)", title, a.textInput.View())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleEnter() (tea.Model, tea.Cmd) {
|
|
||||||
val := a.textInput.Value()
|
|
||||||
if val == "" {
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.apiKeyName == "" {
|
|
||||||
a.apiKeyName = val
|
|
||||||
a.textInput.Reset()
|
|
||||||
a.textInput.Placeholder = "Secret Key Name"
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.secretApiKeyName == "" {
|
|
||||||
a.secretApiKeyName = val
|
|
||||||
a.textInput.Reset()
|
|
||||||
a.state = StateLoadingCredentials
|
|
||||||
return a, retrievePorkbunCredentialsCmd(a.apiKeyName, a.secretApiKeyName)
|
|
||||||
}
|
|
||||||
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleMenuEnter() (tea.Model, tea.Cmd) {
|
|
||||||
i, ok := a.list.SelectedItem().(item)
|
|
||||||
if !ok {
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch i.title {
|
|
||||||
case "Util: Ping":
|
|
||||||
a.state = StateExecuting
|
|
||||||
return a, a.performPing()
|
|
||||||
}
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) performPing() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
resp, err := a.client.Ping()
|
|
||||||
if err != nil {
|
|
||||||
return ErrMsg(err)
|
|
||||||
}
|
|
||||||
return PingResultMsg{IP: resp.YourIP}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func retrievePorkbunCredentialsCmd(apiKeyName, secretKeyName string) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
apiKey, err := pass.Get(apiKeyName)
|
|
||||||
if err != nil {
|
|
||||||
return ErrMsg(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
secretKey, err := pass.Get(secretKeyName)
|
|
||||||
if err != nil {
|
|
||||||
return ErrMsg(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return CredentialsLoadedMsg{
|
|
||||||
APIKey: apiKey,
|
|
||||||
SecretKey: secretKey,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
103
internal/config/config.go
Normal file
103
internal/config/config.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.juancwu.dev/juancwu/porkbacon/internal/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
EncryptedApiKey string `json:"encrypted_api_key"`
|
||||||
|
EncryptedSecretApiKey string `json:"encrypted_secret_api_key"`
|
||||||
|
|
||||||
|
filename string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
userConfigDir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user configuration directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configDir := filepath.Join(userConfigDir, "porkbacon")
|
||||||
|
if err := os.MkdirAll(configDir, 0700); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create config directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := filepath.Join(configDir, "config.json")
|
||||||
|
|
||||||
|
// If file doesn't exist, return empty config with filename set
|
||||||
|
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
||||||
|
return &Config{filename: filename}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fileData, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
err = json.Unmarshal(fileData, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
// If JSON is invalid, return empty config (overwrite) or error?
|
||||||
|
// Let's return error so user knows something is wrong.
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal config file: %w", err)
|
||||||
|
}
|
||||||
|
cfg.filename = filename
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Save() error {
|
||||||
|
fileData, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(cfg.filename, fileData, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) SetCredentials(apiKey, secretKey, password string) error {
|
||||||
|
encApiKey, err := security.Encrypt(apiKey, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
encSecretKey, err := security.Encrypt(secretKey, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.EncryptedApiKey = encApiKey
|
||||||
|
cfg.EncryptedSecretApiKey = encSecretKey
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) GetCredentials(password string) (apiKey, secretKey string, err error) {
|
||||||
|
if cfg.EncryptedApiKey == "" || cfg.EncryptedSecretApiKey == "" {
|
||||||
|
return "", "", fmt.Errorf("credentials not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey, err = security.Decrypt(cfg.EncryptedApiKey, password)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("invalid password or corrupted api key")
|
||||||
|
}
|
||||||
|
|
||||||
|
secretKey, err = security.Decrypt(cfg.EncryptedSecretApiKey, password)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("invalid password or corrupted secret key")
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiKey, secretKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) HasCredentials() bool {
|
||||||
|
return cfg.EncryptedApiKey != "" && cfg.EncryptedSecretApiKey != ""
|
||||||
|
}
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
package pass
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Get(name string) (string, error) {
|
|
||||||
cmd := exec.Command("pass", "show", name)
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("command execution failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(string(output)), nil
|
|
||||||
}
|
|
||||||
98
internal/security/security.go
Normal file
98
internal/security/security.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/scrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
scryptN = 32768
|
||||||
|
scryptR = 8
|
||||||
|
scryptP = 1
|
||||||
|
scryptKeyLen = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// Encrypt takes a plain text string and a password, returning a base64 encoded string
|
||||||
|
// containing the salt, nonce, and ciphertext.
|
||||||
|
func Encrypt(plainText, password string) (string, error) {
|
||||||
|
salt := make([]byte, 16)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := scrypt.Key([]byte(password), salt, scryptN, scryptR, scryptP, scryptKeyLen)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to derive key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create GCM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cipherText := gcm.Seal(nonce, nonce, []byte(plainText), nil)
|
||||||
|
|
||||||
|
combined := append(salt, cipherText...)
|
||||||
|
return base64.StdEncoding.EncodeToString(combined), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt takes the base64 encoded string and password, returning the plain text.
|
||||||
|
func Decrypt(encodedData, password string) (string, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(encodedData)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode base64: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) < 16+12 { // 16 salt + 12 nonce (min)
|
||||||
|
return "", errors.New("invalid data length")
|
||||||
|
}
|
||||||
|
|
||||||
|
salt := data[:16]
|
||||||
|
cipherTextWithNonce := data[16:]
|
||||||
|
|
||||||
|
key, err := scrypt.Key([]byte(password), salt, scryptN, scryptR, scryptP, scryptKeyLen)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to derive key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create GCM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(cipherTextWithNonce) < nonceSize {
|
||||||
|
return "", errors.New("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, actualCipherText := cipherTextWithNonce[:nonceSize], cipherTextWithNonce[nonceSize:]
|
||||||
|
|
||||||
|
plainText, err := gcm.Open(nil, nonce, actualCipherText, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("invalid password or corrupted data")
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(plainText), nil
|
||||||
|
}
|
||||||
20
internal/ui/messages/messages.go
Normal file
20
internal/ui/messages/messages.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import "git.juancwu.dev/juancwu/porkbacon/internal/porkbun"
|
||||||
|
|
||||||
|
type Page int
|
||||||
|
|
||||||
|
const (
|
||||||
|
PageLogin Page = iota
|
||||||
|
PageMenu
|
||||||
|
)
|
||||||
|
|
||||||
|
type SwitchPageMsg struct {
|
||||||
|
Page Page
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionReadyMsg struct {
|
||||||
|
Client *porkbun.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorMsg error
|
||||||
86
internal/ui/model.go
Normal file
86
internal/ui/model.go
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.juancwu.dev/juancwu/porkbacon/internal/config"
|
||||||
|
"git.juancwu.dev/juancwu/porkbacon/internal/ui/messages"
|
||||||
|
"git.juancwu.dev/juancwu/porkbacon/internal/ui/pages/login"
|
||||||
|
"git.juancwu.dev/juancwu/porkbacon/internal/ui/pages/menu"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MainModel struct {
|
||||||
|
currentPage messages.Page
|
||||||
|
login *login.Model
|
||||||
|
menu *menu.Model
|
||||||
|
isMenuInit bool
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() MainModel {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error loading config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return MainModel{
|
||||||
|
currentPage: messages.PageLogin,
|
||||||
|
login: login.New(cfg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MainModel) Init() tea.Cmd {
|
||||||
|
return m.login.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
case messages.SwitchPageMsg:
|
||||||
|
m.currentPage = msg.Page
|
||||||
|
return m, nil
|
||||||
|
case messages.SessionReadyMsg:
|
||||||
|
m.menu = menu.New(msg.Client)
|
||||||
|
if m.width > 0 && m.height > 0 {
|
||||||
|
newMenu, _ := m.menu.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
|
||||||
|
m.menu = newMenu.(*menu.Model)
|
||||||
|
}
|
||||||
|
m.currentPage = messages.PageMenu
|
||||||
|
m.isMenuInit = true
|
||||||
|
return m, m.menu.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
switch m.currentPage {
|
||||||
|
case messages.PageLogin:
|
||||||
|
var newLogin tea.Model
|
||||||
|
newLogin, cmd = m.login.Update(msg)
|
||||||
|
m.login = newLogin.(*login.Model)
|
||||||
|
case messages.PageMenu:
|
||||||
|
var newMenu tea.Model
|
||||||
|
newMenu, cmd = m.menu.Update(msg)
|
||||||
|
m.menu = newMenu.(*menu.Model)
|
||||||
|
}
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MainModel) View() string {
|
||||||
|
switch m.currentPage {
|
||||||
|
case messages.PageLogin:
|
||||||
|
return m.login.View()
|
||||||
|
case messages.PageMenu:
|
||||||
|
return m.menu.View()
|
||||||
|
default:
|
||||||
|
return "Unknown Page"
|
||||||
|
}
|
||||||
|
}
|
||||||
192
internal/ui/pages/login/model.go
Normal file
192
internal/ui/pages/login/model.go
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
package login
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.juancwu.dev/juancwu/porkbacon/internal/config"
|
||||||
|
"git.juancwu.dev/juancwu/porkbacon/internal/porkbun"
|
||||||
|
"git.juancwu.dev/juancwu/porkbacon/internal/ui/messages"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Mode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeSetup Mode = iota
|
||||||
|
ModeUnlock
|
||||||
|
)
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
cfg *config.Config
|
||||||
|
inputs []textinput.Model
|
||||||
|
focusIndex int
|
||||||
|
mode Mode
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg *config.Config) *Model {
|
||||||
|
m := Model{
|
||||||
|
cfg: cfg,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.HasCredentials() {
|
||||||
|
m.mode = ModeUnlock
|
||||||
|
m.inputs = make([]textinput.Model, 1)
|
||||||
|
m.inputs[0] = textinput.New()
|
||||||
|
m.inputs[0].Placeholder = "Master Password"
|
||||||
|
m.inputs[0].EchoMode = textinput.EchoPassword
|
||||||
|
m.inputs[0].Width = 50
|
||||||
|
m.inputs[0].Focus()
|
||||||
|
} else {
|
||||||
|
m.mode = ModeSetup
|
||||||
|
m.inputs = make([]textinput.Model, 3)
|
||||||
|
|
||||||
|
m.inputs[0] = textinput.New()
|
||||||
|
m.inputs[0].Placeholder = "API Key"
|
||||||
|
m.inputs[0].EchoMode = textinput.EchoPassword
|
||||||
|
m.inputs[0].Width = 50
|
||||||
|
m.inputs[0].Focus()
|
||||||
|
|
||||||
|
m.inputs[1] = textinput.New()
|
||||||
|
m.inputs[1].Placeholder = "Secret API Key"
|
||||||
|
m.inputs[1].EchoMode = textinput.EchoPassword
|
||||||
|
m.inputs[1].Width = 50
|
||||||
|
|
||||||
|
m.inputs[2] = textinput.New()
|
||||||
|
m.inputs[2].Placeholder = "Master Password (to encrypt keys)"
|
||||||
|
m.inputs[2].EchoMode = textinput.EchoPassword
|
||||||
|
m.inputs[2].Width = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
return &m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Init() tea.Cmd {
|
||||||
|
return textinput.Blink
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
case "tab", "shift+tab", "enter", "up", "down":
|
||||||
|
s := msg.String()
|
||||||
|
|
||||||
|
if s == "enter" && m.focusIndex == len(m.inputs)-1 {
|
||||||
|
return m.submit()
|
||||||
|
}
|
||||||
|
|
||||||
|
if s == "up" || s == "shift+tab" {
|
||||||
|
m.focusIndex--
|
||||||
|
} else {
|
||||||
|
m.focusIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.focusIndex > len(m.inputs)-1 {
|
||||||
|
m.focusIndex = 0
|
||||||
|
} else if m.focusIndex < 0 {
|
||||||
|
m.focusIndex = len(m.inputs) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cmds := make([]tea.Cmd, len(m.inputs))
|
||||||
|
for i := 0; i <= len(m.inputs)-1; i++ {
|
||||||
|
if i == m.focusIndex {
|
||||||
|
cmds[i] = m.inputs[i].Focus()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.inputs[i].Blur()
|
||||||
|
}
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle character input and blinking
|
||||||
|
cmd := m.updateInputs(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) updateInputs(msg tea.Msg) tea.Cmd {
|
||||||
|
cmds := make([]tea.Cmd, len(m.inputs))
|
||||||
|
for i := range m.inputs {
|
||||||
|
m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
if m.mode == ModeSetup {
|
||||||
|
b.WriteString("Welcome to Porkbacon! Let's set up your credentials.\n\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("Welcome back! Please unlock your vault.\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range m.inputs {
|
||||||
|
b.WriteString(m.inputs[i].View())
|
||||||
|
if i < len(m.inputs)-1 {
|
||||||
|
b.WriteRune('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n\n(Press Enter to confirm, Ctrl+C to quit)\n")
|
||||||
|
|
||||||
|
if m.err != nil {
|
||||||
|
b.WriteString(fmt.Sprintf("\nError: %v\n", m.err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) submit() (tea.Model, tea.Cmd) {
|
||||||
|
var client *porkbun.Client
|
||||||
|
|
||||||
|
if m.mode == ModeSetup {
|
||||||
|
apiKey := m.inputs[0].Value()
|
||||||
|
secretKey := m.inputs[1].Value()
|
||||||
|
password := m.inputs[2].Value()
|
||||||
|
|
||||||
|
if apiKey == "" || secretKey == "" || password == "" {
|
||||||
|
m.err = fmt.Errorf("all fields are required")
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
client = porkbun.New(apiKey, secretKey)
|
||||||
|
_, err := client.Ping()
|
||||||
|
if err != nil {
|
||||||
|
m.err = err
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = m.cfg.SetCredentials(apiKey, secretKey, password)
|
||||||
|
if err != nil {
|
||||||
|
m.err = err
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = m.cfg.Save()
|
||||||
|
if err != nil {
|
||||||
|
m.err = err
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
password := m.inputs[0].Value()
|
||||||
|
apiKey, secretKey, err := m.cfg.GetCredentials(password)
|
||||||
|
if err != nil {
|
||||||
|
m.err = err
|
||||||
|
// Clear password field on error
|
||||||
|
m.inputs[0].SetValue("")
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
client = porkbun.New(apiKey, secretKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
return messages.SessionReadyMsg{Client: client}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
internal/ui/pages/menu/model.go
Normal file
124
internal/ui/pages/menu/model.go
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
package menu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.juancwu.dev/juancwu/porkbacon/internal/porkbun"
|
||||||
|
"git.juancwu.dev/juancwu/porkbacon/internal/ui/messages"
|
||||||
|
"github.com/charmbracelet/bubbles/list"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pingResultMsg struct {
|
||||||
|
IP string
|
||||||
|
}
|
||||||
|
|
||||||
|
type item struct {
|
||||||
|
id uint8
|
||||||
|
title, desc string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i item) ID() uint8 { return i.id }
|
||||||
|
func (i item) Title() string { return i.title }
|
||||||
|
func (i item) Description() string { return i.desc }
|
||||||
|
func (i item) FilterValue() string { return i.title }
|
||||||
|
|
||||||
|
const (
|
||||||
|
domainListAll uint8 = iota
|
||||||
|
domainGetDetails
|
||||||
|
dnsRetrieveRecords
|
||||||
|
dnsCreateRecord
|
||||||
|
dnsEditRecord
|
||||||
|
dnsDeleteRecord
|
||||||
|
utilPing
|
||||||
|
)
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
list list.Model
|
||||||
|
client *porkbun.Client
|
||||||
|
err error
|
||||||
|
output string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(client *porkbun.Client) *Model {
|
||||||
|
items := []list.Item{
|
||||||
|
item{id: domainListAll, title: "Domain: List All", desc: "List all domains in your account"},
|
||||||
|
item{id: domainGetDetails, title: "Domain: Get Details", desc: "Get details for a specific domain"},
|
||||||
|
item{id: dnsRetrieveRecords, title: "DNS: Retrieve Records", desc: "Retrieve DNS records for a domain"},
|
||||||
|
item{id: dnsCreateRecord, title: "DNS: Create Record", desc: "Create a new DNS record"},
|
||||||
|
item{id: dnsEditRecord, title: "DNS: Edit Record", desc: "Edit an existing DNS record"},
|
||||||
|
item{id: dnsDeleteRecord, title: "DNS: Delete Record", desc: "Delete a DNS record"},
|
||||||
|
item{id: utilPing, title: "Util: Ping", desc: "Ping Porkbun"},
|
||||||
|
}
|
||||||
|
|
||||||
|
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
|
||||||
|
l.Title = "Porkbun Actions"
|
||||||
|
|
||||||
|
return &Model{
|
||||||
|
list: l,
|
||||||
|
client: client,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.list.SetWidth(msg.Width)
|
||||||
|
m.list.SetHeight(msg.Height)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
if m.output != "" {
|
||||||
|
if msg.String() == "esc" {
|
||||||
|
m.output = ""
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.String() == "enter" {
|
||||||
|
i, ok := m.list.SelectedItem().(item)
|
||||||
|
if ok {
|
||||||
|
return m.handleSelection(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case pingResultMsg:
|
||||||
|
m.output = fmt.Sprintf("Ping successful!\nYour IP: %s", msg.IP)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case messages.ErrorMsg:
|
||||||
|
m.output = fmt.Sprintf("Error: %v", msg)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.list, cmd = m.list.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) View() string {
|
||||||
|
if m.output != "" {
|
||||||
|
return fmt.Sprintf("%s\n\n(Press Esc to go back)", m.output)
|
||||||
|
}
|
||||||
|
return m.list.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleSelection(i item) (tea.Model, tea.Cmd) {
|
||||||
|
// TODO: Implement other actions. For now, just Ping.
|
||||||
|
switch i.id {
|
||||||
|
case utilPing:
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
resp, err := m.client.Ping()
|
||||||
|
if err != nil {
|
||||||
|
return messages.ErrorMsg(err)
|
||||||
|
}
|
||||||
|
return pingResultMsg{IP: resp.YourIP}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue