feat(ebpf-poc): manage the proxy CA and npm trust

Redirecting a package manager into the proxy is only half the job. The
client then has to trust the proxy's certificate, and npm will not,
because Node from nodejs.org compiles its own roots into the binary and
ignores the system trust store.

Adds `pmgwatch ca` with install, status and remove.

install persists a CA keypair through PMG's own certmanager, so the
certificate survives a proxy restart. Without a persisted CA the daemon
generates an ephemeral one on every start and trust has to be redone
each time. The private key is left owned by the proxy user at 0600, and
a separate public bundle is written for npm, so npm never needs access
to the key.

npm is configured through `npm config set cafile --location=user`, run
as the target user. Environment variables that could shadow that config
are stripped from the child so the read-back verification is
meaningful. The previous cafile is recorded and restored on remove.

install refuses to run while the proxy is up, since the daemon reads the
certificate at startup, and refuses to change options against existing
state so a half-migrated setup cannot happen.
This commit is contained in:
Sahilb315
2026-07-28 17:47:47 +05:30
parent 60f2565145
commit d987512ccf
5 changed files with 679 additions and 5 deletions
+533
View File
@@ -0,0 +1,533 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"
"syscall"
"github.com/safedep/pmg/proxy/certmanager"
)
const defaultCAStateDir = "/var/lib/pmg-ebpf-poc"
type caCommandOptions struct {
proxyUser string
npmUser string
npmBin string
proxyConfigDir string
bundlePath string
statePath string
proxyStatePath string
}
type caState struct {
ProxyUser string `json:"proxy_user"`
NPMUser string `json:"npm_user"`
NPMBin string `json:"npm_bin"`
ProxyConfigDir string `json:"proxy_config_dir"`
BundlePath string `json:"bundle_path"`
PreviousNPMCA string `json:"previous_npm_ca,omitempty"`
PreviousNPMSet bool `json:"previous_npm_ca_set"`
CAFingerprint string `json:"ca_fingerprint"`
CACreated bool `json:"ca_created"`
ProxyStatePath string `json:"proxy_state_path"`
}
type caStatus struct {
CAValid bool
KeyModeValid bool
BundleValid bool
NPMConfigured bool
Fingerprint string
}
func runCACommand(args []string, out io.Writer) error {
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
return printCAUsage(out)
}
if len(args) == 2 && (args[1] == "--help" || args[1] == "-h") {
return printCAUsage(out)
}
switch args[0] {
case "install":
opts, err := parseCAOptions("install", args[1:])
if err != nil {
return err
}
if err := requireRoot(); err != nil {
return err
}
state, err := installCA(opts)
if err != nil {
return err
}
if _, err := fmt.Fprintf(out,
"Persistent CA: %s\nPrivate key owner: %s\nPublic npm bundle: %s\nnpm user: %s\nFingerprint: %s\n",
certmanager.CACertPath(state.ProxyConfigDir), state.ProxyUser, state.BundlePath,
state.NPMUser, state.CAFingerprint); err != nil {
return err
}
return nil
case "status":
opts, err := parseCAOptions("status", args[1:])
if err != nil {
return err
}
if err := requireRoot(); err != nil {
return err
}
status, err := inspectCA(opts)
if err != nil {
return err
}
if err := printCAStatus(out, status); err != nil {
return err
}
if !status.CAValid || !status.KeyModeValid || !status.BundleValid || !status.NPMConfigured {
return errors.New("CA setup is unhealthy")
}
return nil
case "remove":
opts, err := parseCAOptions("remove", args[1:])
if err != nil {
return err
}
if err := requireRoot(); err != nil {
return err
}
if err := removeCA(opts); err != nil {
return err
}
if _, err := fmt.Fprintln(out, "CA setup removed"); err != nil {
return err
}
return nil
default:
return fmt.Errorf("unknown CA command %q, expected install, status, or remove", args[0])
}
}
func printCAUsage(out io.Writer) error {
_, err := fmt.Fprintln(out, `Usage:
pmgwatch ca install --npm-user <user> [--proxy-user pmg-proxy]
pmgwatch ca status
pmgwatch ca remove
install generates or reuses the proxy CA and configures the selected user's npm cafile.
status verifies the CA files, ownership, public bundle, and npm configuration.
remove restores the previous npm cafile and deletes POC-owned CA files.`)
return err
}
func parseCAOptions(name string, args []string) (caCommandOptions, error) {
var opts caCommandOptions
flags := flag.NewFlagSet("ca "+name, flag.ContinueOnError)
flags.SetOutput(io.Discard)
flags.StringVar(&opts.proxyUser, "proxy-user", "pmg-proxy", "user running the PMG proxy")
flags.StringVar(&opts.npmUser, "npm-user", defaultNPMUser(), "user whose npm configuration PMG manages")
flags.StringVar(&opts.npmBin, "npm-bin", "npm", "npm executable used to manage trust")
flags.StringVar(&opts.proxyConfigDir, "proxy-config-dir", "", "PMG proxy config directory")
flags.StringVar(&opts.bundlePath, "bundle", filepath.Join(defaultCAStateDir, "npm-ca-bundle.pem"), "public CA bundle for npm")
flags.StringVar(&opts.statePath, "state", filepath.Join(defaultCAStateDir, "ca-state.json"), "CA setup state")
flags.StringVar(&opts.proxyStatePath, "proxy-state", "", "PMG proxy state file used to guard removal")
if err := flags.Parse(args); err != nil {
return caCommandOptions{}, err
}
if flags.NArg() != 0 {
return caCommandOptions{}, fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " "))
}
if name == "install" && opts.npmUser == "" {
return caCommandOptions{}, errors.New("--npm-user is required when SUDO_USER is not set")
}
return opts, nil
}
func installCA(opts caCommandOptions) (*caState, error) {
proxyAccount, err := lookupSystemAccount(opts.proxyUser)
if err != nil {
return nil, fmt.Errorf("resolve proxy user: %w", err)
}
npmAccount, err := lookupSystemAccount(opts.npmUser)
if err != nil {
return nil, fmt.Errorf("resolve npm user: %w", err)
}
resolveCAPaths(&opts, proxyAccount)
if err := ensureProxyStopped(opts.proxyStatePath); err != nil {
return nil, err
}
npm := npmTrust{binary: opts.npmBin}
state, err := loadCAState(opts.statePath)
switch {
case err == nil:
if err := state.matches(opts); err != nil {
return nil, err
}
case errors.Is(err, os.ErrNotExist):
previous, present, readErr := npm.Current(npmAccount)
if readErr != nil {
return nil, readErr
}
state = &caState{
ProxyUser: opts.proxyUser,
NPMUser: opts.npmUser,
NPMBin: opts.npmBin,
ProxyConfigDir: opts.proxyConfigDir,
BundlePath: opts.bundlePath,
PreviousNPMCA: previous,
PreviousNPMSet: present,
ProxyStatePath: opts.proxyStatePath,
}
default:
return nil, err
}
ca, created, err := ensurePersistentCA(opts.proxyConfigDir, proxyAccount)
if err != nil {
return nil, err
}
state.CACreated = state.CACreated || created
if err := writeClientBundle(opts.bundlePath, ca.Certificate); err != nil {
return nil, err
}
if err := npm.Set(npmAccount, opts.bundlePath); err != nil {
return nil, errors.Join(err, restoreNPM(npm, npmAccount, state))
}
effective, present, err := npm.Current(npmAccount)
if err != nil {
return nil, errors.Join(err, restoreNPM(npm, npmAccount, state))
}
if !present || effective != opts.bundlePath {
setupErr := fmt.Errorf("npm cafile is %q after setup, expected %q", effective, opts.bundlePath)
return nil, errors.Join(setupErr, restoreNPM(npm, npmAccount, state))
}
state.CAFingerprint = certificateFingerprint(ca)
if err := saveCAState(opts.statePath, state); err != nil {
return nil, errors.Join(err, restoreNPM(npm, npmAccount, state))
}
return state, nil
}
func inspectCA(opts caCommandOptions) (caStatus, error) {
state, err := loadCAState(opts.statePath)
if err != nil {
return caStatus{}, err
}
opts = state.options(opts.statePath)
proxyAccount, err := lookupSystemAccount(opts.proxyUser)
if err != nil {
return caStatus{}, err
}
npmAccount, err := lookupSystemAccount(opts.npmUser)
if err != nil {
return caStatus{}, err
}
status := caStatus{}
ca, err := certmanager.LoadCA(opts.proxyConfigDir)
if err == nil && !ca.IsExpired(0) {
status.Fingerprint = certificateFingerprint(ca)
status.CAValid = status.Fingerprint == state.CAFingerprint
}
keyInfo, keyErr := os.Stat(certmanager.CAKeyPath(opts.proxyConfigDir))
if keyErr == nil {
keyStat, ok := keyInfo.Sys().(*syscall.Stat_t)
status.KeyModeValid = ok && keyInfo.Mode().Perm() == 0o600 &&
keyStat.Uid == proxyAccount.UID && keyStat.Gid == proxyAccount.GID
}
if status.CAValid {
bundle, bundleErr := os.ReadFile(opts.bundlePath)
status.BundleValid = bundleErr == nil && bytes.Contains(bundle, ca.Certificate)
}
effective, present, npmErr := (npmTrust{binary: opts.npmBin}).Current(npmAccount)
if npmErr != nil {
return caStatus{}, npmErr
}
status.NPMConfigured = present && effective == opts.bundlePath
return status, nil
}
func removeCA(opts caCommandOptions) error {
state, err := loadCAState(opts.statePath)
if err != nil {
return err
}
opts = state.options(opts.statePath)
if err := ensureProxyStopped(opts.proxyStatePath); err != nil {
return err
}
npmAccount, err := lookupSystemAccount(opts.npmUser)
if err != nil {
return err
}
npm := npmTrust{binary: opts.npmBin}
effective, present, err := npm.Current(npmAccount)
if err != nil {
return err
}
if !present || effective != opts.bundlePath {
return fmt.Errorf("npm cafile changed to %q after setup; refusing to overwrite it", effective)
}
if state.PreviousNPMSet {
if err := npm.Set(npmAccount, state.PreviousNPMCA); err != nil {
return err
}
} else if err := npm.Remove(npmAccount); err != nil {
return err
}
paths := []string{opts.bundlePath}
if state.CACreated {
paths = append(paths,
certmanager.CACertPath(opts.proxyConfigDir),
certmanager.CAKeyPath(opts.proxyConfigDir),
)
}
paths = append(paths, opts.statePath)
for _, path := range paths {
if err := removeFile(path); err != nil {
return err
}
}
return nil
}
func ensurePersistentCA(dir string, account systemAccount) (*certmanager.Certificate, bool, error) {
created := false
ca, err := certmanager.LoadCA(dir)
if err != nil {
status, inspectErr := certmanager.InspectCA(dir)
if inspectErr != nil {
return nil, false, inspectErr
}
if status.CertPresent || status.KeyPresent {
return nil, false, fmt.Errorf("persisted CA is incomplete or unreadable: %w", err)
}
ca, err = certmanager.GenerateCA(certmanager.PersistentCACertManagerConfig())
if err != nil {
return nil, false, fmt.Errorf("generate CA: %w", err)
}
if err := certmanager.SaveCA(dir, ca); err != nil {
return nil, false, fmt.Errorf("save CA: %w", err)
}
created = true
}
if ca.IsExpired(0) {
return nil, false, errors.New("persisted CA is expired")
}
for _, item := range []struct {
path string
mode os.FileMode
}{
{dir, 0o700},
{certmanager.CACertPath(dir), 0o644},
{certmanager.CAKeyPath(dir), 0o600},
} {
if err := os.Chmod(item.path, item.mode); err != nil {
return nil, false, fmt.Errorf("chmod %s: %w", item.path, err)
}
if err := os.Chown(item.path, int(account.UID), int(account.GID)); err != nil {
return nil, false, fmt.Errorf("chown %s: %w", item.path, err)
}
}
return ca, created, nil
}
func writeClientBundle(path string, caPEM []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create bundle directory: %w", err)
}
if err := os.WriteFile(path, certmanager.MergeWithSystemCA(caPEM), 0o644); err != nil {
return fmt.Errorf("write npm CA bundle: %w", err)
}
if err := os.Chmod(path, 0o644); err != nil {
return fmt.Errorf("chmod npm CA bundle: %w", err)
}
return nil
}
func saveCAState(path string, state *caState) error {
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("marshal CA state: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create CA state directory: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write CA state: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("chmod CA state: %w", err)
}
return nil
}
func loadCAState(path string) (*caState, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read CA state %q: %w", path, err)
}
var state caState
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("parse CA state %q: %w", path, err)
}
return &state, nil
}
func (s *caState) matches(opts caCommandOptions) error {
if s.ProxyUser != opts.proxyUser || s.NPMUser != opts.npmUser ||
s.NPMBin != opts.npmBin || s.ProxyConfigDir != opts.proxyConfigDir ||
s.BundlePath != opts.bundlePath {
return errors.New("existing CA state was created with different options; remove it before changing setup")
}
return nil
}
func (s *caState) options(statePath string) caCommandOptions {
return caCommandOptions{
proxyUser: s.ProxyUser,
npmUser: s.NPMUser,
npmBin: s.NPMBin,
proxyConfigDir: s.ProxyConfigDir,
bundlePath: s.BundlePath,
statePath: statePath,
proxyStatePath: s.ProxyStatePath,
}
}
func resolveCAPaths(opts *caCommandOptions, proxy systemAccount) {
if opts.proxyConfigDir == "" {
opts.proxyConfigDir = filepath.Join(proxy.HomeDir, ".config", "safedep", "pmg")
}
if opts.proxyStatePath == "" {
opts.proxyStatePath = filepath.Join(proxy.HomeDir, "state", "proxy.json")
}
}
func ensureProxyStopped(path string) error {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("read proxy state: %w", err)
}
var state struct {
PID int `json:"pid"`
}
if err := json.Unmarshal(data, &state); err != nil {
return fmt.Errorf("parse proxy state: %w", err)
}
if state.PID <= 0 {
return nil
}
process, err := os.FindProcess(state.PID)
if err == nil && process.Signal(syscall.Signal(0)) == nil {
return fmt.Errorf("proxy pid %d is still running; stop it before changing the CA setup", state.PID)
}
return nil
}
func printCAStatus(out io.Writer, status caStatus) error {
rows := []struct {
name string
ok bool
}{
{"Persistent CA", status.CAValid},
{"Private key access", status.KeyModeValid},
{"Public npm bundle", status.BundleValid},
{"npm cafile", status.NPMConfigured},
}
for _, row := range rows {
result := "FAIL"
if row.ok {
result = "PASS"
}
if _, err := fmt.Fprintf(out, "%-20s %s\n", row.name, result); err != nil {
return err
}
}
if status.Fingerprint != "" {
if _, err := fmt.Fprintf(out, "%-20s %s\n", "Fingerprint", status.Fingerprint); err != nil {
return err
}
}
return nil
}
func certificateFingerprint(ca *certmanager.Certificate) string {
sum := sha256.Sum256(ca.X509Cert.Raw)
return hex.EncodeToString(sum[:])
}
func lookupSystemAccount(name string) (systemAccount, error) {
account, err := user.Lookup(name)
if err != nil {
return systemAccount{}, err
}
uid, err := strconv.ParseUint(account.Uid, 10, 32)
if err != nil {
return systemAccount{}, fmt.Errorf("parse uid %q: %w", account.Uid, err)
}
gid, err := strconv.ParseUint(account.Gid, 10, 32)
if err != nil {
return systemAccount{}, fmt.Errorf("parse gid %q: %w", account.Gid, err)
}
return systemAccount{
Name: account.Username,
UID: uint32(uid),
GID: uint32(gid),
HomeDir: account.HomeDir,
}, nil
}
func defaultNPMUser() string {
if name := os.Getenv("SUDO_USER"); name != "" && name != "root" {
return name
}
return ""
}
func requireRoot() error {
if os.Geteuid() != 0 {
return errors.New("run this command as root")
}
return nil
}
func removeFile(path string) error {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove %s: %w", path, err)
}
return nil
}
func restoreNPM(npm npmTrust, account systemAccount, state *caState) error {
if state.PreviousNPMSet {
return npm.Set(account, state.PreviousNPMCA)
}
return npm.Remove(account)
}
+13 -2
View File
@@ -1,7 +1,18 @@
module ebpf-poc
go 1.25.0
go 1.25.1
require github.com/cilium/ebpf v0.22.0
require golang.org/x/sys v0.43.0 // indirect
require (
github.com/safedep/dry v0.0.0-20260716095238-84cd2b3cd3a4 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
)
require github.com/safedep/pmg v0.0.0
replace github.com/safedep/pmg => ..
+20 -2
View File
@@ -1,5 +1,7 @@
github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY=
github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -16,11 +18,27 @@ github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
github.com/safedep/dry v0.0.0-20260716095238-84cd2b3cd3a4 h1:9u5mj4LOteAm89eN0hg6eJ38kzc1ArfEjRiSXcKB2v8=
github.com/safedep/dry v0.0.0-20260716095238-84cd2b3cd3a4/go.mod h1:OO3Tcxd+SBHRaN42jv2RjgwZWsUa8jFHB0mDMi5HWyY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+14 -1
View File
@@ -42,6 +42,13 @@ type options struct {
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "ca" {
if err := runCACommand(os.Args[2:], os.Stdout); err != nil {
exitWithError("pmgwatch ca", err)
}
return
}
var opts options
flag.StringVar(&opts.proxyAddr, "proxy", "", "redirect target as host:port, e.g. 127.0.0.1:8443")
flag.StringVar(&opts.stateFile, "proxy-state", "",
@@ -51,9 +58,15 @@ func main() {
flag.Parse()
if err := run(opts); err != nil {
fmt.Fprintf(os.Stderr, "pmgwatch: %v\n", err)
exitWithError("pmgwatch", err)
}
}
func exitWithError(name string, err error) {
if _, writeErr := fmt.Fprintf(os.Stderr, "%s: %v\n", name, err); writeErr != nil {
os.Exit(1)
}
os.Exit(1)
}
func run(opts options) error {
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
)
type systemAccount struct {
Name string
UID uint32
GID uint32
HomeDir string
}
type npmTrust struct {
binary string
}
func (n npmTrust) Current(account systemAccount) (string, bool, error) {
output, err := n.run(account, "config", "get", "cafile", "--location=user")
if err != nil {
return "", false, err
}
value := strings.TrimSpace(output)
switch value {
case "", "null", "undefined":
return "", false, nil
default:
return value, true, nil
}
}
func (n npmTrust) Set(account systemAccount, path string) error {
_, err := n.run(account, "config", "set", "cafile="+path, "--location=user")
return err
}
func (n npmTrust) Remove(account systemAccount) error {
_, err := n.run(account, "config", "delete", "cafile", "--location=user")
return err
}
func (n npmTrust) run(account systemAccount, args ...string) (string, error) {
binary, err := exec.LookPath(n.binary)
if err != nil {
return "", fmt.Errorf("find npm executable %q: %w", n.binary, err)
}
cmd := exec.Command(binary, args...)
cmd.Env = accountEnvironment(account)
cmd.Dir = account.HomeDir
cmd.SysProcAttr = &syscall.SysProcAttr{
Credential: &syscall.Credential{
Uid: account.UID,
Gid: account.GID,
},
}
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("run npm as %s: %w: %s", account.Name, err, strings.TrimSpace(string(output)))
}
return string(output), nil
}
func accountEnvironment(account systemAccount) []string {
blocked := map[string]struct{}{
"HOME": {},
"USER": {},
"LOGNAME": {},
"XDG_CONFIG_HOME": {},
"NPM_CONFIG_CAFILE": {},
"npm_config_cafile": {},
"NPM_CONFIG_USERCONFIG": {},
"npm_config_userconfig": {},
"NPM_CONFIG_GLOBALCONFIG": {},
"npm_config_globalconfig": {},
}
env := make([]string, 0, len(os.Environ())+4)
for _, item := range os.Environ() {
key, _, ok := strings.Cut(item, "=")
if !ok {
continue
}
if _, skip := blocked[key]; !skip {
env = append(env, item)
}
}
return append(env,
"HOME="+account.HomeDir,
"USER="+account.Name,
"LOGNAME="+account.Name,
"XDG_CONFIG_HOME="+filepath.Join(account.HomeDir, ".config"),
)
}