posh/integration/onepassword/onepassword.go
Kevin Franklin Kim e2ad376b6c initial commit
2023-01-03 15:37:15 +01:00

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
}
}