172 Commits
Author SHA1 Message Date
RandyTheSilly 726153b9be Trim trailing spaces from copied note line (in case user added trailing spaces for Markdown formatting) 2026-05-29 19:33:37 -04:00
RandyTheSilly e8548dd907 Bump dependencies (rcw bump adds Solaris support 2026-05-29 19:15:24 -04:00
RandyTheSilly 768d4f6fbd Prepare for release v0.5.0 2026-02-17 20:36:07 -05:00
RandyTheSilly 83f59c3cd9 Ensure clipboard remains set after CLI terminal closure on Linux+FreeBSD (bug only occured in very specific environments) 2026-02-17 20:30:35 -05:00
RandyTheSilly e9f49fb98a Fix cmd pop-up on background clipclear on Windows 2026-02-17 20:10:24 -05:00
RandyTheSilly 8da2648664 Update draft release notes 2026-02-14 17:12:54 -05:00
RandyTheSilly 6d3e3cdb5b Drop Termux support 2026-02-14 17:11:26 -05:00
RandyTheSilly b6ebd22463 Fix ClearProcess 2026-02-14 17:03:12 -05:00
RandyTheSilly f4fdf65528 Minor documentation updates 2026-02-14 16:20:02 -05:00
RandyTheSilly a091cd4952 Address JetBrains warnings 2026-02-14 16:04:18 -05:00
RandyTheSilly 20d8e141eb Fix build on many platforms 2026-02-13 19:13:06 -05:00
RandyTheSilly 3fd2b13712 Update draft release notes 2026-02-13 18:38:23 -05:00
RandyTheSilly f9aeca5ba9 Bump dependencies (rcw hardening) 2026-02-13 18:23:38 -05:00
RandyTheSilly 273e86d97e Only use index when ranging over slices of structs 2026-02-09 21:28:26 -05:00
RandyTheSilly 7ecd143b81 Use updated "new()" behavior from Go 1.26 2026-02-09 21:12:09 -05:00
RandyTheSilly 9c0ed69d7f Fix TOTPCopier writing to error channel after client is likely done checking, which caused the TOTP routine to stall 2026-02-09 21:03:30 -05:00
RandyTheSilly 50cf92df61 Simplify privkey package 2026-02-08 00:43:38 -05:00
RandyTheSilly 420333e412 Allow custom priv key bytes on all platforms 2026-02-08 00:35:01 -05:00
RandyTheSilly 25edd344c8 Resolve relative ssh dir during config load on iOS 2026-02-07 19:36:46 -05:00
RandyTheSilly 5b591e35d8 Document manual sanity check when not using RCWD 2026-02-07 16:29:05 -05:00
RandyTheSilly 1351360b35 Fix iOS dirs 2026-02-07 16:17:37 -05:00
RandyTheSilly 02a8196061 Bump Go to 1.25.7 2026-02-07 15:07:52 -05:00
RandyTheSilly d7242fee96 Native support for iOS dirs; bind SSH dir to variable 2026-02-07 15:05:15 -05:00
RandyTheSilly ec9c6b5062 Update mod times on original entries in age.AllPasswordEntries() to trigger sync 2026-01-25 16:59:41 -05:00
RandyTheSilly 02fc5c1ffa Bump rcw to add error handling for corrupt entries 2026-01-25 16:27:33 -05:00
RandyTheSilly e1c6469506 Fix clampTrailingWhitespace breaking entries that contain both notes and other fields 2026-01-24 16:34:05 -05:00
RandyTheSilly cb3a554676 Add mass entry verification function (should be used after EntryRefresh); disable CGO across the board 2026-01-24 15:34:34 -05:00
RandyTheSilly b963ccf33e Ensure entries are optimized as part of core.WriteEntry; fix massive inefficiency in core.EntryRefresh 2026-01-24 14:03:33 -05:00
RandyTheSilly 409db78d83 Use nil pointer to indicate missing device ID (rather than FSMisc) 2026-01-21 18:49:16 -05:00
RandyTheSilly 48e084ac0f Make adjustments for easier CGO integration 2026-01-21 18:35:26 -05:00
RandyTheSilly f82b35bc43 Fix Windows background processes dying with terminal window 2026-01-10 23:36:39 -05:00
RandyTheSilly 2054f5b3cf Use clip.exe for setting/clearing clipboard on Windows to avoid Powershell startup latency 2026-01-10 19:08:43 -05:00
RandyTheSilly 07ad0a1bd9 Change order of slices in syncListsT 2026-01-09 00:07:39 -05:00
RandyTheSilly 3b4e75b592 Return sync lists using custom type 2026-01-08 23:39:52 -05:00
RandyTheSilly 4467a8c742 Remove overly-verbose ssh key input prompt 2026-01-08 23:05:06 -05:00
RandyTheSilly a54d15e331 Update draft release notes 2026-01-08 21:23:00 -05:00
RandyTheSilly d0e8640ce6 Re-implement age data upload via SFTP (download will continue to use new method) 2026-01-08 20:39:44 -05:00
RandyTheSilly 07571a698d Handle age timestamping errors in syncLists 2026-01-08 19:05:23 -05:00
RandyTheSilly d21de424cb Simplify error handling syntax 2026-01-08 18:50:10 -05:00
RandyTheSilly d54b59030e Draft documentation for release v0.5.0 2026-01-08 18:20:04 -05:00
RandyTheSilly ed19d0a416 Bump dependencies 2026-01-08 17:21:05 -05:00
RandyTheSilly 5e5a7c57e9 Major sync refactor (do not use SFTP for age files; allow using core.LibmuttonInit with custom device ID prefixes) 2026-01-08 16:59:58 -05:00
RandyTheSilly a18c3ea1d6 Simply check if sanity check file should be generated based on nil input 2026-01-01 21:28:35 -05:00
RandyTheSilly 28286788a0 Fully support non-destructive append in core.LibmuttonInit(); update copyright year for 2026 2026-01-01 19:52:08 -05:00
RandyTheSilly 571bc0a3bd Rename "cfg" package to "config", always use "cfg" for variable names 2025-12-27 12:11:10 -05:00
RandyTheSilly 422ca0295a Allow append mode when config cannot be loaded 2025-12-27 02:09:00 -05:00
RandyTheSilly b707d67113 Fix compilation for Mac/Termux/Windows 2025-12-27 00:25:53 -05:00
RandyTheSilly d8d761b0ca Small comment update 2025-12-26 22:40:38 -05:00
RandyTheSilly bdbdce61aa Remove entry format breaking change warning 2025-12-26 21:49:42 -05:00
RandyTheSilly cc626363c7 Update wiki for new changes; fix compilation for some platforms 2025-12-26 21:14:20 -05:00
RandyTheSilly 6004a94b77 Remove now-redundant Linux/BSD-specific clip clear logic 2025-12-26 20:48:33 -05:00
RandyTheSilly fdce036be6 Improve versatility of getClipCommands() 2025-12-26 19:57:43 -05:00
RandyTheSilly 53d82984d9 Bump dependencies 2025-12-26 19:28:19 -05:00
RandyTheSilly 2880ce2823 Address JetBrains warnings 2025-12-26 19:12:48 -05:00
RandyTheSilly b591e2ef5a Switch config format from ini to json (drop ini dependency since json is now used for syncing) 2025-12-17 00:36:20 -05:00
RandyTheSilly e2d1b8dc09 Fix deletions adjusting to a device ID change incorrectly and leading to server panics 2025-12-11 20:40:29 -05:00
RandyTheSilly 35db3bf20b Rename syncclient "oneOff" functions to be less redundant 2025-12-11 19:34:05 -05:00
RandyTheSilly 941a2dc8b8 Eliminate synccycles package [synccycles.DeviceIDGen() => syncclient.DeviceIDGenFromClient()] 2025-12-11 19:31:37 -05:00
RandyTheSilly 2ab777fd57 Improve server-side panic formatting 2025-12-11 18:56:05 -05:00
RandyTheSilly 4e6c18db8a Report panic details (stack trace w/args&stdin) to clients; drop synccycles.StringGen() (moved to go-boilerplate) 2025-12-11 18:23:01 -05:00
RandyTheSilly 7e7fbd15b4 Initialize deletions list when returning lists from RunJob() 2025-12-10 00:20:36 -05:00
RandyTheSilly 01be63c587 Add missing instances of cleanupOnFail() in synccycles.DeviceIDGen() 2025-12-10 00:13:40 -05:00
RandyTheSilly f16078fe01 Handle more server-side errors from the client 2025-12-10 00:08:42 -05:00
RandyTheSilly e355c810da Sync (fetch) refactor; return info from server using new JSON type 2025-12-09 23:17:59 -05:00
RandyTheSilly ffae2e3002 Export global.RootLength 2025-12-06 12:54:07 -05:00
RandyTheSilly 95a877d0b3 Don't force-exit CLI clients after sync 2025-11-23 13:39:56 -05:00
RandyTheSilly 08bc065785 Add v0.4.4 release notes to main branch 2025-11-23 12:48:22 -05:00
RandyTheSilly 5dc3a5576d Bump golang.org/x/crypto to address CVE-2025-58181 and CVE-2025-47914 2025-11-23 03:38:38 -05:00
RandyTheSilly 3ddce4bdf5 Consistently use "age" when referencing age files, not "aging" 2025-11-23 03:25:20 -05:00
RandyTheSilly 50d51f9d96 Initial password aging support 2025-11-23 03:20:58 -05:00
RandyTheSilly d2a6ed5eaa Move GenTOTP() to core package 2025-11-13 23:46:21 -05:00
RandyTheSilly 04e1b0dd37 Handle Steam TOTP directly in GenTOTP 2025-11-13 22:47:46 -05:00
RandyTheSilly 336dc5c158 Continue dropping support for non-continuous TOTP copy 2025-11-13 22:35:07 -05:00
RandyTheSilly c63dff4092 Drop support for non-continuous TOTP copy 2025-11-13 22:18:02 -05:00
RandyTheSilly 8d1dd48035 Remove mobile clipboard management (this should be the responsibility of the GUI toolkit) 2025-11-13 21:58:37 -05:00
RandyTheSilly 80cf65775a Fix panic on error in clip.TOTPCopier() when not run as a goroutine 2025-11-13 21:14:51 -05:00
RandyTheSilly ac4a86d3a4 Update developer documentation 2025-11-11 01:14:50 -05:00
RandyTheSilly 8aabd01ed2 Update roadmap 2025-11-11 01:09:30 -05:00
RandyTheSilly 6d1513b469 Fix 8f4590cf34 on Windows 2025-11-11 01:01:28 -05:00
RandyTheSilly bc793178e1 Fix compilation for Windows 2025-11-10 23:17:40 -05:00
RandyTheSilly a0f21b31f7 Return errors from TOTPCopier so it can optionally be called on the main thread 2025-11-10 22:37:11 -05:00
RandyTheSilly a3b89fcb96 Fix LaunchClipClearProcess() for GUI platforms 2025-11-10 19:55:36 -05:00
RandyTheSilly 483af75613 Improve clarity of TOTP refresher start message 2025-11-10 01:07:53 -05:00
RandyTheSilly b39a40407f Update stdout logging regarding clipboard refresher 2025-11-10 01:02:05 -05:00
RandyTheSilly 14dda0b091 Fix continuous TOTP copy 2025-11-10 00:35:38 -05:00
RandyTheSilly 75ad2afcfb Set beta version; update beta versioning comment 2025-11-10 00:20:16 -05:00
RandyTheSilly 9703790336 Export ClipClearProcess() 2025-11-09 23:57:26 -05:00
RandyTheSilly 8f4590cf34 Fix subprocesses getting killed when the main process is force killed (fixes rcwd w/TOTP and clipboard clearing issues) 2025-11-09 23:50:15 -05:00
RandyTheSilly bf1399ce8d Better modularize clipboard code (add "clip" package; break up TOTP code 2025-11-09 23:23:47 -05:00
RandyTheSilly d49632d221 Make CopyArgument() documentation more clear 2025-11-09 18:24:45 -05:00
RandyTheSilly 3506504fb1 Prepare for release v0.4.2 2025-11-09 15:56:27 -05:00
RandyTheSilly 09501fda7d Bump Go to 1.25.4; bump dependencies 2025-11-09 15:53:09 -05:00
RandyTheSilly 3905dc6e41 Fix Linux arm64 binaries being built with .exe extensions 2025-10-26 13:16:40 -04:00
RandyTheSilly 7c577b5778 Prepare for release v0.4.1 2025-10-26 13:12:19 -04:00
RandyTheSilly aa10016f77 Allow forcing offline mode in LibmuttonInit() 2025-10-26 13:02:10 -04:00
RandyTheSilly a39952489a Bump Go to 1.25.3; bump dependencies 2025-10-26 12:49:16 -04:00
RandyTheSilly b9175d7ff3 Rename all occurrences of "passphrase" to "password" 2025-10-26 12:28:07 -04:00
RandyTheSilly 235f6b5ed4 Prepare for release v0.4.0 2025-08-17 13:42:35 -04:00
RandyTheSilly 1b02ccc056 Bump dependencies; fix compilation error with dead sys fork 2025-08-17 13:20:18 -04:00
RandyTheSilly 3dc3a75967 Bump Go to v1.24.6; update documentation 2025-08-17 11:39:29 -04:00
RandyTheSilly cc58d3509f Clearly document decrypted entry format 2025-07-19 14:33:05 -04:00
RandyTheSilly d023058630 Properly return errors for non-persistent TOTP copy 2025-07-06 12:48:24 -04:00
RandyTheSilly fb2120c94e Allow setting custom device ID prefix 2025-07-06 11:29:41 -04:00
RandyTheSilly 1a66a30bc1 Fix one-time TOTP copy 2025-06-30 22:59:12 -04:00
RandyTheSilly 591624eff6 Fix interactive compilation on *nix 2025-06-30 22:54:31 -04:00
RandyTheSilly 3fdcc9e96d Allow one-time copy of TOTP codes 2025-06-30 22:46:17 -04:00
RandyTheSilly 9fc9b208b6 Update dependency comments 2025-06-29 13:15:15 -04:00
RandyTheSilly 41a12c6e4a Fix clipClearProcess on iOS 2025-06-29 13:04:44 -04:00
RandyTheSilly bed23e987d Add iOS clipboard support 2025-06-29 12:58:08 -04:00
RandyTheSilly dd622c59e2 Explicitly ignore some errors 2025-06-28 21:25:58 -04:00
RandyTheSilly 362eb75dc4 Fix offlineMode not being written for third-party clients with no client-specific config data 2025-06-28 19:09:58 -04:00
RandyTheSilly d1b5c50d70 Add draft release notes for v0.4.0 2025-06-19 19:16:46 -04:00
RandyTheSilly 1c06713548 Bump go-boilerplate and rcw 2025-06-19 18:13:23 -04:00
RandyTheSilly 37a1a6049f Bump Go to v1.24.4; bump dependencies 2025-06-19 14:39:47 -04:00
RandyTheSilly cf5d5051fd Fix compilation for Android 2025-06-07 21:00:39 -04:00
RandyTheSilly 0f03096ed2 Default to online mode in init 2025-06-07 14:20:49 -04:00
RandyTheSilly d844fb1010 libmuttonserver: Fix deletions renaming 2025-06-07 14:16:54 -04:00
RandyTheSilly 111324cc25 libmuttonserver: When changing device IDs, rename pending old deletions 2025-06-07 13:56:26 -04:00
RandyTheSilly 3daaf2d67a Remove verifyOldLocationExists parameter from RenameLocal() 2025-06-05 20:23:18 -04:00
RandyTheSilly c0f55156d8 Bump go-boilerplate 2025-06-01 17:01:40 -04:00
RandyTheSilly ca4913f054 Fix init when no device ID directory exists 2025-06-01 16:40:24 -04:00
RandyTheSilly 4a0e0f8e9e Ensure old device ID remains active on the client if new device ID registration fails 2025-06-01 16:24:47 -04:00
RandyTheSilly 87041a7bb3 Fix folder sync 2025-06-01 15:43:16 -04:00
RandyTheSilly d0f7b663d5 Simplify target existence checks 2025-06-01 15:24:31 -04:00
RandyTheSilly 5f8789a0f0 Bump go-boilerplate; make error codes sequential 2025-06-01 14:17:51 -04:00
RandyTheSilly 2e3be57e3e Handle unhandled error; remove error handling in server-only function (error would never become visible to user) 2025-06-01 12:35:07 -04:00
RandyTheSilly 4dab9f2c93 Automatically install new entry directory in EntryRefresh() 2025-05-31 20:41:30 -04:00
RandyTheSilly 038cd3670d Forward sanity check error in EntryRefresh() 2025-05-31 20:34:47 -04:00
RandyTheSilly 346fa35dc8 Fix support for entries <4 lines long in EntryRefresh() 2025-05-31 20:28:01 -04:00
RandyTheSilly c4db7dfd59 Fix passphrase setup in EntryRefresh() 2025-05-31 19:57:59 -04:00
RandyTheSilly 2840ff3cf2 Fix backup/temp directory cleaning in EntryRefresh() 2025-05-31 19:20:51 -04:00
RandyTheSilly ca277ba773 Add entry refresh feature; fix ClampTrailingWhitespace ignoring the initial trim 2025-05-31 21:48:11 +00:00
RandyTheSilly 5666974a7f Do not determine online/offline mode based on device ID presence 2025-05-30 16:02:46 +00:00
RandyTheSilly c2fc911693 Re-order ParseConfig offlineMode check to fail earlier 2025-05-30 13:53:51 +00:00
RandyTheSilly 6e74e37e5b Fix SSH config retrieval 2025-05-30 00:36:33 -04:00
RandyTheSilly 3dd07e7165 Add offline mode option to libmutton.ini 2025-05-29 23:39:15 -04:00
RandyTheSilly 9ffd6d5cef Slim down ConfigParser error system 2025-05-29 23:01:03 -04:00
RandyTheSilly a1c47b06a1 Address JetBrains warnings 2025-05-29 22:05:57 -04:00
RandyTheSilly 16aaa4f330 Fix unused error values 2025-05-30 01:18:40 +00:00
RandyTheSilly aa0e242748 Fix inconsistent error message 2025-05-29 21:15:11 -04:00
RandyTheSilly 40f0f35f45 Return errors, rather than printing them 2025-05-30 01:08:43 +00:00
RandyTheSilly 74b8261bc8 Do not rely on importing projects for libmutton initialization 2025-05-29 23:35:50 +00:00
RandyTheSilly 50ab27c674 Ensure libmutton.ini is created with 0600 permissions (on *nix) 2025-05-29 16:52:15 +00:00
RandyTheSilly fa6f057b49 Do not perform sanity check if RetryPassphrase is false (leave to client) 2025-05-28 16:32:03 +00:00
RandyTheSilly 3c5db78da9 Build release binaries to target x86_64_v2 and arm64v8.7 (v8.0 still offered on Linux) 2025-05-21 16:40:26 +00:00
RandyTheSilly 02c663447a wiki: Mention rooted ADB on LineageOS 2025-05-21 15:19:21 +00:00
RandyTheSilly c591601b6c wiki: Add TOTP usage information 2025-05-21 14:41:59 +00:00
RandyTheSilly e64da70edb Bump RCW to v0.2.0 2025-05-20 16:14:53 +00:00
RandyTheSilly 35d053b46f Drop github.com/fortis/go-steam-totp (functionality now convered by github.com/pquerna/otp) 2025-05-16 20:11:20 -04:00
RandyTheSilly cdf41c3d17 Bump RCW to v0.1.2 (fix compilation without RCWD) 2025-05-12 22:00:33 -04:00
RandyTheSilly 26db43eb29 Allow not re-prompting for the RCW passphrase 2025-05-12 21:00:11 -04:00
RandyTheSilly 162e277ad2 Make running RCW as a daemon optional 2025-05-13 00:21:22 +00:00
RandyTheSilly 36bbf88690 Refactor: Split into more packages to reduce server binary size 2025-05-12 16:26:03 +00:00
RandyTheSilly 84d227b803 Bump RCW to v0.1.1 (fix RCWD on Termux) 2025-05-11 17:07:51 -04:00
RandyTheSilly 8860ce4969 Bump RCW to v0.1.0 (fix dynamic thread count breaking decryption across devices 2025-05-11 15:18:43 -04:00
RandyTheSilly dfb5c10dcb Fix building for many non-Linux platforms 2025-05-11 00:41:44 -04:00
RandyTheSilly 491392562a Update documentation for gpgToRCW 2025-05-11 00:28:43 -04:00
RandyTheSilly fbfcdc5320 Update wiki for migration off of GPG 2025-05-09 19:14:08 +00:00
RandyTheSilly 03c040b373 Use go-boilerplate 2025-05-09 16:12:03 +00:00
RandyTheSilly aec42cad96 Remove useless error checks on cmd.Start (missing arguments will not throw an error) 2025-05-09 14:49:35 +00:00
RandyTheSilly 0d66c26878 Handle decryption error 2025-05-09 14:48:37 +00:00
RandyTheSilly ac85577916 Use platform-specific path separator when generating sanity check file 2025-05-08 20:45:32 -04:00
RandyTheSilly fb83d29a23 Do not wait for RCWD socket file creation 2025-05-08 19:42:42 -04:00
RandyTheSilly 008088e2d6 Bump Android dependencies 2025-05-08 19:28:08 -04:00
RandyTheSilly e7293505bc Port to latest rcw development version 2025-05-08 19:27:09 -04:00
RandyTheSilly f549b591c0 Bump rcw to latest development version 2025-05-04 20:04:57 -04:00
RandyTheSilly 3404fc7482 Organize go.mod 2025-05-04 19:49:31 -04:00
RandyTheSilly 21d60b8d0f Add support for the RCW daemon 2025-05-04 19:48:20 -04:00
RandyTheSilly 826d94bc43 Bump version to v0.E.0 (WIP v0.4.0) 2025-05-04 18:05:09 -04:00
RandyTheSilly 8807e8fe79 Use sys and winio forks expected by RCW 2025-05-04 18:00:37 -04:00
RandyTheSilly 649b60e2fe Initial migration from GPG to RCW 2025-05-04 17:41:44 -04:00
RandyTheSilly a95bff8ef4 Return early when generating all strings with complexity <= 0 2025-04-24 19:14:44 -04:00
75 changed files with 2548 additions and 2016 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024-2025 Randall Winkhart
Copyright (c) 2024-2026 Randall Winkhart
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+1 -7
View File
@@ -12,14 +12,8 @@ libmutton is a library for building simple, SSH-synchronized password managers i
See the [developer guide](https://github.com/rwinkhart/libmutton/blob/main/wiki/developers.md).
# Roadmap
#### Release v0.4.0
- [ ] Swap to native (cascade) encryption (custom)
- [ ] Add refresh/re-encrypt functionality
#### Release v0.5.0
- [ ] Implement "netpin" (quick-unlock) with new encryption
- [ ] Password aging support
- [ ] Append UNIX timestamp to entry names
#### Release v1.0.0
- Stabilize API (reduce need for future breaking changes)
- [ ] Create packaging scripts (libmuttonserver)
- [ ] Stable source PKGBUILD
- [ ] Stable source APKBUILD
+96
View File
@@ -0,0 +1,96 @@
package age
import (
"crypto/rand"
"errors"
"math/big"
"os"
"strings"
"time"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/libmutton/crypt"
"github.com/rwinkhart/libmutton/global"
"github.com/rwinkhart/libmutton/synccommon"
)
// Entry creates/updates the age file for a vanity path.
// It does not touch the actual entry, meaning it won't be synced;
// this is because this function is meant to be used on entries that
// are being created or modified, so they will sync. If using this
// to age an entry without modifying the entry, the caller must also
// update the mod time on the entry to trigger a sync.
func Entry(vanityPath string, timestamp int64) error {
ageFilePath := global.AgeDir + global.PathSeparator + strings.ReplaceAll(vanityPath, "/", global.FSPath)
f, err := os.OpenFile(ageFilePath, os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return errors.New("unable to create age file for " + vanityPath + ": " + err.Error())
}
_ = f.Close() // error ignored; if the file could be created, it can probably be closed
if err = os.Chtimes(ageFilePath, time.Now(), time.Unix(timestamp, 0)); err != nil {
return errors.New("unable to set timestamp on age file for " + vanityPath + ": " + err.Error())
}
return nil
}
// AllPasswordEntries adds age data for all un-aged entries containing passwords.
// Each entry is aged with a random timestamp from within the last year to prevent
// all entries having their passwords expire at the same time.
// It also updates the mod time on the actual entry to trigger a sync.
// Leave rcwPassword nil to use RCW demonization.
func AllPasswordEntries(forceReage bool, rcwPassword []byte) error {
allVanityPaths, _, err := synccommon.WalkEntryDir()
if err != nil {
return errors.New("unable to walk entry directory: " + err.Error())
}
now := time.Now()
for _, vanityPath := range allVanityPaths {
// ensure entry is not already aged (unless forcing re-age)
if !forceReage {
// ignore error; we only care if we can access the path or not
isAccessible, _ := back.TargetIsFile(global.AgeDir+global.PathSeparator+strings.ReplaceAll(vanityPath, "/", global.FSPath), true)
if isAccessible {
// entry already aged, skip it
continue
}
}
decSlice, err := crypt.DecryptFileToSlice(global.EntryRoot+vanityPath, rcwPassword)
if err != nil {
return err
}
if decSlice != nil && decSlice[0] != "" {
// calculate random UNIX timestamp from within the last 365 days
offsetInt, _ := rand.Int(rand.Reader, big.NewInt(31557600))
randomOffset := time.Duration(offsetInt.Int64()) * time.Second
if err = Entry(vanityPath, now.Add(-randomOffset).Unix()); err != nil {
return err
}
// update entry mod time to trigger sync
if err = os.Chtimes(global.GetRealPath(vanityPath), now, now); err != nil {
return errors.New("unable to update mod time on entry (" + vanityPath + "): " + err.Error())
}
}
}
return nil
}
// TranslateAgeTimestamp returns a uint8 value indicating
// the interpreted age status of an age timestamp associated
// with an entry.
// Magic number legend:
// 0 -> no age, 1 -> fresh, 2 -> expiring soon (within a month), 3 -> expired
func TranslateAgeTimestamp(timestamp *int64) uint8 {
if timestamp == nil {
return 0
}
daysOld := time.Since(time.Unix(*timestamp, 0)).Hours() / 24
if daysOld >= 365 {
return 3 // expired
} else if daysOld >= 335 {
return 2 // expiring soon
}
return 1 // fresh
}
+68
View File
@@ -0,0 +1,68 @@
//go:build !android && !ios
package clip
import (
"errors"
"fmt"
"os"
"strings"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/libmutton/crypt"
)
// CopyShortcut (given a path) decrypts an
// entry and copies a field to the clipboard.
// Leave rcwPassword nil to use RCW demonization.
func CopyShortcut(realPath string, field int, rcwPassword []byte) error {
// ensure realPath exists and is a file
_, err := back.TargetIsFile(realPath, true)
if err != nil {
return err
}
// decrypt entry
decSlice, err := crypt.DecryptFileToSlice(realPath, rcwPassword)
if err != nil {
return errors.New("unable to decrypt entry: " + err.Error())
}
// if field exists in entry...
if len(decSlice) > field {
if decSlice[field] == "" {
return errors.New("field is empty")
}
switch field {
case 2: // TOTP
fmt.Println(back.AnsiWarning + "[Starting]" + back.AnsiReset + " TOTP clipboard refresher")
errorChan := make(chan error, 1)
go TOTPCopier(decSlice[2], errorChan, nil) // "done" is not needed because the process runs until the program is killed
if err = <-errorChan; err != nil { // handle error from first copy
return errors.New("error encountered in TOTP refresh process: " + err.Error())
}
select {} // block indefinitely
case 4:
decSlice[field] = strings.TrimRight(decSlice[field], " ")
fallthrough
default:
// copy field to clipboard; launch clipboard clearing process
if err = CopyBytes(true, []byte(decSlice[field])); err != nil {
return err
}
return nil
}
} else {
return errors.New("field does not exist in entry")
}
}
// ClearArgument reads the assigned clipboard contents from stdin and passes them to clipClearProcess.
func ClearArgument() error {
assignedContents := back.ReadFromStdin()
if assignedContents == nil {
os.Exit(0) // use os.Exit directly since this function runs out of a background subprocess that is invisible to the user (will never appear in GUI/TUI environment)
}
return ClearProcess(assignedContents)
}
+50
View File
@@ -0,0 +1,50 @@
//go:build !android && !ios
package clip
import (
"bytes"
"errors"
"time"
"github.com/rwinkhart/go-boilerplate/back"
)
// ClearProcess clears the clipboard after 30 seconds if the clipboard contents have not changed.
// assignedContents can be omitted to clear the clipboard immediately and unconditionally.
func ClearProcess(assignedContents []byte) error {
cmdPaste, cmdClear, err := getClipCommands()
if err != nil {
return errors.New("unable to determine clipboard platform: " + err.Error())
}
clearClipboard := func() error {
if err := cmdClear.Run(); err != nil {
return errors.New("unable to clear clipboard")
}
back.Exit(0)
return nil
}
// if assignedContents is empty, clear the clipboard immediately and unconditionally
if assignedContents == nil {
if err := clearClipboard(); err != nil {
return err
}
return nil
}
// wait 30 seconds before checking clipboard contents
time.Sleep(30 * time.Second)
newContents, err := cmdPaste.Output()
if err != nil {
return errors.New("unable to read clipboard contents")
}
if bytes.Equal(assignedContents, bytes.TrimRight(newContents, "\r\n")) {
if err = clearClipboard(); err != nil {
return err
}
}
return nil
}
+34
View File
@@ -0,0 +1,34 @@
//go:build darwin && !ios
package clip
import (
"errors"
"os/exec"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/go-boilerplate/security"
)
// CopyBytes copies a byte slice to the clipboard.
func CopyBytes(clearClipboardAutomatically bool, copySubject []byte) error {
cmd := exec.Command("pbcopy")
_ = back.WriteToStdin(cmd, copySubject, false)
if err := cmd.Run(); err != nil {
security.ZeroizeBytes(copySubject)
return errors.New("unable to copy to clipboard: " + err.Error())
}
if clearClipboardAutomatically {
LaunchClearProcess(copySubject)
} else {
security.ZeroizeBytes(copySubject)
}
return nil
}
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
func getClipCommands() (*exec.Cmd, *exec.Cmd, error) {
cmdClear := exec.Command("pbcopy")
_ = back.WriteToStdin(cmdClear, nil, false)
return exec.Command("pbpaste"), cmdClear, nil
}
+64
View File
@@ -0,0 +1,64 @@
//go:build !windows && !darwin && !android && !ios && !wsl
package clip
import (
"errors"
"os"
"os/exec"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/go-boilerplate/security"
"github.com/rwinkhart/libmutton/global"
)
// CopyBytes copies a byte slice to the clipboard.
func CopyBytes(clearClipboardAutomatically bool, copySubject []byte) error {
// determine whether to use wl-copy (Wayland) or xclip (X11)
sessionIsWayland, err := isWayland()
if err != nil {
return err
}
var cmdCopy *exec.Cmd
if sessionIsWayland {
cmdCopy = exec.Command("wl-copy", "-t", "text/plain")
} else {
cmdCopy = exec.Command("xclip", "-sel", "c", "-t", "text/plain")
}
cmdCopy.SysProcAttr = global.GetSysProcAttr()
_ = back.WriteToStdin(cmdCopy, copySubject, false)
if err = cmdCopy.Run(); err != nil {
security.ZeroizeBytes(copySubject)
return errors.New("unable to copy to clipboard: " + err.Error())
}
if clearClipboardAutomatically {
LaunchClearProcess(copySubject)
} else {
security.ZeroizeBytes(copySubject)
}
return nil
}
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
func getClipCommands() (*exec.Cmd, *exec.Cmd, error) {
sessionIsWayland, err := isWayland()
if err != nil {
return nil, nil, err
}
if sessionIsWayland {
return exec.Command("wl-paste"), exec.Command("wl-copy", "-c"), nil
}
return exec.Command("xclip", "-o", "-sel", "c"), exec.Command("xclip", "-i", "/dev/null", "-sel", "c"), nil
}
// isWayland returns a boolean indicating whether the current session is Wayland.
func isWayland() (bool, error) {
var envSet bool // track whether environment variables are set
if _, envSet = os.LookupEnv("WAYLAND_DISPLAY"); envSet {
return true, nil
} else if _, envSet = os.LookupEnv("DISPLAY"); envSet {
return false, nil
}
return false, errors.New("unable to detect Wayland or X11 session")
}
+40
View File
@@ -0,0 +1,40 @@
//go:build windows || (linux && wsl)
package clip
import (
"errors"
"os/exec"
"syscall"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/go-boilerplate/security"
"golang.org/x/sys/windows"
)
// CopyBytes copies a byte slice to the clipboard.
func CopyBytes(clearClipboardAutomatically bool, copySubject []byte) error {
cmd := exec.Command("clip.exe")
_ = back.WriteToStdin(cmd, copySubject, false)
if err := cmd.Run(); err != nil {
security.ZeroizeBytes(copySubject)
return errors.New("unable to copy to clipboard: " + err.Error())
}
if clearClipboardAutomatically {
LaunchClearProcess(copySubject)
} else {
security.ZeroizeBytes(copySubject)
}
return nil
}
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
func getClipCommands() (*exec.Cmd, *exec.Cmd, error) {
sysProcAttr := &syscall.SysProcAttr{CreationFlags: windows.CREATE_NO_WINDOW}
pasteCMD := exec.Command("powershell.exe", "-c", "Get-Clipboard")
pasteCMD.SysProcAttr = sysProcAttr
clearCMD := exec.Command("clip.exe")
clearCMD.SysProcAttr = sysProcAttr
_ = back.WriteToStdin(clearCMD, nil, false)
return pasteCMD, clearCMD, nil
}
+21
View File
@@ -0,0 +1,21 @@
//go:build !ios && !android && !interactive
package clip
import (
"os"
"os/exec"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/libmutton/global"
)
// LaunchClearProcess launches the timed clipboard clearing process.
// For non-interactive CLI implementations, an entirely separate process is created for this purpose.
func LaunchClearProcess(copySubject []byte) {
cmd := exec.Command(os.Args[0], "clipclear")
cmd.SysProcAttr = global.GetSysProcAttr()
_ = back.WriteToStdin(cmd, copySubject, true)
_ = cmd.Start()
os.Exit(0) // use os.Exit directly since this version of this function is only meant for non-interactive CLI implementations
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !ios && !android && interactive
package clip
// LaunchClearProcess launches the timed clipboard clearing process.
// For interactive GUI/TUI implementations, the clipboard clearing process is launched as a goroutine.
// copySubject can be omitted to clear the clipboard immediately and unconditionally.
func LaunchClearProcess(copySubject []byte) {
go ClearProcess(copySubject)
}
+49
View File
@@ -0,0 +1,49 @@
//go:build !android && !ios
package clip
import (
"time"
"github.com/rwinkhart/libmutton/core"
)
// TOTPCopier is meant to be run as a goroutine to keep
// the clipboard up-to-date with the latest TOTP token.
//
// Note that errorChan should be buffered with capacity 1.
// This is because TOTPCopier only returns errors on the first
// iteration, as subsequent errors are highly unlikely to occur
// and allowing the caller to move on from error-checking is beneficial.
func TOTPCopier(secret string, errorChan chan<- error, done <-chan bool) {
var firstRun = true
var currentTime time.Time
var token string
var err error
for {
currentTime = time.Now()
token, err = core.GenTOTP(secret, currentTime)
if firstRun && err != nil {
errorChan <- err
}
err = CopyBytes(false, []byte(token))
if firstRun {
if err != nil {
errorChan <- err
} else {
errorChan <- nil // indicate that first copy was successful
}
firstRun = false
}
// sleep till next 30-second interval
time.Sleep(time.Duration(30-(currentTime.Second()%30)) * time.Second)
// exit after sleep if indicated (will not update clipboard again)
select {
case <-done:
return
default:
}
}
}
+82
View File
@@ -0,0 +1,82 @@
package config
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"`
ClientSpecific *map[string]any `json:"clientSpecific"`
}
// Load loads libmuttoncfg.json and returns the configuration.
func Load() (*CfgT, error) {
cfgBytes, err := os.ReadFile(global.CfgPath)
if err != nil {
return nil, errors.New("unable to load libmuttoncfg.json: " + err.Error())
}
var cfg CfgT
if err = json.Unmarshal(cfgBytes, &cfg); err != nil {
return nil, errors.New("unable to unmarshal libmuttoncfg.json: " + err.Error())
}
return &cfg, nil
}
// Write 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 Write(cfg *CfgT, appendMode bool) error {
start:
if appendMode {
// check if any fields are nil
var hasNilFields bool
cfgValue := reflect.ValueOf(&cfg.Libmutton).Elem()
for _, field := range cfgValue.Fields() {
if field.IsNil() {
hasNilFields = true
break
}
}
// load old cfg and copy nil fields
if hasNilFields {
oldCfg, err := Load()
if err != nil {
// failed to load config, leave append mode
appendMode = false
goto start
}
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())
}
if err = os.WriteFile(global.CfgPath, cfgBytes, 0600); err != nil {
return errors.New("unable to write new/updated cfg to libmuttoncfg.json: " + err.Error())
}
return nil
}
-35
View File
@@ -1,35 +0,0 @@
package core
import (
"os"
)
type ByteInputFetcher func(prompt string) []byte
var (
PassphraseInputFunction ByteInputFetcher // Clients should set this to a function that fetches hidden input from the user
Home, _ = os.UserHomeDir()
)
const (
LibmuttonVersion = "0.3.1" // Untagged releases feature a letter suffix corresponding to the eventual release version, e.g "0.2.A" -> "0.2.0", "0.2.B" -> "0.2.1"
FSSpace = "\u259d" // ▝ Space/list separator
FSPath = "\u259e" // ▞ Path separator
FSMisc = "\u259f" // ▟ Misc. field separator (if \u259d is already used)
AnsiError = "\033[38;5;9m"
AnsiReset = "\033[0m"
ErrorRead = 101
ErrorWrite = 102
ErrorSyncProcess = 103
ErrorServerConnection = 104
ErrorTargetNotFound = 105
ErrorTargetExists = 106
ErrorTargetWrongType = 107
ErrorDecryption = 108
ErrorEncryption = 109
ErrorClipboard = 110
ErrorOther = 111
)
-20
View File
@@ -1,20 +0,0 @@
//go:build !windows
package core
var (
EntryRoot = Home + "/.local/share/libmutton" // Path to libmutton entry directory
ConfigDir = Home + "/.config/libmutton" // Path to libmutton configuration directory
ConfigPath = ConfigDir + "/libmutton.ini" // Path to libmutton configuration file
)
const (
PathSeparator = "/" // Platform-specific path separator
IsWindows = false // Platform indicator
)
// enableVirtualTerminalProcessing is a dummy function on UNIX-like systems (only needed on Windows).
// TODO Remove after migration off of GPG, as pinentry is responsible for disabling ANSI escape sequence interpretation.
func enableVirtualTerminalProcessing() {
return
}
-31
View File
@@ -1,31 +0,0 @@
//go:build windows
package core
import (
"os"
"syscall"
)
var (
EntryRoot = Home + "\\AppData\\Local\\libmutton\\entries" // Path to libmutton entry directory
ConfigDir = Home + "\\AppData\\Local\\libmutton\\config" // Path to libmutton configuration directory
ConfigPath = ConfigDir + "\\libmutton.ini" // Path to libmutton configuration file
)
const (
PathSeparator = "\\" // Platform-specific path separator
IsWindows = true // Platform indicator
)
// enableVirtualTerminalProcessing ensures ANSI escape sequences are interpreted properly on Windows.
// TODO Remove after migration off of GPG, as pinentry is responsible for disabling ANSI escape sequence interpretation.
func enableVirtualTerminalProcessing() {
stdout := syscall.Handle(os.Stdout.Fd())
var originalMode uint32
syscall.GetConsoleMode(stdout, &originalMode)
originalMode |= 0x0004
syscall.MustLoadDLL("kernel32").MustFindProc("SetConsoleMode").Call(uintptr(stdout), uintptr(originalMode))
}
-34
View File
@@ -1,34 +0,0 @@
//go:build android && !termux
package core
import (
"strings"
"time"
"golang.design/x/clipboard"
)
// clipClearProcess clears the clipboard after 30 seconds if the clipboard contents have not changed.
// assignedContents can be omitted to clear the clipboard immediately and unconditionally.
func clipClearProcess(assignedContents string) {
clearClipboard := func() {
clipboard.Write(clipboard.FmtText, []byte(""))
Exit(0)
}
// if assignedContents is empty, clear the clipboard immediately and unconditionally
if assignedContents == "" {
clearClipboard()
return
}
// wait 30 seconds before checking clipboard contents
time.Sleep(30 * time.Second)
newContents := clipboard.Read(clipboard.FmtText)
if assignedContents == strings.TrimRight(string(newContents), "\r\n") {
clearClipboard()
}
}
-40
View File
@@ -1,40 +0,0 @@
//go:build !android || termux
package core
import (
"strings"
"time"
)
// clipClearProcess clears the clipboard after 30 seconds if the clipboard contents have not changed.
// assignedContents can be omitted to clear the clipboard immediately and unconditionally.
func clipClearProcess(assignedContents string) {
cmdPaste, cmdClear := getClipCommands()
clearClipboard := func() {
err := cmdClear.Run()
if err != nil {
PrintError("Failed to clear clipboard", ErrorClipboard, true)
}
Exit(0)
}
// if assignedContents is empty, clear the clipboard immediately and unconditionally
if assignedContents == "" {
clearClipboard()
return
}
// wait 30 seconds before checking clipboard contents
time.Sleep(30 * time.Second)
newContents, err := cmdPaste.Output()
if err != nil {
PrintError("Failed to read clipboard contents", ErrorClipboard, true)
}
if assignedContents == strings.TrimRight(string(newContents), "\r\n") {
clearClipboard()
}
}
-114
View File
@@ -1,114 +0,0 @@
package core
import (
"fmt"
"io/fs"
"os"
"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 {
cfg, err := ini.Load(ConfigPath)
if err != nil {
PrintError("Failed to load libmutton.ini: "+err.Error(), ErrorRead, true)
}
return cfg
}
// 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),
// missingValueError (an error message to display if a key is missing a value, set to "" for auto-generated or "0" to exit/return silently with code 0).
// Returns: config (slice of values for the specified keys),
// error (nil if no error occurred, otherwise an error using the generated or provided message).
func ParseConfig(valuesRequested [][2]string, missingValueError string) ([]string, error) {
var err error
cfg := loadConfig()
var config []string
for _, pair := range valuesRequested {
value := cfg.Section(pair[0]).Key(pair[1]).String()
// ensure specified key has a value
if value == "" {
switch missingValueError {
case "":
err = fmt.Errorf("Failed to find value for key \"%s\" in section \"[%s]\" in libmutton.ini", pair[1], pair[0])
case "0":
Exit(0) // hard (expected) exit for CLI; GUI/TUI continue silently
default:
err = fmt.Errorf("%s", missingValueError)
}
PrintError(err.Error(), ErrorRead, false)
// if interactive (soft exit), return nil and the error to be handled by the caller
return nil, err
}
config = append(config, value)
}
return config, err
}
// GenDeviceIDList returns a pointer to a slice of all registered device IDs.
// Requires: errorOnFail (set to true to throw an error if the devices directory cannot be read/does not exist)
func GenDeviceIDList(errorOnFail bool) *[]fs.DirEntry {
// create a slice of all registered devices
deviceIDList, err := os.ReadDir(ConfigDir + PathSeparator + "devices")
if err != nil {
if errorOnFail {
PrintError("Failed to read the devices directory: "+err.Error(), ErrorRead, true)
} else {
return nil // a nil return value indicates that the devices directory could not be read/does not exist
}
}
return &deviceIDList
}
// 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) {
var cfg *ini.File
if append {
// load existing ini file
cfg = loadConfig()
} 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 aquire 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
err := cfg.SaveTo(ConfigPath)
if err != nil {
PrintError("Failed to save libmutton.ini: "+err.Error(), ErrorWrite, true)
}
}
-88
View File
@@ -1,88 +0,0 @@
package core
import (
"bufio"
"fmt"
"os"
"strings"
"time"
steamtotp "github.com/fortis/go-steam-totp"
"github.com/pquerna/otp/totp"
)
// CopyArgument copies a field from an entry to the clipboard.
func CopyArgument(targetLocation string, field int) {
if isFile, _ := TargetIsFile(targetLocation, true, 2); isFile {
decryptedEntry := DecryptGPG(targetLocation)
var copySubject string // will store data to be copied
// ensure field exists in entry
if len(decryptedEntry) > field {
// ensure field is not empty
if decryptedEntry[field] == "" {
PrintError("Field is empty", ErrorTargetNotFound, true)
}
if field != 2 {
copySubject = decryptedEntry[field]
} else { // TOTP mode
var secret string // stores secret for TOTP generation
var forSteam bool // indicates whether to generate TOTP in Steam format
if strings.HasPrefix(decryptedEntry[2], "steam@") {
secret = decryptedEntry[2][6:]
forSteam = true
} else {
secret = decryptedEntry[2]
}
fmt.Println("Clipboard will be kept up to date with the current TOTP code until this process is closed")
for { // keep token copied to clipboard, refresh on 30-second intervals
currentTime := time.Now()
copyString(true, GenTOTP(secret, currentTime, forSteam))
// sleep until next 30-second interval
time.Sleep(time.Duration(30-(currentTime.Second()%30)) * time.Second)
}
}
} else {
PrintError("Field does not exist in entry", ErrorTargetNotFound, true)
}
// copy field to clipboard, launch clipboard clearing process
copyString(false, copySubject)
}
}
// ClipClearArgument reads the assigned clipboard contents from stdin and passes them to clipClearProcess.
func ClipClearArgument() {
// read previous clipboard contents from stdin
clipScanner := bufio.NewScanner(os.Stdin)
if clipScanner.Scan() {
assignedContents := clipScanner.Text()
clipClearProcess(assignedContents)
} else {
os.Exit(0) // use os.Exit instead of core.Exit, as this function runs out of a background subprocess that is invisible to the user (will never appear in GUI/TUI environment)
}
}
// GenTOTP generates a TOTP token from a secret (supports standard and Steam TOTP).
func GenTOTP(secret string, time time.Time, forSteam bool) string {
var totpToken string
var err error
if forSteam {
totpToken, err = steamtotp.GenerateAuthCode(secret, time)
} else {
totpToken, err = totp.GenerateCode(secret, time)
}
if err != nil {
PrintError("Error generating TOTP code", ErrorOther, true)
}
return totpToken
}
-28
View File
@@ -1,28 +0,0 @@
//go:build darwin
package core
import (
"os/exec"
)
// copyString copies a string to the clipboard.
func copyString(continuous bool, copySubject string) {
cmd := exec.Command("pbcopy")
WriteToStdin(cmd, copySubject)
err := cmd.Run()
if err != nil {
PrintError("Failed to copy to clipboard: "+err.Error(), ErrorClipboard, true)
}
if !continuous {
LaunchClipClearProcess(copySubject)
}
}
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
func getClipCommands() (*exec.Cmd, *exec.Cmd) {
cmdClear := exec.Command("pbcopy")
WriteToStdin(cmdClear, "")
return exec.Command("pbpaste"), cmdClear
}
-18
View File
@@ -1,18 +0,0 @@
//go:build android && !termux
package core
import (
"golang.design/x/clipboard"
)
// TODO Investigate background clipboard clearing and on-app-close clipboard clearing for Android
// copyString copies a string to the clipboard.
func copyString(continuous bool, copySubject string) {
clipboard.Write(clipboard.FmtText, []byte(copySubject))
if !continuous {
LaunchClipClearProcess(copySubject)
}
}
-28
View File
@@ -1,28 +0,0 @@
//go:build android && termux
package core
import (
"os/exec"
)
// copyString copies a string to the clipboard.
func copyString(continuous bool, copySubject string) {
cmd := exec.Command("termux-clipboard-set")
WriteToStdin(cmd, copySubject)
err := cmd.Run()
if err != nil {
PrintError("Failed to copy to clipboard: "+err.Error(), ErrorClipboard, true)
}
if !continuous {
LaunchClipClearProcess(copySubject)
}
}
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
func getClipCommands() (*exec.Cmd, *exec.Cmd) {
cmdClear := exec.Command("termux-clipboard-set")
WriteToStdin(cmdClear, "")
return exec.Command("termux-clipboard-get"), cmdClear
}
-41
View File
@@ -1,41 +0,0 @@
//go:build !windows && !darwin && !android && !termux && !wsl
package core
import (
"os"
"os/exec"
)
// copyString copies a string to the clipboard.
func copyString(continuous bool, copySubject string) {
var envSet, isWayland bool // track whether environment variables are set
var cmdCopy *exec.Cmd
// determine whether to use wl-copy (Wayland) or xclip (X11)
if _, envSet = os.LookupEnv("WAYLAND_DISPLAY"); envSet {
cmdCopy = exec.Command("wl-copy", "-t", "text/plain")
isWayland = true
} else if _, envSet = os.LookupEnv("DISPLAY"); envSet {
cmdCopy = exec.Command("xclip", "-sel", "c", "-t", "text/plain")
} else {
PrintError("Clipboard platform could not be determined", ErrorClipboard, true)
}
WriteToStdin(cmdCopy, copySubject)
err := cmdCopy.Run()
if err != nil {
PrintError("Failed to copy to clipboard: "+err.Error(), ErrorClipboard, true)
}
if !continuous {
LaunchClipClearProcess(copySubject, isWayland)
}
}
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
func getClipCommands() (*exec.Cmd, *exec.Cmd) {
if os.Args[2] == "true" { // wayland
return exec.Command("wl-paste"), exec.Command("wl-copy", "-c")
}
return exec.Command("xclip", "-o", "-sel", "c"), exec.Command("xclip", "-i", "/dev/null", "-sel", "c")
}
-27
View File
@@ -1,27 +0,0 @@
//go:build windows || (linux && wsl)
package core
import (
"fmt"
"os/exec"
"strings"
)
// copyString copies a string to the clipboard.
func copyString(continuous bool, copySubject string) {
cmd := exec.Command("powershell.exe", "-c", fmt.Sprintf("echo '%s' | Set-Clipboard", strings.ReplaceAll(copySubject, "'", "''")))
err := cmd.Run()
if err != nil {
PrintError("Failed to copy to clipboard: "+err.Error(), ErrorClipboard, true)
}
if !continuous {
LaunchClipClearProcess(copySubject)
}
}
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
func getClipCommands() (*exec.Cmd, *exec.Cmd) {
return exec.Command("powershell.exe", "-c", "Get-Clipboard"), exec.Command("powershell.exe", "-c", "Set-Clipboard")
}
+22 -7
View File
@@ -1,19 +1,34 @@
package core
import (
"errors"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/libmutton/crypt"
)
// GetOldEntryData decrypts and returns old entry data (with all required lines present).
func GetOldEntryData(targetLocation string, field int) []string {
// ensure targetLocation exists
TargetIsFile(targetLocation, true, 2)
// This is a wrapper around the DecryptFileToSlice function that ensures all required lines are present in the returned slice.
// This makes it ideal for editing entries, as it guarantees at least a baseline slice length.
// Leave rcwPassword nil to use RCW demonization.
func GetOldEntryData(realPath string, field int, rcwPassword []byte) ([]string, error) {
// ensure realPath exists and is a file
_, err := back.TargetIsFile(realPath, true)
if err != nil {
return nil, err
}
// read old entry data
unencryptedEntry := DecryptGPG(targetLocation)
decryptedEntry, err := crypt.DecryptFileToSlice(realPath, rcwPassword)
if err != nil {
return nil, errors.New("unable to decrypt entry: " + err.Error())
}
// return the old entry data with all required lines present
if field > 0 {
return ensureSliceLength(unencryptedEntry, field)
} else {
return unencryptedEntry
return ensureSliceLength(decryptedEntry, field), nil
}
return decryptedEntry, nil
}
// ensureSliceLength is a utility function that ensures a slice is long enough to contain the specified index.
-10
View File
@@ -1,10 +0,0 @@
//go:build !interactive
package core
import "os"
// Exit (hard) is meant to be used in non-interactive CLI implementations to exit the program after an operation.
func Exit(code int) {
os.Exit(code)
}
-8
View File
@@ -1,8 +0,0 @@
//go:build interactive
package core
// Exit (soft) is meant to be used in interactive implementations (GUIs/TUIs) to keep the program running after an operation.
func Exit(code int) int {
return code
}
-35
View File
@@ -1,35 +0,0 @@
package core
import (
"os/exec"
"strings"
)
// TODO GPG support is a temporary feature - It will be replaced with a different encryption scheme in the future
// DecryptGPG decrypts a GPG-encrypted file and returns the contents as a slice of (trimmed) strings.
func DecryptGPG(targetLocation string) []string {
cmd := exec.Command("gpg", "--pinentry-mode", "loopback", "-q", "-d", targetLocation)
output, err := cmd.Output()
// ensure ANSI escape sequences are interpreted properly on Windows
enableVirtualTerminalProcessing()
if err != nil {
PrintError("Failed to decrypt \""+targetLocation+"\" - Ensure it is a valid GPG-encrypted file and that you entered your passphrase correctly", ErrorDecryption, true)
}
return strings.Split(string(output), "\n")
}
// EncryptGPG encrypts a slice of strings using GPG and returns the encrypted data as a byte slice.
func EncryptGPG(input []string) []byte {
gpgCfg, _ := ParseConfig([][2]string{{"LIBMUTTON", "gpgID"}}, "")
cmd := exec.Command("gpg", "-q", "-r", gpgCfg[0], "-e")
WriteToStdin(cmd, strings.Join(input, "\n"))
encryptedBytes, err := cmd.Output()
if err != nil {
PrintError("Failed to encrypt data - Ensure that you have a valid GPG ID set in libmutton.ini", ErrorEncryption, true)
}
return encryptedBytes
}
+94 -85
View File
@@ -1,96 +1,105 @@
package core
import (
"os"
"os/exec"
"strconv"
"cmp"
"errors"
"maps"
"strings"
"time"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/libmutton/config"
"github.com/rwinkhart/libmutton/global"
"github.com/rwinkhart/libmutton/syncclient"
"github.com/rwinkhart/rcw/wrappers"
)
// GpgUIDListGen generates a list of all GPG key IDs on the system and returns them as a slice of strings.
func GpgUIDListGen() []string {
cmd := exec.Command("gpg", "-k", "--with-colons")
gpgOutputBytes, _ := cmd.Output()
gpgOutputLines := strings.Split(string(gpgOutputBytes), "\n")
var uidSlice []string
for _, line := range gpgOutputLines {
if strings.HasPrefix(line, "uid") {
uid := strings.Split(line, ":")[9]
uidSlice = append(uidSlice, uid)
}
}
return uidSlice
}
// GpgKeyGen generates a new GPG key and returns the key ID.
func GpgKeyGen() string {
gpgGenTempFile := CreateTempFile()
defer func(name string) {
_ = os.Remove(name) // error ignored; if the file could be created, it can probably be removed
}(gpgGenTempFile.Name())
// create and write gpg-gen file
unixTime := strconv.FormatInt(time.Now().Unix(), 10)
_, _ = gpgGenTempFile.WriteString(strings.Join([]string{"Key-Type: eddsa", "Key-Curve: ed25519", "Key-Usage: sign", "Subkey-Type: ecdh", "Subkey-Curve: cv25519", "Subkey-Usage: encrypt", "Name-Real: libmutton-" + unixTime, "Name-Comment: gpg-libmutton", "Name-Email: github.com/rwinkhart/libmutton", "Expire-Date: 0"}, "\n")) // error ignored; if the file could be created, it can probably be written to
// close gpg-gen file
_ = gpgGenTempFile.Close() // error ignored; if the file could be created, it can probably be closed
// generate GPG key based on gpg-gen file
cmd := exec.Command("gpg", "-q", "--batch", "--generate-key", gpgGenTempFile.Name())
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
err := cmd.Run()
if err != nil {
PrintError("Failed to generate GPG key: "+err.Error(), ErrorOther, true)
// LibmuttonInit creates the libmutton config structure based on user input.
// deviceIDPrefix can be left blank to use the system hostname.
// clientSpecificCfg can be left nil if not needed.
func LibmuttonInit(inputCB func(prompt string) string, rcwPassword []byte, appendMode, forceOfflineMode bool, deviceIDPrefix string, clientSpecificCfg map[string]any) error {
// handle clientSpecificCfg
newCfg := &config.CfgT{}
if clientSpecificCfg != nil {
newClientSpecificMap := make(map[string]any)
maps.Copy(newClientSpecificMap, clientSpecificCfg)
newCfg.ClientSpecific = &newClientSpecificMap
}
return "libmutton-" + unixTime + " (gpg-libmutton) <github.com/rwinkhart/libmutton>"
}
// DirInit creates the libmutton directories.
// Returns: oldDeviceID (from before the directory reset; will be FSMisc if there is no pre-existing ID).
func DirInit(preserveOldConfigDir bool) string {
// create EntryRoot
err := os.MkdirAll(EntryRoot, 0700)
if err != nil {
PrintError("Failed to create \""+EntryRoot+"\": "+err.Error(), ErrorWrite, true)
}
// get old device ID before its potential removal
oldDeviceID := GetCurrentDeviceID()
// remove existing config directory (if it exists and not in append mode)
if !preserveOldConfigDir {
_, isAccessible := TargetIsFile(ConfigDir, false, 1)
if isAccessible {
err = os.RemoveAll(ConfigDir)
if err != nil {
PrintError("Failed to remove existing config directory: "+err.Error(), ErrorWrite, true)
}
}
}
// create config directory w/devices subdirectory
err = os.MkdirAll(ConfigDir+PathSeparator+"devices", 0700)
if err != nil {
PrintError("Failed to create \""+ConfigDir+"\": "+err.Error(), ErrorWrite, true)
}
return oldDeviceID
}
// GetOldDeviceID returns the current device ID or
// FSMisc if there is no device ID (e.g. first run).
func GetCurrentDeviceID() string {
deviceIDList := GenDeviceIDList(false) // errorOnFail is false so that nil is received when the devices directory does not exist
var deviceID string
if deviceIDList != nil && len(*deviceIDList) > 0 { // ensure not derferencing nil, which occurs when the devices directory does not exist
deviceID = (*deviceIDList)[0].Name()
var r string
if !forceOfflineMode {
r = strings.ToLower(inputCB("Configure SSH settings (for synchronization)? (Y/n)"))
} else {
deviceID = FSMisc // indicates to server that no device ID is being replaced
r = "n"
}
return deviceID
if len(r) > 0 && r[0] == 'n' {
// initialize libmutton directories
_, err := global.DirInit(appendMode)
if err != nil {
return errors.New("unable to initialize libmutton directories: " + err.Error())
}
// write config file
newCfg.Libmutton.OfflineMode = new(true)
if err = config.Write(newCfg, false); err != nil {
return err
}
} else {
// ensure ssh key file exists (and is a file)
fallbackSSHKey := global.SSHDir + global.PathSeparator + "id_ed25519"
sshKeyPath := cmp.Or(back.ExpandPathWithHome(inputCB("SSH private identity file path (falls back to \""+fallbackSSHKey+"\"):")), fallbackSSHKey)
_, err := back.TargetIsFile(sshKeyPath, true)
if err != nil {
return errors.New("unable to find SSH identity file: " + err.Error())
}
// get other ssh info from user
var sshKeyProtected bool
r = strings.ToLower(inputCB("Is the identity file password-protected? (y/N)"))
if len(r) > 0 && r[0] == 'y' {
sshKeyProtected = true
}
// perform operations based on collected user input
//// initialize libmutton directories
oldDeviceID, err := global.DirInit(appendMode)
if err != nil {
return errors.New("unable to initialize libmutton directories: " + err.Error())
}
//// write config file
//// temporarily leave sshEntryRootPath, sshAgeDirPath, and sshIsWindows as nil/default 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 = new(inputCB("Remote SSH username:"))
newCfg.Libmutton.SSHIP = new(inputCB("Remote SSH IP/domain:"))
newCfg.Libmutton.SSHPort = new(inputCB("Remote SSH port:"))
newCfg.Libmutton.SSHKeyPath = &sshKeyPath
newCfg.Libmutton.SSHKeyProtected = &sshKeyProtected
if err = config.Write(newCfg, appendMode); err != nil { // pass appendMode to allow not completely destroying existing (client-specific) config
return err
}
// generate and register device ID
sshEntryRoot, sshAgeDir, sshIsWindows, err := syncclient.GenDeviceID(oldDeviceID, deviceIDPrefix)
if err != nil {
return errors.New("unable to generate device ID: " + err.Error())
}
// update config file
newCfg.Libmutton.SSHEntryRootPath = &sshEntryRoot
newCfg.Libmutton.SSHAgeDirPath = &sshAgeDir
newCfg.Libmutton.SSHIsWindows = &sshIsWindows
if err = config.Write(newCfg, true); err != nil {
return err
}
}
// generate rcw sanity check file
if err := RCWSanityCheckGen(rcwPassword); err != nil {
return err
}
return nil
}
// RCWSanityCheckGen generates the RCW sanity check file for libmutton.
func RCWSanityCheckGen(password []byte) error {
if err := wrappers.GenSanityCheck(global.CfgDir+global.PathSeparator+"sanity.rcw", password, true); err != nil {
return errors.New("unable to generate sanity check file: " + err.Error())
}
return nil
}
-21
View File
@@ -1,21 +0,0 @@
//go:build (windows || darwin || android || termux || wsl) && !interactive
package core
import (
"os"
"os/exec"
)
// LaunchClipClearProcess launches the timed clipboard clearing process.
// For non-interactive CLI implementations, an entirely separate process is created for this purpose.
func LaunchClipClearProcess(copySubject string) {
executableName := os.Args[0]
cmd := exec.Command(executableName, "clipclear")
WriteToStdin(cmd, copySubject)
err := cmd.Start()
if err != nil {
PrintError("Failed to launch automated clipboard clearing process - Does this libmutton implementation support the \"clipclear\" argument?", ErrorClipboard, true)
}
os.Exit(0) // use os.Exit directly since this version of this function is only meant for non-interactive CLI implementations
}
-22
View File
@@ -1,22 +0,0 @@
//go:build !windows && !darwin && !android && !termux && !wsl && !interactive
package core
import (
"os"
"os/exec"
"strconv"
)
// LaunchClipClearProcess launches the timed clipboard clearing process.
// For non-interactive CLI implementations, an entirely separate process is created for this purpose.
func LaunchClipClearProcess(copySubject string, isWayland bool) {
executableName := os.Args[0]
cmd := exec.Command(executableName, "clipclear", strconv.FormatBool(isWayland))
WriteToStdin(cmd, copySubject)
err := cmd.Start()
if err != nil {
PrintError("Failed to launch automated clipboard clearing process - Does this libmutton implementation support the \"clipclear\" argument?", ErrorClipboard, true)
}
os.Exit(0) // use os.Exit directly since this version of this function is only meant for non-interactive CLI implementations
}
-10
View File
@@ -1,10 +0,0 @@
//go:build (windows || darwin || android || termux || wsl) && interactive
package core
// LaunchClipClearProcess launches the timed clipboard clearing process.
// For interactive GUI/TUI implementations, the clipboard clearing process is launched as a goroutine.
// copySubject can be omitted to clear the clipboard immediately and unconditionally.
func LaunchClipClearProcess(copySubject string) {
go clipClearProcess(copySubject)
}
-16
View File
@@ -1,16 +0,0 @@
//go:build !windows && !darwin && !android && !termux && !wsl && interactive
package core
import (
"os"
"strconv"
)
// LaunchClipClearProcess launches the timed clipboard clearing process.
// For interactive GUI/TUI implementations, the clipboard clearing process is launched as a goroutine.
// copySubject can be omitted to clear the clipboard immediately and unconditionally.
func LaunchClipClearProcess(copySubject string, isWayland bool) {
os.Args = []string{os.Args[0], "", strconv.FormatBool(isWayland)}
go clipClearProcess(copySubject)
}
-8
View File
@@ -1,8 +0,0 @@
//go:build !windows
package core
// TargetLocationFormat returns the full location of an entry (given the name) formatted for the current platform.
func TargetLocationFormat(targetLocationIncomplete string) string {
return EntryRoot + targetLocationIncomplete
}
-10
View File
@@ -1,10 +0,0 @@
//go:build windows
package core
import "strings"
// TargetLocationFormat returns the full location of an entry (given the name) formatted for the current platform.
func TargetLocationFormat(targetLocationIncomplete string) string {
return EntryRoot + strings.ReplaceAll(targetLocationIncomplete, "/", PathSeparator)
}
+220
View File
@@ -0,0 +1,220 @@
package core
import (
"errors"
"os"
"strings"
"time"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/libmutton/age"
"github.com/rwinkhart/libmutton/crypt"
"github.com/rwinkhart/libmutton/global"
"github.com/rwinkhart/libmutton/syncclient"
"github.com/rwinkhart/libmutton/synccommon"
"github.com/rwinkhart/rcw/wrappers"
)
// WriteEntry writes decSlice to an encrypted file at realPath.
// If the entry contains an updated password, an age file is also created.
// Leave rcwPassword nil to use RCW demonization.
func WriteEntry(realPath string, decSlice []string, passwordIsNew bool, rcwPassword []byte) error {
err := os.WriteFile(realPath, crypt.EncryptBytes([]byte(strings.Join(clampTrailingWhitespace(decSlice), "\n")), rcwPassword), 0600)
if err != nil {
return errors.New("unable to write to file: " + err.Error())
}
if decSlice != nil {
if passwordIsNew { // update age data when password changes
if decSlice[0] != "" { // if the password change was NOT a removal, update the age file
if err = age.Entry(global.GetVanityPath(realPath), time.Now().Unix()); err != nil {
return errors.New("unable to update age data: " + err.Error())
}
} else { // if the password change was a removal, remove the associated age file
if err = syncclient.ShearRemote(global.GetVanityPath(realPath), true); err != nil {
return errors.New("unable to remove age data: " + err.Error())
}
}
}
}
return nil
}
// EntryRefresh re-encrypts all libmutton entries with a new password
// and optimizes each entry to ensure they are as slim as possible.
func EntryRefresh(oldRCWPassword, newRCWPassword []byte, removeOldDir bool) error {
// ensure global.EntryRoot+"-new" and global.EntryRoot-"old" do not exist
dirEnds := []string{"-new", "-old"}
for i, dirEnd := range dirEnds {
if i == 1 && !removeOldDir {
if _, err := os.Stat(global.EntryRoot + "-old"); !os.IsNotExist(err) {
return errors.New("unable to refresh entries: \"" + global.EntryRoot + "-old\" already exists")
}
}
if err := os.RemoveAll(global.EntryRoot + dirEnd); err != nil {
return errors.New("unable to remove \"" + global.EntryRoot + dirEnd + "\": " + err.Error())
}
}
// create output directory structure (global.EntryRoot + "-new"/*)
entries, folders, err := synccommon.WalkEntryDir()
if err != nil {
return errors.New("unable to walk entry directory: " + err.Error())
}
for _, folder := range folders {
fullPath := global.EntryRoot + "-new" + strings.ReplaceAll(folder, "/", global.PathSeparator)
if err := os.MkdirAll(fullPath, 0700); err != nil {
return errors.New("unable to create temporary directory \"" + fullPath + "\": " + err.Error())
}
}
// decrypt, optimize, and re-encrypt each entry
for _, vanityPath := range entries {
realPath := global.GetRealPath(vanityPath)
encBytes, err := os.ReadFile(realPath)
if err != nil {
return errors.New("unable to open \"" + realPath + "\" for decryption: " + err.Error())
}
decBytes, err := wrappers.Decrypt(encBytes, oldRCWPassword, false)
if err != nil {
return err
}
// split & optimize entry
decSlice := clampTrailingWhitespace(strings.Split(string(decBytes), "\n"))
// re-encrypt the entry with the new password
encBytes = wrappers.Encrypt([]byte(strings.Join(decSlice, "\n")), newRCWPassword, true, false)
// write the entry to the new directory
if err = os.WriteFile(global.EntryRoot+"-new"+strings.ReplaceAll(vanityPath, "/", global.PathSeparator), encBytes, 0600); err != nil {
return errors.New("unable to write to file: " + err.Error())
}
}
// swap the new directory with the old one
if err = os.Rename(global.EntryRoot, global.EntryRoot+"-old"); err != nil {
return errors.New("unable to rename old directory: " + err.Error())
}
if err = os.Rename(global.EntryRoot+"-new", global.EntryRoot); err != nil {
return errors.New("unable to rename new directory: " + err.Error())
}
// generate new sanity check file
if err = RCWSanityCheckGen(newRCWPassword); err != nil {
return err
}
return nil
}
// VerifyEntries decrypts all entries to memory and returns an error if
// any failures are encountered. Failures likely indicate corrupt entries.
func VerifyEntries(rcwPassword []byte) error {
entries, _, err := synccommon.WalkEntryDir()
if err != nil {
return errors.New("unable to walk entry directory: " + err.Error())
}
for _, vanityPath := range entries {
realPath := global.GetRealPath(vanityPath)
encBytes, err := os.ReadFile(realPath)
if err != nil {
return errors.New("unable to open \"" + realPath + "\" for decryption: " + err.Error())
}
decBytes, err := wrappers.Decrypt(encBytes, rcwPassword, false)
if err != nil {
return errors.New("unable to verify \"" + vanityPath + "\" (decryption failure): " + err.Error())
}
if len(decBytes) < 1 {
return errors.New("unable to verify \"" + vanityPath + "\" (contains 0/nil bytes)")
}
}
return nil
}
// clampTrailingWhitespace ensures the provided decSlice contains no trailing blank lines.
// If decSlice contains a note, it strips trailing newlines, carriage returns, and tabs from
// each line in the note. Additionally, it removes single trailing spaces and truncates
// multiple trailing spaces to two (for Markdown formatting).
func clampTrailingWhitespace(decSlice []string) []string {
decSlice = back.RemoveTrailingEmptyStrings(decSlice)
if len(decSlice) >= 4 {
for i, noteLine := range decSlice[4:] {
// remove trailing tabs, carriage returns, and newlines
noteLine = strings.TrimRight(noteLine, "\t\r\n")
// determine the number of trailing spaces in the trimmed line
var endSpacesCount int
for j := len(noteLine) - 1; j >= 0; j-- {
if noteLine[j] != ' ' {
break
}
endSpacesCount++
}
// remove single spaces, truncate multiple spaces (leave two for Markdown formatting)
switch endSpacesCount {
case 0:
// no trailing spaces
decSlice[i+4] = noteLine
case 1:
// remove the single trailing space
decSlice[i+4] = strings.TrimRight(noteLine, " ")
default:
// truncate the trailing spaces to two
decSlice[i+4] = noteLine[:len(noteLine)-endSpacesCount+2]
}
}
}
return decSlice
}
// EntryAddPrecheck ensures the directory meant to contain a new
// entry exists and that realPath is not already used.
// Returns: statusCode (0 = success, 1 = realPath already exists, 2 = containing directory is invalid).
func EntryAddPrecheck(realPath string) (uint8, error) {
// ensure realPath does not already exist
isAccessible, _ := back.TargetIsFile(realPath, false) // error is ignored because dir/file status is irrelevant
if isAccessible {
return 1, errors.New("target location already exists")
}
// ensure target containing directory exists and is not a file
containingDir := realPath[:strings.LastIndex(realPath, global.PathSeparator)]
_, err := back.TargetIsFile(containingDir, false)
if err != nil {
return 2, errors.New("\"" + containingDir + "\" is not a valid containing directory: " + err.Error())
}
return 0, nil
}
// EntryIsNotEmpty iterates through entryData and returns true if any line is not empty.
func EntryIsNotEmpty(entryData []string) bool {
for _, line := range entryData {
if line != "" {
return true
}
}
return false
}
// GenTOTP generates a TOTP token from a secret.
// Prefix `secret` with "steam@" for Steam TOTP format.
func GenTOTP(secret string, time time.Time) (string, error) {
var totpToken string
var err error
if strings.HasPrefix(secret, "steam@") {
totpToken, err = totp.GenerateCodeCustom(secret[6:], time, totp.ValidateOpts{Period: 30, Digits: 5, Encoder: otp.EncoderSteam})
} else {
totpToken, err = totp.GenerateCode(secret, time)
}
if err != nil {
return "", errors.New("unable to generate TOTP token: " + err.Error())
}
return totpToken, nil
}
-216
View File
@@ -1,216 +0,0 @@
package core
import (
"crypto/rand"
"fmt"
"io"
"math"
"math/big"
"os"
"os/exec"
"strings"
)
// TargetIsFile checks if the targetLocation is a file, directory, or is inaccessible.
// Requires: failCondition (0 = fail on inaccessible, 1 = fail on inaccessible&file, 2 = fail on inaccessible&directory).
// Returns: isFile, isAccessible.
func TargetIsFile(targetLocation string, errorOnFail bool, failCondition uint8) (bool, bool) {
targetInfo, err := os.Stat(targetLocation)
if err != nil {
if errorOnFail {
PrintError("Failed to access \""+targetLocation+"\" - Ensure it exists and has the correct permissions", ErrorTargetNotFound, true)
}
return false, false
}
if targetInfo.IsDir() {
if errorOnFail && failCondition == 2 {
PrintError("\""+targetLocation+"\" is a directory", ErrorTargetWrongType, true)
}
return false, true
} else {
if errorOnFail && failCondition == 1 {
PrintError("\""+targetLocation+"\" is a file", ErrorTargetWrongType, true)
}
return true, true
}
}
// WriteEntry writes entryData to an encrypted file at targetLocation.
func WriteEntry(targetLocation string, entryData []string) {
encryptedBytes := EncryptGPG(entryData)
err := os.WriteFile(targetLocation, encryptedBytes, 0600)
if err != nil {
PrintError("Failed to write to file: "+err.Error(), ErrorWrite, true)
}
}
// WriteToStdin is a utility function that writes a string to a command's stdin.
// TODO unexport (import?) after migration off of GPG
func WriteToStdin(cmd *exec.Cmd, input string) {
stdin, err := cmd.StdinPipe()
if err != nil {
PrintError("Failed to access stdin for system command: "+err.Error(), ErrorOther, true)
}
go func() {
defer func(stdin io.WriteCloser) {
_ = stdin.Close() // error ignored; if stdin could be accessed, it can probably be closed
}(stdin)
_, _ = io.WriteString(stdin, input)
}()
}
// CreateTempFile creates a temporary file and returns a pointer to it.
func CreateTempFile() *os.File {
tempFile, err := os.CreateTemp("", "*.markdown")
if err != nil {
PrintError("Failed to create temporary file: "+err.Error(), ErrorWrite, true)
}
return tempFile
}
// RemoveTrailingEmptyStrings removes empty strings from the end of a slice.
func RemoveTrailingEmptyStrings(slice []string) []string {
for i := len(slice) - 1; i >= 0; i-- {
if slice[i] != "" {
return slice[:i+1]
}
}
return nil
}
// ClampTrailingWhitespace strips trailing newlines, carriage returns, and tabs from each line in a note.
// Additionally, it removes single trailing spaces and truncates multiple trailing spaces to two (for Markdown formatting).
func ClampTrailingWhitespace(note []string) {
for i, line := range note {
// remove trailing tabs, carriage returns, and newlines
note[i] = strings.TrimRight(line, "\t\r\n")
// determine the number of trailing spaces
var endSpacesCount int
for j := len(line) - 1; j >= 0; j-- {
if line[j] != ' ' {
break
}
endSpacesCount++
}
// remove single spaces, truncate multiple spaces (leave two for Markdown formatting)
switch endSpacesCount {
case 0:
// do nothing
case 1:
// remove the trailing space
note[i] = strings.TrimRight(line, " ")
default:
// truncate the trailing spaces to two
note[i] = line[:len(line)-endSpacesCount+2]
}
}
}
// EntryAddPrecheck ensures the directory meant to contain a new
// entry exists and that the target entry location is not already used.
// Returns: statusCode (0 = success, 1 = target location already exists, 2 = containing directory is invalid).
func EntryAddPrecheck(targetLocation string) uint8 {
// ensure target location does not already exist
_, isAccessible := TargetIsFile(targetLocation, false, 0)
if isAccessible {
PrintError("Target location already exists", ErrorTargetExists, false)
return 1 // inform interactive clients that the target location already exists
}
// ensure target containing directory exists and is a directory (not a file)
containingDir := targetLocation[:strings.LastIndex(targetLocation, PathSeparator)]
isFile, isAccisAccessible := TargetIsFile(containingDir, false, 1)
if isFile || !isAccisAccessible {
PrintError("\""+containingDir+"\" is not a valid containing directory", ErrorTargetWrongType, false)
return 2 // inform interactive clients that the containing directory is invalid
}
return 0
}
// StringGen generates a random string of a specified length and complexity.
// Requires: complexity (minimum percentage of special characters to be returned in the generated string; set to 0 to generate a simple string),
// complexCharsetLevel (1 = safe for filenames, 2 = safe for most password entries, 3 = safe only for well-made password entries)
func StringGen(length int, complexity float64, complexCharsetLevel uint8) string {
var actualSpecialChars int // track the number of special characters in the generated string
var minSpecialChars int // track the minimum number of special characters to accept
var extendedCharset string // additions to character set used for complex strings
charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // default character set used for all strings
const extendedCharsetFiles = "!#$%&+,-.;=@_~^()[]{}`'" // additional special characters for complex strings (safe in file names)
const extendedCharsetMostPassword = "*:><?|" // additional special characters for complex strings (NOT safe in file names)
const extendedCharsetSpecialPassword = "\"/\\" // additional special characters for complex strings (NOT safe in file names)
if complexity > 0 {
minSpecialChars = int(math.Round(float64(length) * complexity)) // determine minimum number of special characters to accept
switch complexCharsetLevel {
case 1:
extendedCharset = extendedCharsetFiles
case 2:
extendedCharset = extendedCharsetMostPassword + extendedCharsetFiles[:len(extendedCharsetFiles)-9]
case 3:
extendedCharset = extendedCharsetFiles + extendedCharsetMostPassword + extendedCharsetSpecialPassword
}
charset += extendedCharset
}
// loop until a string of the desired complexity is generated
for {
// generate a random string
result := make([]byte, length)
for i := range result {
val, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
result[i] = charset[val.Int64()]
}
// return early if the string is not complex
if complexity == 0 {
return string(result)
}
// count the number of special characters in the generated string
for _, char := range string(result) {
if strings.ContainsRune(extendedCharset, char) {
actualSpecialChars++
}
}
// return the generated string if it contains enough special characters
if actualSpecialChars >= minSpecialChars {
return string(result)
}
// reset special character counter
fmt.Println("Regenerating string until desired complexity is achieved...")
actualSpecialChars = 0
}
}
// EntryIsNotEmpty iterates through entryData and returns true if any line is not empty.
func EntryIsNotEmpty(entryData []string) bool {
for _, line := range entryData {
if line != "" {
return true
}
}
return false
}
// ExpandPathWithHome, given a path (as a string) containing "~", returns the path with "~" expanded to the user's home directory.
func ExpandPathWithHome(path string) string {
return strings.Replace(path, "~", Home, 1)
}
// PrintError prints an error message in the standard libmutton format and exits with the specified exit code.
// Requires: message (the error message to print),
// exitCode (the exit code to use),
// forceHardExit (if true, exit immediately; if false, allow soft exit for interactive clients).
func PrintError(message string, exitCode int, forceHardExit bool) {
fmt.Println(AnsiError + message + AnsiReset)
if forceHardExit {
os.Exit(exitCode)
} else {
Exit(exitCode)
}
}
+102
View File
@@ -0,0 +1,102 @@
package crypt
import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/libmutton/global"
"github.com/rwinkhart/rcw/daemon"
"github.com/rwinkhart/rcw/wrappers"
)
var RetryPassword = true
// RCWDArgument reads the password from stdin and caches it via an RCW daemon.
func RCWDArgument() {
password := back.ReadFromStdin()
if password == nil {
os.Exit(0) // use os.Exit directly since this function is only intended for non-interactive CLI clients
}
daemon.Start(password)
}
// DecryptFileToSlice decrypts an RCW wrapped file
// and returns the contents as a slice of (trimmed) strings.
// Leave rcwPassword nil to use RCW demonization.
func DecryptFileToSlice(realPath string, rcwPassword []byte) ([]string, error) {
// read encrypted file
encBytes, err := os.ReadFile(realPath)
if err != nil {
return nil, errors.New("unable to open \"" + realPath + "\" for decryption: " + err.Error())
}
// if no password was provided, assume daemon mode
if rcwPassword == nil {
rcwPassword = launchRCWDProcess()
// if rcwPassword is still nil, the daemon is already running;
// use it to encrypt the data
if rcwPassword == nil {
return strings.Split(string(daemon.GetDec(encBytes)), "\n"), nil
}
}
// if the daemon is not being used/was not already running,
// use wrappers.Decrypt directly to avoid waiting for socket file creation
decBytes, err := wrappers.Decrypt(encBytes, rcwPassword, true)
if err != nil {
return nil, errors.New("unable to decrypt \"" + realPath + "\": " + err.Error())
}
return strings.Split(string(decBytes), "\n"), nil
}
// EncryptBytes encrypts a byte slice using RCW and returns the encrypted data.
// Leave rcwPassword nil to use RCW demonization.
// If providing rcwPassword, it is up to the client to perform the sanity check!
func EncryptBytes(decBytes, rcwPassword []byte) []byte {
// if no rcwPassword was provided, assume daemon mode
if rcwPassword == nil {
rcwPassword = launchRCWDProcess()
// if rcwPassword is still nil, the daemon is already running;
// use it to encrypt the data
if rcwPassword == nil {
return daemon.GetEnc(decBytes, true)
}
}
// if the daemon is not being used/was not already running,
// use wrappers.Encrypt directly to avoid waiting for socket file creation
return wrappers.Encrypt(decBytes, rcwPassword, true, true)
}
// launchRCWDProcess launches an RCW daemon to cache a password.
// If the daemon is not already running OR if not running in daemonize mode,
// it collects and returns the password (otherwise returns nil).
func launchRCWDProcess() []byte {
if daemon.IsOpen() {
return nil
}
var password []byte
if RetryPassword {
for {
password = global.GetPassword("RCW Password:")
if err := wrappers.RunSanityCheck(global.CfgDir+global.PathSeparator+"sanity.rcw", password); err == nil {
break
}
fmt.Println(back.AnsiError + "Incorrect password" + back.AnsiReset)
}
} else {
// in this mode, it is up to the client to perform the sanity check
password = global.GetPassword("RCW Password:")
}
cmd := exec.Command(os.Args[0], "startrcwd")
cmd.SysProcAttr = global.GetSysProcAttr()
_ = back.WriteToStdin(cmd, password, false)
_ = cmd.Start()
return password
}
+99
View File
@@ -1,3 +1,102 @@
**libmutton v0.4.4**
November 23, 2025
This release fixes a minor bug and dependency-derived security issues.
## Fixes
- (711515f3a1e368c28abb63c25b6a79a309844645) Fix subprocesses being killed prematurely when the main process is force quit
- Fixes issues with master password caching and clipboard clearing
## Dependencies
- Bumped (direct/replaced)
- golang.org/x/crypto: v0.43.0 => v0.45.0
- Addresses CVE-2025-58181 and CVE-2025-47914
- github.com/rwinkhart/rcw: v0.2.3 => v0.2.4
---
**libmutton v0.4.2**
November 09, 2025
This is purely a dependency bump release.
## Dependencies
- Bumped (direct/replaced)
- Go: v1.25.3 => v1.25.4
- github.com/rwinkhart/rcw: v0.2.2 => v0.2.3
- github.com/rwinkhart/peercred-mini: v0.1.1 => v0.1.2
- github.com/rwinkhart/sys: v0.37.0 => v0.38.0
- github.com/rwinkhart/go-winio-easy-pipe-handles: switched to tagged v0.1.0 of `github.com/rwinkhart/go-winio`
---
**libmutton v0.4.1**
October 26, 2025
This is a dependency bump release with only minor changes for developers.
## Breaking (for developers)
- (b9175d7ff3bea3bc703c20468defebcb1ac7e412) Standardized on "password" rather than a mix of "passphrase"/"password"
- Some function names changed as a result
- (aa10016f77b0be66ab75279df967f5d080d2b826) Developers using `LibmuttonInit` can now force clients into offline mode programmatically
- This means `LibmuttonInit` now requires an extra parameter
## Dependencies
- Bumped (direct/replaced)
- Go: v1.24.6 => v1.25.3
- github.com/pkg/sftp: v1.13.9 => v1.13.10
- golang.org/x/crypto: v0.41.0 => v0.43.0
- github.com/rwinkhart/sys: v0.35.0 => v0.37.0
---
**libmutton v0.4.0**
August 17, 2025
This is the largest update to libmutton yet, and as such, these patch notes are non-exhaustive.
Many minor changes and fixes to features mentioned in these patch notes have been made.
Please see the commit history for a more complete list of changes.
## Breaking (for users)
- GPG has been entirely replaced with [RCW](https://github.com/rwinkhart/rcw)
- All entries created in previous versions must be converted using [this conversion program](https://github.com/rwinkhart/sshyp-labs/releases/tag/v2.0.0)
- Steam TOTP keys now must be in base32 format
- (3c5db78da9861e09112ac308c086c2d19f2abfeb) Release binaries of `libmuttonserver` now target x86_64_v2 and arm64v8.7
- (3dd07e7165874e323586c21a14853935bb3dc61c) Offline mode is now manually specified in libmutton.ini
## Breaking (for developers)
- libmutton has been further modularized
- The `core` package has been split into `core`, `global`, `crypt`, and `cfg`
- The `sync` package has been split into `syncclient`, `syncserver`, `synccommon`, and `synccycles`
- This decreases the size of the server binary and allows for more modular clients
- (40f0f35f452008997bb5ecfb72f496646c42ba06) Errors are now returned, rather than printed
## Features
- (111324cc25e84a5bd8b6b8206b90b00394311590) Deletions (server-side) now follow the client through device ID changes
- (ca277ba7735dd75cc94b26c1c2357f7d3192e26b) Added the `EntryRefresh()` function for re-encrypting entries with a new passphrase/optimizing bloated entries
- (74b8261bc88fc122a25df5207b34edc3dee81e9a) Added the `LibmuttonInit()` function to ensure clients initialize libmutton as intended
- (bed23e987d89b133204991c2c34b8fa1e8e335c9) (41a12c6e4a7c09b22fac0faf6bdef5993264f5de) Added iOS clipboard support
- (3fdcc9e96d71bb4bef3baf9f43a627782237b142) (1a66a30bc184528ec971306497cca3597072202a) (d023058630958842f0f521ba2bef43b12955b9d8) Support one-time TOTP copy
- (fb2120c94ebc69f7ac8e15163f1e27b2c8057d9a) Support custom device ID prefixes
## Fixes
- (50ab27c6749d13fc6842d92259f8e0fd8fa5340c) libmutton.ini is now always created with 0600 permissions on *nix platforms
- (4a0e0f8e9e96805b27232001bce81336ac2e6a4d) Old devices IDs are no longer removed from clients when registration of a new device ID fails
## Dependencies
- Dropped
- github.com/fortis/go-steam-totp (functionality now covered by github.com/pquerna/otp)
- Bumped (direct)
- Go: v1.24.2 => v1.24.6
- github.com/pquerna/otp: v1.4.1-0.20231130234153-3357de7c0481 => v1.5.0
- golang.design/x/clipboard: v0.7.0 => v0.7.1
- golang.org/x/crypto: v0.37.0 => v0.41.0
- Added (direct)
- github.com/rwinkhart/go-boilerplate v0.1.0
- github.com/rwinkhart/rcw v0.2.2
---
**libmutton v0.3.1**
April 21, 2025
+52
View File
@@ -0,0 +1,52 @@
**libmutton v0.5.0**
February 17, 2026
This release adds password age-tracking support and features large refactors that improve the robustness of core functionality. Many breaking changes are present. Highlights are listed below.
## Breaking (for users+developers)
- **USERS**: These changes *require* updating your copy of `libmuttonserver`; additionally, you *must* re-generate your config file however your client allows (e.g. `mutn init`)
- (50d51f9d96cca194bc89206fdd56de4db7a696a5) Password age tracking support
- libmutton now tracks when passwords were last updated
- Clients can use this information to warn users when passwords should be changed
- Random (within the last year) age data can be added to existing password entries
- This allows easing into this feature, rather than having all your passwords expire on the same day
- Developers: see the new `age` package
- (e355c810da06d794fd5a6ed10b8a886afa7f3492) (f16078fe01ec8a14fd7f2cd059340b33908ca1d6) (4e6c18db8a6716dcf1a9b73fd523f2f7161ce7f2) (2ab777fd571bb9b79e7601667e5c1f5b15400d03) (941a2dc8b80cb6e0327b33714c4a894962621bda) (35db3bf20b8ab3705bf349fdc7ed4b8e7899cddb) (5e5a7c57e9c5633edb74a968e46f21e141ab81ef) (07571a698d32eb414c24cdf84327d1868a0b455a) (d0e8640ce6e90583a7ea5ff5b3ed2cfdc2e00801) (3b4e75b592241b8405c4cb49dd40859773b7b027) Massive sync refactor
- Sync info is now passed from the server to the client using rigidly defined JSON types
- This greatly improves code maintainability and reduces potential for bugs
- Sync now supports password age tracking data
- (b591e2ef5a6da7f8fbee099c25349dd61433b297) (571bc0a3bd8773771c01191d9b7b8f03c7c5b436) New config file format
- `libmutton.ini` is now `libmuttoncfg.json`
- This format change was made since the JSON package was being adopted for the sync refactor (the `gopkg.in/ini.v1` dependency can now be dropped)
- Developers: see the new `config` package, replacing the old `cfg` package
- (6d3e3cdb5bcfccd103aadfcbb4789ce001bad946) Dropped built-in Termux support to simplify builds (clients can still implement this on their own)
## Breaking (for developers)
- (bf1399ce8d6e39d04d3cdad2d99ae787c88effdf) (97037903360c70221e138b22630e7ef90ec378bc) (a0f21b31f743eab4cc9d0f879e3b5fc4d7627ad0) (80cf65775ada4ce7c0f1c6c3ab93d5bb561518ad) (8d1dd48035bd3abb6f4b8847c5a39a61be53f3a4) (c63dff409210e438c27a429b9fdb023a7bba66af) (336dc5c158af21214bd097c2a0eadbc44f7c2cf6) (04e1b0dd37d372fb93c168273761a7285f819dc8) (d2a6ed5eaa6600a44912edbebb3b2df0a78320b0) (fdce036be6d42cf8ab97a9fb66f88b243639252f) (6004a94b7718a97bdc847ea99a8a39761c7b0075) Massive clipboard refactor
- Added new `clip` package
- Non-continuous TOTP copy must use `core.GenTOTP` directly
- The typical copy workflow now only supports continuous copy
- Dropped mobile clipboard support
- This can/should be handled by the GUI toolkit
- (28286788a045a090be67b4f2919b09a8be570801) (a18c3ea1d6412dbb88fba5a5f69693450be9e97c) (5e5a7c57e9c5633edb74a968e46f21e141ab81ef) (4467a8c7421bb4b60e5088bca851c1fd7d4fec50) `core.LibmuttonInit` overhaul
- Now supports non-destructive append
- Now supports custom device ID prefixes
## New (non-breaking)
- (cb3a554676ff8d43f4350f6ce74af786c003bf3b) Mass entry verification
- (d7242fee96b32b6b1ba4cec6952b143019b59b78) (1351360b35b37763fdb418d6d600c86a1e03b360) Native iOS support
## Fixes
- (2054f5b3cf2d3136b3704ddf01a6861eaa7e5ebf) Reduced clipboard latency on Windows
- (f82b35bc4398030568a38cd806b5e017811f10a1) Fixed background processes dying w/terminal on Windows
## Dependencies (direct/replaced)
- Dropped
- golang.design/x/clipboard
- gopkg.in/ini.v1
- Bumped
- Go: v1.25.4 => v1.26.0
- github.com/rwinkhart/go-boilerplate: v0.1.0 => v0.3.1
- github.com/rwinkhart/rcw: v0.2.4 => v0.3.0
- golang.org/x/crypto: v0.45.0 => v0.48.0
- github.com/rwinkhart/sys: v0.38.0 => v0.41.0
+19
View File
@@ -0,0 +1,19 @@
package global
var (
GetPassword func(prompt string) []byte // Clients should set this to a function that fetches hidden input from the user
RootLength = len(EntryRoot)
)
const (
LibmuttonVersion = "0.5.0" // Untagged releases feature a letter suffix corresponding to the eventual release version, e.g "0.B.0" -> "0.2.0", "0.2.A" -> "0.2.1"
FSSpace = "\u259d" // ▝ Space/list separator
FSPath = "\u259e" // ▞ Path separator
FSMisc = "\u259f" // ▟ Misc. field separator (if \u259d is already used)
ErrorSyncProcess = 104
ErrorDecryption = 105
ErrorEncryption = 106
ErrorClipboard = 107
)
+27
View File
@@ -0,0 +1,27 @@
//go:build ios
package global
import (
"path/filepath"
"github.com/rwinkhart/go-boilerplate/back"
)
func init() {
back.Home = fruityHome
}
var (
fruityHome = filepath.Join(back.Home, "Library", "Application Support", "libmutton-ios") // private, non-iCloud-backed dir
EntryRoot = fruityHome + "/entries" // Path to libmutton entry directory
CfgDir = fruityHome + "/config" // Path to libmutton configuration directory
CfgPath = CfgDir + "/libmuttoncfg.json" // Path to libmutton configuration file
AgeDir = CfgDir + "/age" // Path to libmutton password age directory
SSHDir = fruityHome + "/ssh" // Path to SSH directory
)
const (
PathSeparator = "/" // Platform-specific path separator
IsWindows = false // Platform indicator
)
+18
View File
@@ -0,0 +1,18 @@
//go:build !windows && !ios && !android
package global
import "github.com/rwinkhart/go-boilerplate/back"
var (
EntryRoot = back.Home + "/.local/share/libmutton" // Path to libmutton entry directory
CfgDir = back.Home + "/.config/libmutton" // Path to libmutton configuration directory
CfgPath = CfgDir + "/libmuttoncfg.json" // Path to libmutton configuration file
AgeDir = CfgDir + "/age" // Path to libmutton password age directory
SSHDir = back.Home + "/.ssh" // Path to SSH directory
)
const (
PathSeparator = "/" // Platform-specific path separator
IsWindows = false // Platform indicator
)
+18
View File
@@ -0,0 +1,18 @@
//go:build windows
package global
import "github.com/rwinkhart/go-boilerplate/back"
var (
EntryRoot = back.Home + "\\AppData\\Local\\libmutton\\entries" // Path to libmutton entry directory
CfgDir = back.Home + "\\AppData\\Local\\libmutton\\config" // Path to libmutton configuration directory
CfgPath = CfgDir + "\\libmuttoncfg.json" // Path to libmutton configuration file
AgeDir = CfgDir + "\\age" // Path to libmutton password age directory
SSHDir = back.Home + "\\.ssh" // Path to SSH directory
)
const (
PathSeparator = "\\" // Platform-specific path separator
IsWindows = true // Platform indicator
)
+34
View File
@@ -0,0 +1,34 @@
package global
import (
"errors"
"io/fs"
"os"
)
// GetCurrentDeviceID returns the current device ID or
// nil if there is no device ID (e.g. first run).
func GetCurrentDeviceID() (*string, error) {
deviceIDList, err := GenDeviceIDList()
if err != nil {
return nil, errors.New("unable to generate device ID list: " + err.Error())
}
var deviceID string
if len(deviceIDList) > 0 {
deviceID = (deviceIDList)[0].Name()
} else {
return nil, nil // nil device ID indicates to server that no device ID is being replaced
}
return &deviceID, nil
}
// GenDeviceIDList returns a slice of all registered device IDs.
// Requires: errorOnFail (set to true to throw an error if the devices directory cannot be read/does not exist)
func GenDeviceIDList() ([]fs.DirEntry, error) {
// create a slice of all registered devices
deviceIDList, err := os.ReadDir(CfgDir + PathSeparator + "devices")
if err != nil {
return nil, errors.New("unable to read devices directory: " + err.Error())
}
return deviceIDList, nil
}
+8
View File
@@ -0,0 +1,8 @@
package global
import "strings"
// GetRealAgePath returns the full path to an age file (given the vanity path)
func GetRealAgePath(vanityPath string) string {
return AgeDir + PathSeparator + strings.ReplaceAll(vanityPath, "/", FSPath)
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows
package global
// GetRealPath returns the full path to an entry (given the vanity path).
func GetRealPath(vanityPath string) string {
return EntryRoot + vanityPath
}
// GetVanityPath returns a vanityPath given a realPath
func GetVanityPath(realPath string) string {
return realPath[RootLength:]
}
+17
View File
@@ -0,0 +1,17 @@
//go:build windows
package global
import (
"strings"
)
// GetRealPath returns the full path to an entry (given the vanity path).
func GetRealPath(vanityPath string) string {
return EntryRoot + strings.ReplaceAll(vanityPath, "/", PathSeparator)
}
// GetVanityPath returns a vanityPath given a realPath
func GetVanityPath(realPath string) string {
return strings.ReplaceAll(realPath[RootLength:], "\\", "/")
}
+44
View File
@@ -0,0 +1,44 @@
package global
import (
"errors"
"os"
"github.com/rwinkhart/go-boilerplate/back"
)
// DirInit creates the libmutton directories.
// Returns: oldDeviceID (from before the directory reset; will be FSMisc if there is no pre-existing ID).
func DirInit(preserveOldCfgDir bool) (*string, error) {
var err error
// create EntryRoot
if err = os.MkdirAll(EntryRoot, 0700); err != nil {
return nil, errors.New("unable to create \"" + EntryRoot + "\": " + err.Error())
}
// get old device ID before its potential removal
oldDeviceID, _ := GetCurrentDeviceID() // error ignored; oldDeviceID is set to nil on error, which is the correct assumption
// remove existing config directory (if it exists and not in append mode)
if !preserveOldCfgDir {
isAccessible, _ := back.TargetIsFile(CfgDir, false) // error is ignored because dir/file status is irrelevant
if isAccessible {
if err = os.RemoveAll(CfgDir); err != nil {
return nil, errors.New("unable to remove existing config directory: " + err.Error())
}
}
}
// create config directory w/devices subdirectory
if err = os.MkdirAll(CfgDir+PathSeparator+"devices", 0700); err != nil {
return nil, errors.New("unable to create \"" + CfgDir + "\": " + err.Error())
}
// create password age directory
if err = os.MkdirAll(AgeDir, 0700); err != nil {
return nil, errors.New("unable to create \"" + AgeDir + "\": " + err.Error())
}
return oldDeviceID, nil
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package global
import "syscall"
func GetSysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{Setsid: true}
}
+13
View File
@@ -0,0 +1,13 @@
//go:build windows
package global
import (
"syscall"
"golang.org/x/sys/windows"
)
func GetSysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS}
}
+14 -12
View File
@@ -1,21 +1,23 @@
module github.com/rwinkhart/libmutton
go 1.24.2
go 1.26.3
require (
github.com/fortis/go-steam-totp v0.0.0-20171114202746-18e928674727
github.com/pkg/sftp v1.13.9
github.com/pquerna/otp v1.4.1-0.20231130234153-3357de7c0481
golang.design/x/clipboard v0.7.0 // only for Android builds
golang.org/x/crypto v0.37.0
gopkg.in/ini.v1 v1.67.0
github.com/pkg/sftp v1.13.10
github.com/pquerna/otp v1.5.0
github.com/rwinkhart/go-boilerplate v0.3.1
github.com/rwinkhart/rcw v0.3.1
golang.org/x/crypto v0.52.0
golang.org/x/sys v0.45.0
)
require (
github.com/boombuler/barcode v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/kr/fs v0.1.0 // indirect
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect; only for Android builds
golang.org/x/image v0.26.0 // indirect; only for Android builds
golang.org/x/mobile v0.0.0-20250408133729-978277e7eaf7 // indirect; only for Android builds
golang.org/x/sys v0.32.0 // indirect
github.com/rwinkhart/peercred-mini v0.1.5 // indirect
)
replace golang.org/x/sys => github.com/rwinkhart/sys v0.45.0
replace github.com/Microsoft/go-winio => github.com/rwinkhart/go-winio v0.1.1
+22 -94
View File
@@ -1,106 +1,34 @@
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
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/fortis/go-steam-totp v0.0.0-20171114202746-18e928674727 h1:1RkPJqfzrncAuh9xgoslr9OplZskm+VRA9lkucHPQZ4=
github.com/fortis/go-steam-totp v0.0.0-20171114202746-18e928674727/go.mod h1:wRAWHbTlpt0C4kwnKoa42L2Phrv6uIq+c50P2uKpb7I=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw=
github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
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/pquerna/otp v1.4.1-0.20231130234153-3357de7c0481 h1:FkxbO331O7mS5EJkP+MCi0o2gswh/Aezs+//NmefrR8=
github.com/pquerna/otp v1.4.1-0.20231130234153-3357de7c0481/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/rwinkhart/go-boilerplate v0.3.1 h1:vkVRuptO2s1yPzzwpvtiiB0c/hPB6yr0p1mzZ2Myb9E=
github.com/rwinkhart/go-boilerplate v0.3.1/go.mod h1:ES13A2r9fnCVfyezwMBgY/RgA4pOIudOUXz3Jk/ikes=
github.com/rwinkhart/go-winio v0.1.1 h1:kAJKiqneR7cUR01Wn5/doAAV4kOGTEGPug4oinXc5N4=
github.com/rwinkhart/go-winio v0.1.1/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0=
github.com/rwinkhart/peercred-mini v0.1.5 h1:9sBR0ascteCJfzfhv7Gq/juqu7OOU+AphiEIG1+LcWA=
github.com/rwinkhart/peercred-mini v0.1.5/go.mod h1:C73IeDteQwsKp1+PDdUBj4FC6dTziAJNbQsVDZSWE+M=
github.com/rwinkhart/rcw v0.3.1 h1:qmozfcoEm71ttA8YSoH96y42Z2sFhCvY45GGtFPWVhg=
github.com/rwinkhart/rcw v0.3.1/go.mod h1:TreA2OUl64v2UprL4xpXdDM7w8lJNzQ544rUIl8omtI=
github.com/rwinkhart/sys v0.45.0 h1:HXZL0SuyToqKkvakaUuRAdH8sPUHelfO6q4PQRpL26Q=
github.com/rwinkhart/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.design/x/clipboard v0.7.0 h1:4Je8M/ys9AJumVnl8m+rZnIvstSnYj1fvzqYrU3TXvo=
golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA6dnq2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/mobile v0.0.0-20250408133729-978277e7eaf7 h1:8MGTx39304caZ/OMsjPfuxUoDGI2tRas92F5x97tIYc=
golang.org/x/mobile v0.0.0-20250408133729-978277e7eaf7/go.mod h1:ftACcHgQ7vaOnQbHOHvXt9Y6bEPHrs5Ovk67ClwrPJA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+104 -28
View File
@@ -2,17 +2,19 @@ package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strconv"
"runtime/debug"
"strings"
"github.com/rwinkhart/libmutton/core"
"github.com/rwinkhart/libmutton/sync"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/go-boilerplate/other"
"github.com/rwinkhart/libmutton/global"
"github.com/rwinkhart/libmutton/synccommon"
"github.com/rwinkhart/libmutton/syncserver"
)
const ansiBold = "\033[1m"
func main() {
args := os.Args
if len(args) < 2 {
@@ -32,42 +34,116 @@ func main() {
}
}
// allow reporting panic details to clients
defer func() {
if r := recover(); r != nil {
fmt.Printf(
"{\"errMsg\":\"SERVER-SIDE PANIC OCCURRED: %v%sARGS: %v%sSTDIN: %v%sSTACK TRACE: %s\"}",
r,
global.FSSpace+global.FSSpace,
global.FSSpace+strings.Join(os.Args, global.FSSpace),
global.FSSpace+global.FSSpace,
global.FSSpace+strings.Join(stdin, global.FSSpace),
global.FSSpace+global.FSSpace,
global.FSSpace+strings.ReplaceAll(strings.ReplaceAll(string(debug.Stack()), "\n", global.FSSpace), "\t", ""),
)
return
}
}()
switch args[1] {
case "fetch":
// print all information needed for syncing to stdout for interpretation by the client
// stdin[0] is expected to be the device ID
sync.GetRemoteDataFromServer(stdin[0])
syncserver.GetRemoteDataFromServer(stdin[0])
case "rename":
// move an entry to a new location before using fallthrough to add its previous iteration to the deletions directory
// stdin[0] is evaluated after fallthrough
// stdin[1] is expected to be the OLD incomplete target location with FSPath representing path separators - Always pass in UNIX format
// stdin[2] is expected to be the NEW incomplete target location with FSPath representing path separators - Always pass in UNIX format
sync.RenameLocal(strings.ReplaceAll(stdin[1], core.FSPath, "/"), strings.ReplaceAll(stdin[2], core.FSPath, "/"), true)
// stdin[1] is expected to be the OLD vanityPath with FSPath representing path separators - Always pass in UNIX format
// stdin[2] is expected to be the NEW vanityPath with FSPath representing path separators - Always pass in UNIX format
if err := synccommon.RenameLocal(strings.ReplaceAll(stdin[1], global.FSPath, "/"), strings.ReplaceAll(stdin[2], global.FSPath, "/")); err != nil {
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
return
}
fallthrough // fallthrough to add the old entry to the deletions directory
case "shear":
// shear an entry from the server and add it to the deletions directory
// stdin[0] is expected to be the device ID
// stdin[1] is expected to be the incomplete target location with FSPath representing path separators - Always pass in UNIX format
sync.ShearLocal(strings.ReplaceAll(stdin[1], core.FSPath, "/"), stdin[0])
// stdin[1] is expected to be the vanityPath with FSPath representing path separators - Always pass in UNIX format
_, _, err := synccommon.ShearLocal(strings.ReplaceAll(stdin[1], global.FSPath, "/"), stdin[0], false)
if err != nil {
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
return
}
case "shear-age":
// shear ONLY the age file associated with an entry from the server and add it to the deletions directory
// stdin[0] is expected to be the device ID
// stdin[1] is expected to be the vanityPath with FSPath representing path separators - Always pass in UNIX format
_, _, err := synccommon.ShearLocal(strings.ReplaceAll(stdin[1], global.FSPath, "/"), stdin[0], true)
if err != nil {
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
return
}
case "addfolder":
// add a new folder to the server
// stdin[0] is expected to be the incomplete target location with FSPath representing path separators - Always pass in UNIX format
sync.AddFolderLocal(strings.ReplaceAll(stdin[0], core.FSPath, "/"))
// stdin[0] is expected to be the vanityPath with FSPath representing path separators - Always pass in UNIX format
if err := synccommon.AddFolderLocal(strings.ReplaceAll(stdin[0], global.FSPath, "/")); err != nil {
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
return
}
case "register":
// register a new device ID
// stdin[0] is expected to be the device ID
// stdin[1] is expected to be the old device ID (for removal)
fileToClose, _ := os.OpenFile(core.ConfigDir+core.PathSeparator+"devices"+core.PathSeparator+stdin[0], os.O_CREATE|os.O_WRONLY, 0600) // errors ignored; failure unlikely to occur if init was successful; "register" is not a user-facing argument and thus the error would not be visible
_ = fileToClose.Close()
if stdin[1] != core.FSMisc { // sync.FSMisc is used to indicate that no device ID is being replaced
_ = os.RemoveAll(core.ConfigDir + core.PathSeparator + "devices" + core.PathSeparator + stdin[1])
// stdin[0] is expected to be JSON matching type synccommon.RegisterReqT
var registerReq synccommon.RegisterReqT
err := json.Unmarshal([]byte(stdin[0]), &registerReq)
if err != nil {
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
return
}
// print EntryRoot and bool indicating OS type to stdout for client to store in config
fmt.Print(core.EntryRoot + core.FSSpace + strconv.FormatBool(core.IsWindows))
f, err := os.OpenFile(global.CfgDir+global.PathSeparator+"devices"+global.PathSeparator+registerReq.NewDeviceID, os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
return
}
_ = f.Close()
if registerReq.OldDeviceID != nil { // nil is used to indicate that no device ID is being replaced
// remove the old device ID file
if err = os.RemoveAll(global.CfgDir + global.PathSeparator + "devices" + global.PathSeparator + *registerReq.OldDeviceID); err != nil {
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
return
}
// carry over deletions from the old device ID to the new one
deletionsDirRoot := global.CfgDir + global.PathSeparator + "deletions" + global.PathSeparator
deletionsList, err := os.ReadDir(deletionsDirRoot)
if err != nil {
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
return
}
for i := range deletionsList {
affectedIDVanityPath := strings.Split(deletionsList[i].Name(), global.FSSpace)
if affectedIDVanityPath[0] == *registerReq.OldDeviceID {
if err = os.Rename(deletionsDirRoot+deletionsList[i].Name(), deletionsDirRoot+registerReq.NewDeviceID+global.FSSpace+affectedIDVanityPath[1]+global.FSSpace+affectedIDVanityPath[2]); err != nil {
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
return
}
}
}
}
// print EntryRoot, AgeDir and bool indicating OS type to stdout for client to store in config
registerRespBytes, err := json.Marshal(synccommon.RegisterRespT{EntryRoot: global.EntryRoot, AgeDir: global.AgeDir, IsWindows: global.IsWindows})
if err != nil {
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
return
}
fmt.Print(string(registerRespBytes))
case "init":
// create the necessary directories for libmuttonserver to function
core.DirInit(false)
_ = os.MkdirAll(core.ConfigDir+core.PathSeparator+"deletions", 0700) // error ignored; failure would have occurred by this point in core.DirInit
_, err := global.DirInit(false)
if err != nil {
other.PrintError("Failed to initialize libmuttonserver directories: "+err.Error(), back.ErrorWrite)
}
_ = os.MkdirAll(global.CfgDir+global.PathSeparator+"deletions", 0700) // error ignored; failure would have occurred by this point in core.DirInit
fmt.Println("libmuttonserver directories initialized")
case "version":
versionServer()
@@ -77,13 +153,13 @@ func main() {
}
func helpServer() {
fmt.Print(ansiBold + "\nlibmuttonserver | Copyright (c) 2024-2025 Randall Winkhart\n" + core.AnsiReset + `
fmt.Print(back.AnsiBold + "\nlibmuttonserver | Copyright (c) 2024-2026 Randall Winkhart\n" + back.AnsiReset + `
This software exists under the MIT license; you may redistribute it under certain conditions.
This program comes with absolutely no warranty; type "libmuttonserver version" for details.
` + ansiBold + "Usage:" + core.AnsiReset + ` libmuttonserver <argument>
` + back.AnsiBold + "Usage:" + back.AnsiReset + ` libmuttonserver <argument>
` + ansiBold + "Arguments (user):" + core.AnsiReset + `
` + back.AnsiBold + "Arguments (user):" + back.AnsiReset + `
help Bring up this menu
version Display version and license information
init Create the necessary directories for libmuttonserver to function` + "\n\n")
@@ -91,7 +167,7 @@ This program comes with absolutely no warranty; type "libmuttonserver version" f
}
func versionServer() {
fmt.Print(ansiBold + "\n MIT License" + core.AnsiReset + `
fmt.Print(back.AnsiBold + "\n MIT License" + back.AnsiReset + `
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
@@ -116,5 +192,5 @@ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
OR OTHER DEALINGS IN THE SOFTWARE.` + "\n\n---------------------------------------------------------")
fmt.Print(ansiBold + "\n\n libmuttonserver" + core.AnsiReset + " Version " + core.LibmuttonVersion + "\n\n Copyright (c) 2024-2025: Randall Winkhart" + "\n\n")
fmt.Print(back.AnsiBold + "\n\n libmuttonserver" + back.AnsiReset + " Version " + global.LibmuttonVersion + "\n\n Copyright (c) 2024-2026: Randall Winkhart" + "\n\n")
}
+10 -8
View File
@@ -1,13 +1,15 @@
#!/bin/sh
# This script generates portable libmuttonserver release binaries for the following platforms:
# - Linux (x86_64_v1)
# - Linux (aarch64)
# - Windows (x86_64_v1)
# - Windows (aarch64)
# - Linux (x86_64_v2)
# - Linux (arm64v8.0)
# - Linux (arm64v8.7)
# - Windows (x86_64_v2)
# - Windows (arm64v8.7)
mkdir -p ./1output
cd ..
GOOS=linux CGO_ENABLED=0 GOAMD64=v1 go build -o ./packaging/1output/libmuttonserver-linux-x86_64_v1 -ldflags="-s -w" -trimpath ./libmuttonserver.go
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o ./packaging/1output/libmuttonserver-linux-aarch64 -ldflags="-s -w" -trimpath ./libmuttonserver.go
GOOS=windows CGO_ENABLED=0 GOAMD64=v1 go build -o ./packaging/1output/libmuttonserver-windows-x86_64_v1.exe -ldflags="-s -w" -trimpath ./libmuttonserver.go
GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -o ./packaging/1output/libmuttonserver-windows-aarch64.exe -ldflags="-s -w" -trimpath ./libmuttonserver.go
GOOS=linux CGO_ENABLED=0 GOAMD64=v2 go build -o ./packaging/1output/libmuttonserver-linux-x86_64_v2 -ldflags="-s -w" -trimpath ./libmuttonserver.go
GOOS=linux CGO_ENABLED=0 GOARCH=arm64 GOARM64=v8.0 CGO_ENABLED=0 go build -o ./packaging/1output/libmuttonserver-linux-arm64v8.0 -ldflags="-s -w" -trimpath ./libmuttonserver.go
GOOS=linux CGO_ENABLED=0 GOARCH=arm64 GOARM64=v8.7 CGO_ENABLED=0 go build -o ./packaging/1output/libmuttonserver-linux-arm64v8.7 -ldflags="-s -w" -trimpath ./libmuttonserver.go
GOOS=windows CGO_ENABLED=0 GOAMD64=v2 go build -o ./packaging/1output/libmuttonserver-windows-x86_64_v2.exe -ldflags="-s -w" -trimpath ./libmuttonserver.go
GOOS=windows CGO_ENABLED=0 GOARCH=arm64 GOARM64=v8.7 CGO_ENABLED=0 go build -o ./packaging/1output/libmuttonserver-windows-arm64v8.7.exe -ldflags="-s -w" -trimpath ./libmuttonserver.go
+38
View File
@@ -0,0 +1,38 @@
package privkey
import (
"errors"
"os"
)
var keyBytesProvider = func(sshKeyPath *string) ([]byte, error) {
if sshKeyPath != nil {
key, err := os.ReadFile(*sshKeyPath)
if err != nil {
return nil, errors.New("unable to read private key: " + *sshKeyPath)
}
return key, nil
}
return nil, errors.New("unable to identify private key location (nil config)")
}
// GetBytes returns the SSH private key bytes.
// On iOS, it calls the provider function that was set by the iOS app
// using SetKeyBytesProvider or SetKeyBytes.
func GetBytes(sshKeyPath *string) ([]byte, error) {
keyBytes, err := keyBytesProvider(sshKeyPath)
if err != nil {
return nil, errors.New("unable to get key bytes from provider: " + err.Error())
}
if len(keyBytes) == 0 {
return nil, errors.New("provider returned empty key bytes")
}
return keyBytes, nil
}
// SetBytes allows the iOS app to directly set the SSH key bytes.
func SetBytes(keyBytes []byte) {
keyBytesProvider = func(*string) ([]byte, error) {
return keyBytes, nil
}
}
-5
View File
@@ -1,5 +0,0 @@
package sync
import "github.com/rwinkhart/libmutton/core"
var RootLength = len(core.EntryRoot) // length of core.EntryRoot string
-466
View File
@@ -1,466 +0,0 @@
package sync
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/pkg/sftp"
"github.com/rwinkhart/libmutton/core"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
// ANSI color constants used only in this file
const (
ansiDelete = "\033[38;5;1m"
ansiDownload = "\033[38;5;2m"
ansiUpload = "\033[38;5;4m"
)
// GetSSHClient returns an SSH client connection to the server (also returns the remote EntryRoot and an indicator of the server's OS).
// Only supports key-based authentication (passphrases are supported for CLI-based implementations).
func GetSSHClient(manualSync bool) (*ssh.Client, string, bool) {
// get SSH config info, exit if not configured (displaying an error if the sync job was called manually)
var sshUserConfig []string
var missingValueError string
if manualSync {
missingValueError = "SSH settings not fully configured"
} else {
missingValueError = "0" // allow silent exit at this point in offline mode
}
sshUserConfig, _ = core.ParseConfig([][2]string{{"LIBMUTTON", "sshUser"}, {"LIBMUTTON", "sshIP"}, {"LIBMUTTON", "sshPort"}, {"LIBMUTTON", "sshKey"}, {"LIBMUTTON", "sshKeyProtected"}, {"LIBMUTTON", "sshEntryRoot"}, {"LIBMUTTON", "sshIsWindows"}}, missingValueError)
var user, ip, port, keyFile, keyFileProtected, entryRoot string
var isWindows bool
var err error
for i, key := range sshUserConfig {
switch i {
case 0:
user = key
case 1:
ip = key
case 2:
port = key
case 3:
keyFile = key
case 4:
keyFileProtected = key
case 5:
entryRoot = key
case 6:
isWindows, err = strconv.ParseBool(key)
if err != nil {
core.PrintError("Sync failed - Unable to parse server OS type: "+err.Error(), core.ErrorRead, true)
}
}
}
// read private key
key, err := os.ReadFile(keyFile)
if err != nil {
core.PrintError("Sync failed - Unable to read private key: "+keyFile, core.ErrorRead, true)
}
// parse private key
var parsedKey ssh.Signer
if keyFileProtected != "true" {
parsedKey, err = ssh.ParsePrivateKey(key)
} else {
parsedKey, err = ssh.ParsePrivateKeyWithPassphrase(key, core.PassphraseInputFunction("Enter passphrase for your SSH keyfile:"))
}
if err != nil {
core.PrintError("Sync failed - Unable to parse private key: "+keyFile, core.ErrorRead, true)
}
// read known hosts file
var hostKeyCallback ssh.HostKeyCallback
hostKeyCallback, err = knownhosts.New(core.Home + core.PathSeparator + ".ssh" + core.PathSeparator + "known_hosts")
if err != nil {
core.PrintError("Sync failed - Unable to read known hosts file: "+err.Error(), core.ErrorRead, true)
}
// configure SSH client
sshConfig := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(parsedKey),
},
HostKeyCallback: hostKeyCallback,
Timeout: 3 * time.Second,
}
// connect to SSH server
sshClient, err := ssh.Dial("tcp", ip+":"+port, sshConfig)
if err != nil {
core.PrintError("Sync failed - Unable to connect to remote server: "+err.Error(), core.ErrorServerConnection, false) // do not crash/close interactive clients
return nil, "", false
}
return sshClient, entryRoot, isWindows
}
// GetSSHOutput runs a command over SSH and returns the output as a string.
func GetSSHOutput(sshClient *ssh.Client, cmd, stdin string) string {
// create a session
sshSession, err := sshClient.NewSession()
if err != nil {
core.PrintError("Sync failed - Unable to establish SSH session: "+err.Error(), core.ErrorServerConnection, true)
}
// provide stdin data for session
sshSession.Stdin = strings.NewReader(stdin)
// run the provided command
var output []byte
output, err = sshSession.CombinedOutput(cmd)
if err != nil {
core.PrintError("Sync failed - Unable to run SSH command: "+err.Error(), core.ErrorSyncProcess, true)
}
// convert the output to a string and remove leading/trailing whitespace
outputString := string(output)
outputString = strings.TrimSpace(outputString)
return outputString
}
// getRemoteDataFromClient returns a map of remote entries to their modification times, a list of remote folders, a list of queued deletions, and the current server&client times as UNIX timestamps.
func getRemoteDataFromClient(sshClient *ssh.Client, manualSync bool) (map[string]int64, []string, []string, int64, int64) {
// get remote output over SSH
deviceIDList := core.GenDeviceIDList(true)
if len(*deviceIDList) == 0 {
if manualSync {
core.PrintError("Sync failed - No device ID found", core.ErrorTargetNotFound, true)
} else {
core.Exit(0) // exit silently if the sync job was called automatically, as the user may just be in offline mode
}
}
clientTime := time.Now().Unix() // get client time now to avoid accuracy issues caused by unpredictable sync time
output := GetSSHOutput(sshClient, "libmuttonserver fetch", (*deviceIDList)[0].Name())
// split output into slice based on occurrences of FSSpace
outputSlice := strings.Split(output, core.FSSpace)
// parse output/re-form lists
if len(outputSlice) != 5 { // ensure information from server is complete
core.PrintError("Sync failed - Unable to fetch remote data; server returned an unexpected response", core.ErrorSyncProcess, true)
}
serverTime, err := strconv.ParseInt(outputSlice[0], 10, 64)
if err != nil {
core.PrintError("Sync failed - Unable to parse server time: "+err.Error(), core.ErrorRead, true)
}
entries := strings.Split(outputSlice[1], core.FSMisc)[1:]
modsStrings := strings.Split(outputSlice[2], core.FSMisc)[1:]
folders := strings.Split(outputSlice[3], core.FSMisc)[1:]
deletions := strings.Split(outputSlice[4], core.FSMisc)[1:]
// convert the mod times to int64
var mods []int64
var mod int64
for _, modString := range modsStrings {
mod, err = strconv.ParseInt(modString, 10, 64)
if err != nil {
core.PrintError("Sync failed - Unable to parse mod time: "+err.Error(), core.ErrorRead, true)
}
mods = append(mods, mod)
}
// map remote entries to their modification times
entryModMap := make(map[string]int64)
for i, entry := range entries {
entryModMap[entry] = mods[i]
}
return entryModMap, folders, deletions, serverTime, clientTime
}
// getLocalData returns a map of local entries to their modification times.
func getLocalData() map[string]int64 {
// get a list of all entries
entries, _ := WalkEntryDir()
// get a list of all entry modification times
modList := getModTimes(entries)
// map the entries to their modification times
entryModMap := make(map[string]int64)
for i, entry := range entries {
entryModMap[entry] = modList[i]
}
// return the lists
return entryModMap
}
// targetLocationFormatSFTP formats the target location to match the remote server's entry directory and path separator.
func targetLocationFormatSFTP(targetName, serverEntryRoot string, serverIsWindows bool) string {
if !serverIsWindows {
return serverEntryRoot + targetName
} else {
return serverEntryRoot + strings.ReplaceAll(targetName, "/", "\\")
}
}
// sftpSync takes two slices of entries (one for downloads and one for uploads) and syncs them between the client and server using SFTP.
func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, downloadList, uploadList []string) {
// create an SFTP client from sshClient
sftpClient, err := sftp.NewClient(sshClient)
if err != nil {
core.PrintError("Sync failed - Unable to establish SFTP session: "+err.Error(), core.ErrorServerConnection, true)
}
defer func(sftpClient *sftp.Client) {
err = sftpClient.Close()
if err != nil {
core.PrintError("Sync failed - Unable to close SFTP client: "+err.Error(), core.ErrorServerConnection, true)
}
}(sftpClient)
// iterate over the download list
var filesTransferred bool
for _, entryName := range downloadList {
filesTransferred = true // set a flag to indicate that files have been downloaded (used to determine whether to print a gap between download and upload messages)
fmt.Println("Downloading " + ansiDownload + entryName + core.AnsiReset)
// store path to remote entry
remoteEntryFullPath := targetLocationFormatSFTP(entryName, sshEntryRoot, sshIsWindows)
// save modification time of remote file
var fileInfo os.FileInfo
fileInfo, err = sftpClient.Stat(remoteEntryFullPath)
if err != nil {
core.PrintError("Sync failed - Unable to get remote file info (mod time): "+err.Error(), core.ErrorRead, true)
}
modTime := fileInfo.ModTime()
// open remote file
var remoteFile *sftp.File
remoteFile, err = sftpClient.Open(remoteEntryFullPath)
if err != nil {
core.PrintError("Sync failed - Unable to open remote file: "+err.Error(), core.ErrorRead, true)
}
// store path to local entry
localEntryFullPath := core.TargetLocationFormat(entryName)
// create local file
var localFile *os.File
localFile, err = os.OpenFile(localEntryFullPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
if err != nil {
core.PrintError("Sync failed - Unable to create local file: "+err.Error(), core.ErrorWrite, true)
}
// download the file
_, err = remoteFile.WriteTo(localFile)
if err != nil {
core.PrintError("Sync failed - Unable to download remote file: "+err.Error(), core.ErrorSyncProcess, true)
}
// close the files
_ = remoteFile.Close() // errors ignored; if the files could be opened/created, it can probably be closed
_ = localFile.Close()
// set the modification time of the local file to match the value saved from the remote file (from before the download)
err = os.Chtimes(localEntryFullPath, time.Now(), modTime)
}
if filesTransferred {
fmt.Println() // add a gap between download and upload messages
}
// iterate over the upload list
filesTransferred = false
for _, entryName := range uploadList {
filesTransferred = true // set a flag to indicate that files have been uploaded (used to determine whether to print a gap between upload and sync complete messages)
fmt.Println("Uploading " + ansiUpload + entryName + core.AnsiReset)
// store path to local entry
localEntryFullPath := core.TargetLocationFormat(entryName)
// save modification time of local file
var fileInfo os.FileInfo
fileInfo, err = os.Stat(localEntryFullPath)
if err != nil {
core.PrintError("Sync failed - Unable to get local file info (mod time): "+err.Error(), core.ErrorRead, true)
}
modTime := fileInfo.ModTime()
// open local file
var localFile *os.File
localFile, err = os.Open(localEntryFullPath)
if err != nil {
core.PrintError("Sync failed - Unable to open local file: "+err.Error(), core.ErrorRead, true)
}
// store path to remote entry
remoteEntryFullPath := targetLocationFormatSFTP(entryName, sshEntryRoot, sshIsWindows)
// create remote file
var remoteFile *sftp.File
remoteFile, err = sftpClient.OpenFile(remoteEntryFullPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY)
if err != nil {
core.PrintError("Sync failed - Unable to create remote file ("+remoteEntryFullPath+"): "+err.Error(), core.ErrorWrite, true)
}
// upload the file
_, err = localFile.WriteTo(remoteFile)
if err != nil {
core.PrintError("Sync failed - Unable to upload local file: "+err.Error(), core.ErrorSyncProcess, true)
}
// close the files
_ = localFile.Close() // errors ignored; if the files could be opened/created, it can probably be closed
_ = remoteFile.Close()
// set permissions on remote file
err = sftpClient.Chmod(remoteEntryFullPath, 0600)
if err != nil {
core.PrintError("Sync failed - Unable to set permissions on remote file: "+err.Error(), core.ErrorSyncProcess, true)
}
// set the modification time of the remote file to match the value saved from the local file (from before the upload)
err = sftpClient.Chtimes(remoteEntryFullPath, time.Now(), modTime)
}
if filesTransferred {
fmt.Println() // add a gap between upload and sync complete messages
}
}
// syncLists determines which entries need to be downloaded and uploaded for synchronizations and calls sftpSync with this information.
// Using maps means that syncing will be done in an arbitrary order, but it is a worthy tradeoff for speed and simplicity.
func syncLists(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows, timeSynced, returnLists bool, localEntryModMap, remoteEntryModMap map[string]int64) [3][]string {
// initialize slices to store entries that need to be downloaded or uploaded
var downloadList, uploadList []string
// iterate over client entries
for entry, localModTime := range localEntryModMap {
// check if the entry is present in the server map
if remoteModTime, present := remoteEntryModMap[entry]; present {
// entry exists on both client and server, compare mod times
if remoteModTime > localModTime {
fmt.Println(ansiDownload+entry+core.AnsiReset, "is newer on server, adding to download list")
downloadList = append(downloadList, entry)
} else if remoteModTime < localModTime {
fmt.Println(ansiUpload+entry+core.AnsiReset, "is newer on client, adding to upload list")
uploadList = append(uploadList, entry)
}
// remove entry from remoteEntryModMap (process of elimination)
delete(remoteEntryModMap, entry)
} else {
fmt.Println(ansiUpload+entry+core.AnsiReset, "does not exist on server, adding to upload list")
uploadList = append(uploadList, entry)
}
}
// iterate over remaining entries in remoteEntryModMap
for entry := range remoteEntryModMap {
fmt.Println(ansiDownload+entry+core.AnsiReset, "does not exist on client, adding to download list")
downloadList = append(downloadList, entry)
}
// call sftpSync with the download and upload lists
if timeSynced && (max(len(downloadList), len(uploadList)) > 0) { // only call sftpSync if there are entries to download or upload
fmt.Println() // add a gap between list-add messages and the actual sync messages from sftpSync
sftpSync(sshClient, sshEntryRoot, sshIsWindows, downloadList, uploadList)
} else if !timeSynced {
// do not call sftpSync if the client and server times are out of sync
core.Exit(1)
}
fmt.Println("Client is synchronized with server")
if returnLists {
return [3][]string{nil, downloadList, uploadList}
}
return [3][]string{nil, nil, nil}
}
// deletionSync removes entries from the client that have been deleted on the server (multi-client deletion).
func deletionSync(deletions []string) {
var filesDeleted bool
for _, deletion := range deletions {
filesDeleted = true // set a flag to indicate that files have been deleted (used to determine whether to print a gap between deletion and other messages)
fmt.Println(ansiDelete+deletion+core.AnsiReset, "has been sheared, removing locally (if it exists)")
err := os.RemoveAll(core.TargetLocationFormat(deletion))
if err != nil {
core.PrintError("Sync failed - Failed to shear "+deletion+" locally: "+err.Error(), core.ErrorWrite, true)
}
}
if filesDeleted {
fmt.Println() // add a gap between deletion and other messages
}
}
// folderSync creates folders on the client (from the given list of folder names).
func folderSync(folders []string) {
for _, folder := range folders {
// store the full local path of the folder
folderFullPath := core.TargetLocationFormat(folder)
// check if folder already exists
isFile, isAccessible := core.TargetIsFile(folderFullPath, false, 1)
if !isFile && !isAccessible {
err := os.MkdirAll(folderFullPath, 0700)
if err != nil {
core.PrintError("Sync failed - Failed to create folder ("+folder+"): "+err.Error(), core.ErrorWrite, true)
}
} else if isFile {
core.PrintError("Sync failed - Failed to create folder ("+folder+") - A file with the same name already exists", core.ErrorTargetExists, true)
}
}
}
// RunJob runs the SSH sync job.
// Setting manualSync to true will throw errors if sync is not configured (online mode is assumed).
// Setting returnLists to true will return the deletions, downloads, and uploads lists for use by the client.
func RunJob(manualSync, returnLists bool) [3][]string {
// get SSH client to re-use throughout the sync process
sshClient, sshEntryRoot, sshIsWindows := GetSSHClient(manualSync)
if sshClient == nil { // indicate SSH dialing failure for interactive clients
return [3][]string{nil, nil, nil}
}
defer func(sshClient *ssh.Client) {
err := sshClient.Close()
if err != nil {
core.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), core.ErrorServerConnection, true)
}
}(sshClient)
// fetch remote lists
remoteEntryModMap, remoteFolders, deletions, serverTime, clientTime := getRemoteDataFromClient(sshClient, manualSync)
// sync deletions
deletionSync(deletions)
// sync folders
folderSync(remoteFolders)
// fetch local lists
localEntryModMap := getLocalData()
// prior to syncing lists, ensure the client and server clocks are synced within 45 seconds
var timeSynced = true
timeDiff := serverTime - clientTime
if timeDiff < -45 || timeDiff > 45 {
timeSynced = false
fmt.Print(core.AnsiError + "Client and server clocks are out of sync.\n\nPlease ensure both clocks are correct before attempting to sync again.\n\nA dry sync output will be printed below (if any operations would have been performed). It is strongly recommended to review it and manually update the modification times as applicable to ensure the correct version of each entry is kept.\n\nIf the client's clock is at fault, update the modification times of any entries pending upload, even if the correct (upload) operation is being performed on them. Failure to do so could result in entries being uploaded to the server with the incorrect modification times (could result in data loss).\n\n" + core.AnsiReset)
}
// sync new and updated entries
var lists [3][]string
if returnLists {
lists = syncLists(sshClient, sshEntryRoot, sshIsWindows, timeSynced, true, localEntryModMap, remoteEntryModMap)
lists[0] = deletions
return lists
}
syncLists(sshClient, sshEntryRoot, sshIsWindows, timeSynced, false, localEntryModMap, remoteEntryModMap)
core.Exit(0) // exit program if running non-interactively
return lists // dummy return for when not returning lists
}
-111
View File
@@ -1,111 +0,0 @@
package sync
import (
"fmt"
"os"
"strings"
"github.com/rwinkhart/libmutton/core"
)
// getModTimes returns a list of all entry modification times.
func getModTimes(entryList []string) []int64 {
var modList []int64
for _, file := range entryList {
modTime, _ := os.Stat(core.TargetLocationFormat(file))
modList = append(modList, modTime.ModTime().Unix())
}
return modList
}
// ShearLocal removes the target file or directory from the local system.
// Returns: deviceID (only on client; for use in ShearRemoteFromClient),
// isDir (only on client; for use in ShearRemoteFromClient).
// If the local system is a server, it will also add the target to the deletions list for all clients (except the requesting client).
// This function should only be used directly by the server binary.
func ShearLocal(targetLocationIncomplete, clientDeviceID string) (string, bool) {
// determine if running on a server
var onServer bool
if clientDeviceID != "" {
onServer = true
}
deviceIDList := core.GenDeviceIDList(true)
// add the sheared target (incomplete, vanity) to the deletions list (if running on a server)
if onServer {
for _, device := range *deviceIDList {
if device.Name() != clientDeviceID {
fileToClose, err := os.OpenFile(core.ConfigDir+core.PathSeparator+"deletions"+core.PathSeparator+device.Name()+core.FSSpace+strings.ReplaceAll(targetLocationIncomplete, "/", core.FSPath), os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
// do not print error as there is currently no way of seeing server-side errors
// failure to add the target to the deletions list will exit the program and result in a client re-uploading the target (non-critical)
os.Exit(core.ErrorWrite)
}
_ = fileToClose.Close() // error ignored; if the file could be created, it can probably be closed
}
}
}
// get the full targetLocation path and remove the target
targetLocationComplete := core.TargetLocationFormat(targetLocationIncomplete)
var isFile bool
if !onServer { // error if target does not exist on client, needed because os.RemoveAll does not return an error if target does not exist
isFile, _ = core.TargetIsFile(targetLocationComplete, true, 0)
}
err := os.RemoveAll(targetLocationComplete)
if err != nil {
core.PrintError("Failed to remove local target: "+err.Error(), core.ErrorWrite, true)
}
if !onServer && len(*deviceIDList) > 0 { // return the device ID if running on the client and a device ID exists (online mode)
return (*deviceIDList)[0].Name(), !isFile
}
return "", true
// do not exit program, as this function is used as part of ShearRemoteFromClient
}
// RenameLocal renames oldLocationIncomplete to newLocationIncomplete on the local system.
// This function should only be used directly by the server binary.
func RenameLocal(oldLocationIncomplete, newLocationIncomplete string, verifyOldLocationExists bool) {
// get full paths for both locations
oldLocation := core.TargetLocationFormat(oldLocationIncomplete)
newLocation := core.TargetLocationFormat(newLocationIncomplete)
if verifyOldLocationExists {
core.TargetIsFile(oldLocation, true, 0)
}
// ensure newLocation does not exist
_, isAccessible := core.TargetIsFile(newLocation, false, 0)
if isAccessible {
core.PrintError("\""+newLocation+"\" already exists", core.ErrorTargetExists, true)
}
// rename oldLocation to newLocation
err := os.Rename(oldLocation, newLocation)
if err != nil {
core.PrintError("Failed to rename - Does the target containing directory exist?", core.ErrorTargetNotFound, true)
}
// do not exit program, as this function is used as part of RenameRemoteFromClient
}
// AddFolderLocal creates a new entry-containing directory on the local system.
// This function should only be used directly by the server binary.
func AddFolderLocal(targetLocationIncomplete string) {
// get the full targetLocation path and create the target
targetLocationComplete := core.TargetLocationFormat(targetLocationIncomplete)
err := os.Mkdir(targetLocationComplete, 0700)
if err != nil {
if os.IsExist(err) {
fmt.Println(ansiUpload + "Directory already exists - libmutton will still ensure it exists on the server")
} else {
core.PrintError("Failed to create directory: "+err.Error(), core.ErrorWrite, true)
}
}
// do not exit program, as this function is used as part of AddFolderRemoteFromClient
}
-47
View File
@@ -1,47 +0,0 @@
//go:build !windows
package sync
import (
"io/fs"
"os"
"path/filepath"
"github.com/rwinkhart/libmutton/core"
)
// WalkEntryDir walks the entry directory and returns lists of all files and directories found (two separate lists).
// Regardless of platform, all paths are stored with forward slashes (UNIX-style).
func WalkEntryDir() ([]string, []string) {
// define file/directory containing slices so that they may be accessed by the anonymous WalkDir function
var fileList []string
var dirList []string
// walk entry directory
_ = filepath.WalkDir(core.EntryRoot,
func(fullPath string, entry fs.DirEntry, err error) error {
// check for errors encountered while walking directory
if err != nil {
if os.IsNotExist(err) {
core.PrintError("The entry directory does not exist - Initialize libmutton to create it", core.ErrorOther, true)
} else {
core.PrintError("An unexpected error occurred while generating the entry list: "+err.Error(), core.ErrorOther, true)
}
}
// trim root path from each path before storing
trimmedPath := fullPath[RootLength:]
// append the path to the appropriate slice
if !entry.IsDir() {
fileList = append(fileList, trimmedPath)
} else {
dirList = append(dirList, trimmedPath)
}
return nil
})
return fileList, dirList
}
-48
View File
@@ -1,48 +0,0 @@
//go:build windows
package sync
import (
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/rwinkhart/libmutton/core"
)
// WalkEntryDir walks the entry directory and returns lists of all files and directories found (two separate lists).
// Regardless of platform, all paths are stored with forward slashes (UNIX-style).
func WalkEntryDir() ([]string, []string) {
// define file/directory containing slices so that they may be accessed by the anonymous WalkDir function
var fileList []string
var dirList []string
// walk entry directory
_ = filepath.WalkDir(core.EntryRoot,
func(fullPath string, entry fs.DirEntry, err error) error {
// check for errors encountered while walking directory
if err != nil {
if os.IsNotExist(err) {
core.PrintError("The entry directory does not exist - Initialize libmutton to create it", core.ErrorOther, true)
} else {
core.PrintError("An unexpected error occurred while generating the entry list: "+err.Error(), core.ErrorOther, true)
}
}
// trim root path from each path before storing and replace backslashes with forward slashes
trimmedPath := strings.ReplaceAll(fullPath[RootLength:], "\\", "/")
// append the path to the appropriate slice
if !entry.IsDir() {
fileList = append(fileList, trimmedPath)
} else {
dirList = append(dirList, trimmedPath)
}
return nil
})
return fileList, dirList
}
-47
View File
@@ -1,47 +0,0 @@
package sync
import (
"math/rand"
"os"
"strconv"
"strings"
"time"
"github.com/rwinkhart/libmutton/core"
)
// DeviceIDGen generates a new client device ID and registers it with the server (will replace existing one).
// Device IDs are only needed for online synchronization.
// Device IDs are guaranteed unique as the current UNIX time is appended to them.
// Returns: the remote EntryRoot and OS type indicator.
func DeviceIDGen(oldDeviceID string) (string, string) {
// generate new device ID
deviceIDPrefix, _ := os.Hostname()
deviceIDSuffix := core.StringGen(rand.Intn(32)+48, 0.2, 1) + "-" + strconv.FormatInt(time.Now().Unix(), 10)
newDeviceID := deviceIDPrefix + "-" + deviceIDSuffix
// create new device ID file (locally)
fileToClose, err := os.OpenFile(core.ConfigDir+core.PathSeparator+"devices"+core.PathSeparator+newDeviceID, os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
core.PrintError("Failed to create local device ID file: "+err.Error(), core.ErrorWrite, true)
}
_ = fileToClose.Close() // error ignored; if the file could be created, it can probably be closed
// remove old device ID file (locally; may not exist)
err = os.RemoveAll(core.ConfigDir + core.PathSeparator + "devices" + core.PathSeparator + oldDeviceID)
if err != nil {
core.PrintError("Failed to remove old device ID file (locally): "+err.Error(), core.ErrorWrite, true)
}
// register new device ID with server and fetch remote EntryRoot and OS type
// also removes the old device ID file (remotely)
// manualSync is true so the user is alerted if device ID registration fails
sshClient, _, _ := GetSSHClient(true)
sshEntryRootSSHIsWindows := strings.Split(GetSSHOutput(sshClient, "libmuttonserver register", newDeviceID+"\n"+oldDeviceID), core.FSSpace)
err = sshClient.Close()
if err != nil {
core.PrintError("Init failed - Unable to close SSH client: "+err.Error(), core.ErrorServerConnection, true)
}
return sshEntryRootSSHIsWindows[0], sshEntryRootSSHIsWindows[1]
}
-83
View File
@@ -1,83 +0,0 @@
package sync
import (
"strings"
"github.com/rwinkhart/libmutton/core"
)
// ShearRemoteFromClient removes the target file or directory from the local system and calls the server to remove it remotely and add it to the deletions list.
// It can safely be called in offline mode, as well, so this is the intended interface for shearing (ShearLocal should only be used directly by the server binary).
func ShearRemoteFromClient(targetLocationIncomplete string, forceOffline bool) {
deviceID, isDir := ShearLocal(targetLocationIncomplete, "") // remove the target from the local system and get the device ID of the client
if !forceOffline && deviceID != "" { // ensure a device ID exists (online mode)
// create an SSH client; manualSync is false in case a device ID exists but SSH is not configured
sshClient, _, _ := GetSSHClient(false)
// ensure targetLocationIncomplete ends with a slash if it is a directory (for clarity in shear message)
if isDir && !strings.HasSuffix(targetLocationIncomplete, "/") {
targetLocationIncomplete += "/"
}
// call the server to remotely shear the target and add it to the deletions list
GetSSHOutput(sshClient, "libmuttonserver shear", deviceID+"\n"+strings.ReplaceAll(targetLocationIncomplete, core.PathSeparator, core.FSPath))
// close the SSH client
err := sshClient.Close()
if err != nil {
core.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), core.ErrorServerConnection, true)
}
}
core.Exit(0) // sync is not required after shearing since the target has already been removed from the local system
}
// RenameRemoteFromClient renames oldLocationIncomplete to newLocationIncomplete on the local system and calls the server to perform the rename remotely and add the old target to the deletions list.
// It can safely be called in offline mode, as well, so this is the intended interface for renaming (RenameLocal should only be used directly by the server binary).
func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string, forceOffline bool) {
RenameLocal(oldLocationIncomplete, newLocationIncomplete, false) // move the target on the local system
deviceIDList := core.GenDeviceIDList(true)
if !forceOffline && len(*deviceIDList) > 0 { // ensure a device ID exists (online mode)
// create an SSH client; manualSync is false in case a device ID exists but SSH is not configured
sshClient, _, _ := GetSSHClient(false)
// call the server to move the target on the remote system and add the old target to the deletions list
GetSSHOutput(sshClient, "libmuttonserver rename",
(*deviceIDList)[0].Name()+"\n"+
strings.ReplaceAll(oldLocationIncomplete, core.PathSeparator, core.FSPath)+"\n"+
strings.ReplaceAll(newLocationIncomplete, core.PathSeparator, core.FSPath))
// close the SSH client
err := sshClient.Close()
if err != nil {
core.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), core.ErrorServerConnection, true)
}
}
core.Exit(0)
}
// AddFolderRemoteFromClient creates a new entry-containing directory on the local system and calls the server to create the folder remotely.
// It can safely be called in offline mode, as well, so this is the intended interface for adding folders (AddFolderLocal should only be used directly by the server binary).
func AddFolderRemoteFromClient(targetLocationIncomplete string, forceOffline bool) {
AddFolderLocal(targetLocationIncomplete) // add the folder on the local system
deviceIDList := core.GenDeviceIDList(true)
if !forceOffline && len(*deviceIDList) > 0 { // ensure a device ID exists (online mode)
// create an SSH client; manualSync is false in case a device ID exists but SSH is not configured
sshClient, _, _ := GetSSHClient(false)
// call the server to create the folder remotely
GetSSHOutput(sshClient, "libmuttonserver addfolder", strings.ReplaceAll(targetLocationIncomplete, core.PathSeparator, core.FSPath)) // call the server to create the folder remotely
// close the SSH client
err := sshClient.Close()
if err != nil {
core.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), core.ErrorServerConnection, true)
}
}
core.Exit(0)
}
-58
View File
@@ -1,58 +0,0 @@
package sync
import (
"fmt"
"os"
"strings"
"time"
"github.com/rwinkhart/libmutton/core"
)
// GetRemoteDataFromServer prints to stdout the remote entries, mod times, folders, and deletions.
// Lists in output are separated by FSSpace.
// Output is meant to be captured over SSH for interpretation by the client.
func GetRemoteDataFromServer(clientDeviceID string) {
entryList, dirList := WalkEntryDir()
modList := getModTimes(entryList)
deletionsList, err := os.ReadDir(core.ConfigDir + core.PathSeparator + "deletions")
if err != nil {
core.PrintError("Failed to read the deletions directory: "+err.Error(), core.ErrorRead, true)
}
// print the current UNIX timestamp to stdout
fmt.Print(time.Now().Unix())
// print the lists to stdout
// entry list
fmt.Print(core.FSSpace)
for _, entry := range entryList {
fmt.Print(core.FSMisc + entry)
}
// modification time list
fmt.Print(core.FSSpace)
for _, mod := range modList {
fmt.Print(core.FSMisc)
fmt.Print(mod)
}
// directory/folder list
fmt.Print(core.FSSpace)
for _, dir := range dirList {
fmt.Print(core.FSMisc + dir)
}
// deletions list
fmt.Print(core.FSSpace)
for _, deletion := range deletionsList {
// print deletion if it is relevant to the current client device
affectedIDTargetLocationIncomplete := strings.Split(deletion.Name(), core.FSSpace)
if affectedIDTargetLocationIncomplete[0] == clientDeviceID {
fmt.Print(core.FSMisc + strings.ReplaceAll(affectedIDTargetLocationIncomplete[1], core.FSPath, "/"))
// assume successful client deletion and remove deletions file (if assumption is somehow false, worst case scenario is that the client will re-upload the deleted entry)
_ = os.Remove(core.ConfigDir + core.PathSeparator + "deletions" + core.PathSeparator + deletion.Name()) // error ignored; function not run from a user-facing argument and thus the error would not be visible
}
}
}
+443
View File
@@ -0,0 +1,443 @@
package syncclient
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/pkg/sftp"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/libmutton/age"
"github.com/rwinkhart/libmutton/config"
"github.com/rwinkhart/libmutton/global"
"github.com/rwinkhart/libmutton/privkey"
"github.com/rwinkhart/libmutton/synccommon"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
type syncListsT struct {
Delete []string
Download []string
Upload []string
}
// GetSSHClient
// Returns:
// sshClient,
// offlineMode (whether the client is in offline mode).
// sshIsWindows (whether the remote server is running Windows),
// sshEntryRoot (the root directory for entries 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) {
// get SSH config info
cfg, err := config.Load()
if err != nil {
return nil, false, nil, nil, nil, errors.New("unable to parse SSH config: " + err.Error())
}
if *cfg.Libmutton.OfflineMode {
return nil, true, nil, nil, nil, nil
}
// get private key
key, err := privkey.GetBytes(cfg.Libmutton.SSHKeyPath)
if err != nil {
return nil, false, nil, nil, nil, err
}
// parse private key
var parsedKey ssh.Signer
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, nil, nil, nil, errors.New("unable to parse private key: " + err.Error())
}
// read known hosts file
var hostKeyCallback ssh.HostKeyCallback
hostKeyCallback, err = knownhosts.New(global.SSHDir + global.PathSeparator + "known_hosts")
if err != nil {
return nil, false, nil, nil, nil, errors.New("unable to read known hosts file: " + err.Error())
}
// configure SSH client
sshCfg := &ssh.ClientConfig{
User: *cfg.Libmutton.SSHUser,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(parsedKey),
},
HostKeyCallback: hostKeyCallback,
Timeout: 3 * time.Second,
}
// connect to SSH server
sshClient, err := ssh.Dial("tcp", *cfg.Libmutton.SSHIP+":"+*cfg.Libmutton.SSHPort, sshCfg)
if err != nil {
return nil, false, nil, nil, nil, errors.New("unable to connect to remote server: " + err.Error())
}
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.
func GetSSHOutput(sshClient *ssh.Client, cmd, stdin string) ([]byte, error) {
// create a session
sshSession, err := sshClient.NewSession()
if err != nil {
return nil, errors.New("unable to establish SSH session: " + err.Error())
}
// provide stdin data for session
sshSession.Stdin = strings.NewReader(stdin)
// run the provided command
var output []byte
output, err = sshSession.CombinedOutput(cmd)
if err != nil {
return nil, errors.New("unable to run SSH command: " + err.Error())
}
return output, nil
}
// getRemoteDataFromClient returns:
// a map of remote vanityPaths to their containing folders and mod+age timestamps,
// a list of queued deletions,
// and the current server&client times as UNIX timestamps.
func getRemoteDataFromClient(sshClient *ssh.Client) (synccommon.EntryMapT, []synccommon.Deletion, int64, int64, error) {
// get remote output over SSH
deviceIDList, err := global.GenDeviceIDList()
if err != nil {
return nil, nil, 0, 0, err
}
if len(deviceIDList) == 0 {
return nil, nil, 0, 0, errors.New("no device ID found")
}
clientTime := time.Now().Unix() // get client time now to avoid accuracy issues caused by unpredictable sync time
output, err := GetSSHOutput(sshClient, "libmuttonserver fetch", (deviceIDList)[0].Name())
if err != nil {
return nil, nil, 0, 0, errors.New("unable to run remote command: " + err.Error())
}
var fetchResp synccommon.FetchRespT
if err = json.Unmarshal(output, &fetchResp); err != nil {
fmt.Println(string(output))
return nil, nil, 0, 0, errors.New("unable to unmarshal server fetch response: " + err.Error())
}
if fetchResp.ErrMsg != nil {
return nil, nil, 0, 0, errors.New("unable to complete fetch; server-side error occurred: " + strings.ReplaceAll(*fetchResp.ErrMsg, global.FSSpace, "\n"))
}
return fetchResp.Entries, fetchResp.Deletions, fetchResp.ServerTime, clientTime, nil
}
// getRealPathSFTP formats the vanityPath to match the remote server's entry/age file directory and path separator.
func getRealPathSFTP(vanityPath, serverEntryRoot string, serverIsWindows bool) string {
if !serverIsWindows {
return serverEntryRoot + vanityPath
}
return serverEntryRoot + strings.ReplaceAll(vanityPath, "/", "\\")
}
// getRealPathSFTP formats the vanityPath to match the remote server's entry/age file directory and path separator.
func getRealAgePathSFTP(vanityPath, serverAgeDir string, serverIsWindows bool) string {
if !serverIsWindows {
return serverAgeDir + "/" + strings.ReplaceAll(vanityPath, "/", global.FSPath)
}
return serverAgeDir + "\\" + strings.ReplaceAll(vanityPath, "/", global.FSPath)
}
// sftpSync takes two slices of entries (one for downloads and one for uploads) and syncs them between the client and server using SFTP.
func sftpSync(sshClient *ssh.Client, sshEntryRoot, sshAgeDir string, sshIsWindows bool, syncListsV *syncListsT) error {
// create an SFTP client from sshClient
sftpClient, err := sftp.NewClient(sshClient)
if err != nil {
return errors.New("unable to establish SFTP session: " + err.Error())
}
defer func(sftpClient *sftp.Client) {
_ = sftpClient.Close()
}(sftpClient)
// iterate over the download list
var filesTransferred bool
for _, vanityPath := range syncListsV.Download {
filesTransferred = true // set a flag to indicate that files have been downloaded (used to determine whether to print a gap between download and upload messages)
fmt.Println("Downloading " + back.AnsiGreen + vanityPath + back.AnsiReset)
// store path to remote entry
remoteFileRealPath := getRealPathSFTP(vanityPath, sshEntryRoot, sshIsWindows)
// save modification time of remote file
var fileInfo os.FileInfo
fileInfo, err = sftpClient.Stat(remoteFileRealPath)
if err != nil {
return errors.New("unable to get remote file info (mod time): " + err.Error())
}
modTime := fileInfo.ModTime()
// open remote file
var remoteFile *sftp.File
remoteFile, err = sftpClient.Open(remoteFileRealPath)
if err != nil {
return errors.New("unable to open remote file: " + err.Error())
}
// store path to local file
localFileRealPath := global.GetRealPath(vanityPath)
// create local file
var localFile *os.File
localFile, err = os.OpenFile(localFileRealPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
if err != nil {
return errors.New("unable to create local file: " + err.Error())
}
// download the file
_, err = remoteFile.WriteTo(localFile)
if err != nil {
_ = localFile.Close()
return errors.New("unable to download remote file: " + err.Error())
}
// close the files
_ = remoteFile.Close() // errors ignored; if the files could be opened/created, it can probably be closed
_ = localFile.Close()
// set the modification time of the local file to match the value saved from the remote file (from before the download)
if err = os.Chtimes(localFileRealPath, time.Now(), modTime); err != nil {
return errors.New("unable to set local file modification time: " + err.Error())
}
}
if filesTransferred {
fmt.Println() // add a gap between download and upload messages
}
// iterate over the upload list
filesTransferred = false
for _, vanityPath := range syncListsV.Upload {
// determine if local file is an age file
var isAgeFile bool
if strings.HasPrefix(vanityPath, global.FSMisc) {
vanityPath = strings.TrimLeft(vanityPath, global.FSMisc)
isAgeFile = true
} else {
filesTransferred = true // set a flag to indicate that files have been uploaded (used to determine whether to print a gap between upload and sync complete messages)
fmt.Println("Uploading " + back.AnsiBlue + vanityPath + back.AnsiReset)
}
// store path to local file
var localFileRealPath string
if isAgeFile {
localFileRealPath = global.GetRealAgePath(vanityPath)
} else {
localFileRealPath = global.GetRealPath(vanityPath)
}
// save modification time of local file
var fileInfo os.FileInfo
fileInfo, err = os.Stat(localFileRealPath)
if err != nil {
return errors.New("unable to get local file info (mod time): " + err.Error())
}
modTime := fileInfo.ModTime()
// open local file
var localFile *os.File
localFile, err = os.Open(localFileRealPath)
if err != nil {
return errors.New("unable to open local file: " + err.Error())
}
// store path to remote entry
var remoteFileRealPath string
if isAgeFile {
remoteFileRealPath = getRealAgePathSFTP(vanityPath, sshAgeDir, sshIsWindows)
} else {
remoteFileRealPath = getRealPathSFTP(vanityPath, sshEntryRoot, sshIsWindows)
}
// create remote file
var remoteFile *sftp.File
remoteFile, err = sftpClient.OpenFile(remoteFileRealPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY)
if err != nil {
_ = localFile.Close()
return errors.New("unable to create remote file: " + err.Error())
}
// upload the file
_, err = localFile.WriteTo(remoteFile)
if err != nil {
_ = localFile.Close()
_ = remoteFile.Close()
return errors.New("unable to upload local file: " + err.Error())
}
// close the files
_ = localFile.Close() // errors ignored; if the files could be opened/created, it can probably be closed
_ = remoteFile.Close()
// set permissions on remote file
if err = sftpClient.Chmod(remoteFileRealPath, 0600); err != nil {
return errors.New("unable to set permissions on remote file: " + err.Error())
}
// set the modification time of the remote file to match the value saved from the local file (from before the upload)
if err = sftpClient.Chtimes(remoteFileRealPath, time.Now(), modTime); err != nil {
return errors.New("unable to set remote file modification time: " + err.Error())
}
}
if filesTransferred {
fmt.Println() // add a gap between upload and sync complete messages
}
return nil
}
// syncLists determines which entries need to be downloaded and uploaded
// for synchronization and calls sftpSync with this information.
func syncLists(sshClient *ssh.Client, sshEntryRoot, sshAgeDir string, sshIsWindows bool, timeSyncedErr error, localEntryMap, remoteEntryMap synccommon.EntryMapT) (*syncListsT, error) {
// initialize slices to store entries that need to be downloaded or uploaded
var syncListsV syncListsT
// iterate over client entries in local map
for vanityPath, localInfo := range localEntryMap {
// check if the entry is present on the server
if _, exists := remoteEntryMap[vanityPath]; exists {
// entry exists on both client and server, compare mod times
remoteInfo := remoteEntryMap[vanityPath]
if remoteInfo.ModTime > localInfo.ModTime {
fmt.Println(back.AnsiGreen+vanityPath+back.AnsiReset, "is newer on server, adding to download list")
syncListsV.Download = append(syncListsV.Download, vanityPath)
if remoteInfo.AgeTimestamp != nil {
if err := age.Entry(vanityPath, *remoteInfo.AgeTimestamp); err != nil {
return nil, errors.New("unable to update age timestamp for " + vanityPath + ": " + err.Error())
}
}
} else if remoteInfo.ModTime < localInfo.ModTime {
fmt.Println(back.AnsiBlue+vanityPath+back.AnsiReset, "is newer on client, adding to upload list")
syncListsV.Upload = append(syncListsV.Upload, vanityPath)
if localInfo.AgeTimestamp != nil && localInfo.AgeTimestamp != remoteInfo.AgeTimestamp {
syncListsV.Upload = append(syncListsV.Upload, global.FSMisc+vanityPath)
}
}
// remove entry from remote map (process of elimination)
delete(remoteEntryMap, vanityPath)
} else {
fmt.Println(back.AnsiBlue+vanityPath+back.AnsiReset, "does not exist on server, adding to upload list")
syncListsV.Upload = append(syncListsV.Upload, vanityPath)
if localInfo.AgeTimestamp != nil {
syncListsV.Upload = append(syncListsV.Upload, global.FSMisc+vanityPath)
}
}
}
// iterate over remaining entries in remote map
for vanityPath, remoteInfo := range remoteEntryMap {
fmt.Println(back.AnsiGreen+vanityPath+back.AnsiReset, "does not exist on client, adding to download list")
syncListsV.Download = append(syncListsV.Download, vanityPath)
if err := os.MkdirAll(global.GetRealPath(remoteInfo.ContainingFolder), 0700); err != nil {
return nil, errors.New("unable to create containing folder for " + vanityPath + ": " + err.Error())
}
if remoteInfo.AgeTimestamp != nil {
if err := age.Entry(vanityPath, *remoteInfo.AgeTimestamp); err != nil {
return nil, errors.New("unable to update age timestamp for " + vanityPath + ": " + err.Error())
}
}
}
// call sftpSync with the download and upload lists
if timeSyncedErr == nil && (max(len(syncListsV.Download), len(syncListsV.Upload)) > 0) { // only call sftpSync if there are entries to download or upload
fmt.Println() // add a gap between list-add messages and the actual sync messages from sftpSync
if err := sftpSync(sshClient, sshEntryRoot, sshAgeDir, sshIsWindows, &syncListsV); err != nil {
return nil, errors.New("unable to sync entries: " + err.Error())
}
fmt.Println("Client is synchronized with server")
}
return &syncListsV, timeSyncedErr
}
// deletionSync removes entries from the client that have been deleted on the server (multi-client deletion).
func deletionSync(deletions []synccommon.Deletion) error {
var entryDeleted bool
for i := range deletions {
if !deletions[i].IsAgeFile {
entryDeleted = true // set a flag to indicate that at least one entry has been deleted (used to determine whether to print a gap between deletion and other messages)
fmt.Println(synccommon.AnsiDelete+deletions[i].VanityPath+back.AnsiReset, "has been sheared, removing locally (if it exists)")
}
if err := os.RemoveAll(global.GetRealPath(deletions[i].VanityPath)); err != nil {
if !deletions[i].IsAgeFile {
return errors.New("unable to shear " + deletions[i].VanityPath + " locally: " + err.Error())
}
return errors.New("unable to shear age file for " + deletions[i].VanityPath + " locally: " + err.Error())
}
}
if entryDeleted {
fmt.Println() // add a gap between deletion and other messages
}
return nil
}
// RunJob runs the SSH sync job and returns deletions, downloads,
// and uploads lists for the client to report to the user.
func RunJob() (*syncListsT, error) {
// get SSH client to re-use throughout the sync process
sshClient, offlineMode, sshIsWindows, sshEntryRoot, sshAgeDir, err := GetSSHClient()
if offlineMode {
return nil, nil
}
if err != nil {
return nil, errors.New("unable to connect to SSH client: " + err.Error())
}
defer func(sshClient *ssh.Client) {
_ = sshClient.Close()
}(sshClient)
// fetch remote lists
remoteEntryMap, deletions, serverTime, clientTime, err := getRemoteDataFromClient(sshClient)
if err != nil {
return nil, errors.New("unable to fetch remote data: " + err.Error())
}
// sync deletions
if err = deletionSync(deletions); err != nil {
return nil, errors.New("unable to sync deletions: " + err.Error())
}
// fetch local lists
localEntryMap, err := synccommon.GetAllEntryData()
if err != nil {
return nil, errors.New("unable to fetch local entry data: " + err.Error())
}
// before syncing lists, ensure the client and server clocks are synced within 45 seconds
var timeSyncedErr error
timeDiff := serverTime - clientTime
if timeDiff < -45 || timeDiff > 45 {
timeSyncedErr = errors.New("client and server clocks are out of sync\n\nplease ensure both clocks are correct before attempting to sync again\n\na dry sync has been performed; it is strongly recommended to review it and manually update the modification times as applicable to ensure the correct version of each entry is kept\n\nif the client's clock is at fault, update the modification times of any entries pending upload, even if the correct (upload) operation is being performed on them; failure to do so could result in entries being uploaded to the server with the incorrect modification times (could result in data loss)" + back.AnsiReset)
}
// sync new and updated entries
// if time is not synced, the time sync error and upload/download lists will be returned here
syncListsV, err := syncLists(sshClient, *sshEntryRoot, *sshAgeDir, *sshIsWindows, timeSyncedErr, localEntryMap, remoteEntryMap)
if err != nil {
return syncListsV, errors.New("unable to sync entries: " + err.Error())
}
// add deletions info to sync lists
for i := range deletions {
if !deletions[i].IsAgeFile {
syncListsV.Delete = append(syncListsV.Delete, deletions[i].VanityPath)
}
}
return syncListsV, nil
}
+235
View File
@@ -0,0 +1,235 @@
package syncclient
import (
"encoding/json"
"errors"
"math/rand"
"os"
"strconv"
"strings"
"time"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/go-boilerplate/security"
"github.com/rwinkhart/libmutton/global"
"github.com/rwinkhart/libmutton/synccommon"
)
// ShearRemote removes the target file or directory from
// the local system and calls the server to remove it remotely and
// add it to the deletions list.
// It can safely be called in offline mode, as well, so this is
// the intended interface for shearing (ShearLocal should only
// be used directly by the server binary).
func ShearRemote(vanityPath string, onlyShearAgeFile bool) error {
deviceID, isDir, err := synccommon.ShearLocal(vanityPath, "", onlyShearAgeFile) // remove the target from the local system and get the device ID of the client
if err != nil {
return errors.New("unable to shear target locally: " + err.Error())
}
var modifier string
var output []byte
sshClient, offlineMode, _, _, _, err := GetSSHClient()
if offlineMode {
goto end
}
if err != nil {
return errors.New("unable to connect to SSH client: " + err.Error())
}
if deviceID == "" {
return errors.New("unable to shear target remotely: no device ID found")
}
// ensure vanityPath ends with a slash if it is a directory (for clarity in shear message)
if isDir && !strings.HasSuffix(vanityPath, "/") {
vanityPath += "/"
}
// call the server to remotely shear the target and add it to the deletions list
if onlyShearAgeFile {
modifier = "-age"
}
output, err = GetSSHOutput(sshClient, "libmuttonserver shear"+modifier, deviceID+"\n"+strings.ReplaceAll(vanityPath, global.PathSeparator, global.FSPath))
if err != nil {
return errors.New("unable to shear target remotely: " + err.Error())
}
if len(output) > 11 {
return errors.New("unable to complete shear; server-side error occurred: " + strings.ReplaceAll(string(output)[11:len(output)-2], global.FSSpace, "\n"))
}
// close the SSH client
if err = sshClient.Close(); err != nil {
return errors.New("unable to close SSH client: " + err.Error())
}
end:
back.Exit(0) // sync is not required after shearing since the target has already been removed from the local system
return nil
}
// RenameRemote renames oldVanityPath to newVanityPath on
// the local system and calls the server to perform the rename remotely and add the
// old target to the deletions list.
// It can safely be called in offline mode, as well, so this is the intended
// interface for renaming (RenameLocal should only be used directly by the server binary).
func RenameRemote(oldVanityPath, newVanityPath string) error {
// move the target on the local system
if err := synccommon.RenameLocal(oldVanityPath, newVanityPath); err != nil {
return errors.New("unable to rename target locally: " + err.Error())
}
deviceIDList, err := global.GenDeviceIDList()
if err != nil {
return errors.New("unable to generate device ID list: " + err.Error())
}
if deviceIDList[0].Name() == "" {
return errors.New("unable to rename target remotely: no device ID found")
}
// create an SSH client
var output []byte
sshClient, offlineMode, _, _, _, err := GetSSHClient()
if offlineMode {
goto end
}
if err != nil {
return errors.New("unable to connect to SSH client: " + err.Error())
}
// call the server to move the target on the remote system and add the old target to the deletions list
output, err = GetSSHOutput(sshClient, "libmuttonserver rename",
(deviceIDList)[0].Name()+"\n"+
strings.ReplaceAll(oldVanityPath, global.PathSeparator, global.FSPath)+"\n"+
strings.ReplaceAll(newVanityPath, global.PathSeparator, global.FSPath))
if err != nil {
return errors.New("unable to rename target remotely: " + err.Error())
}
if len(output) > 11 {
return errors.New("unable to complete rename; server-side error occurred: " + strings.ReplaceAll(string(output)[11:len(output)-2], global.FSSpace, "\n"))
}
// close the SSH client
if err = sshClient.Close(); err != nil {
return errors.New("unable to close SSH client: " + err.Error())
}
end:
back.Exit(0)
return nil
}
// AddFolderRemote creates a new entry-containing directory
// on the local system and calls the server to create the folder remotely.
// It can safely be called in offline mode, as well, so this is the
// intended interface for adding folders (AddFolderLocal should only be
// used directly by the server binary).
func AddFolderRemote(vanityPath string) error {
// add the folder on the local system
if err := synccommon.AddFolderLocal(vanityPath); err != nil {
return errors.New("unable to add folder locally: " + err.Error())
}
// create an SSH client
var output []byte
sshClient, offlineMode, _, _, _, err := GetSSHClient()
if offlineMode {
goto end
}
if err != nil {
return errors.New("unable to connect to SSH client: " + err.Error())
}
// call the server to create the folder remotely
output, err = GetSSHOutput(sshClient, "libmuttonserver addfolder", strings.ReplaceAll(vanityPath, global.PathSeparator, global.FSPath)) // call the server to create the folder remotely
if err != nil {
return errors.New("unable to add folder remotely: " + err.Error())
}
if len(output) > 11 {
return errors.New("unable to complete addfolder; server-side error occurred: " + strings.ReplaceAll(string(output)[11:len(output)-2], global.FSSpace, "\n"))
}
// close the SSH client
if err = sshClient.Close(); err != nil {
return errors.New("unable to close SSH client: " + err.Error())
}
end:
back.Exit(0)
return nil
}
// GenDeviceID generates a new client device ID
// and registers it with the server (will replace existing one).
// Device IDs are only needed for online synchronization.
// 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 *string, prefix string) (string, string, bool, error) {
// generate new device ID
if prefix == "" {
prefix, _ = os.Hostname()
}
newDeviceID := prefix + "-" + string(security.BytesGen(rand.Intn(32)+48, 0.2, 1)) + "-" + strconv.FormatInt(time.Now().Unix(), 10)
// create new device ID file (locally)
newDeviceIDPath := global.CfgDir + global.PathSeparator + "devices" + global.PathSeparator + newDeviceID
var oldDeviceIDPath string
if oldDeviceID != nil {
oldDeviceIDPath = global.CfgDir + global.PathSeparator + "devices" + global.PathSeparator + *oldDeviceID
}
f, err := os.OpenFile(newDeviceIDPath, os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
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
cleanupOnFail := func() {
// remove new device ID file
_ = os.RemoveAll(newDeviceIDPath)
if oldDeviceID != nil {
// restore old device ID file (if it existed and has already been removed due to DirInit)
if isAccessible, _ := back.TargetIsFile(oldDeviceIDPath, true); !isAccessible {
f, _ := os.OpenFile(oldDeviceIDPath, os.O_CREATE|os.O_WRONLY, 0600)
_ = f.Close() // error ignored; if the file could be created, it can probably be closed
}
}
}
// register new device ID with server and fetch remote EntryRoot and OS type
// also removes the old device ID file (remotely)
// if registration fails, remove the new device ID file locally and return before removing the old one
sshClient, _, _, _, _, err := GetSSHClient()
if err != nil {
cleanupOnFail()
return "", "", false, errors.New("unable to connect to SSH client: " + err.Error())
}
registerReqBytes, err := json.Marshal(synccommon.RegisterReqT{NewDeviceID: newDeviceID, OldDeviceID: oldDeviceID})
if err != nil {
return "", "", false, errors.New("unable to marshal client register request: " + err.Error())
}
output, err := GetSSHOutput(sshClient, "libmuttonserver register", string(registerReqBytes))
if err != nil {
cleanupOnFail()
return "", "", false, errors.New("unable to register device ID with server: " + err.Error())
}
var registerResp synccommon.RegisterRespT
if err = json.Unmarshal(output, &registerResp); err != nil {
cleanupOnFail()
return "", "", false, errors.New("unable to unmarshal server register response: " + err.Error())
}
if registerResp.ErrMsg != nil {
cleanupOnFail()
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
// remove old device ID file (locally; may not exist)
if oldDeviceID != nil {
if err = os.RemoveAll(oldDeviceIDPath); err != nil {
cleanupOnFail()
return "", "", false, errors.New("unable to remove old device ID file (locally): " + err.Error())
}
}
return registerResp.EntryRoot, registerResp.AgeDir, registerResp.IsWindows, nil
}
+207
View File
@@ -0,0 +1,207 @@
package synccommon
import (
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/rwinkhart/go-boilerplate/back"
"github.com/rwinkhart/libmutton/global"
)
// ANSI color constants used only in this file
const (
AnsiDelete = "\033[38;5;1m"
)
// FetchRespT defines the structure of responses from `libmuttonserver fetch`.
type FetchRespT struct {
ErrMsg *string `json:"errMsg"` // nil if no error occurred
ServerTime int64 `json:"serverTime"`
Deletions []Deletion `json:"deletions"`
Entries EntryMapT `json:"entries"`
}
type Deletion struct {
VanityPath string `json:"vanityPath"`
IsAgeFile bool `json:"isAgeFile"`
}
type EntryMapT map[string]EntryT // map vanity paths to containing folders and mod+age timestamps
type EntryT struct {
ContainingFolder string `json:"containingFolder"`
ModTime int64 `json:"modTime"`
AgeTimestamp *int64 `json:"ageTimestamp"` // nil if no age file is present (non-password entry)
}
// RegisterRespT defines the structure of responses from `libmuttonserver register`
type RegisterRespT struct {
ErrMsg *string `json:"errMsg"` // nil if no error occurred
EntryRoot string `json:"entryRoot"`
AgeDir string `json:"ageDir"`
IsWindows bool `json:"isWindows"`
}
type RegisterReqT struct {
NewDeviceID string `json:"newDeviceID"`
OldDeviceID *string `json:"oldDeviceID"` // nil if not replacing an existing device ID
}
// GetAllEntryData returns a map of all vanity paths to
// their respective containing folders and mod+age timestamps.
func GetAllEntryData() (EntryMapT, error) {
var err error
entryList, _, err := WalkEntryDir()
if err != nil {
return nil, errors.New("unable to walk entry directory: " + err.Error())
}
// initialize vanityPath keys in map
outputEntries := make(EntryMapT)
var modInfo, ageInfo os.FileInfo
for _, vanityPath := range entryList {
containingFolder := vanityPath[:strings.LastIndex(vanityPath, "/")]
modInfo, err = os.Stat(global.GetRealPath(vanityPath))
if err != nil {
return nil, errors.New("unable to read mod time for " + vanityPath + ": " + err.Error())
}
ageInfo, err = os.Stat(global.GetRealAgePath(vanityPath))
var ageTimestamp *int64
if err == nil {
ageTimestamp = new(ageInfo.ModTime().Unix())
} else if !os.IsNotExist(err) {
return nil, errors.New("unable to read age time for " + vanityPath + ": " + err.Error())
}
outputEntries[vanityPath] = EntryT{ContainingFolder: containingFolder, ModTime: modInfo.ModTime().Unix(), AgeTimestamp: ageTimestamp}
}
return outputEntries, nil
}
// ShearLocal removes the target file or directory from the local system.
// Returns: deviceID (only on client; for use in ShearRemoteFromClient),
// isDir (only on client; for use in ShearRemoteFromClient).
// If the local system is a server, it will also add the target to the deletions list for all clients (except the requesting client).
// This function should only be used directly by the server binary.
func ShearLocal(vanityPath, clientDeviceID string, onlyShearAgeFile bool) (string, bool, error) {
// determine if running on a server
var onServer bool
if clientDeviceID != "" {
onServer = true
}
deviceIDList, err := global.GenDeviceIDList()
if err != nil {
return "", false, errors.New("unable to generate device ID list: " + err.Error())
}
// add the sheared vanityPath to the deletions list (if running on a server)
if onServer {
for i := range deviceIDList {
if deviceIDList[i].Name() != clientDeviceID {
if !onlyShearAgeFile {
f, err := os.OpenFile(global.CfgDir+global.PathSeparator+"deletions"+global.PathSeparator+deviceIDList[i].Name()+global.FSSpace+"entry"+global.FSSpace+strings.ReplaceAll(vanityPath, "/", global.FSPath), os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
// failure to add the target to the deletions list will exit the program and result in a client re-uploading the target (non-critical)
return "", false, err
}
_ = f.Close() // error ignored; if the file could be created, it can probably be closed
}
f, err := os.OpenFile(global.CfgDir+global.PathSeparator+"deletions"+global.PathSeparator+deviceIDList[i].Name()+global.FSSpace+"age"+global.FSSpace+strings.ReplaceAll(vanityPath, "/", global.FSPath), os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
// failure to add the target to the deletions list will exit the program and result in a client re-uploading the target (non-critical)
return "", false, err
}
_ = f.Close() // error ignored; if the file could be created, it can probably be closed
}
}
}
// remove the target locally
realPath := global.GetRealPath(vanityPath)
var isFile bool
if !onServer { // error if target does not exist on client, needed because os.RemoveAll does not return an error if target does not exist
isAccessible, err := back.TargetIsFile(realPath, true)
if !isAccessible {
return "", false, err
}
if err == nil { // fails if target is a directory, so no error indicates a file
isFile = true
}
}
if !onlyShearAgeFile {
if err = os.RemoveAll(realPath); err != nil {
return "", false, errors.New("unable to remove local entry (" + vanityPath + "): " + err.Error())
}
}
if err = ShearAgeFileLocal(vanityPath); err != nil {
return "", false, err
}
if !onServer && len(deviceIDList) > 0 { // return the device ID if running on the client and a device ID exists (online mode)
return (deviceIDList)[0].Name(), !isFile, nil
}
return "", true, nil
// do not exit program, as this function is used as part of ShearRemoteFromClient
}
// ShearAgeFileLocal removes the age file for a vanity path.
// This function should only be used directly by the server binary.
func ShearAgeFileLocal(vanityPath string) error {
if err := os.RemoveAll(global.AgeDir + global.PathSeparator + strings.ReplaceAll(vanityPath, "/", global.FSPath)); err != nil {
return errors.New("unable to remove age file for " + vanityPath + ": " + err.Error())
}
return nil
}
// RenameLocal renames oldLocationIncomplete to newLocationIncomplete on the local system.
// This function should only be used directly by the server binary.
func RenameLocal(oldVanityPath, newVanityPath string) error {
// get full paths for both locations
oldRealPath := global.GetRealPath(oldVanityPath)
oldRealAgePath := global.GetRealAgePath(oldVanityPath)
newRealPath := global.GetRealPath(newVanityPath)
newRealAgePath := global.GetRealAgePath(newVanityPath)
// ensure newLocation does not exist
isAccessible, _ := back.TargetIsFile(newRealPath, true) // error is ignored because dir/file status is irrelevant
if isAccessible {
return errors.New("new target (" + newRealPath + ") already exists")
}
// rename oldLocation to newLocation
if err := os.Rename(oldRealPath, newRealPath); err != nil {
return errors.New("unable to rename: " + err.Error())
}
// do the same for the age file (if one exists) - also back up timestamp first
fileInfo, err := os.Stat(oldRealAgePath)
if err == nil { // assume age file does not exist if os.Stat errors
if err = os.Rename(oldRealAgePath, newRealAgePath); err != nil {
return errors.New("unable to rename: " + err.Error())
}
if err = os.Chtimes(newRealAgePath, time.Now(), fileInfo.ModTime()); err != nil {
return errors.New("unable to set timestamp on age file for " + newVanityPath + ": " + err.Error())
}
}
return nil
// do not exit program, as this function is used as part of RenameRemoteFromClient
}
// AddFolderLocal creates a new entry-containing directory on the local system.
// This function should only be used directly by the server binary.
func AddFolderLocal(vanityPath string) error {
// create the target locally
realPath := global.GetRealPath(vanityPath)
if err := os.Mkdir(realPath, 0700); err != nil {
if os.IsExist(err) {
fmt.Println(back.AnsiBlue + "Directory already exists - libmutton will still ensure it exists on the server")
} else {
return errors.New("unable to create directory: " + err.Error())
}
}
return nil
// do not exit program, as this function is used as part of AddFolderRemoteFromClient
}
+47
View File
@@ -0,0 +1,47 @@
package synccommon
import (
"errors"
"io/fs"
"os"
"path/filepath"
"github.com/rwinkhart/libmutton/global"
)
// WalkEntryDir walks the entry directory and returns lists of all files and
// directories found (two separate lists) relative to the libmutton entry root.
// Regardless of platform, all paths are stored with forward slashes (UNIX-style).
func WalkEntryDir() ([]string, []string, error) {
// define file/directory containing slices so that they may be accessed by the anonymous WalkDir function
var fileList, dirList []string
// walk entry directory
err := filepath.WalkDir(global.EntryRoot,
func(realPath string, entry fs.DirEntry, err error) error {
// check for errors encountered while walking directory
if err != nil {
if os.IsNotExist(err) {
return errors.New("entry directory does not exist; initialize libmutton to create it")
}
return errors.New("an unexpected error occurred while generating the entry list: " + err.Error())
}
// trim root path from each path before storing and replace backslashes with forward slashes
vanityPath := global.GetVanityPath(realPath)
// append the path to the appropriate slice
if !entry.IsDir() {
fileList = append(fileList, vanityPath)
} else {
dirList = append(dirList, vanityPath)
}
return nil
})
if err != nil {
return nil, nil, err
}
return fileList, dirList, nil
}
+61
View File
@@ -0,0 +1,61 @@
package syncserver
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/rwinkhart/libmutton/global"
"github.com/rwinkhart/libmutton/synccommon"
)
// GetRemoteDataFromServer prints to stdout the remote entries, mod times, folders, and deletions.
// Output is meant to be captured over SSH for interpretation by the client.
func GetRemoteDataFromServer(clientDeviceID string) {
// collect info
entryMap, err := synccommon.GetAllEntryData()
if err != nil {
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
return
}
deletionsList, err := os.ReadDir(global.CfgDir + global.PathSeparator + "deletions")
if err != nil {
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
return
}
// form response
var fetchResp synccommon.FetchRespT
//// server time
fetchResp.ServerTime = time.Now().Unix()
//// deletions
for i := range deletionsList {
// perform deletion if it is relevant to the current client device
affectedIDVanityPath := strings.Split(deletionsList[i].Name(), global.FSSpace)
if affectedIDVanityPath[0] == clientDeviceID {
var isAgeFile bool
if affectedIDVanityPath[1] == "age" {
isAgeFile = true
}
fetchResp.Deletions = append(fetchResp.Deletions, synccommon.Deletion{VanityPath: strings.ReplaceAll(affectedIDVanityPath[2], global.FSPath, "/"), IsAgeFile: isAgeFile})
// assume successful client deletion and remove deletions file (if assumption is somehow false, worst case scenario is that the client will re-upload the deleted entry)
if err = os.RemoveAll(global.CfgDir + global.PathSeparator + "deletions" + global.PathSeparator + deletionsList[i].Name()); err != nil {
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
return
}
}
}
//// entries
fetchResp.Entries = entryMap
// marshal and print response to send to client
fetchRespBytes, err := json.Marshal(fetchResp)
if err != nil {
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
return
}
fmt.Print(string(fetchRespBytes))
}
+1 -9
View File
@@ -1,10 +1,2 @@
## Planned Breaking Changes
Leading up to the v1.0.0 release, breaking changes are both expected and planned. These changes are expected to require manual intervention from the end-user, and thus MUTN/libmutton should not be used prior to v1.0.0 if this is not acceptable.
These changes include, but may expand beyond the following:
- Migration to Go-native encryption (no reliance on GnuPG)
- Will be based on symmetrical encryption
- Will eventually allow combining multiple common encryption algorithms (cascading encryption)
- Password aging data will be stored for each entry (to remind the user when it is time to change passwords)
- Will be included in entry names or in external file (to prevent needing to decrypt entries to access this information)
Leading up to the v1.0.0 release, breaking changes are both expected and planned. These changes are expected to require manual intervention from the end-user, and thus MUTN/libmutton should not be used prior to v1.0.0 if this is not acceptable.
-3
View File
@@ -1,3 +0,0 @@
## Known Bugs - libmutton
- On Windows, GPG is sometimes (seems unpredictable) incredibly slow to start (often after a reboot), leading to many operations seemingly hanging
- **This will be addressed** in the migration off of GPG that will take place before v1.0.0
-3
View File
@@ -6,6 +6,3 @@ The intended way for libmutton to interact with the system clipboard is for it t
Clipboard managers save a history of what has been copied to the clipboard, which is already a big enough issue on its own for people who copy sensitive information to their clipboard. Some **clipboard managers simply will not allow the clipboard to be empty** and will replace its contents with the last copied item if you attempt to clear it. One such naughty clipboard manager is **KDE Klipper**, which comes **packaged into KDE Plasma** and is typically **enabled by default** on most distributions. Due to this behavior, **KDE Klipper breaks libmutton's clipboard clearing functionality** and should not be left enabled.
It is likely other popular clipboard managers exhibit this behavior. I noticed it with KDE Klipper, which is what prompted me to create this wiki page. **Clipboard managers should not be enabled by default in any environment** or distribution due to their **potential security implications**.
### Termux cannot clear the clipboard from the background
***
If using libmutton on Termux (Android), the clipboard may not successfully be cleared after the 30 second timeout period if Termux is not actively in the foreground when the sleep timer expires. This is an unfortunate side-effect of running on Android and cannot be easily fixed. Due to Termux being at the bottom of the platform support priority list, I will not be investing time into working around this.
+26 -30
View File
@@ -3,48 +3,44 @@
libmutton was designed to be usable as a library for building other compatible password managers off of. [MUTN](https://github.com/rwinkhart/MUTN) is the official reference CLI password manager, however libmutton can be implemented in many other unique ways.
All functionality in the `core` and `sync` packages are designed to be used in other implementations.
If any functionality in these two packages proves to be difficult to implement in a third-party client, please open an issue so that it can be addressed.
## Dependency Replacements (IMPORTANT)
In libmutton's `go.mod`, you'll see dependency replacements for `golang.org/x/sys` and `github.com/Microsoft/go-winio`. Full support for Windows and FreeBSD requires these patched modules, so be sure to maintain these same replacements in your client's `go.mod`!
## Build Tags
Custom build tags can (and sometimes must) be used to achieve desired results.
These are as follows:
- `interactive`: If making an interactive interface (GUI/TUI/interactive CLI), you probably need to use this build tag. Without it, your entire program will exit after any given operation is completed. This behavior is only desired for non-interactive CLI implementations, such as MUTN. Currently, most errors will result in the program exiting **even with this build tag**. Specific types of errors (such as config parsing/SSH dialing errors) have been made exempt from this behavior.
- `interactive`: If making an interactive interface (GUI/TUI/interactive CLI), you probably need to use this build tag. Without it, your entire program will exit after any given operation is completed. This behavior is only desired for non-interactive CLI implementations, such as MUTN.
- `wsl`: Allows creating a Linux binary that can interact with the Windows clipboard (for WSL)
- `termux`: Allows creating an Android binary that can interact with the Termux clipboard (for Android)
## Required Global Variable Manipulation
libmutton provides a `PassphraseInputFunction` global variable that all clients must set to support passphrase-protected SSH identity files. This approach allows for different types of clients (CLI, GUI, TUI) to prompt for the passphrase in the most appropriate way.
- `global.GetPassword` must be set to allow for different types of clients (CLI, GUI, TUI) to prompt for the password in the most appropriate way.
- `crypt.RetryPassword`, true by default, determines whether the crypt package should verify user-typed passwords and re-prompt if needed. Turn this off to handle this uniquely in the client.
## Required Argument (clipclear)
The `clipclear` argument should be accepted by all non-interactive CLI libmutton implementations (not required for interactive GUI/TUI implementations). In order to clear the clipboard on a timer, non-interactive libmutton-based password managers call another instance of their executable with the `clipclear` argument (e.g. `mutn clipclear`) with the intended clipboard contents provided via STDIN. If after 30 seconds the clipboard contents have not changed, they are cleared. Please accept a `clipclear` argument that calls `core.ClipClearArgument()`.
## Required Arguments
- `clipclear`: Should be accepted by all non-interactive CLI libmutton implementations (not required for interactive GUI/TUI implementations). In order to clear the clipboard on a timer, non-interactive libmutton-based password managers call another instance of their executable with the `clipclear` argument (e.g. `mutn clipclear`) with the intended clipboard contents provided via STDIN. If after 30 seconds the clipboard contents have not changed, they are cleared. Please accept a `clipclear` argument that calls `clip.ClearArgument()`.
- `startrcwd`: Should be accepted by all libmutton implementations making use of the RCW daemon to cache passwords. Please accept a `startrcwd` argument that calls `crypt.RCWDArgument()`.
## Mobile Clipboard Management
In an effort to reduce dependencies not needed in most environments, libmutton no longer provides clipboard management for mobile platforms. This should be handled by your GUI toolkit/framework.
## Configuration
libmutton-based password manager clients should all share the same INI configuration file.
libmutton-based password manager clients should all share the same JSON configuration file.
On UNIX-like systems, this is located at `~/.config/libmutton/libmutton.ini`. On Windows, it is located at `~\AppData\Local\libmutton\config\libmutton.ini`.
On UNIX-like systems, this is located at `~/.config/libmutton/libmuttoncfg.json`. On Windows, it is located at `~\AppData\Local\libmutton\config\libmuttoncfg.json`.
If creating a third-party client that requires extra configuration to be stored, please use the `ClientSpecific` map in the `config.CfgT` type (as used by `config.Write()`) to save your application-specific configuration.
### Base `libmutton.ini` Layout
The current base layout of `libmutton.ini` will change leading up to release v1.0.0. As of right now, the specification is as follows:
```
[LIBMUTTON]
gpgID = <gpg key id>
sshUser = <remote user>
sshIP = <remote ip>
sshPort = <remote ssh port>
sshKey = <ssh private key identity file path>
sshKeyProtected = <true/false>
sshEntryRoot = <remote entry root>
sshIsWindows = <true/false>
```
If creating a third-party client that requires extra configuration to be stored, please use the same file and create a new INI section for your application-specific configuration, e.g.:
```
[THIRD-PARTY-CLIENT-NAME]
configKey = <value>
```
This ensures that a user can use multiple client applications with the same configuration while avoiding conflicts.
# Relevant Bugs Affecting Third-Party Client Implementations
- Password-protected SSH identity files currently only prompt for password entry in the CLI, and thus they are not yet supported in GUI/TUI implementations
## Entry Format
A decrypted libmutton entry is a plaintext file where each line indicates a new field in the entry.
These fields are as follows:
```
0/first line: password
1/second line: username
2/third line: TOTP secret
3/fourth line: URL
4+/fifth line+: notes
```
+3 -4
View File
@@ -1,8 +1,7 @@
## Migrating From Other Password Managers
**Important Notice**: The libmutton entry format is not final and has two [breaking changes planned prior to release v1.0.0](https://github.com/rwinkhart/libmutton/blob/main/wiki/breaking.md). This guide will be updated accordingly.
### pass
libmutton-based password managers *currently* use GnuPG encryption and an entry format similar to that of [pass](https://www.passwordstore.org/). Because of this, any entries in `pass` format can simply be dropped into `~/.local/share/libmutton`.
Current libmutton-based password managers use a custom, embedded cryptography agent ([RCW](https://github.com/rwinkhart/rcw)). GPG-encrypted entries (from `pass` or libmutton releases prior to v0.4.0) can simply be decrypted and re-encrypted with RCW. A [conversion program](https://github.com/rwinkhart/sshyp-labs/releases/tag/v2.0.0) has been published for this purpose.
### sshyp
sshyp, though also `pass`-compatible, makes some changes to the entry format that take effect once the entry has been imported. The changes made by sshyp are not compatible with libmutton, and as such sshyp entries must be converted before they can be used. A script for doing that has been created and is available in the sshyp extension store. Simply run `sshyp tweak`, go to the "extension management" menu, and download the "export-to-libmutton" extension. After doing this, the `sshyp export` command can be used to export entries in libmutton format.
`sshyp`, though also `pass`-compatible, makes some changes to the entry format that take effect once the entry has been imported. The changes made by `sshyp` are not compatible with libmutton, and as such `sshyp` entries must be converted before they can be used. A script for exporting to libmutton (prior to v0.4.0) has been published to the `sshyp` extension store. Simply run `sshyp tweak`, go to the "extension management" menu, and download the "export-to-libmutton" extension. After doing this, the `sshyp export` command can be used to export entries in libmutton v0.3.X format. If migrating into libmutton v0.4.0 or later, a second [conversion program](https://github.com/rwinkhart/sshyp-labs/releases/tag/v2.0.0) will be needed to re-encrypt the exported `sshyp` entries.
### Other
The formats for many other password managers can be converted to the `pass` format with community scripts. Some of these scripts are listed [here](https://www.passwordstore.org/#migration). Once converted, entries can be dropped into `~/.local/share/libmutton`.
The formats for many other password managers can be converted to the `pass` format with community scripts. Some of these scripts are listed [here](https://www.passwordstore.org/#migration). Once converted, entries can be dropped into `~/.local/share/libmutton`. If migrating into libmutton v0.4.0 or later, a second [conversion program](https://github.com/rwinkhart/sshyp-labs/releases/tag/v2.0.0) will be needed to re-encrypt the converted entries.
+12
View File
@@ -0,0 +1,12 @@
## Miscellaneous Tips
### TOTP Support
#### Standard 6-digit TOTP
For standard TOTP, all the user needs to do is add their TOTP secret (NOT the full URL) string as the value for the TOTP field in an entry.
#### Steam TOTP
For Steam TOTP, the user must first extract their TOTP secret from the Steam app for Android.
There are several methods for doing this, but the most consistent I have found is [detailed here](https://github.com/JustArchiNET/ArchiSteamFarm/discussions/2786). Note that a rooted phone is not required if using a ROM like LineageOS that offers rooted ADB (must be enabled in developer options and adb must be started with `adb -d root`).
This method will result in a Base64-encoded Steam TOTP secret. libmutton requires a base32-encoded secret, so this secret must be converted as follows (on Linux/FreeBSD/Mac): `printf '<shared_secret>' | base64 -d | base32`
To signal to libmutton that this TOTP secret is for Steam, prepend it with "steam@" when adding it to the TOTP field in an entry, e.g. "steam@bAsE32sEcReTkEy". This will tell libmutton to use the Steam-specific TOTP encoder.