Switch config format from ini to json (drop ini dependency since json is now used for syncing)

This commit is contained in:
2025-12-17 00:36:20 -05:00
parent e2d1b8dc09
commit b591e2ef5a
11 changed files with 138 additions and 199 deletions
+82
View File
@@ -0,0 +1,82 @@
package cfg
import (
"encoding/json"
"errors"
"os"
"reflect"
"github.com/rwinkhart/libmutton/global"
)
type CfgT struct {
Libmutton struct {
OfflineMode *bool `json:"offlineMode"`
SSHUser *string `json:"sshUser"`
SSHIP *string `json:"sshIP"`
SSHPort *string `json:"sshPort"`
SSHEntryRootPath *string `json:"sshEntryRootPath"`
SSHAgeDirPath *string `json:"sshAgeDirPath"`
SSHKeyPath *string `json:"sshKeyPath"`
SSHKeyProtected *bool `json:"sshKeyProtected"`
SSHIsWindows *bool `json:"sshIsWindows"`
} `json:"libmutton"`
ThirdParty *map[string]any `json:"thirdParty"`
}
// LoadConfig loads libmuttoncfg.json and returns the configuration.
func LoadConfig() (*CfgT, error) {
cfgBytes, err := os.ReadFile(global.ConfigPath)
if err != nil {
return nil, errors.New("unable to load libmuttoncfg.json: " + err.Error())
}
var cfg CfgT
err = json.Unmarshal(cfgBytes, &cfg)
if err != nil {
return nil, errors.New("unable to unmarshal libmuttoncfg.json: " + err.Error())
}
return &cfg, nil
}
// WriteConfig writes cfg to libmuttoncfg.json.
// If used in append mode, any nil values in the
// input cfg will be substituted with the existing values.
func WriteConfig(cfg *CfgT, appendMode bool) error {
if appendMode {
// check if any fields are nil
var hasNilFields bool
cfgValue := reflect.ValueOf(&cfg.Libmutton).Elem()
for i := 0; i < cfgValue.NumField(); i++ {
field := cfgValue.Field(i)
if field.IsNil() {
hasNilFields = true
break
}
}
// load old cfg and copy nil fields
if hasNilFields {
oldCfg, err := LoadConfig()
if err != nil {
return err
}
oldValue := reflect.ValueOf(&oldCfg.Libmutton).Elem()
for i := 0; i < cfgValue.NumField(); i++ {
field := cfgValue.Field(i)
if field.IsNil() {
field.Set(oldValue.Field(i))
}
}
}
}
cfgBytes, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return errors.New("unable to marshal new/updated cfg: " + err.Error())
}
err = os.WriteFile(global.ConfigPath, cfgBytes, 0600)
if err != nil {
return errors.New("unable to write new/updated cfg to libmuttoncfg.json: " + err.Error())
}
return nil
}
-98
View File
@@ -1,98 +0,0 @@
package cfg
import (
"errors"
"fmt"
"github.com/rwinkhart/libmutton/global"
"gopkg.in/ini.v1"
)
// loadConfig loads the libmutton.ini file and returns the configuration.
// It is a utility function for ParseConfig and WriteConfig; do not call directly.
func loadConfig() (*ini.File, error) {
cfg, err := ini.Load(global.ConfigPath)
if err != nil {
return nil, errors.New("unable to load libmutton.ini: " + err.Error())
}
return cfg, nil
}
// ParseConfig reads the libmutton.ini file and returns a slice of values for the specified keys.
// Requires: valuesRequested (a slice of length 2 arrays each containing a section and a key name).
// Returns: config (slice of values for the specified keys).
// If requesting SSH config, request "LIBMUTTON/offlineMode" first to avoid errors.
func ParseConfig(valuesRequested [][2]string) ([]string, error) {
cfg, err := loadConfig()
if err != nil {
return nil, err
}
var config []string
for _, pair := range valuesRequested {
value := cfg.Section(pair[0]).Key(pair[1]).String()
// ensure specified key has a value
if value == "" {
return nil, fmt.Errorf("unable to find value for key \"%s\" in section \"[%s]\" in libmutton.ini", pair[1], pair[0])
}
config = append(config, value)
// notify requester immediately if in offline mode
if pair[1] == "offlineMode" && pair[0] == "LIBMUTTON" && value == "true" {
return config, nil
}
}
return config, err
}
// WriteConfig writes the provided key-value pairs under the specified section headers in the libmutton.ini file.
// Requires: valuesToWrite (a slice of length 3 arrays each containing a section, a key name, and a value),
// prune (a slice similar to valuesToWrite to allow removing the specified keys from an existing config),
// append (set to true to append to the existing libmutton.ini file, false to overwrite it).
func WriteConfig(valuesToWrite [][3]string, keysToPrune [][2]string, append bool) error {
var cfg *ini.File
var err error
if append {
// load existing ini file
cfg, err = loadConfig()
if err != nil {
return errors.New("unable to load existing libmutton.ini: " + err.Error())
}
} else {
// create empty ini container
cfg = ini.Empty()
}
// set all specified key-value pairs in their respective sections
var section *ini.Section
for _, trio := range valuesToWrite {
if cfg.Section(trio[0]) == nil {
// create and acquire section if it doesn't exist
section, _ = cfg.NewSection(trio[0])
} else {
// acquire existing section
section = cfg.Section(trio[0])
}
// set key-value pair
section.Key(trio[1]).SetValue(trio[2])
}
// prune specified keys from the existing config
if append && len(keysToPrune) > 0 {
// remove specified keys pairs from the existing config
for _, pair := range keysToPrune {
cfg.Section(pair[0]).DeleteKey(pair[1])
}
}
// save to libmutton.ini
setUmask(0077) // only give permissions to owner
err = cfg.SaveTo(global.ConfigPath)
if err != nil {
return errors.New("unable to save libmutton.ini: " + err.Error())
}
return nil
}
-12
View File
@@ -1,12 +0,0 @@
//go:build !windows
package cfg
import "syscall"
// setUmask sets the file mode creation mask (umask) for the current process.
// The call to syscall.Umask needs to be embedded in another function to allow
// compilation on Windows.
func setUmask(umask int) {
syscall.Umask(umask)
}
-8
View File
@@ -1,8 +0,0 @@
//go:build windows
package cfg
// setUmask is a dummy function on Windows.
func setUmask(umask int) {
return
}
+30 -24
View File
@@ -3,7 +3,7 @@ package core
import (
"cmp"
"errors"
"strconv"
"maps"
"strings"
"github.com/rwinkhart/go-boilerplate/back"
@@ -15,7 +15,15 @@ import (
// LibmuttonInit creates the libmutton config structure based on user input.
// rcwPassword and clientSpecificIniData can be left blank if not needed.
func LibmuttonInit(inputCB func(prompt string) string, clientSpecificIniData [][3]string, rcwPassword []byte, preserveOldConfigDir bool, forceOfflineMode bool) error {
func LibmuttonInit(inputCB func(prompt string) string, clientSpecificIniData map[string]any, rcwPassword []byte, preserveOldConfigDir bool, forceOfflineMode bool) error {
// handle clientSpecificIniData
newCfg := &cfg.CfgT{}
if clientSpecificIniData != nil {
newThirdPartyMap := make(map[string]any)
maps.Copy(newThirdPartyMap, clientSpecificIniData)
newCfg.ThirdParty = &newThirdPartyMap
}
var r string
if !forceOfflineMode {
r = strings.ToLower(inputCB("Configure SSH settings (for synchronization)? (Y/n)"))
@@ -28,14 +36,13 @@ func LibmuttonInit(inputCB func(prompt string) string, clientSpecificIniData [][
if err != nil {
return errors.New("unable to initialize libmutton directories: " + err.Error())
}
// write config file
if len(clientSpecificIniData) > 0 {
err = cfg.WriteConfig(append(clientSpecificIniData, [][3]string{{"LIBMUTTON", "offlineMode", "true"}}...), nil, false)
} else {
err = cfg.WriteConfig([][3]string{{"LIBMUTTON", "offlineMode", "true"}}, nil, false)
}
offlineMode := true
newCfg.Libmutton.OfflineMode = &offlineMode
err = cfg.WriteConfig(newCfg, false)
if err != nil {
return errors.New("unable to write config file: " + err.Error())
return err
}
} else {
// ensure ssh key file exists (and is a file)
@@ -63,30 +70,29 @@ func LibmuttonInit(inputCB func(prompt string) string, clientSpecificIniData [][
return errors.New("unable to initialize libmutton directories: " + err.Error())
}
//// write config file
//// temporarily assign sshEntryRoot and sshIsWindows to null to pass initial device ID registration
err = cfg.WriteConfig(append(
clientSpecificIniData,
[][3]string{
{"LIBMUTTON", "offlineMode", "false"},
{"LIBMUTTON", "sshUser", sshUser},
{"LIBMUTTON", "sshIP", sshIP},
{"LIBMUTTON", "sshPort", sshPort},
{"LIBMUTTON", "sshKey", sshKeyPath},
{"LIBMUTTON", "sshKeyProtected", strconv.FormatBool(sshKeyProtected)},
{"LIBMUTTON", "sshEntryRoot", "null"},
{"LIBMUTTON", "sshAgeDir", "null"},
{"LIBMUTTON", "sshIsWindows", "false"}}...), nil, false)
//// temporarily leave sshEntryRoot, sshAgeDir, and sshIsWindows as nil to pass initial device ID registration
newCfg.Libmutton.OfflineMode = &forceOfflineMode // forceOfflineMode must be false to reach this point, so we can avoid the extra declaration
newCfg.Libmutton.SSHUser = &sshUser
newCfg.Libmutton.SSHIP = &sshIP
newCfg.Libmutton.SSHPort = &sshPort
newCfg.Libmutton.SSHKeyPath = &sshKeyPath
newCfg.Libmutton.SSHKeyProtected = &sshKeyProtected
err = cfg.WriteConfig(newCfg, false)
if err != nil {
return errors.New("unable to write config file: " + err.Error())
return err
}
// generate and register device ID
sshEntryRoot, sshAgeDir, sshIsWindows, err := syncclient.GenDeviceID(oldDeviceID, "")
if err != nil {
return errors.New("unable to generate device ID: " + err.Error())
}
err = cfg.WriteConfig([][3]string{{"LIBMUTTON", "sshEntryRoot", sshEntryRoot}, {"LIBMUTTON", "sshAgeDir", sshAgeDir}, {"LIBMUTTON", "sshIsWindows", sshIsWindows}}, nil, true)
// update config file
newCfg.Libmutton.SSHEntryRootPath = &sshEntryRoot
newCfg.Libmutton.SSHAgeDirPath = &sshAgeDir
newCfg.Libmutton.SSHIsWindows = &sshIsWindows
err = cfg.WriteConfig(newCfg, true)
if err != nil {
return errors.New("unable to write config file: " + err.Error())
return err
}
}
// generate rcw sanity check file (if requested)
+1 -1
View File
@@ -7,7 +7,7 @@ import "github.com/rwinkhart/go-boilerplate/back"
var (
EntryRoot = back.Home + "/.local/share/libmutton" // Path to libmutton entry directory
ConfigDir = back.Home + "/.config/libmutton" // Path to libmutton configuration directory
ConfigPath = ConfigDir + "/libmutton.ini" // Path to libmutton configuration file
ConfigPath = ConfigDir + "/libmuttoncfg.json" // Path to libmutton configuration file
AgeDir = ConfigDir + "/age" // Path to libmutton password age directory
)
+1 -1
View File
@@ -7,7 +7,7 @@ import "github.com/rwinkhart/go-boilerplate/back"
var (
EntryRoot = back.Home + "\\AppData\\Local\\libmutton\\entries" // Path to libmutton entry directory
ConfigDir = back.Home + "\\AppData\\Local\\libmutton\\config" // Path to libmutton configuration directory
ConfigPath = ConfigDir + "\\libmutton.ini" // Path to libmutton configuration file
ConfigPath = ConfigDir + "\\libmuttoncfg.json" // Path to libmutton configuration file
AgeDir = ConfigDir + "\\age" // Path to libmutton password age directory
)
-1
View File
@@ -8,7 +8,6 @@ require (
github.com/rwinkhart/go-boilerplate v0.1.1-0.20251211171453-df1fb21b2366
github.com/rwinkhart/rcw v0.2.3
golang.org/x/crypto v0.45.0
gopkg.in/ini.v1 v1.67.0
)
require (
-2
View File
@@ -30,7 +30,5 @@ golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+16 -44
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
@@ -26,70 +25,43 @@ import (
// sshEntryRoot (the root directory for entries on the remote server),
// sshAgeDir (the directory housing age files on the remote server),
// Only supports key-based authentication (passwords are supported for CLI-based implementations).
func GetSSHClient() (*ssh.Client, bool, bool, string, string, error) {
func GetSSHClient() (*ssh.Client, bool, *bool, *string, *string, error) {
// get SSH config info
sshUserConfig, err := cfg.ParseConfig([][2]string{{"LIBMUTTON", "offlineMode"}, {"LIBMUTTON", "sshUser"}, {"LIBMUTTON", "sshIP"}, {"LIBMUTTON", "sshPort"}, {"LIBMUTTON", "sshKey"}, {"LIBMUTTON", "sshKeyProtected"}, {"LIBMUTTON", "sshEntryRoot"}, {"LIBMUTTON", "sshAgeDir"}, {"LIBMUTTON", "sshIsWindows"}})
if len(sshUserConfig) == 1 {
// offline mode is enabled
return nil, true, false, "", "", nil
}
cfg, err := cfg.LoadConfig()
if err != nil {
return nil, false, false, "", "", errors.New("unable to parse SSH config: " + err.Error())
return nil, false, nil, nil, nil, errors.New("unable to parse SSH config: " + err.Error())
}
var user, ip, port, keyFile, keyFileProtected, entryRoot, ageDir string
var isWindows bool
for i, key := range sshUserConfig {
switch i {
case 1:
user = key
case 2:
ip = key
case 3:
port = key
case 4:
keyFile = key
case 5:
keyFileProtected = key
case 6:
entryRoot = key
case 7:
ageDir = key
case 8:
isWindows, err = strconv.ParseBool(key)
if err != nil {
return nil, false, false, "", "", errors.New("unable to parse server OS type: " + err.Error())
}
}
if *cfg.Libmutton.OfflineMode {
return nil, true, nil, nil, nil, nil
}
// read private key
key, err := os.ReadFile(keyFile)
key, err := os.ReadFile(*cfg.Libmutton.SSHKeyPath)
if err != nil {
return nil, false, false, "", "", errors.New("unable to read private key: " + keyFile)
return nil, false, nil, nil, nil, errors.New("unable to read private key: " + *cfg.Libmutton.SSHKeyPath)
}
// parse private key
var parsedKey ssh.Signer
if keyFileProtected != "true" {
if !*cfg.Libmutton.SSHKeyProtected {
parsedKey, err = ssh.ParsePrivateKey(key)
} else {
parsedKey, err = ssh.ParsePrivateKeyWithPassphrase(key, global.GetPassword("Enter password for your SSH keyfile:"))
}
if err != nil {
return nil, false, false, "", "", errors.New("unable to parse private key: " + keyFile)
return nil, false, nil, nil, nil, errors.New("unable to parse private key: " + *cfg.Libmutton.SSHKeyPath)
}
// read known hosts file
var hostKeyCallback ssh.HostKeyCallback
hostKeyCallback, err = knownhosts.New(back.Home + global.PathSeparator + ".ssh" + global.PathSeparator + "known_hosts")
if err != nil {
return nil, false, false, "", "", errors.New("unable to read known hosts file: " + err.Error())
return nil, false, nil, nil, nil, errors.New("unable to read known hosts file: " + err.Error())
}
// configure SSH client
sshConfig := &ssh.ClientConfig{
User: user,
User: *cfg.Libmutton.SSHUser,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(parsedKey),
},
@@ -98,12 +70,12 @@ func GetSSHClient() (*ssh.Client, bool, bool, string, string, error) {
}
// connect to SSH server
sshClient, err := ssh.Dial("tcp", ip+":"+port, sshConfig)
sshClient, err := ssh.Dial("tcp", *cfg.Libmutton.SSHIP+":"+*cfg.Libmutton.SSHPort, sshConfig)
if err != nil {
return nil, false, false, "", "", errors.New("unable to connect to remote server: " + err.Error())
return nil, false, nil, nil, nil, errors.New("unable to connect to remote server: " + err.Error())
}
return sshClient, false, isWindows, entryRoot, ageDir, nil
return sshClient, false, cfg.Libmutton.SSHIsWindows, cfg.Libmutton.SSHEntryRootPath, cfg.Libmutton.SSHAgeDirPath, nil
}
// GetSSHOutput runs a command over SSH and returns the output as a string.
@@ -546,7 +518,7 @@ func RunJob(returnLists bool) ([3][]string, error) {
// sync new and updated entries
var lists [3][]string
if returnLists {
lists, err = syncLists(sshClient, sshEntryRoot, sshAgeDir, sshIsWindows, timeSynced, true, localEntryModMap, remoteEntryModMap, localAgeTimestampMap, remoteAgeTimestampMap)
lists, err = syncLists(sshClient, *sshEntryRoot, *sshAgeDir, *sshIsWindows, timeSynced, true, localEntryModMap, remoteEntryModMap, localAgeTimestampMap, remoteAgeTimestampMap)
if err != nil {
return [3][]string{nil, nil, nil}, errors.New("unable to sync entries: " + err.Error())
}
@@ -558,7 +530,7 @@ func RunJob(returnLists bool) ([3][]string, error) {
}
return lists, nil
}
_, err = syncLists(sshClient, sshEntryRoot, sshAgeDir, sshIsWindows, timeSynced, false, localEntryModMap, remoteEntryModMap, localAgeTimestampMap, remoteAgeTimestampMap)
_, err = syncLists(sshClient, *sshEntryRoot, *sshAgeDir, *sshIsWindows, timeSynced, false, localEntryModMap, remoteEntryModMap, localAgeTimestampMap, remoteAgeTimestampMap)
if err != nil {
return [3][]string{nil, nil, nil}, errors.New("unable to sync entries: " + err.Error())
}
+8 -8
View File
@@ -167,7 +167,7 @@ end:
// Device IDs are guaranteed unique as the current UNIX time is appended to them.
// Leave prefix empty to use the current hostname as the prefix.
// Returns: the remote EntryRoot, the remote AgeDir, and OS type indicator.
func GenDeviceID(oldDeviceID, prefix string) (string, string, string, error) {
func GenDeviceID(oldDeviceID, prefix string) (string, string, bool, error) {
// generate new device ID
if prefix == "" {
prefix, _ = os.Hostname()
@@ -179,7 +179,7 @@ func GenDeviceID(oldDeviceID, prefix string) (string, string, string, error) {
oldDeviceIDPath := global.ConfigDir + global.PathSeparator + "devices" + global.PathSeparator + oldDeviceID
f, err := os.OpenFile(newDeviceIDPath, os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return "", "", "", errors.New("unable to create local device ID file: " + err.Error())
return "", "", false, errors.New("unable to create local device ID file: " + err.Error())
}
_ = f.Close() // error ignored; if the file could be created, it can probably be closed
@@ -201,22 +201,22 @@ func GenDeviceID(oldDeviceID, prefix string) (string, string, string, error) {
sshClient, _, _, _, _, err := GetSSHClient()
if err != nil {
cleanupOnFail()
return "", "", "", errors.New("unable to connect to SSH client: " + err.Error())
return "", "", false, errors.New("unable to connect to SSH client: " + err.Error())
}
output, err := GetSSHOutput(sshClient, "libmuttonserver register", newDeviceID+"\n"+oldDeviceID)
if err != nil {
cleanupOnFail()
return "", "", "", errors.New("unable to register device ID with server: " + err.Error())
return "", "", false, errors.New("unable to register device ID with server: " + err.Error())
}
var registerResp synccommon.RegisterResp
err = json.Unmarshal(output, &registerResp)
if err != nil {
cleanupOnFail()
return "", "", "", errors.New("unable to unmarshal server register response: " + err.Error())
return "", "", false, errors.New("unable to unmarshal server register response: " + err.Error())
}
if registerResp.ErrMsg != nil {
cleanupOnFail()
return "", "", "", errors.New("unable to complete register; server-side error occurred: " + strings.ReplaceAll(*registerResp.ErrMsg, global.FSSpace, "\n"))
return "", "", false, errors.New("unable to complete register; server-side error occurred: " + strings.ReplaceAll(*registerResp.ErrMsg, global.FSSpace, "\n"))
}
_ = sshClient.Close() // ignore error; non-critical/unlikely/not much could be done about it
@@ -224,8 +224,8 @@ func GenDeviceID(oldDeviceID, prefix string) (string, string, string, error) {
err = os.RemoveAll(oldDeviceIDPath)
if err != nil {
cleanupOnFail()
return "", "", "", errors.New("unable to remove old device ID file (locally): " + err.Error())
return "", "", false, errors.New("unable to remove old device ID file (locally): " + err.Error())
}
return registerResp.EntryRoot, registerResp.AgeDir, strconv.FormatBool(registerResp.IsWindows), nil
return registerResp.EntryRoot, registerResp.AgeDir, registerResp.IsWindows, nil
}