mirror of
https://github.com/foomo/posh.git
synced 2025-10-16 12:45:38 +00:00
365 lines
9.9 KiB
Go
365 lines
9.9 KiB
Go
package onepassword
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/1Password/connect-sdk-go/connect"
|
|
"github.com/1Password/connect-sdk-go/onepassword"
|
|
"github.com/foomo/posh/pkg/cache"
|
|
"github.com/foomo/posh/pkg/log"
|
|
"github.com/joho/godotenv"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
type (
|
|
OnePassword struct {
|
|
l log.Logger
|
|
cache cache.Namespace
|
|
connect connect.Client
|
|
uuidRegex *regexp.Regexp
|
|
tokenFilename string
|
|
watching map[string]bool
|
|
last time.Time
|
|
}
|
|
Option func(*OnePassword) error
|
|
)
|
|
|
|
// ------------------------------------------------------------------------------------------------
|
|
// ~ Options
|
|
// ------------------------------------------------------------------------------------------------
|
|
|
|
func WithTokenFilename(v string) Option {
|
|
return func(o *OnePassword) error {
|
|
o.tokenFilename = v
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------------------
|
|
// ~ Constructor
|
|
// ------------------------------------------------------------------------------------------------
|
|
|
|
func New(l log.Logger, cache cache.Cache, opts ...Option) (*OnePassword, error) {
|
|
inst := &OnePassword{
|
|
l: l,
|
|
cache: cache.Get("onePasswordClient"),
|
|
uuidRegex: regexp.MustCompile(`^[a-z0-9]{26}$`),
|
|
watching: map[string]bool{},
|
|
}
|
|
for _, opt := range opts {
|
|
if opt != nil {
|
|
if err := opt(inst); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
if client, err := connect.NewClientFromEnvironment(); err != nil {
|
|
l.Debug("not able to create connect client", err.Error())
|
|
} else {
|
|
inst.connect = client
|
|
}
|
|
return inst, nil
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------------------
|
|
// ~ Public methods
|
|
// ------------------------------------------------------------------------------------------------
|
|
|
|
func (op *OnePassword) Session(account string) (bool, error) {
|
|
var sessChanged bool
|
|
sess := os.Getenv("OP_SESSION_" + account)
|
|
|
|
if op.tokenFilename != "" {
|
|
if err := godotenv.Overload(op.tokenFilename); err != nil {
|
|
op.l.Debug("could not load session from env file")
|
|
sessChanged = true
|
|
} else if value := os.Getenv("OP_SESSION_" + account); sess != value {
|
|
sessChanged = true
|
|
} else {
|
|
op.l.Debug("INFO: loaded op session from file " + value)
|
|
}
|
|
}
|
|
|
|
if sessChanged || op.last.IsZero() || time.Since(op.last) > time.Minute*10 {
|
|
out, err := exec.Command("op", "account", "--account", account, "get", "--format", "json").Output()
|
|
if err != nil {
|
|
return false, fmt.Errorf("%w: %s", err, string(out))
|
|
}
|
|
|
|
var data struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
if err := json.Unmarshal(out, &data); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if data.Name == account {
|
|
op.last = time.Now()
|
|
op.watch(account)
|
|
return true, nil
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (op *OnePassword) SignIn(ctx context.Context, account string) error {
|
|
if ok, _ := op.Session(account); ok {
|
|
return nil
|
|
}
|
|
|
|
// create command
|
|
cmd := exec.CommandContext(ctx,
|
|
"op", "signin",
|
|
"--account", account,
|
|
"--raw",
|
|
)
|
|
|
|
var stdoutBuf bytes.Buffer
|
|
cmd.Stdout = &stdoutBuf
|
|
cmd.Stdin = os.Stdin
|
|
|
|
// start the process and wait till it's finished
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
} else if err := cmd.Wait(); err != nil {
|
|
return err
|
|
}
|
|
|
|
token := strings.TrimSuffix(stdoutBuf.String(), "\n")
|
|
if token == "" {
|
|
return errors.New("failed to retrieve 1password token!")
|
|
} else if err := os.Setenv(fmt.Sprintf("OP_SESSION_%s", account), token); err != nil {
|
|
return err
|
|
} else {
|
|
op.l.Infof(`If you need op outside the shell, run:
|
|
|
|
$ export OP_SESSION_%s=%s
|
|
`, account, token)
|
|
}
|
|
|
|
if op.tokenFilename != "" {
|
|
if err := os.MkdirAll(path.Dir(op.tokenFilename), os.ModePerm); err != nil {
|
|
return err
|
|
} else if err := os.WriteFile(op.tokenFilename, []byte(fmt.Sprintf("OP_SESSION_%s=%s\n", account, token)), 0600); err != nil {
|
|
return err
|
|
} else {
|
|
op.l.Infof(`Session env has been stored for your convenience at:
|
|
|
|
%s
|
|
`, op.tokenFilename)
|
|
}
|
|
}
|
|
op.watch(account)
|
|
return nil
|
|
}
|
|
|
|
func (op *OnePassword) Get(ctx context.Context, account, vaultUUID, itemUUID, field string) (string, error) {
|
|
if op.connect != nil {
|
|
if fields := op.connectGet(vaultUUID, itemUUID); len(fields) == 0 {
|
|
return "", fmt.Errorf("could not find secret '%s' '%s'", vaultUUID, itemUUID)
|
|
} else if value, ok := fields[field]; !ok {
|
|
return "", fmt.Errorf("could not find field %s", field)
|
|
} else {
|
|
return strings.ReplaceAll(strings.TrimSpace(value), "\\n", "\n"), nil
|
|
}
|
|
} else {
|
|
if ok, _ := op.Session(account); !ok {
|
|
return "", ErrNotSignedIn
|
|
} else if fields := op.clientGet(ctx, vaultUUID, itemUUID); len(fields) == 0 {
|
|
return "", fmt.Errorf("could not find secret '%s' '%s'", vaultUUID, itemUUID)
|
|
} else if value, ok := fields[field]; !ok {
|
|
return "", fmt.Errorf("could not find field %s", field)
|
|
} else {
|
|
return strings.ReplaceAll(strings.TrimSpace(value), "\\n", "\n"), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (op *OnePassword) GetOnetimePassword(ctx context.Context, account, uuid string) (string, error) {
|
|
if ok, _ := op.Session(account); !ok {
|
|
return "", ErrNotSignedIn
|
|
}
|
|
|
|
out, err := exec.CommandContext(ctx,
|
|
"op", "item", "get", "--otp", uuid,
|
|
).Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return strings.ReplaceAll(strings.TrimSpace(string(out)), "\\n", "\n"), nil
|
|
}
|
|
|
|
func (op *OnePassword) Render(ctx context.Context, source string) ([]byte, error) {
|
|
tpl, err := template.New("1password").
|
|
Delims("<% ", " %>").
|
|
Option("missingkey=error").
|
|
Funcs(
|
|
template.FuncMap{
|
|
"env": func(name string) (string, error) {
|
|
value := os.Getenv(name)
|
|
if value == "" {
|
|
return "", fmt.Errorf("env variable %q was empty", name)
|
|
}
|
|
return value, nil
|
|
},
|
|
"op": func(account, vaultUUID, itemUUID, field string) (string, error) {
|
|
return op.Get(ctx, account, vaultUUID, itemUUID, field)
|
|
},
|
|
"indent": func(spaces int, v string) string {
|
|
pad := strings.Repeat(" ", spaces)
|
|
return strings.ReplaceAll(v, "\n", "\n"+pad)
|
|
},
|
|
"quote": func(v string) string {
|
|
return "'" + v + "'"
|
|
},
|
|
"replace": func(old, new, v string) string {
|
|
return strings.ReplaceAll(v, old, new)
|
|
},
|
|
"base64": func(v string) string {
|
|
return base64.StdEncoding.EncodeToString([]byte(v))
|
|
},
|
|
},
|
|
).
|
|
Parse(source)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := bytes.NewBuffer([]byte{})
|
|
if err := tpl.Execute(out, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
func (op *OnePassword) RenderFile(ctx context.Context, source string) ([]byte, error) {
|
|
in, err := os.ReadFile(source)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out, err := op.Render(ctx, string(in))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (op *OnePassword) RenderFileTo(ctx context.Context, source, target string) error {
|
|
out, err := op.RenderFile(ctx, source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
value := fmt.Sprintf(
|
|
"# Code generated by shell %s - DO NOT EDIT.\n%s",
|
|
time.Now().Format("2006-01-02 15:04:05"),
|
|
string(out),
|
|
)
|
|
return os.WriteFile(target, []byte(value), 0600)
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------------------
|
|
// ~ Private methods
|
|
// ------------------------------------------------------------------------------------------------
|
|
|
|
func (op *OnePassword) clientGet(ctx context.Context, vaultUUID string, itemUUID string) map[string]string {
|
|
return op.cache.Get(strings.Join([]string{vaultUUID, itemUUID}, "#"), func() interface{} {
|
|
ret := map[string]string{}
|
|
var v struct {
|
|
Vault struct {
|
|
ID string `json:"id"`
|
|
} `json:"vault"`
|
|
Fields []struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"` // CONCEALED, STRING
|
|
Label string `json:"label"`
|
|
Value interface{} `json:"value"`
|
|
} `json:"fields"`
|
|
}
|
|
if res, err := exec.CommandContext(ctx,
|
|
"op", "item", "get", itemUUID, "--format", "json",
|
|
).CombinedOutput(); err != nil {
|
|
op.l.Error("failed to retrieve item", err.Error())
|
|
return ret
|
|
} else if err := json.Unmarshal(res, &v); err != nil {
|
|
op.l.Error("failed to retrieve item", err.Error())
|
|
return ret
|
|
} else if v.Vault.ID != vaultUUID {
|
|
op.l.Error("failed to retrieve item", "wrong vault UUID %s for item %s", vaultUUID, itemUUID)
|
|
return ret
|
|
} else {
|
|
ret := map[string]string{}
|
|
aliases := map[string]string{
|
|
"notesPlain": "notes",
|
|
}
|
|
for _, field := range v.Fields {
|
|
if alias, ok := aliases[field.Label]; ok {
|
|
ret[alias] = fmt.Sprintf("%v", field.Value)
|
|
} else {
|
|
ret[field.Label] = fmt.Sprintf("%v", field.Value)
|
|
}
|
|
}
|
|
return ret
|
|
}
|
|
}).(map[string]string)
|
|
}
|
|
|
|
func (op *OnePassword) connectGet(vaultUUID, itemUUID string) map[string]string {
|
|
return op.cache.Get(strings.Join([]string{vaultUUID, itemUUID}, "#"), func() interface{} {
|
|
ret := map[string]string{}
|
|
var item *onepassword.Item
|
|
if op.uuidRegex.Match([]byte(itemUUID)) {
|
|
if v, err := op.connect.GetItem(itemUUID, vaultUUID); err != nil {
|
|
op.l.Error("failed to retrieve item:", err.Error())
|
|
return ret
|
|
} else {
|
|
item = v
|
|
}
|
|
} else {
|
|
if v, err := op.connect.GetItemByTitle(itemUUID, vaultUUID); err != nil {
|
|
op.l.Error("failed to retrieve item by title:", err.Error())
|
|
return ret
|
|
} else {
|
|
item = v
|
|
}
|
|
}
|
|
for _, f := range item.Fields {
|
|
ret[f.Label] = f.Value
|
|
}
|
|
return ret
|
|
}).(map[string]string)
|
|
}
|
|
|
|
func (op *OnePassword) watch(account string) {
|
|
if v, ok := op.watching[account]; !ok || !v {
|
|
go func() {
|
|
for {
|
|
if ok, err := op.Session(account); err != nil {
|
|
op.l.Warnf("\n1password session keep alive failed for '%s' (%s)", account, err.Error())
|
|
op.watching[account] = false
|
|
return
|
|
} else if !ok {
|
|
op.l.Warnf("\n1password session keep alive failed for '%s'", account)
|
|
op.watching[account] = false
|
|
return
|
|
}
|
|
time.Sleep(time.Minute * 15)
|
|
}
|
|
}()
|
|
op.watching[account] = true
|
|
}
|
|
}
|