Full web : fichiers restants

This commit is contained in:
cedric committed 2026-10-23 17:43:20 +02:00
1 parent ec3b7c9a01
commit 4826fa5b82
26 files changed
+2537 -793

No files matched your search

+180 -43
View File
@@ -1,6 +1,8 @@
// Package pve is a minimal Proxmox VE API client: API token auth, generic JSON,
// form-encoded writes, task polling and version check. No typed SDK on purpose:
// objects stay generic (map[string]any) and are interpreted by the adapters.
// Package pve is a minimal Proxmox VE API client: ticket auth with the root@pam
// credentials, generic JSON, form-encoded writes, task polling and version check.
// No typed SDK on purpose: objects stay generic (map[string]any) and are
// interpreted by the adapters. What the API does not cover (the password hashes
// of the pve realm) goes through a Shell, an SSH session on a node.
package pve
import (
@@ -15,10 +17,10 @@ import (
"io"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
)
@@ -26,23 +28,42 @@ import (
type Options struct {
Name string // cluster name in the config (for messages only)
BaseURL string // https://host:8006
TokenID string // user@realm!tokenname
TokenSecret string // never logged
CAFile string // PEM bundle to trust (optional)
CAPEM []byte // PEM bundle given inline (web UI); CAFile wins when both are set
User string // user@realm, root@pam by default
Password string // never logged
CAPEM []byte // PEM bundle to trust (optional)
Fingerprint string // SHA-256 of the leaf certificate, "AA:BB:..." (optional, pinning)
Insecure bool // skip TLS verification (lab only)
Timeout time.Duration
Shell Shell // SSH access to a node (optional)
}
// Shell runs a command on a node of the cluster (SSH), stdin fed with in.
type Shell interface {
Run(ctx context.Context, cmd string, in []byte) ([]byte, error)
}
// ticketTTL: PVE tickets live 2 hours (pve-access-control AccessControl.pm,
// $ticket_lifetime); a new one is requested well before.
const ticketTTL = 90 * time.Minute
// Client talks to the PVE API of one cluster.
type Client struct {
name string
base *url.URL
auth string
http *http.Client
name string
base *url.URL
user string
password string
http *http.Client
shell Shell
mu sync.Mutex
ticket string
csrf string
ticketAt time.Time
}
// ErrNoShell: the operation needs an SSH access to a node.
var ErrNoShell = errors.New("SSH access required")
// APIError is a non-2xx answer of the API.
type APIError struct {
Method, Path string
@@ -75,13 +96,40 @@ func New(o Options) (*Client, error) {
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, fmt.Errorf("cluster %s: invalid url %q", o.Name, o.BaseURL)
}
if o.TokenID == "" || o.TokenSecret == "" {
return nil, fmt.Errorf("cluster %s: token id and secret are required", o.Name)
if o.User == "" {
o.User = "root@pam"
}
if o.Password == "" {
return nil, fmt.Errorf("cluster %s: the password of %s is required", o.Name, o.User)
}
tlsCfg, err := TLSConfig(o.Name, o.CAPEM, o.Fingerprint, o.Insecure)
if err != nil {
return nil, err
}
timeout := o.Timeout
if timeout == 0 {
timeout = 30 * time.Second
}
return &Client{
name: o.Name,
base: u,
user: o.User,
password: o.Password,
shell: o.Shell,
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{TLSClientConfig: tlsCfg, Proxy: http.ProxyFromEnvironment},
},
}, nil
}
// TLSConfig builds the TLS settings of a cluster: certificate pinning (self-signed
// PVE certificates), a CA bundle, the system authorities, or no verification.
func TLSConfig(name string, caPEM []byte, fingerprint string, insecure bool) (*tls.Config, error) {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
switch {
case o.Fingerprint != "":
want := normalizeFingerprint(o.Fingerprint)
case fingerprint != "":
want := normalizeFingerprint(fingerprint)
// Self-signed PVE certificates: pin the leaf certificate instead of a CA.
tlsCfg.InsecureSkipVerify = true
tlsCfg.VerifyPeerCertificate = func(raw [][]byte, _ [][]*x509.Certificate) error {
@@ -90,49 +138,112 @@ func New(o Options) (*Client, error) {
}
sum := sha256.Sum256(raw[0])
if got := hex.EncodeToString(sum[:]); got != want {
return fmt.Errorf("certificate fingerprint mismatch (got %s)", got)
return fmt.Errorf("certificate fingerprint mismatch (got %s)", FormatFingerprint(got))
}
return nil
}
case o.CAFile != "" || len(o.CAPEM) > 0:
pem, src := o.CAPEM, "the CA certificate"
if o.CAFile != "" {
var err error
if pem, err = os.ReadFile(o.CAFile); err != nil {
return nil, fmt.Errorf("cluster %s: %w", o.Name, err)
}
src = o.CAFile
}
case len(caPEM) > 0:
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("cluster %s: no certificate in %s", o.Name, src)
if !pool.AppendCertsFromPEM(caPEM) {
return nil, fmt.Errorf("cluster %s: no certificate in the CA bundle", name)
}
tlsCfg.RootCAs = pool
case o.Insecure:
case insecure:
tlsCfg.InsecureSkipVerify = true
}
timeout := o.Timeout
if timeout == 0 {
timeout = 30 * time.Second
return tlsCfg, nil
}
// PeerFingerprint connects to the API and returns the SHA-256 fingerprint of the
// certificate it presents, without verifying it: the administrator pins it on
// the first connection (trust on first use) and sees it in the cluster list.
func PeerFingerprint(ctx context.Context, baseURL string) (string, error) {
u, err := url.Parse(baseURL)
if err != nil || u.Host == "" {
return "", fmt.Errorf("invalid url %q", baseURL)
}
return &Client{
name: o.Name,
base: u,
auth: "PVEAPIToken=" + o.TokenID + "=" + o.TokenSecret,
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{TLSClientConfig: tlsCfg, Proxy: http.ProxyFromEnvironment},
},
}, nil
host := u.Host
if u.Port() == "" {
host += ":8006"
}
d := tls.Dialer{Config: &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true}}
conn, err := d.DialContext(ctx, "tcp", host)
if err != nil {
return "", err
}
defer conn.Close()
certs := conn.(*tls.Conn).ConnectionState().PeerCertificates
if len(certs) == 0 {
return "", errors.New("no peer certificate")
}
sum := sha256.Sum256(certs[0].Raw)
return FormatFingerprint(hex.EncodeToString(sum[:])), nil
}
// FormatFingerprint prints a SHA-256 fingerprint like PVE does ("AA:BB:...").
func FormatFingerprint(fp string) string {
fp = strings.ToUpper(normalizeFingerprint(fp))
var b strings.Builder
for i := 0; i < len(fp); i += 2 {
if i > 0 {
b.WriteByte(':')
}
b.WriteString(fp[i:min(i+2, len(fp))])
}
return b.String()
}
// Name returns the cluster name given in the options.
func (c *Client) Name() string { return c.name }
// Shell returns the SSH access to a node, or ErrNoShell.
func (c *Client) Shell() (Shell, error) {
if c.shell == nil {
return nil, fmt.Errorf("cluster %s: %w", c.name, ErrNoShell)
}
return c.shell, nil
}
func normalizeFingerprint(s string) string {
return strings.ToLower(strings.NewReplacer(":", "", " ", "").Replace(s))
}
// login gets a ticket and a CSRF token (POST /access/ticket). Accounts with a
// second factor are refused: the tool cannot answer the TFA challenge.
func (c *Client) login(ctx context.Context) error {
var res struct {
Ticket string `json:"ticket"`
CSRF string `json:"CSRFPreventionToken"`
NeedTFA any `json:"NeedTFA"`
}
f := url.Values{"username": {c.user}, "password": {c.password}}
if err := c.send(ctx, http.MethodPost, "access/ticket", nil, f, &res, false); err != nil {
var ae *APIError
if errors.As(err, &ae) && ae.Status == http.StatusUnauthorized {
return fmt.Errorf("cluster %s: authentication of %s failed", c.name, c.user)
}
return err
}
if Str(res.NeedTFA) == "1" {
return fmt.Errorf("cluster %s: %s has a second factor (TFA), not supported", c.name, c.user)
}
if res.Ticket == "" {
return fmt.Errorf("cluster %s: no ticket in the login answer", c.name)
}
c.ticket, c.csrf, c.ticketAt = res.Ticket, res.CSRF, time.Now()
return nil
}
// Login checks the credentials (and renews the ticket when needed).
func (c *Client) Login(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.ticket != "" && time.Since(c.ticketAt) < ticketTTL {
return nil
}
return c.login(ctx)
}
// Get reads path (relative to /api2/json) and decodes "data" into out.
func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error {
return c.do(ctx, http.MethodGet, path, query, nil, out)
@@ -153,6 +264,25 @@ func (c *Client) Delete(ctx context.Context, path string, params url.Values, out
}
func (c *Client) do(ctx context.Context, method, path string, query, form url.Values, out any) error {
if err := c.Login(ctx); err != nil {
return err
}
err := c.send(ctx, method, path, query, form, out, true)
var ae *APIError
if errors.As(err, &ae) && ae.Status == http.StatusUnauthorized {
// ticket expired early (key rotation on the cluster): log in again once
c.mu.Lock()
c.ticket = ""
c.mu.Unlock()
if err := c.Login(ctx); err != nil {
return err
}
err = c.send(ctx, method, path, query, form, out, true)
}
return err
}
func (c *Client) send(ctx context.Context, method, path string, query, form url.Values, out any, auth bool) error {
u := *c.base
u.Path = "/api2/json/" + strings.TrimLeft(path, "/")
if len(query) > 0 {
@@ -166,14 +296,21 @@ func (c *Client) do(ctx context.Context, method, path string, query, form url.Va
if err != nil {
return err
}
req.Header.Set("Authorization", c.auth)
if auth {
c.mu.Lock()
req.AddCookie(&http.Cookie{Name: "PVEAuthCookie", Value: c.ticket})
if method != http.MethodGet {
req.Header.Set("CSRFPreventionToken", c.csrf)
}
c.mu.Unlock()
}
req.Header.Set("Accept", "application/json")
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
resp, err := c.http.Do(req)
if err != nil {
// url.Error contains the URL only, never the Authorization header.
// url.Error contains the URL only, never the ticket nor the password.
return fmt.Errorf("cluster %s: %w", c.name, err)
}
defer resp.Body.Close()
+187
View File
@@ -0,0 +1,187 @@
package pvetest
import (
"net/http"
"sort"
"strings"
)
// access fakes /access/groups, /access/roles and /access/users (user.cfg).
func (s *Server) access(w http.ResponseWriter, r *http.Request, method string, seg []string) {
f := r.PostForm
if method == http.MethodDelete {
f = r.URL.Query()
}
switch {
case len(seg) == 2 && seg[1] == "groups" && method == "GET":
var out []any
for _, id := range sortedKeys(s.Groups) {
o := map[string]any{"groupid": id, "users": strings.Join(s.members(id), ",")}
for k, v := range s.Groups[id] {
o[k] = v
}
out = append(out, o)
}
reply(w, out)
case len(seg) == 2 && seg[1] == "groups" && method == "POST":
id := f.Get("groupid")
if s.Groups[id] != nil {
fail(w, 500, "create group failed: group '"+id+"' already exists")
return
}
s.Groups[id] = setFields(map[string]any{}, f, "groupid")
reply(w, nil)
case len(seg) == 3 && seg[1] == "groups":
g := s.Groups[seg[2]]
if g == nil {
fail(w, 500, "group '"+seg[2]+"' does not exist")
return
}
switch method {
case "PUT":
setFields(g, f)
case "DELETE":
delete(s.Groups, seg[2])
for _, u := range s.Users {
u["groups"] = strings.Join(remove(split(u["groups"]), seg[2]), ",")
}
}
reply(w, nil)
case len(seg) == 2 && seg[1] == "roles" && method == "GET":
out := []any{
map[string]any{"roleid": "Administrator", "privs": "Sys.Audit,Sys.Modify", "special": 1},
map[string]any{"roleid": "PVEAuditor", "privs": "Sys.Audit", "special": 1},
}
for _, id := range sortedKeys(s.Roles) {
out = append(out, map[string]any{"roleid": id, "privs": s.Roles[id], "special": 0})
}
reply(w, out)
case len(seg) == 2 && seg[1] == "roles" && method == "POST":
s.Roles[f.Get("roleid")] = f.Get("privs")
reply(w, nil)
case len(seg) == 3 && seg[1] == "roles":
if _, ok := s.Roles[seg[2]]; !ok {
fail(w, 500, "role '"+seg[2]+"' does not exist")
return
}
if method == "DELETE" {
delete(s.Roles, seg[2])
} else {
s.Roles[seg[2]] = f.Get("privs")
}
reply(w, nil)
case len(seg) == 2 && seg[1] == "users" && method == "GET":
var out []any
for _, id := range sortedKeys(s.Users) {
realm := id[strings.LastIndex(id, "@")+1:]
o := map[string]any{"userid": id, "realm-type": realm, "tokens": []any{}}
for k, v := range s.Users[id] {
o[k] = v
}
out = append(out, o)
}
reply(w, out)
case len(seg) == 2 && seg[1] == "users" && method == "POST":
id := f.Get("userid")
if s.Users[id] != nil {
fail(w, 500, "create user failed: user '"+id+"' already exists")
return
}
if !s.groupsExist(w, f.Get("groups")) {
return
}
s.Users[id] = setFields(map[string]any{"enable": 1}, f, "userid", "password")
reply(w, nil)
case len(seg) == 3 && seg[1] == "users":
u := s.Users[seg[2]]
if u == nil {
fail(w, 500, "user '"+seg[2]+"' does not exist")
return
}
switch method {
case "PUT":
if !s.groupsExist(w, f.Get("groups")) {
return
}
setFields(u, f)
case "DELETE":
delete(s.Users, seg[2])
delete(s.Shadow, strings.TrimSuffix(seg[2], "@pve"))
}
reply(w, nil)
default:
fail(w, 501, "not implemented in fake: "+method+" "+strings.Join(seg, "/"))
}
}
func (s *Server) groupsExist(w http.ResponseWriter, groups string) bool {
for _, g := range split(groups) {
if s.Groups[g] == nil {
fail(w, 500, "no such group '"+g+"'")
return false
}
}
return true
}
func (s *Server) members(group string) []string {
var out []string
for id, u := range s.Users {
for _, g := range split(u["groups"]) {
if g == group {
out = append(out, id)
}
}
}
sort.Strings(out)
return out
}
// setFields copies form values; an empty value removes the field (like user.cfg).
func setFields(m map[string]any, f map[string][]string, skip ...string) map[string]any {
sk := map[string]bool{}
for _, k := range skip {
sk[k] = true
}
for k, v := range f {
if sk[k] || len(v) == 0 {
continue
}
if v[0] == "" {
delete(m, k)
continue
}
m[k] = v[0]
}
return m
}
func split(v any) []string {
s, _ := v.(string)
var out []string
for _, x := range strings.Split(s, ",") {
if x = strings.TrimSpace(x); x != "" {
out = append(out, x)
}
}
return out
}
func remove(l []string, x string) []string {
out := l[:0]
for _, e := range l {
if e != x {
out = append(out, e)
}
}
return out
}
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
+53 -21
View File
@@ -1,7 +1,9 @@
// Package pvetest is an in-memory fake of the subset of the PVE 9.x API used by
// pve-confsync, for tests. It mimics the behaviours that matter for the engine:
// parsed datacenter options on GET, property strings on PUT, SDN pending/running
// configurations, global SDN lock with token, rollback and reload task.
// pve-confsync, for tests and the demo. It mimics the behaviours that matter for
// the engine: ticket login of root@pam, parsed datacenter options on GET,
// property strings on PUT, SDN pending/running configurations, global SDN lock
// with token, rollback and reload task, groups, roles and accounts of user.cfg,
// and an SSH server serving /etc/pve/priv/shadow.cfg (see ssh.go).
package pvetest
import (
@@ -43,29 +45,47 @@ func (s SDN) clone() SDN {
return c
}
// Password of root@pam on the fake clusters (API and SSH).
const Password = "root-secret"
// Server is a fake PVE cluster.
type Server struct {
*httptest.Server
mu sync.Mutex
Version string // pve-manager version, e.g. 9.1.4
Options map[string]string // datacenter.cfg as written (property strings)
Pending SDN
Running SDN
Lock string
Perms map[string]map[string]any
FailOn string // "METHOD path" answering 500, to test rollbacks
Calls []string // "METHOD path" of every write
locks int
mu sync.Mutex
Version string // pve-manager version, e.g. 9.1.4
Password string // root@pam password
Options map[string]string // datacenter.cfg as written (property strings)
Pending SDN
Running SDN
Lock string
Groups map[string]map[string]any // groupid -> fields (comment)
Roles map[string]string // custom roles: roleid -> privs
Users map[string]map[string]any // userid -> fields (groups as "a,b")
Shadow map[string]string // user without realm -> hash (priv/shadow.cfg)
FailOn string // "METHOD path" answering 500, to test rollbacks
Calls []string // "METHOD path" of every write, "SSH write" for shadow.cfg
locks int
ssh *sshServer
}
// New starts a fake cluster.
const ticket, csrf = "PVE:root@pam:FAKE::sig", "fake-csrf"
// New starts a fake cluster (API in HTTPS and SSH).
func New(version string) *Server {
s := &Server{Version: version, Options: map[string]string{}, Pending: newSDN(), Running: newSDN(),
Perms: map[string]map[string]any{}}
s := &Server{Version: version, Password: Password, Options: map[string]string{}, Pending: newSDN(), Running: newSDN(),
Groups: map[string]map[string]any{}, Roles: map[string]string{}, Shadow: map[string]string{},
Users: map[string]map[string]any{"root@pam": {"enable": 1, "email": "root@localhost"}}}
s.Server = httptest.NewTLSServer(http.HandlerFunc(s.handle))
s.ssh = startSSH(s)
return s
}
// Close stops the API and the SSH server.
func (s *Server) Close() {
s.ssh.close()
s.Server.Close()
}
// Commit makes the pending SDN configuration the running one (like an apply).
func (s *Server) Commit() {
s.mu.Lock()
@@ -87,12 +107,24 @@ func fail(w http.ResponseWriter, code int, msg string) {
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
if !strings.HasPrefix(r.Header.Get("Authorization"), "PVEAPIToken=") {
_ = r.ParseForm()
p := strings.TrimPrefix(r.URL.Path, "/api2/json/")
if r.Method == http.MethodPost && p == "access/ticket" {
if r.PostForm.Get("username") != "root@pam" || r.PostForm.Get("password") != s.Password {
fail(w, 401, "authentication failure")
return
}
reply(w, map[string]any{"ticket": ticket, "CSRFPreventionToken": csrf, "username": "root@pam"})
return
}
if c, err := r.Cookie("PVEAuthCookie"); err != nil || c.Value != ticket {
fail(w, 401, "no ticket")
return
}
_ = r.ParseForm()
p := strings.TrimPrefix(r.URL.Path, "/api2/json/")
if r.Method != http.MethodGet && r.Header.Get("CSRFPreventionToken") != csrf {
fail(w, 401, "Permission denied - invalid csrf token")
return
}
seg := strings.Split(p, "/")
key := r.Method + " " + p
if r.Method != http.MethodGet {
@@ -130,8 +162,8 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
reply(w, out)
case r.Method == "GET" && len(seg) == 5 && seg[0] == "nodes" && seg[2] == "tasks" && seg[4] == "status":
reply(w, map[string]any{"status": "stopped", "exitstatus": "OK"})
case key == "GET access/permissions":
reply(w, s.Perms)
case strings.HasPrefix(p, "access/"):
s.access(w, r, r.Method, seg)
case key == "GET cluster/options":
reply(w, s.parsedOptions())
case key == "PUT cluster/options":
+143
View File
@@ -0,0 +1,143 @@
package pvetest
import (
"bufio"
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/binary"
"fmt"
"io"
"net"
"strings"
"golang.org/x/crypto/ssh"
)
// sshServer answers the commands pve-confsync runs on a node: pveversion, the
// read of priv/shadow.cfg and its locked write (adapter password).
type sshServer struct {
ln net.Listener
key ssh.PublicKey
}
func startSSH(s *Server) *sshServer {
_, priv, _ := ed25519.GenerateKey(rand.Reader)
signer, _ := ssh.NewSignerFromKey(priv)
cfg := &ssh.ServerConfig{
PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
s.mu.Lock()
defer s.mu.Unlock()
if c.User() == "root" && string(pass) == s.Password {
return nil, nil
}
return nil, fmt.Errorf("denied")
},
}
cfg.AddHostKey(signer)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
srv := &sshServer{ln: ln, key: signer.PublicKey()}
go func() {
for {
nc, err := ln.Accept()
if err != nil {
return
}
go s.serveSSH(nc, cfg)
}
}()
return srv
}
func (x *sshServer) close() { x.ln.Close() }
// SSHAddr returns the host and port of the fake SSH server.
func (s *Server) SSHAddr() (string, int) {
a := s.ssh.ln.Addr().(*net.TCPAddr)
return a.IP.String(), a.Port
}
// SSHHostKey returns the host key of the fake node (authorized_keys format).
func (s *Server) SSHHostKey() string {
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(s.ssh.key)))
}
func (s *Server) serveSSH(nc net.Conn, cfg *ssh.ServerConfig) {
_, chans, reqs, err := ssh.NewServerConn(nc, cfg)
if err != nil {
nc.Close()
return
}
go ssh.DiscardRequests(reqs)
for nch := range chans {
if nch.ChannelType() != "session" {
_ = nch.Reject(ssh.UnknownChannelType, "session only")
continue
}
ch, reqs, err := nch.Accept()
if err != nil {
continue
}
go func() {
defer ch.Close()
for req := range reqs {
if req.Type != "exec" {
_ = req.Reply(false, nil)
continue
}
var p struct{ Cmd string }
_ = ssh.Unmarshal(req.Payload, &p)
_ = req.Reply(true, nil)
status := s.exec(p.Cmd, ch, ch, ch.Stderr())
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, uint32(status))
_, _ = ch.SendRequest("exit-status", false, b)
return
}
}()
}
}
func (s *Server) exec(cmd string, in io.Reader, out, errw io.Writer) int {
switch {
case strings.HasPrefix(cmd, "pveversion"):
fmt.Fprintf(out, "pve-manager/%s/abcdef (running kernel: 6.14.8-2-pve)\n", s.Version)
return 0
case strings.HasPrefix(cmd, "cat /etc/pve/priv/shadow.cfg"):
s.mu.Lock()
defer s.mu.Unlock()
for _, u := range sortedKeys(s.Shadow) {
fmt.Fprintf(out, "%s:%s:\n", u, s.Shadow[u])
}
return 0
case strings.Contains(cmd, "confsync-shadow-write"):
data, _ := io.ReadAll(in)
s.mu.Lock()
defer s.mu.Unlock()
s.Calls = append(s.Calls, "SSH write shadow.cfg")
if s.FailOn == "SSH write shadow.cfg" {
fmt.Fprintln(errw, "injected failure")
return 1
}
sc := bufio.NewScanner(bytes.NewReader(data))
for sc.Scan() {
p := strings.SplitN(sc.Text(), "\t", 3)
switch {
case len(p) == 3 && p[0] == "set":
s.Shadow[p[1]] = p[2]
case len(p) >= 2 && p[0] == "del":
delete(s.Shadow, p[1])
default:
fmt.Fprintln(errw, "invalid operation")
return 1
}
}
fmt.Fprintln(out, "ok")
return 0
}
fmt.Fprintf(errw, "fake: unknown command %q\n", cmd)
return 127
}