mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat(proxy): add persistent proxy server with start/stop/env/status commands
Adds pmg proxy command group for running a long-lived MITM proxy that intercepts all package manager traffic without requiring PMG shims or wrappers. Targets CI/CD pipelines where env vars can be set globally. - pmg proxy start: starts proxy with npm+pypi interceptors, writes state file (pid/addr/ca-cert-path), auto-blocks suspicious packages - pmg proxy stop: sends SIGTERM to the running proxy - pmg proxy env: emits HTTP_PROXY/HTTPS_PROXY/SSL_CERT_FILE etc. as shell exports, or writes directly to $GITHUB_ENV with --gha - pmg proxy status: shows running/stopped status GHA usage: pmg proxy start & pmg proxy env --gha # populates env for all subsequent steps npm install # intercepted automatically, no wrapper needed Also adds .github/workflows/persistent-proxy-e2e.yml to validate the persistent proxy mode end-to-end in CI.
This commit is contained in:
@@ -0,0 +1,102 @@
|
|||||||
|
name: Persistent Proxy E2E
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'cmd/proxy/**'
|
||||||
|
- 'internal/proxystate/**'
|
||||||
|
- 'internal/flows/cert.go'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
persistent-proxy-e2e:
|
||||||
|
name: Persistent Proxy E2E
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Source
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
|
- name: Build PMG
|
||||||
|
run: make
|
||||||
|
|
||||||
|
- name: Add pmg to PATH
|
||||||
|
run: echo "$GITHUB_WORKSPACE/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Setup PMG
|
||||||
|
run: pmg setup install
|
||||||
|
|
||||||
|
- name: Start persistent proxy
|
||||||
|
run: |
|
||||||
|
pmg proxy start &
|
||||||
|
# Poll until proxy writes its state file (up to 10s)
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
pmg proxy status && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Inject proxy env vars into workflow environment
|
||||||
|
run: pmg proxy env --gha
|
||||||
|
|
||||||
|
- name: Verify proxy env vars are set
|
||||||
|
run: |
|
||||||
|
echo "HTTP_PROXY=$HTTP_PROXY"
|
||||||
|
echo "NODE_EXTRA_CA_CERTS=$NODE_EXTRA_CA_CERTS"
|
||||||
|
test -n "$HTTP_PROXY"
|
||||||
|
test -n "$NODE_EXTRA_CA_CERTS"
|
||||||
|
test -f "$NODE_EXTRA_CA_CERTS"
|
||||||
|
|
||||||
|
- name: Test: benign package installs successfully
|
||||||
|
run: |
|
||||||
|
mkdir benign-test && cd benign-test
|
||||||
|
npm init -y
|
||||||
|
npm install lodash@4.17.21
|
||||||
|
test -d node_modules/lodash
|
||||||
|
echo "SUCCESS: lodash installed through proxy"
|
||||||
|
cd .. && rm -rf benign-test
|
||||||
|
|
||||||
|
- name: Test: malicious package is blocked
|
||||||
|
run: |
|
||||||
|
mkdir malicious-test && cd malicious-test
|
||||||
|
npm init -y
|
||||||
|
if npm --no-cache --prefer-online install safedep-test-pkg@0.1.3; then
|
||||||
|
echo "ERROR: safedep-test-pkg was not blocked!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -d "node_modules/safedep-test-pkg" ]; then
|
||||||
|
echo "ERROR: safedep-test-pkg found in node_modules!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "SUCCESS: safedep-test-pkg blocked by persistent proxy"
|
||||||
|
cd .. && rm -rf malicious-test
|
||||||
|
|
||||||
|
- name: Test: pip installs through proxy
|
||||||
|
run: |
|
||||||
|
python -m venv venv && source venv/bin/activate
|
||||||
|
pip install requests==2.32.4
|
||||||
|
python -c "import requests; print('pip ok:', requests.__version__)"
|
||||||
|
deactivate && rm -rf venv
|
||||||
|
|
||||||
|
- name: Stop proxy
|
||||||
|
if: always()
|
||||||
|
run: pmg proxy stop || true
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
"github.com/safedep/pmg/internal/proxystate"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newEnvCommand() *cobra.Command {
|
||||||
|
var gha bool
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "env",
|
||||||
|
Short: "Print proxy environment variables (use with eval or --gha for GitHub Actions)",
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
return runEnv(gha)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVar(&gha, "gha", false, "Write env vars to $GITHUB_ENV instead of stdout")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runEnv(gha bool) error {
|
||||||
|
cfg := config.Get()
|
||||||
|
statePath := proxystate.StatePath(cfg.ConfigDir())
|
||||||
|
|
||||||
|
state, err := proxystate.Read(statePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("proxy not running — start with 'pmg proxy start' first: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyURL := fmt.Sprintf("http://%s", state.Addr)
|
||||||
|
noProxy := "localhost,127.0.0.1,::1"
|
||||||
|
|
||||||
|
vars := []string{
|
||||||
|
fmt.Sprintf("HTTP_PROXY=%s", proxyURL),
|
||||||
|
fmt.Sprintf("HTTPS_PROXY=%s", proxyURL),
|
||||||
|
fmt.Sprintf("http_proxy=%s", proxyURL),
|
||||||
|
fmt.Sprintf("https_proxy=%s", proxyURL),
|
||||||
|
fmt.Sprintf("NO_PROXY=%s", noProxy),
|
||||||
|
fmt.Sprintf("no_proxy=%s", noProxy),
|
||||||
|
"NODE_USE_ENV_PROXY=1",
|
||||||
|
fmt.Sprintf("NODE_EXTRA_CA_CERTS=%s", state.CACertPath),
|
||||||
|
fmt.Sprintf("SSL_CERT_FILE=%s", state.CACertPath),
|
||||||
|
fmt.Sprintf("REQUESTS_CA_BUNDLE=%s", state.CACertPath),
|
||||||
|
fmt.Sprintf("PIP_CERT=%s", state.CACertPath),
|
||||||
|
fmt.Sprintf("PIP_PROXY=%s", proxyURL),
|
||||||
|
"PIP_RETRIES=0",
|
||||||
|
fmt.Sprintf("YARN_HTTP_PROXY=%s", proxyURL),
|
||||||
|
fmt.Sprintf("YARN_HTTPS_PROXY=%s", proxyURL),
|
||||||
|
fmt.Sprintf("YARN_HTTPS_CA_FILE_PATH=%s", state.CACertPath),
|
||||||
|
}
|
||||||
|
|
||||||
|
if gha {
|
||||||
|
return writeGitHubEnv(vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeShellExports(vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeShellExports(vars []string) error {
|
||||||
|
w := bufio.NewWriter(os.Stdout)
|
||||||
|
for _, kv := range vars {
|
||||||
|
// Split on first '=' so we can quote only the value, handling paths with spaces.
|
||||||
|
k, v, _ := strings.Cut(kv, "=")
|
||||||
|
if _, err := fmt.Fprintf(w, "export %s=%q\n", k, v); err != nil {
|
||||||
|
return fmt.Errorf("write env var: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeGitHubEnv(vars []string) error {
|
||||||
|
ghEnvFile := os.Getenv("GITHUB_ENV")
|
||||||
|
if ghEnvFile == "" {
|
||||||
|
return fmt.Errorf("$GITHUB_ENV is not set — are you running in GitHub Actions?")
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(ghEnvFile, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open $GITHUB_ENV file: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = f.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
w := bufio.NewWriter(f)
|
||||||
|
for _, v := range vars {
|
||||||
|
if _, err := fmt.Fprintf(w, "%s\n", v); err != nil {
|
||||||
|
return fmt.Errorf("write to $GITHUB_ENV: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func NewProxyCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "proxy",
|
||||||
|
Short: "Manage the persistent PMG proxy server",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(newStartCommand())
|
||||||
|
cmd.AddCommand(newStopCommand())
|
||||||
|
cmd.AddCommand(newEnvCommand())
|
||||||
|
cmd.AddCommand(newStatusCommand())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/safedep/dry/log"
|
||||||
|
"github.com/safedep/pmg/analyzer"
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
"github.com/safedep/pmg/internal/flows"
|
||||||
|
"github.com/safedep/pmg/internal/proxystate"
|
||||||
|
pmgproxy "github.com/safedep/pmg/proxy"
|
||||||
|
"github.com/safedep/pmg/proxy/certmanager"
|
||||||
|
"github.com/safedep/pmg/proxy/interceptors"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newStartCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "start",
|
||||||
|
Short: "Start the persistent PMG proxy server (runs in foreground)",
|
||||||
|
RunE: runStart,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStart(_ *cobra.Command, _ []string) error {
|
||||||
|
cfg := config.Get()
|
||||||
|
statePath := proxystate.StatePath(cfg.ConfigDir())
|
||||||
|
|
||||||
|
if existing, err := proxystate.Read(statePath); err == nil && existing.IsRunning() {
|
||||||
|
return fmt.Errorf("proxy already running (pid %d, addr %s) — run 'pmg proxy stop' first", existing.PID, existing.Addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
caCertPath := filepath.Join(cfg.ConfigDir(), "proxy-ca.pem")
|
||||||
|
caCert, _, err := flows.SetupCACertificate(cfg.ConfigDir(), caCertPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setup CA certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certMgr, err := certmanager.NewCertificateManagerWithCA(caCert, certmanager.DefaultCertManagerConfig())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create certificate manager: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
malysisAnalyzer, err := analyzer.NewMalysisAnalyzer(analyzer.MalysisQueryAnalyzerConfig{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create analyzer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := interceptors.NewInMemoryAnalysisCache()
|
||||||
|
stats := interceptors.NewAnalysisStatsCollector()
|
||||||
|
confirmationChan := make(chan *interceptors.ConfirmationRequest, 100)
|
||||||
|
go autoBlockConfirmations(confirmationChan)
|
||||||
|
|
||||||
|
factory := interceptors.NewInterceptorFactory(
|
||||||
|
malysisAnalyzer, cache, stats, confirmationChan, interceptors.InterceptorContext{},
|
||||||
|
)
|
||||||
|
|
||||||
|
var interceptorList []pmgproxy.Interceptor
|
||||||
|
for _, eco := range interceptors.SupportedEcosystems() {
|
||||||
|
i, ferr := factory.CreateInterceptor(eco)
|
||||||
|
if ferr != nil {
|
||||||
|
return fmt.Errorf("create interceptor for %s: %w", eco.String(), ferr)
|
||||||
|
}
|
||||||
|
interceptorList = append(interceptorList, i)
|
||||||
|
}
|
||||||
|
interceptorList = append(interceptorList, interceptors.NewAuditLoggerInterceptor())
|
||||||
|
|
||||||
|
proxyConfig := pmgproxy.DefaultProxyConfig()
|
||||||
|
proxyConfig.CertManager = certMgr
|
||||||
|
proxyConfig.Interceptors = interceptorList
|
||||||
|
|
||||||
|
server, err := pmgproxy.NewProxyServer(proxyConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create proxy server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := server.Start(); err != nil {
|
||||||
|
return fmt.Errorf("start proxy server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state := proxystate.State{
|
||||||
|
PID: os.Getpid(),
|
||||||
|
Addr: server.Address(),
|
||||||
|
CACertPath: caCertPath,
|
||||||
|
}
|
||||||
|
if err := proxystate.Write(statePath, state); err != nil {
|
||||||
|
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = server.Stop(stopCtx)
|
||||||
|
return fmt.Errorf("write proxy state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("PMG persistent proxy running on %s (pid %d)", state.Addr, state.PID)
|
||||||
|
if _, err := fmt.Fprintf(os.Stderr, "PMG proxy running on %s\nRun: eval $(pmg proxy env) # or: pmg proxy env --gha\n", state.Addr); err != nil {
|
||||||
|
log.Warnf("failed to write startup message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||||
|
<-sigCh
|
||||||
|
|
||||||
|
close(confirmationChan)
|
||||||
|
_ = proxystate.Remove(statePath)
|
||||||
|
|
||||||
|
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return server.Stop(stopCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoBlockConfirmations drains the confirmation channel and always denies,
|
||||||
|
// appropriate for non-interactive CI/CD environments.
|
||||||
|
func autoBlockConfirmations(ch chan *interceptors.ConfirmationRequest) {
|
||||||
|
for req := range ch {
|
||||||
|
log.Warnf("Persistent proxy: auto-blocking suspicious package %s", req.PackageVersion.GetPackage().GetName())
|
||||||
|
req.ResponseChan <- false
|
||||||
|
close(req.ResponseChan)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
"github.com/safedep/pmg/internal/proxystate"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newStatusCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show the status of the persistent PMG proxy server",
|
||||||
|
RunE: runStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStatus(_ *cobra.Command, _ []string) error {
|
||||||
|
cfg := config.Get()
|
||||||
|
statePath := proxystate.StatePath(cfg.ConfigDir())
|
||||||
|
|
||||||
|
state, err := proxystate.Read(statePath)
|
||||||
|
if err != nil {
|
||||||
|
if _, werr := fmt.Fprintln(os.Stdout, "PMG proxy: not running (no state file)"); werr != nil {
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.IsRunning() {
|
||||||
|
_, err = fmt.Fprintf(os.Stdout, "PMG proxy: running (pid %d, addr %s, ca %s)\n",
|
||||||
|
state.PID, state.Addr, state.CACertPath)
|
||||||
|
} else {
|
||||||
|
_, err = fmt.Fprintf(os.Stdout, "PMG proxy: stopped (stale state for pid %d — run 'pmg proxy stop' to clean up)\n", state.PID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
"github.com/safedep/pmg/internal/proxystate"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newStopCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "stop",
|
||||||
|
Short: "Stop the running persistent PMG proxy server",
|
||||||
|
RunE: runStop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStop(_ *cobra.Command, _ []string) error {
|
||||||
|
cfg := config.Get()
|
||||||
|
statePath := proxystate.StatePath(cfg.ConfigDir())
|
||||||
|
|
||||||
|
state, err := proxystate.Read(statePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("no proxy state found — is the proxy running? (%w)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.IsRunning() {
|
||||||
|
_ = proxystate.Remove(statePath)
|
||||||
|
return fmt.Errorf("proxy process (pid %d) is not running; state file cleaned up", state.PID)
|
||||||
|
}
|
||||||
|
|
||||||
|
proc, err := os.FindProcess(state.PID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("find proxy process (pid %d): %w", state.PID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||||
|
return fmt.Errorf("send SIGTERM to proxy (pid %d): %w", state.PID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fmt.Fprintf(os.Stdout, "Sent SIGTERM to PMG proxy (pid %d, addr %s)\n", state.PID, state.Addr); err != nil {
|
||||||
|
return fmt.Errorf("write stop message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/safedep/pmg/cmd/executors"
|
"github.com/safedep/pmg/cmd/executors"
|
||||||
landlockCmd "github.com/safedep/pmg/cmd/landlock"
|
landlockCmd "github.com/safedep/pmg/cmd/landlock"
|
||||||
"github.com/safedep/pmg/cmd/npm"
|
"github.com/safedep/pmg/cmd/npm"
|
||||||
|
proxyCmd "github.com/safedep/pmg/cmd/proxy"
|
||||||
"github.com/safedep/pmg/cmd/pypi"
|
"github.com/safedep/pmg/cmd/pypi"
|
||||||
sandboxCmd "github.com/safedep/pmg/cmd/sandbox"
|
sandboxCmd "github.com/safedep/pmg/cmd/sandbox"
|
||||||
"github.com/safedep/pmg/cmd/setup"
|
"github.com/safedep/pmg/cmd/setup"
|
||||||
@@ -148,6 +149,7 @@ func main() {
|
|||||||
cmd.AddCommand(pypi.NewUvCommand())
|
cmd.AddCommand(pypi.NewUvCommand())
|
||||||
cmd.AddCommand(pypi.NewPoetryCommand())
|
cmd.AddCommand(pypi.NewPoetryCommand())
|
||||||
cmd.AddCommand(executors.NewPipxCommand())
|
cmd.AddCommand(executors.NewPipxCommand())
|
||||||
|
cmd.AddCommand(proxyCmd.NewProxyCommand())
|
||||||
cmd.AddCommand(version.NewVersionCommand())
|
cmd.AddCommand(version.NewVersionCommand())
|
||||||
cmd.AddCommand(setup.NewSetupCommand())
|
cmd.AddCommand(setup.NewSetupCommand())
|
||||||
cmd.AddCommand(setup.NewRemoveCommand())
|
cmd.AddCommand(setup.NewRemoveCommand())
|
||||||
|
|||||||
Reference in New Issue
Block a user