Use runtime-dir Unix sockets for local gRPC

This commit is contained in:
Joe Julian
2026-04-11 08:26:37 -07:00
parent c017308aa1
commit 2ef571c241
16 changed files with 346 additions and 29 deletions
+3
View File
@@ -100,6 +100,9 @@ You will need the Android SDK and NDK installed and configured for real device o
Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type. Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type.
See [`docs/desktop-automation.md`](./docs/desktop-automation.md). See [`docs/desktop-automation.md`](./docs/desktop-automation.md).
On desktop, KeePassGO now listens on a Unix socket by default under the user runtime directory.
Set `KEEPASSGO_GRPC_ADDR` or `-grpc-addr` to override it, for example `tcp://127.0.0.1:47777`.
## Browser Extension ## Browser Extension
Firefox and Chromium browser integration is available through the local gRPC API plus a native messaging bridge. Firefox and Chromium browser integration is available through the local gRPC API plus a native messaging bridge.
+2 -2
View File
@@ -2,9 +2,9 @@
Shared extension assets for Firefox and Chromium-based browsers live here. Shared extension assets for Firefox and Chromium-based browsers live here.
- `manifest.firefox.json` uses the fixed Firefox extension id `browser@keepassgo.invalid` - `manifest.firefox.json` uses the fixed Firefox extension id `browser@keepassgo.com`
- `manifest.chromium.json` is the Chromium/Chrome manifest template - `manifest.chromium.json` is the Chromium/Chrome manifest template
- `background.js` talks to the native messaging host `org.keepassgo.browser` - `background.js` talks to the native messaging host `com.keepassgo.browser`
- `content.js` fills username and password fields on the current page - `content.js` fills username and password fields on the current page
- `options.html` stores the local gRPC address and API token in browser extension storage - `options.html` stores the local gRPC address and API token in browser extension storage
+2 -2
View File
@@ -1,7 +1,7 @@
const ext = globalThis.browser ?? globalThis.chrome; const ext = globalThis.browser ?? globalThis.chrome;
const nativeHost = "org.keepassgo.browser"; const nativeHost = "com.keepassgo.browser";
const defaultSettings = { const defaultSettings = {
grpcAddress: "127.0.0.1:47777", grpcAddress: "",
bearerToken: "" bearerToken: ""
}; };
+1 -1
View File
@@ -25,7 +25,7 @@
], ],
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "browser@keepassgo.invalid" "id": "browser@keepassgo.com"
} }
} }
} }
+1 -1
View File
@@ -17,7 +17,7 @@
<form id="settings-form" class="settings-form"> <form id="settings-form" class="settings-form">
<label> <label>
<span>gRPC address</span> <span>gRPC address</span>
<input id="grpc-address" name="grpc-address" type="text" value="127.0.0.1:47777" autocomplete="off"> <input id="grpc-address" name="grpc-address" type="text" value="" placeholder="Leave blank for the local default socket" autocomplete="off">
</label> </label>
<label> <label>
<span>API token</span> <span>API token</span>
+1 -1
View File
@@ -18,7 +18,7 @@ async function loadSettings() {
if (!response?.success) { if (!response?.success) {
throw new Error(response?.error || "Could not load settings."); throw new Error(response?.error || "Could not load settings.");
} }
document.getElementById("grpc-address").value = response.settings.grpcAddress || "127.0.0.1:47777"; document.getElementById("grpc-address").value = response.settings.grpcAddress || "";
document.getElementById("bearer-token").value = response.settings.bearerToken || ""; document.getElementById("bearer-token").value = response.settings.bearerToken || "";
} }
+24 -2
View File
@@ -8,9 +8,11 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"git.julianfamily.org/keepassgo/internal/browserbridge" "git.julianfamily.org/keepassgo/internal/browserbridge"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
) )
func main() { func main() {
@@ -39,6 +41,8 @@ func runInstallNativeHost(args []string) error {
browserName := fs.String("browser", string(browserbridge.BrowserFirefox), "target browser: firefox, chrome, chromium") browserName := fs.String("browser", string(browserbridge.BrowserFirefox), "target browser: firefox, chrome, chromium")
binaryPath := fs.String("binary", "", "path to keepassgo-browser-bridge binary") binaryPath := fs.String("binary", "", "path to keepassgo-browser-bridge binary")
extensionID := fs.String("extension-id", "", "browser extension id (required for chrome/chromium)") extensionID := fs.String("extension-id", "", "browser extension id (required for chrome/chromium)")
extensionKey := fs.String("extension-key", "", "Chromium manifest public key used to derive a fixed extension id")
extensionKeyFile := fs.String("extension-key-file", "", "path to a Chromium manifest public key file")
outputPath := fs.String("output", "", "native host manifest output path") outputPath := fs.String("output", "", "native host manifest output path")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
@@ -51,7 +55,25 @@ func runInstallNativeHost(args []string) error {
} }
path = resolved path = resolved
} }
installed, err := browserbridge.InstallManifest(browserbridge.Browser(strings.TrimSpace(*browserName)), path, strings.TrimSpace(*extensionID), strings.TrimSpace(*outputPath)) resolvedExtensionID := strings.TrimSpace(*extensionID)
if resolvedExtensionID == "" {
keyValue := strings.TrimSpace(*extensionKey)
if keyValue == "" && strings.TrimSpace(*extensionKeyFile) != "" {
data, err := os.ReadFile(strings.TrimSpace(*extensionKeyFile))
if err != nil {
return err
}
keyValue = string(data)
}
if keyValue != "" {
derivedID, err := browserbridge.ChromiumExtensionIDFromManifestKey(keyValue)
if err != nil {
return err
}
resolvedExtensionID = derivedID
}
}
installed, err := browserbridge.InstallManifest(browserbridge.Browser(strings.TrimSpace(*browserName)), path, resolvedExtensionID, strings.TrimSpace(*outputPath))
if err != nil { if err != nil {
return err return err
} }
@@ -61,7 +83,7 @@ func runInstallNativeHost(args []string) error {
func runStatus(args []string) error { func runStatus(args []string) error {
fs := flag.NewFlagSet("status", flag.ContinueOnError) fs := flag.NewFlagSet("status", flag.ContinueOnError)
grpcAddr := fs.String("grpc-addr", browserbridge.DefaultGRPCAddress, "KeePassGO local gRPC address") grpcAddr := fs.String("grpc-addr", grpcaddr.Default(runtime.GOOS), "KeePassGO local gRPC address")
token := fs.String("token", "", "KeePassGO API bearer token") token := fs.String("token", "", "KeePassGO API bearer token")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
+23 -3
View File
@@ -28,6 +28,20 @@ The browser integration uses:
The browser feature intentionally stays on the same secure gRPC surface used by other trusted automation. The browser feature intentionally stays on the same secure gRPC surface used by other trusted automation.
## Default Listener
On desktop KeePassGO listens on a Unix socket by default:
- primary location: under the user runtime directory
- fallback: `/run/user/<uid>` if present
- final fallback: a private directory under the system temp directory
Override the listener with `-grpc-addr` or `KEEPASSGO_GRPC_ADDR`, for example:
```bash
KEEPASSGO_GRPC_ADDR=tcp://127.0.0.1:47777 ./keepassgo
```
## Native Host ## Native Host
Build the bridge: Build the bridge:
@@ -45,10 +59,16 @@ Install a Firefox native messaging manifest:
Install a Chromium native messaging manifest: Install a Chromium native messaging manifest:
```bash ```bash
./keepassgo-browser-bridge install-native-host --browser chromium --binary /absolute/path/to/keepassgo-browser-bridge --extension-id <your-extension-id> ./keepassgo-browser-bridge install-native-host --browser chromium --binary /absolute/path/to/keepassgo-browser-bridge --extension-key-file /path/to/chromium-extension-public-key.txt
``` ```
Chrome and Chromium require the actual extension id in the native host manifest. Chrome and Chromium require the actual extension id in the native host manifest. KeePassGO can derive that id from the Chromium manifest public key so you do not have to type it separately.
For a fixed Chromium ID:
1. Keep a stable Chromium extension signing key outside the repo.
2. Add the corresponding public key to the Chromium manifest as `"key": "<base64-public-key>"`.
3. Use the same public key with `install-native-host --extension-key-file ...` so the native host manifest is locked to that stable extension ID.
## Extension Setup ## Extension Setup
@@ -56,7 +76,7 @@ Firefox:
1. Load `browser/extension/manifest.firefox.json` as a temporary add-on or package it as an extension. 1. Load `browser/extension/manifest.firefox.json` as a temporary add-on or package it as an extension.
2. Open the extension settings page. 2. Open the extension settings page.
3. Set the KeePassGO gRPC address, usually `127.0.0.1:47777`. 3. Leave the gRPC address blank to use the local default Unix socket, or set an explicit address if you overrode the listener.
4. Paste an API token scoped for browser login lookup and credential copy. 4. Paste an API token scoped for browser login lookup and credential copy.
Chromium / Chrome: Chromium / Chrome:
+50 -3
View File
@@ -4,10 +4,13 @@ import (
"errors" "errors"
"fmt" "fmt"
"net" "net"
"os"
"path/filepath"
"strings" "strings"
"sync" "sync"
"git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
"git.julianfamily.org/keepassgo/internal/passwords" "git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/vault"
@@ -27,6 +30,7 @@ type Host struct {
lastModel vault.Model lastModel vault.Model
started bool started bool
listenAddr string listenAddr string
socketPath string
} }
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) { func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
@@ -35,7 +39,11 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
return nil, nil return nil, nil
} }
listener, err := net.Listen("tcp", addr) network, endpoint, err := grpcaddr.Parse(addr)
if err != nil {
return nil, err
}
listener, socketPath, err := listen(network, endpoint)
if err != nil { if err != nil {
return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err) return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err)
} }
@@ -50,7 +58,8 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
listener: listener, listener: listener,
lifecycle: lifecycle, lifecycle: lifecycle,
dirty: dirty, dirty: dirty,
listenAddr: listener.Addr().String(), listenAddr: formatListenAddress(network, listener.Addr().String(), socketPath),
socketPath: socketPath,
started: true, started: true,
} }
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) { if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
@@ -91,7 +100,13 @@ func (h *Host) Stop() error {
} }
h.started = false h.started = false
h.grpcServer.Stop() h.grpcServer.Stop()
return h.listener.Close() err := h.listener.Close()
if h.socketPath != "" {
if removeErr := os.Remove(h.socketPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) && err == nil {
err = removeErr
}
}
return err
} }
func (h *Host) SyncFromLifecycle() error { func (h *Host) SyncFromLifecycle() error {
@@ -120,3 +135,35 @@ func (h *Host) SyncFromLifecycle() error {
h.server.SetSessionState(h.lastModel, locked, dirty) h.server.SetSessionState(h.lastModel, locked, dirty)
return nil return nil
} }
func listen(network, endpoint string) (net.Listener, string, error) {
if network == "unix" {
if err := os.MkdirAll(filepath.Dir(endpoint), 0o700); err != nil {
return nil, "", err
}
if err := os.Remove(endpoint); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, "", err
}
listener, err := net.Listen("unix", endpoint)
if err != nil {
return nil, "", err
}
if err := os.Chmod(endpoint, 0o600); err != nil {
_ = listener.Close()
return nil, "", err
}
return listener, endpoint, nil
}
listener, err := net.Listen(network, endpoint)
if err != nil {
return nil, "", err
}
return listener, "", nil
}
func formatListenAddress(network, listenerAddr, socketPath string) string {
if network == "unix" {
return "unix://" + socketPath
}
return listenerAddr
}
+42 -1
View File
@@ -2,10 +2,13 @@ package api
import ( import (
"context" "context"
"errors"
"net" "net"
"os"
"testing" "testing"
"git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
"git.julianfamily.org/keepassgo/internal/passwords" "git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/vault"
@@ -42,10 +45,14 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
} }
defer func() { _ = host.Stop() }() defer func() { _ = host.Stop() }()
network, endpoint, err := grpcaddr.Parse(host.Address())
if err != nil {
t.Fatalf("Parse(host.Address()) error = %v", err)
}
conn, err := grpc.NewClient("passthrough:///"+host.Address(), conn, err := grpc.NewClient("passthrough:///"+host.Address(),
grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return net.Dial("tcp", host.Address()) return net.Dial(network, endpoint)
}), }),
) )
if err != nil { if err != nil {
@@ -80,3 +87,37 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock") t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock")
} }
} }
func TestStartHostServesOverUnixSocket(t *testing.T) {
t.Parallel()
socketDir := t.TempDir()
socketPath := socketDir + "/keepassgo.sock"
lifecycle := &session.Manager{}
if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
),
},
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err)
}
host, err := StartHost("unix://"+socketPath, lifecycle, passwords.DefaultProfiles(), nil, func() bool { return false })
if err != nil {
t.Fatalf("StartHost() error = %v", err)
}
if got := host.Address(); got != "unix://"+socketPath {
t.Fatalf("host.Address() = %q, want %q", got, "unix://"+socketPath)
}
if _, err := os.Stat(socketPath); err != nil {
t.Fatalf("Stat(socketPath) error = %v", err)
}
if err := host.Stop(); err != nil {
t.Fatalf("Stop() error = %v", err)
}
if _, err := os.Stat(socketPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("socket exists after Stop(), err = %v, want not-exist", err)
}
}
+2 -4
View File
@@ -16,6 +16,7 @@ import (
"git.julianfamily.org/keepassgo/internal/apiapproval" "git.julianfamily.org/keepassgo/internal/apiapproval"
"git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/appui/platform" "git.julianfamily.org/keepassgo/internal/appui/platform"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
"git.julianfamily.org/keepassgo/internal/passwords" "git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/vault"
@@ -56,10 +57,7 @@ func Main() {
} }
func defaultGRPCAddr(goos string) string { func defaultGRPCAddr(goos string) string {
if strings.EqualFold(strings.TrimSpace(goos), "android") { return grpcaddr.Default(goos)
return "off"
}
return "127.0.0.1:47777"
} }
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error { func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
+33 -4
View File
@@ -2,22 +2,26 @@ package browserbridge
import ( import (
"context" "context"
"crypto/sha256"
"encoding/base64"
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
) )
const ( const (
NativeHostName = "org.keepassgo.browser" NativeHostName = "com.keepassgo.browser"
DefaultGRPCAddress = "127.0.0.1:47777" defaultFirefoxID = "browser@keepassgo.com"
defaultFirefoxID = "browser@keepassgo.invalid"
maxNativeMessageSize = 1024 * 1024 maxNativeMessageSize = 1024 * 1024
chromiumIDBytes = 16
) )
type Request struct { type Request struct {
@@ -136,7 +140,7 @@ func (r Request) Connection() (Connection, error) {
BearerToken: strings.TrimSpace(r.BearerToken), BearerToken: strings.TrimSpace(r.BearerToken),
} }
if conn.GRPCAddress == "" { if conn.GRPCAddress == "" {
conn.GRPCAddress = DefaultGRPCAddress conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
} }
if conn.BearerToken == "" { if conn.BearerToken == "" {
return Connection{}, fmt.Errorf("browser bridge bearer token is required") return Connection{}, fmt.Errorf("browser bridge bearer token is required")
@@ -277,6 +281,31 @@ func Manifest(browser Browser, binaryPath, extensionID string) (NativeHostManife
} }
} }
func ChromiumExtensionIDFromManifestKey(raw string) (string, error) {
normalized := strings.TrimSpace(raw)
normalized = strings.ReplaceAll(normalized, "-----BEGIN PUBLIC KEY-----", "")
normalized = strings.ReplaceAll(normalized, "-----END PUBLIC KEY-----", "")
normalized = strings.ReplaceAll(normalized, "\n", "")
normalized = strings.ReplaceAll(normalized, "\r", "")
normalized = strings.ReplaceAll(normalized, "\t", "")
normalized = strings.ReplaceAll(normalized, " ", "")
if normalized == "" {
return "", fmt.Errorf("chromium extension key is required")
}
publicKeyDER, err := base64.StdEncoding.DecodeString(normalized)
if err != nil {
return "", fmt.Errorf("decode chromium extension key: %w", err)
}
hash := sha256.Sum256(publicKeyDER)
var builder strings.Builder
builder.Grow(chromiumIDBytes * 2)
for _, b := range hash[:chromiumIDBytes] {
builder.WriteByte('a' + ((b >> 4) & 0x0f))
builder.WriteByte('a' + (b & 0x0f))
}
return builder.String(), nil
}
func DefaultManifestPath(browser Browser) (string, error) { func DefaultManifestPath(browser Browser) (string, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
+34
View File
@@ -7,6 +7,8 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings"
"testing" "testing"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
@@ -39,6 +41,9 @@ func TestReadRequestAndWriteResponse(t *testing.T) {
if req.Action != "find-logins" || req.BearerToken != "secret" { if req.Action != "find-logins" || req.BearerToken != "secret" {
t.Fatalf("ReadRequest() = %#v, want action and token preserved", req) t.Fatalf("ReadRequest() = %#v, want action and token preserved", req)
} }
if conn, err := req.Connection(); err != nil || conn.GRPCAddress != "127.0.0.1:47777" {
t.Fatalf("req.Connection() = (%#v, %v), want explicit tcp address preserved", conn, err)
}
var output bytes.Buffer var output bytes.Buffer
if err := WriteResponse(&output, Response{Success: true, Version: "1"}); err != nil { if err := WriteResponse(&output, Response{Success: true, Version: "1"}); err != nil {
@@ -118,6 +123,22 @@ func TestHandleRequestRequiresBearerToken(t *testing.T) {
} }
} }
func TestRequestConnectionDefaultsAddress(t *testing.T) {
t.Parallel()
req := Request{Action: "status", BearerToken: "secret"}
conn, err := req.Connection()
if err != nil {
t.Fatalf("Connection() error = %v", err)
}
if conn.GRPCAddress == "" {
t.Fatal("Connection().GRPCAddress = empty, want default address")
}
if runtime.GOOS != "windows" && !strings.HasPrefix(conn.GRPCAddress, "unix://") && conn.GRPCAddress != "off" {
t.Fatalf("Connection().GRPCAddress = %q, want unix socket default on this platform", conn.GRPCAddress)
}
}
func TestInstallManifest(t *testing.T) { func TestInstallManifest(t *testing.T) {
t.Parallel() t.Parallel()
@@ -147,6 +168,19 @@ func TestInstallManifest(t *testing.T) {
} }
} }
func TestChromiumExtensionIDFromManifestKey(t *testing.T) {
t.Parallel()
const publicKey = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAMfW0u1k4K5A0uN2s0aH7uQKpM3x5Hf8mZfY1xVh0m7E2mJ7M8GiV4m0g0I2w9U9D1yqGQ6w8jzH5v8t7qB2RjMCAwEAAQ=="
got, err := ChromiumExtensionIDFromManifestKey(publicKey)
if err != nil {
t.Fatalf("ChromiumExtensionIDFromManifestKey() error = %v", err)
}
if got != "okcdfigpojphpoecpglkkmkjmiaefmpd" {
t.Fatalf("ChromiumExtensionIDFromManifestKey() = %q, want %q", got, "okcdfigpojphpoecpglkkmkjmiaefmpd")
}
}
type fakeClient struct { type fakeClient struct {
status *keepassgov1.GetSessionStatusResponse status *keepassgov1.GetSessionStatusResponse
matches []*keepassgov1.BrowserLoginMatch matches []*keepassgov1.BrowserLoginMatch
+14 -5
View File
@@ -4,8 +4,10 @@ import (
"context" "context"
"fmt" "fmt"
"net" "net"
"runtime"
"strings" "strings"
"git.julianfamily.org/keepassgo/internal/grpcaddr"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
@@ -18,20 +20,27 @@ type GRPCClient struct {
func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient, context.Context, error) { func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
if strings.TrimSpace(conn.GRPCAddress) == "" { if strings.TrimSpace(conn.GRPCAddress) == "" {
conn.GRPCAddress = DefaultGRPCAddress conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
} }
if strings.TrimSpace(conn.BearerToken) == "" { if strings.TrimSpace(conn.BearerToken) == "" {
return nil, nil, nil, fmt.Errorf("browser bridge bearer token is required") return nil, nil, nil, fmt.Errorf("browser bridge bearer token is required")
} }
address := strings.TrimSpace(conn.GRPCAddress) network, endpoint, err := grpcaddr.Parse(conn.GRPCAddress)
grpcConn, err := grpc.NewClient("passthrough:///"+address, if err != nil {
return nil, nil, nil, err
}
target := endpoint
if network == "unix" {
target = "passthrough:///" + endpoint
}
grpcConn, err := grpc.NewClient(target,
grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return net.Dial("tcp", address) return net.Dial(network, endpoint)
}), }),
) )
if err != nil { if err != nil {
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", address, err) return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", strings.TrimSpace(conn.GRPCAddress), err)
} }
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+strings.TrimSpace(conn.BearerToken)) ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+strings.TrimSpace(conn.BearerToken))
return grpcConn, &GRPCClient{client: keepassgov1.NewVaultServiceClient(grpcConn)}, ctx, nil return grpcConn, &GRPCClient{client: keepassgov1.NewVaultServiceClient(grpcConn)}, ctx, nil
+66
View File
@@ -0,0 +1,66 @@
package grpcaddr
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
)
const socketName = "keepassgo-grpc.sock"
func Default(goos string) string {
if strings.EqualFold(strings.TrimSpace(goos), "android") {
return "off"
}
if strings.EqualFold(strings.TrimSpace(goos), "windows") {
return "127.0.0.1:47777"
}
return "unix://" + DefaultSocketPath()
}
func DefaultSocketPath() string {
return filepath.Join(runtimeDir(), "keepassgo", socketName)
}
func runtimeDir() string {
if dir := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); dir != "" {
return dir
}
if runtime.GOOS != "windows" {
uid := strconv.Itoa(os.Getuid())
runUserDir := filepath.Join("/run/user", uid)
if info, err := os.Stat(runUserDir); err == nil && info.IsDir() {
return runUserDir
}
}
return filepath.Join(os.TempDir(), fmt.Sprintf("keepassgo-runtime-%d", os.Getuid()))
}
func Parse(raw string) (network, endpoint string, err error) {
value := strings.TrimSpace(raw)
switch {
case value == "":
return "", "", fmt.Errorf("gRPC address is required")
case strings.EqualFold(value, "off"):
return "", "", nil
case strings.HasPrefix(value, "unix://"):
path := strings.TrimSpace(strings.TrimPrefix(value, "unix://"))
if path == "" {
return "", "", fmt.Errorf("unix gRPC socket path is required")
}
return "unix", path, nil
case strings.HasPrefix(value, "tcp://"):
addr := strings.TrimSpace(strings.TrimPrefix(value, "tcp://"))
if addr == "" {
return "", "", fmt.Errorf("tcp gRPC address is required")
}
return "tcp", addr, nil
case strings.HasPrefix(value, "/"):
return "unix", value, nil
default:
return "tcp", value, nil
}
}
+48
View File
@@ -0,0 +1,48 @@
package grpcaddr
import (
"path/filepath"
"runtime"
"testing"
)
func TestDefaultUsesUnixSocketOnUnixLikeSystems(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix default is not expected on windows")
}
t.Setenv("XDG_RUNTIME_DIR", "/tmp/keepassgo-runtime-test")
got := Default("linux")
want := "unix:///tmp/keepassgo-runtime-test/keepassgo/keepassgo-grpc.sock"
if got != want {
t.Fatalf("Default() = %q, want %q", got, want)
}
}
func TestParse(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantNetwork string
wantEnd string
}{
{name: "unix scheme", input: "unix:///tmp/keepassgo.sock", wantNetwork: "unix", wantEnd: "/tmp/keepassgo.sock"},
{name: "tcp scheme", input: "tcp://127.0.0.1:47777", wantNetwork: "tcp", wantEnd: "127.0.0.1:47777"},
{name: "bare path", input: filepath.Clean("/tmp/keepassgo.sock"), wantNetwork: "unix", wantEnd: filepath.Clean("/tmp/keepassgo.sock")},
{name: "bare tcp", input: "127.0.0.1:47777", wantNetwork: "tcp", wantEnd: "127.0.0.1:47777"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotNetwork, gotEnd, err := Parse(tt.input)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if gotNetwork != tt.wantNetwork || gotEnd != tt.wantEnd {
t.Fatalf("Parse() = (%q, %q), want (%q, %q)", gotNetwork, gotEnd, tt.wantNetwork, tt.wantEnd)
}
})
}
}