Each VM's firecracker now runs inside a per-VM chroot dropped to the registered owner UID via firecracker-jailer. Closes the broad ambient- sudo escalation surface that survived Phase A: the helper still needs caps for tap/bridge/dm/loop/iptables, but the VMM itself no longer runs as root in the host root filesystem. The host helper stages each chroot up front: hard-links the kernel and (optional) initrd, mknods block-device drives + /dev/vhost-vsock, copies in the firecracker binary (jailer opens it O_RDWR so a ro bind fails with EROFS), and bind-mounts /usr/lib + /lib trees read-only so the dynamic linker can resolve. Self-binds the chroot first so the findmnt-guarded cleanup can recurse safely. AF_UNIX sun_path is 108 bytes; the chroot path easily blows past that. Daemon-side launch pre-symlinks the short request socket path to the long chroot socket before Machine.Start so the SDK's poll/connect sees the short path while the kernel resolves to the chroot socket. --new-pid-ns is intentionally disabled — jailer's PID-namespace fork makes the SDK see the parent exit and tear the API socket down too early. CapabilityBoundingSet for the helper expands to add CAP_FOWNER, CAP_KILL, CAP_MKNOD, CAP_SETGID, CAP_SETUID, CAP_SYS_CHROOT alongside the existing CAP_CHOWN/CAP_DAC_OVERRIDE/CAP_NET_ADMIN/CAP_NET_RAW/ CAP_SYS_ADMIN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1040 lines
34 KiB
Go
1040 lines
34 KiB
Go
package roothelper
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"golang.org/x/sys/unix"
|
|
|
|
"banger/internal/daemon/dmsnap"
|
|
"banger/internal/daemon/fcproc"
|
|
"banger/internal/firecracker"
|
|
"banger/internal/hostnat"
|
|
"banger/internal/installmeta"
|
|
"banger/internal/paths"
|
|
"banger/internal/rpc"
|
|
"banger/internal/system"
|
|
)
|
|
|
|
const (
|
|
methodEnsureBridge = "priv.ensure_bridge"
|
|
methodCreateTap = "priv.create_tap"
|
|
methodDeleteTap = "priv.delete_tap"
|
|
methodSyncResolverRouting = "priv.sync_resolver_routing"
|
|
methodClearResolverRouting = "priv.clear_resolver_routing"
|
|
methodEnsureNAT = "priv.ensure_nat"
|
|
methodCreateDMSnapshot = "priv.create_dm_snapshot"
|
|
methodCleanupDMSnapshot = "priv.cleanup_dm_snapshot"
|
|
methodRemoveDMSnapshot = "priv.remove_dm_snapshot"
|
|
methodFsckSnapshot = "priv.fsck_snapshot"
|
|
methodReadExt4File = "priv.read_ext4_file"
|
|
methodWriteExt4Files = "priv.write_ext4_files"
|
|
methodResolveFirecrackerBin = "priv.resolve_firecracker_binary"
|
|
methodLaunchFirecracker = "priv.launch_firecracker"
|
|
methodEnsureSocketAccess = "priv.ensure_socket_access"
|
|
methodFindFirecrackerPID = "priv.find_firecracker_pid"
|
|
methodKillProcess = "priv.kill_process"
|
|
methodSignalProcess = "priv.signal_process"
|
|
methodProcessRunning = "priv.process_running"
|
|
methodCleanupJailerChroot = "priv.cleanup_jailer_chroot"
|
|
rootfsDMNamePrefix = "fc-rootfs-"
|
|
vmTapPrefix = "tap-fc-"
|
|
tapPoolPrefix = "tap-pool-"
|
|
vmResolverRouteDomain = "~vm"
|
|
defaultFirecrackerBinaryName = "firecracker"
|
|
)
|
|
|
|
type NetworkConfig struct {
|
|
BridgeName string `json:"bridge_name"`
|
|
BridgeIP string `json:"bridge_ip"`
|
|
CIDR string `json:"cidr"`
|
|
}
|
|
|
|
type Ext4Write struct {
|
|
GuestPath string `json:"guest_path"`
|
|
Data []byte `json:"data"`
|
|
Mode uint32 `json:"mode"`
|
|
}
|
|
|
|
type FirecrackerLaunchRequest struct {
|
|
BinaryPath string `json:"binary_path"`
|
|
VMID string `json:"vm_id"`
|
|
SocketPath string `json:"socket_path"`
|
|
LogPath string `json:"log_path"`
|
|
MetricsPath string `json:"metrics_path"`
|
|
KernelImagePath string `json:"kernel_image_path"`
|
|
InitrdPath string `json:"initrd_path,omitempty"`
|
|
KernelArgs string `json:"kernel_args"`
|
|
Drives []firecracker.DriveConfig `json:"drives"`
|
|
TapDevice string `json:"tap_device"`
|
|
VSockPath string `json:"vsock_path"`
|
|
VSockCID uint32 `json:"vsock_cid"`
|
|
VCPUCount int `json:"vcpu_count"`
|
|
MemoryMiB int `json:"memory_mib"`
|
|
Network NetworkConfig `json:"network"`
|
|
Jailer *JailerLaunchOpts `json:"jailer,omitempty"`
|
|
}
|
|
|
|
// JailerLaunchOpts mirrors firecracker.JailerOpts for the RPC wire. UID
|
|
// and GID are the (un)privileged target the jailer drops to; the helper
|
|
// enforces they match the registered owner so the daemon can't ask the
|
|
// helper to run firecracker as an arbitrary user.
|
|
type JailerLaunchOpts struct {
|
|
Binary string `json:"binary"`
|
|
ChrootBaseDir string `json:"chroot_base_dir"`
|
|
UID int `json:"uid"`
|
|
GID int `json:"gid"`
|
|
}
|
|
|
|
type findPIDResult struct {
|
|
PID int `json:"pid"`
|
|
}
|
|
|
|
type processRunningResult struct {
|
|
Running bool `json:"running"`
|
|
}
|
|
|
|
type readExt4FileResult struct {
|
|
Data []byte `json:"data"`
|
|
}
|
|
|
|
type resolveFirecrackerResult struct {
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
type launchFirecrackerResult struct {
|
|
PID int `json:"pid"`
|
|
}
|
|
|
|
type Client struct {
|
|
socketPath string
|
|
}
|
|
|
|
func NewClient(socketPath string) *Client {
|
|
return &Client{socketPath: strings.TrimSpace(socketPath)}
|
|
}
|
|
|
|
func (c *Client) EnsureBridge(ctx context.Context, cfg NetworkConfig) error {
|
|
_, err := rpc.Call[struct{}](ctx, c.socketPath, methodEnsureBridge, cfg)
|
|
return err
|
|
}
|
|
|
|
func (c *Client) CreateTap(ctx context.Context, cfg NetworkConfig, tapName string) error {
|
|
_, err := rpc.Call[struct{}](ctx, c.socketPath, methodCreateTap, struct {
|
|
NetworkConfig
|
|
TapName string `json:"tap_name"`
|
|
}{NetworkConfig: cfg, TapName: tapName})
|
|
return err
|
|
}
|
|
|
|
func (c *Client) DeleteTap(ctx context.Context, tapName string) error {
|
|
_, err := rpc.Call[struct{}](ctx, c.socketPath, methodDeleteTap, struct {
|
|
TapName string `json:"tap_name"`
|
|
}{TapName: tapName})
|
|
return err
|
|
}
|
|
|
|
func (c *Client) SyncResolverRouting(ctx context.Context, bridgeName, serverAddr string) error {
|
|
_, err := rpc.Call[struct{}](ctx, c.socketPath, methodSyncResolverRouting, struct {
|
|
BridgeName string `json:"bridge_name"`
|
|
ServerAddr string `json:"server_addr"`
|
|
}{BridgeName: bridgeName, ServerAddr: serverAddr})
|
|
return err
|
|
}
|
|
|
|
func (c *Client) ClearResolverRouting(ctx context.Context, bridgeName string) error {
|
|
_, err := rpc.Call[struct{}](ctx, c.socketPath, methodClearResolverRouting, struct {
|
|
BridgeName string `json:"bridge_name"`
|
|
}{BridgeName: bridgeName})
|
|
return err
|
|
}
|
|
|
|
func (c *Client) EnsureNAT(ctx context.Context, guestIP, tap string, enable bool) error {
|
|
_, err := rpc.Call[struct{}](ctx, c.socketPath, methodEnsureNAT, struct {
|
|
GuestIP string `json:"guest_ip"`
|
|
Tap string `json:"tap"`
|
|
Enable bool `json:"enable"`
|
|
}{GuestIP: guestIP, Tap: tap, Enable: enable})
|
|
return err
|
|
}
|
|
|
|
func (c *Client) CreateDMSnapshot(ctx context.Context, rootfsPath, cowPath, dmName string) (dmsnap.Handles, error) {
|
|
return rpc.Call[dmsnap.Handles](ctx, c.socketPath, methodCreateDMSnapshot, struct {
|
|
RootfsPath string `json:"rootfs_path"`
|
|
COWPath string `json:"cow_path"`
|
|
DMName string `json:"dm_name"`
|
|
}{RootfsPath: rootfsPath, COWPath: cowPath, DMName: dmName})
|
|
}
|
|
|
|
func (c *Client) CleanupDMSnapshot(ctx context.Context, handles dmsnap.Handles) error {
|
|
_, err := rpc.Call[struct{}](ctx, c.socketPath, methodCleanupDMSnapshot, handles)
|
|
return err
|
|
}
|
|
|
|
func (c *Client) RemoveDMSnapshot(ctx context.Context, target string) error {
|
|
_, err := rpc.Call[struct{}](ctx, c.socketPath, methodRemoveDMSnapshot, struct {
|
|
Target string `json:"target"`
|
|
}{Target: target})
|
|
return err
|
|
}
|
|
|
|
func (c *Client) FsckSnapshot(ctx context.Context, dmDev string) error {
|
|
_, err := rpc.Call[struct{}](ctx, c.socketPath, methodFsckSnapshot, struct {
|
|
DMDev string `json:"dm_dev"`
|
|
}{DMDev: dmDev})
|
|
return err
|
|
}
|
|
|
|
func (c *Client) ReadExt4File(ctx context.Context, imagePath, guestPath string) ([]byte, error) {
|
|
result, err := rpc.Call[readExt4FileResult](ctx, c.socketPath, methodReadExt4File, struct {
|
|
ImagePath string `json:"image_path"`
|
|
GuestPath string `json:"guest_path"`
|
|
}{ImagePath: imagePath, GuestPath: guestPath})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return result.Data, nil
|
|
}
|
|
|
|
func (c *Client) WriteExt4Files(ctx context.Context, imagePath string, files []Ext4Write) error {
|
|
_, err := rpc.Call[struct{}](ctx, c.socketPath, methodWriteExt4Files, struct {
|
|
ImagePath string `json:"image_path"`
|
|
Files []Ext4Write `json:"files"`
|
|
}{ImagePath: imagePath, Files: files})
|
|
return err
|
|
}
|
|
|
|
func (c *Client) ResolveFirecrackerBinary(ctx context.Context, requested string) (string, error) {
|
|
result, err := rpc.Call[resolveFirecrackerResult](ctx, c.socketPath, methodResolveFirecrackerBin, struct {
|
|
Requested string `json:"requested"`
|
|
}{Requested: requested})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return result.Path, nil
|
|
}
|
|
|
|
func (c *Client) LaunchFirecracker(ctx context.Context, req FirecrackerLaunchRequest) (int, error) {
|
|
result, err := rpc.Call[launchFirecrackerResult](ctx, c.socketPath, methodLaunchFirecracker, req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.PID, nil
|
|
}
|
|
|
|
func (c *Client) CleanupJailerChroot(ctx context.Context, chrootRoot string) error {
|
|
_, err := rpc.Call[struct{}](ctx, c.socketPath, methodCleanupJailerChroot, struct {
|
|
ChrootRoot string `json:"chroot_root"`
|
|
}{ChrootRoot: chrootRoot})
|
|
return err
|
|
}
|
|
|
|
func (c *Client) EnsureSocketAccess(ctx context.Context, socketPath, label string) error {
|
|
_, err := rpc.Call[struct{}](ctx, c.socketPath, methodEnsureSocketAccess, struct {
|
|
SocketPath string `json:"socket_path"`
|
|
Label string `json:"label"`
|
|
}{SocketPath: socketPath, Label: label})
|
|
return err
|
|
}
|
|
|
|
func (c *Client) FindFirecrackerPID(ctx context.Context, apiSock string) (int, error) {
|
|
result, err := rpc.Call[findPIDResult](ctx, c.socketPath, methodFindFirecrackerPID, struct {
|
|
APISock string `json:"api_sock"`
|
|
}{APISock: apiSock})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.PID, nil
|
|
}
|
|
|
|
func (c *Client) KillProcess(ctx context.Context, pid int) error {
|
|
_, err := rpc.Call[struct{}](ctx, c.socketPath, methodKillProcess, struct {
|
|
PID int `json:"pid"`
|
|
}{PID: pid})
|
|
return err
|
|
}
|
|
|
|
func (c *Client) SignalProcess(ctx context.Context, pid int, signal string) error {
|
|
_, err := rpc.Call[struct{}](ctx, c.socketPath, methodSignalProcess, struct {
|
|
PID int `json:"pid"`
|
|
Signal string `json:"signal"`
|
|
}{PID: pid, Signal: signal})
|
|
return err
|
|
}
|
|
|
|
func (c *Client) ProcessRunning(ctx context.Context, pid int, apiSock string) (bool, error) {
|
|
result, err := rpc.Call[processRunningResult](ctx, c.socketPath, methodProcessRunning, struct {
|
|
PID int `json:"pid"`
|
|
APISock string `json:"api_sock"`
|
|
}{PID: pid, APISock: apiSock})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return result.Running, nil
|
|
}
|
|
|
|
type Server struct {
|
|
meta installmeta.Metadata
|
|
runner system.CommandRunner
|
|
logger *slog.Logger
|
|
listener net.Listener
|
|
}
|
|
|
|
func Open() (*Server, error) {
|
|
meta, err := installmeta.Load(installmeta.DefaultPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.MkdirAll(installmeta.DefaultRootHelperRuntimeDir, 0o711); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.Chmod(installmeta.DefaultRootHelperRuntimeDir, 0o711); err != nil {
|
|
return nil, err
|
|
}
|
|
return &Server{
|
|
meta: meta,
|
|
runner: system.NewRunner(),
|
|
// JSON to match bangerd. Mixed text/JSON streams in the
|
|
// merged journalctl made the daemon side painful to grep;
|
|
// this aligns the helper so a single greppable shape spans
|
|
// both units.
|
|
logger: slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) Close() error {
|
|
if s == nil || s.listener == nil {
|
|
return nil
|
|
}
|
|
return s.listener.Close()
|
|
}
|
|
|
|
func (s *Server) Serve(ctx context.Context) error {
|
|
_ = os.Remove(installmeta.DefaultRootHelperSocketPath)
|
|
listener, err := net.Listen("unix", installmeta.DefaultRootHelperSocketPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.listener = listener
|
|
defer listener.Close()
|
|
defer os.Remove(installmeta.DefaultRootHelperSocketPath)
|
|
if err := os.Chmod(installmeta.DefaultRootHelperSocketPath, 0o600); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chown(installmeta.DefaultRootHelperSocketPath, s.meta.OwnerUID, s.meta.OwnerGID); err != nil {
|
|
return err
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
defer close(done)
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = listener.Close()
|
|
case <-done:
|
|
}
|
|
}()
|
|
|
|
for {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
default:
|
|
}
|
|
var netErr net.Error
|
|
if errors.As(err, &netErr) && netErr.Temporary() {
|
|
time.Sleep(100 * time.Millisecond)
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
go s.handleConn(conn)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleConn(conn net.Conn) {
|
|
defer conn.Close()
|
|
if err := s.authorizeConn(conn); err != nil {
|
|
_ = json.NewEncoder(conn).Encode(rpc.NewError("unauthorized", err.Error()))
|
|
return
|
|
}
|
|
var req rpc.Request
|
|
if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&req); err != nil {
|
|
_ = json.NewEncoder(conn).Encode(rpc.NewError("bad_request", err.Error()))
|
|
return
|
|
}
|
|
// Adopt the daemon's op id so a single greppable id covers the
|
|
// whole call chain (CLI → daemon → helper). Entry log at debug
|
|
// level keeps production quiet; the completion log fires at
|
|
// info-on-success / error-on-failure with duration so an
|
|
// operator can see at a glance how long each privileged op
|
|
// took.
|
|
ctx := rpc.WithOpID(context.Background(), req.OpID)
|
|
start := time.Now()
|
|
if s.logger != nil {
|
|
s.logger.Debug("helper rpc", "method", req.Method, "op_id", req.OpID)
|
|
}
|
|
resp := s.dispatch(ctx, req)
|
|
if !resp.OK && resp.Error != nil && resp.Error.OpID == "" && req.OpID != "" {
|
|
resp.Error.OpID = req.OpID
|
|
}
|
|
if s.logger != nil {
|
|
duration := time.Since(start).Milliseconds()
|
|
if !resp.OK && resp.Error != nil {
|
|
s.logger.Error("helper rpc failed", "method", req.Method, "op_id", req.OpID, "duration_ms", duration, "code", resp.Error.Code, "message", resp.Error.Message)
|
|
} else {
|
|
s.logger.Info("helper rpc completed", "method", req.Method, "op_id", req.OpID, "duration_ms", duration)
|
|
}
|
|
}
|
|
_ = json.NewEncoder(conn).Encode(resp)
|
|
}
|
|
|
|
func (s *Server) authorizeConn(conn net.Conn) error {
|
|
unixConn, ok := conn.(*net.UnixConn)
|
|
if !ok {
|
|
return errors.New("root helper requires unix connections")
|
|
}
|
|
rawConn, err := unixConn.SyscallConn()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var cred *unix.Ucred
|
|
var controlErr error
|
|
if err := rawConn.Control(func(fd uintptr) {
|
|
cred, controlErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if controlErr != nil {
|
|
return controlErr
|
|
}
|
|
if cred == nil {
|
|
return errors.New("missing peer credentials")
|
|
}
|
|
if int(cred.Uid) == 0 || int(cred.Uid) == s.meta.OwnerUID {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("uid %d is not allowed to use the root helper", cred.Uid)
|
|
}
|
|
|
|
func (s *Server) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|
switch req.Method {
|
|
case methodEnsureBridge:
|
|
params, err := rpc.DecodeParams[NetworkConfig](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
return marshalResultOrError(struct{}{}, s.ensureBridge(ctx, params))
|
|
case methodCreateTap:
|
|
params, err := rpc.DecodeParams[struct {
|
|
NetworkConfig
|
|
TapName string `json:"tap_name"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
return marshalResultOrError(struct{}{}, s.createTap(ctx, params.NetworkConfig, params.TapName))
|
|
case methodDeleteTap:
|
|
params, err := rpc.DecodeParams[struct {
|
|
TapName string `json:"tap_name"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
return marshalResultOrError(struct{}{}, s.deleteTap(ctx, params.TapName))
|
|
case methodSyncResolverRouting:
|
|
params, err := rpc.DecodeParams[struct {
|
|
BridgeName string `json:"bridge_name"`
|
|
ServerAddr string `json:"server_addr"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
return marshalResultOrError(struct{}{}, s.syncResolverRouting(ctx, params.BridgeName, params.ServerAddr))
|
|
case methodClearResolverRouting:
|
|
params, err := rpc.DecodeParams[struct {
|
|
BridgeName string `json:"bridge_name"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
return marshalResultOrError(struct{}{}, s.clearResolverRouting(ctx, params.BridgeName))
|
|
case methodEnsureNAT:
|
|
params, err := rpc.DecodeParams[struct {
|
|
GuestIP string `json:"guest_ip"`
|
|
Tap string `json:"tap"`
|
|
Enable bool `json:"enable"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
return marshalResultOrError(struct{}{}, hostnat.Ensure(ctx, s.runner, params.GuestIP, params.Tap, params.Enable))
|
|
case methodCreateDMSnapshot:
|
|
params, err := rpc.DecodeParams[struct {
|
|
RootfsPath string `json:"rootfs_path"`
|
|
COWPath string `json:"cow_path"`
|
|
DMName string `json:"dm_name"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
if err := s.validateManagedPath(params.RootfsPath, paths.ResolveSystem().StateDir); err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
if err := s.validateManagedPath(params.COWPath, paths.ResolveSystem().StateDir); err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
if err := validateDMName(params.DMName); err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
result, err := dmsnap.Create(ctx, s.runner, params.RootfsPath, params.COWPath, params.DMName)
|
|
return marshalResultOrError(result, err)
|
|
case methodCleanupDMSnapshot:
|
|
params, err := rpc.DecodeParams[dmsnap.Handles](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
return marshalResultOrError(struct{}{}, dmsnap.Cleanup(ctx, s.runner, params))
|
|
case methodRemoveDMSnapshot:
|
|
params, err := rpc.DecodeParams[struct {
|
|
Target string `json:"target"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
return marshalResultOrError(struct{}{}, dmsnap.Remove(ctx, s.runner, params.Target))
|
|
case methodFsckSnapshot:
|
|
params, err := rpc.DecodeParams[struct {
|
|
DMDev string `json:"dm_dev"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
return marshalResultOrError(struct{}{}, s.fsckSnapshot(ctx, params.DMDev))
|
|
case methodReadExt4File:
|
|
params, err := rpc.DecodeParams[struct {
|
|
ImagePath string `json:"image_path"`
|
|
GuestPath string `json:"guest_path"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
data, readErr := system.ReadExt4File(ctx, s.runner, params.ImagePath, params.GuestPath)
|
|
return marshalResultOrError(readExt4FileResult{Data: data}, readErr)
|
|
case methodWriteExt4Files:
|
|
params, err := rpc.DecodeParams[struct {
|
|
ImagePath string `json:"image_path"`
|
|
Files []Ext4Write `json:"files"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
return marshalResultOrError(struct{}{}, s.writeExt4Files(ctx, params.ImagePath, params.Files))
|
|
case methodResolveFirecrackerBin:
|
|
params, err := rpc.DecodeParams[struct {
|
|
Requested string `json:"requested"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
path, resolveErr := s.resolveFirecrackerBinary(params.Requested)
|
|
return marshalResultOrError(resolveFirecrackerResult{Path: path}, resolveErr)
|
|
case methodLaunchFirecracker:
|
|
params, err := rpc.DecodeParams[FirecrackerLaunchRequest](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
pid, launchErr := s.launchFirecracker(ctx, params)
|
|
return marshalResultOrError(launchFirecrackerResult{PID: pid}, launchErr)
|
|
case methodEnsureSocketAccess:
|
|
params, err := rpc.DecodeParams[struct {
|
|
SocketPath string `json:"socket_path"`
|
|
Label string `json:"label"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
return marshalResultOrError(struct{}{}, s.ensureSocketAccess(ctx, params.SocketPath, params.Label))
|
|
case methodFindFirecrackerPID:
|
|
params, err := rpc.DecodeParams[struct {
|
|
APISock string `json:"api_sock"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
pid, findErr := fcproc.New(s.runner, fcproc.Config{}, s.logger).FindPID(ctx, params.APISock)
|
|
return marshalResultOrError(findPIDResult{PID: pid}, findErr)
|
|
case methodKillProcess:
|
|
params, err := rpc.DecodeParams[struct {
|
|
PID int `json:"pid"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
_, killErr := s.runner.Run(ctx, "kill", "-KILL", strconv.Itoa(params.PID))
|
|
return marshalResultOrError(struct{}{}, killErr)
|
|
case methodSignalProcess:
|
|
params, err := rpc.DecodeParams[struct {
|
|
PID int `json:"pid"`
|
|
Signal string `json:"signal"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
signal := strings.TrimSpace(params.Signal)
|
|
if signal == "" {
|
|
signal = "TERM"
|
|
}
|
|
_, signalErr := s.runner.Run(ctx, "kill", "-"+signal, strconv.Itoa(params.PID))
|
|
return marshalResultOrError(struct{}{}, signalErr)
|
|
case methodProcessRunning:
|
|
params, err := rpc.DecodeParams[struct {
|
|
PID int `json:"pid"`
|
|
APISock string `json:"api_sock"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
return marshalResultOrError(processRunningResult{Running: system.ProcessRunning(params.PID, params.APISock)}, nil)
|
|
case methodCleanupJailerChroot:
|
|
params, err := rpc.DecodeParams[struct {
|
|
ChrootRoot string `json:"chroot_root"`
|
|
}](req)
|
|
if err != nil {
|
|
return rpc.NewError("bad_params", err.Error())
|
|
}
|
|
systemLayout := paths.ResolveSystem()
|
|
if err := s.validateManagedPath(params.ChrootRoot, systemLayout.StateDir, systemLayout.RuntimeDir); err != nil {
|
|
return rpc.NewError("invalid_path", err.Error())
|
|
}
|
|
err = fcproc.New(s.runner, fcproc.Config{}, s.logger).CleanupJailerChroot(ctx, params.ChrootRoot)
|
|
return marshalResultOrError(struct{}{}, err)
|
|
default:
|
|
return rpc.NewError("unknown_method", req.Method)
|
|
}
|
|
}
|
|
|
|
func (s *Server) ensureBridge(ctx context.Context, cfg NetworkConfig) error {
|
|
return fcproc.New(s.runner, fcproc.Config{
|
|
BridgeName: cfg.BridgeName,
|
|
BridgeIP: cfg.BridgeIP,
|
|
CIDR: cfg.CIDR,
|
|
}, s.logger).EnsureBridge(ctx)
|
|
}
|
|
|
|
func (s *Server) createTap(ctx context.Context, cfg NetworkConfig, tapName string) error {
|
|
if err := validateTapName(tapName); err != nil {
|
|
return err
|
|
}
|
|
return fcproc.New(s.runner, fcproc.Config{
|
|
BridgeName: cfg.BridgeName,
|
|
BridgeIP: cfg.BridgeIP,
|
|
CIDR: cfg.CIDR,
|
|
}, s.logger).CreateTapOwned(ctx, tapName, s.meta.OwnerUID, s.meta.OwnerGID)
|
|
}
|
|
|
|
func (s *Server) deleteTap(ctx context.Context, tapName string) error {
|
|
if err := validateTapName(tapName); err != nil {
|
|
return err
|
|
}
|
|
_, err := s.runner.Run(ctx, "ip", "link", "del", tapName)
|
|
return err
|
|
}
|
|
|
|
func (s *Server) syncResolverRouting(ctx context.Context, bridgeName, serverAddr string) error {
|
|
if strings.TrimSpace(bridgeName) == "" || strings.TrimSpace(serverAddr) == "" {
|
|
return nil
|
|
}
|
|
if _, err := system.LookupExecutable("resolvectl"); err != nil {
|
|
return nil
|
|
}
|
|
if _, err := s.runner.Run(ctx, "resolvectl", "dns", bridgeName, serverAddr); err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.runner.Run(ctx, "resolvectl", "domain", bridgeName, vmResolverRouteDomain); err != nil {
|
|
return err
|
|
}
|
|
_, err := s.runner.Run(ctx, "resolvectl", "default-route", bridgeName, "no")
|
|
return err
|
|
}
|
|
|
|
func (s *Server) clearResolverRouting(ctx context.Context, bridgeName string) error {
|
|
if strings.TrimSpace(bridgeName) == "" {
|
|
return nil
|
|
}
|
|
if _, err := system.LookupExecutable("resolvectl"); err != nil {
|
|
return nil
|
|
}
|
|
_, err := s.runner.Run(ctx, "resolvectl", "revert", bridgeName)
|
|
return err
|
|
}
|
|
|
|
func (s *Server) fsckSnapshot(ctx context.Context, dmDev string) error {
|
|
if strings.TrimSpace(dmDev) == "" {
|
|
return errors.New("dm device is required")
|
|
}
|
|
if _, err := s.runner.Run(ctx, "e2fsck", "-fy", dmDev); err != nil {
|
|
if code := system.ExitCode(err); code < 0 || code > 1 {
|
|
return fmt.Errorf("fsck snapshot: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) writeExt4Files(ctx context.Context, imagePath string, files []Ext4Write) error {
|
|
for _, file := range files {
|
|
mode := os.FileMode(file.Mode)
|
|
if mode == 0 {
|
|
mode = 0o644
|
|
}
|
|
if err := system.WriteExt4FileOwned(ctx, s.runner, imagePath, file.GuestPath, mode, 0, 0, file.Data); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) resolveFirecrackerBinary(requested string) (string, error) {
|
|
requested = strings.TrimSpace(requested)
|
|
if requested == "" {
|
|
requested = defaultFirecrackerBinaryName
|
|
}
|
|
cfg := fcproc.Config{FirecrackerBin: requested}
|
|
resolved, err := fcproc.New(s.runner, cfg, s.logger).ResolveBinary()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := validateRootExecutable(resolved); err != nil {
|
|
return "", err
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
func (s *Server) launchFirecracker(ctx context.Context, req FirecrackerLaunchRequest) (int, error) {
|
|
systemLayout := paths.ResolveSystem()
|
|
for _, path := range []string{req.SocketPath, req.VSockPath} {
|
|
if err := s.validateManagedPath(path, systemLayout.RuntimeDir); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
for _, path := range []string{req.LogPath, req.MetricsPath, req.KernelImagePath} {
|
|
if err := s.validateManagedPath(path, systemLayout.StateDir); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
if strings.TrimSpace(req.InitrdPath) != "" {
|
|
if err := s.validateManagedPath(req.InitrdPath, systemLayout.StateDir); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
if err := validateTapName(req.TapDevice); err != nil {
|
|
return 0, err
|
|
}
|
|
if err := validateRootExecutable(req.BinaryPath); err != nil {
|
|
return 0, err
|
|
}
|
|
for _, drive := range req.Drives {
|
|
if err := s.validateLaunchDrivePath(drive, systemLayout.StateDir); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
mgr := fcproc.New(s.runner, fcproc.Config{BridgeName: req.Network.BridgeName, BridgeIP: req.Network.BridgeIP, CIDR: req.Network.CIDR}, s.logger)
|
|
mc, err := s.buildLaunchMachineConfig(ctx, req, systemLayout, mgr)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
// Pre-Start symlink: see localPrivilegedOps.LaunchFirecracker for
|
|
// the AF_UNIX sun_path-length rationale.
|
|
if err := s.exposeJailerSockets(req); err != nil {
|
|
return 0, fmt.Errorf("expose jailer sockets: %w", err)
|
|
}
|
|
machine, err := firecracker.NewMachine(ctx, mc)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if err := machine.Start(ctx); err != nil {
|
|
if pid := mgr.ResolvePID(context.Background(), machine, mc.SocketPath); pid > 0 {
|
|
_, _ = s.runner.Run(context.Background(), "kill", "-KILL", strconv.Itoa(pid))
|
|
}
|
|
return 0, err
|
|
}
|
|
if req.Jailer == nil {
|
|
// Belt-and-suspenders only on the legacy direct-firecracker path;
|
|
// the jailer drops to the configured uid before creating the
|
|
// socket, so its perms are correct by construction.
|
|
if err := mgr.EnsureSocketAccessFor(ctx, mc.SocketPath, "firecracker api socket", s.meta.OwnerUID, s.meta.OwnerGID); err != nil {
|
|
return 0, err
|
|
}
|
|
if strings.TrimSpace(mc.VSockPath) != "" {
|
|
if err := mgr.EnsureSocketAccessFor(ctx, mc.VSockPath, "firecracker vsock socket", s.meta.OwnerUID, s.meta.OwnerGID); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
}
|
|
pid := mgr.ResolvePID(context.Background(), machine, mc.SocketPath)
|
|
if pid <= 0 {
|
|
return 0, errors.New("firecracker started but pid could not be resolved")
|
|
}
|
|
return pid, nil
|
|
}
|
|
|
|
// buildLaunchMachineConfig assembles the firecracker.MachineConfig used by
|
|
// launchFirecracker, performing the chroot staging when jailer is enabled.
|
|
// In the non-jailer case it's a straight field copy from the request.
|
|
//
|
|
// In the jailer case it:
|
|
// - validates JailerLaunchOpts (binary executable, chroot under RuntimeDir,
|
|
// uid/gid match the registered owner — the daemon can't ask the helper to
|
|
// drop firecracker into an arbitrary uid)
|
|
// - calls fcproc.PrepareJailerChroot to build the chroot tree
|
|
// - rewrites SocketPath and VSockPath to host-visible chroot paths and
|
|
// KernelImagePath/InitrdPath/Drives[].Path to chroot-internal names
|
|
func (s *Server) buildLaunchMachineConfig(ctx context.Context, req FirecrackerLaunchRequest, layout paths.Layout, mgr *fcproc.Manager) (firecracker.MachineConfig, error) {
|
|
mc := firecracker.MachineConfig{
|
|
BinaryPath: req.BinaryPath,
|
|
VMID: req.VMID,
|
|
SocketPath: req.SocketPath,
|
|
LogPath: req.LogPath,
|
|
MetricsPath: req.MetricsPath,
|
|
KernelImagePath: req.KernelImagePath,
|
|
InitrdPath: req.InitrdPath,
|
|
KernelArgs: req.KernelArgs,
|
|
Drives: req.Drives,
|
|
TapDevice: req.TapDevice,
|
|
VSockPath: req.VSockPath,
|
|
VSockCID: req.VSockCID,
|
|
VCPUCount: req.VCPUCount,
|
|
MemoryMiB: req.MemoryMiB,
|
|
Logger: s.logger,
|
|
}
|
|
if req.Jailer == nil {
|
|
return mc, nil
|
|
}
|
|
if err := s.validateJailerOpts(*req.Jailer, layout); err != nil {
|
|
return firecracker.MachineConfig{}, err
|
|
}
|
|
chrootRoot := firecracker.JailerChrootRoot(req.Jailer.ChrootBaseDir, req.VMID)
|
|
driveSpecs := make([]fcproc.ChrootDriveSpec, 0, len(req.Drives))
|
|
chrootDrives := make([]firecracker.DriveConfig, 0, len(req.Drives))
|
|
for _, d := range req.Drives {
|
|
name := chrootDriveName(d)
|
|
driveSpecs = append(driveSpecs, fcproc.ChrootDriveSpec{ChrootName: name, HostPath: d.Path})
|
|
chrootDrives = append(chrootDrives, firecracker.DriveConfig{
|
|
ID: d.ID,
|
|
Path: "/" + name,
|
|
ReadOnly: d.ReadOnly,
|
|
IsRoot: d.IsRoot,
|
|
})
|
|
}
|
|
wantVSock := strings.TrimSpace(req.VSockPath) != ""
|
|
if err := mgr.PrepareJailerChroot(ctx, chrootRoot,
|
|
req.Jailer.UID, req.Jailer.GID,
|
|
req.BinaryPath,
|
|
req.KernelImagePath, "vmlinux",
|
|
req.InitrdPath, "initrd",
|
|
driveSpecs, wantVSock,
|
|
); err != nil {
|
|
return firecracker.MachineConfig{}, fmt.Errorf("prepare jailer chroot: %w", err)
|
|
}
|
|
// See localPrivilegedOps.buildLaunchMachineConfig for why SocketPath
|
|
// stays the short req path but VSockPath becomes chroot-internal.
|
|
_ = chrootRoot
|
|
if wantVSock {
|
|
mc.VSockPath = firecracker.JailerVSockName
|
|
}
|
|
mc.KernelImagePath = "/vmlinux"
|
|
if strings.TrimSpace(req.InitrdPath) != "" {
|
|
mc.InitrdPath = "/initrd"
|
|
} else {
|
|
mc.InitrdPath = ""
|
|
}
|
|
mc.Drives = chrootDrives
|
|
// LogPath stays set so buildProcessRunner's openLogFile captures firecracker
|
|
// stderr via cmd.Stderr. buildConfig clears sdk.Config.LogPath for jailer
|
|
// mode to avoid PUT /logger with a host path firecracker can't open.
|
|
mc.MetricsPath = ""
|
|
mc.Jailer = &firecracker.JailerOpts{
|
|
Binary: req.Jailer.Binary,
|
|
ChrootBaseDir: req.Jailer.ChrootBaseDir,
|
|
UID: req.Jailer.UID,
|
|
GID: req.Jailer.GID,
|
|
}
|
|
return mc, nil
|
|
}
|
|
|
|
func (s *Server) validateJailerOpts(opts JailerLaunchOpts, layout paths.Layout) error {
|
|
if err := validateRootExecutable(opts.Binary); err != nil {
|
|
return fmt.Errorf("jailer binary: %w", err)
|
|
}
|
|
// Chroot base must live under StateDir so hard-links into the chroot
|
|
// share a filesystem with the image cache (RuntimeDir is tmpfs and
|
|
// would EXDEV on os.Link). RuntimeDir is also accepted because the
|
|
// jailer is happy on tmpfs when the kernel/drives happen to colocate
|
|
// (e.g. tests).
|
|
if err := s.validateManagedPath(opts.ChrootBaseDir, layout.StateDir, layout.RuntimeDir); err != nil {
|
|
return fmt.Errorf("jailer chroot base: %w", err)
|
|
}
|
|
if opts.UID != s.meta.OwnerUID || opts.GID != s.meta.OwnerGID {
|
|
return fmt.Errorf("jailer uid/gid (%d:%d) must match registered owner (%d:%d)", opts.UID, opts.GID, s.meta.OwnerUID, s.meta.OwnerGID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// exposeJailerSockets makes the chroot-internal sockets reachable at the
|
|
// host paths the daemon already references (sc.apiSock, vm.Runtime.VSockPath).
|
|
// AF_UNIX connect(2) follows symlinks, so a symlink keeps the rest of the
|
|
// daemon code unchanged. Computes both host targets from the chroot root and
|
|
// the chroot-internal name, so the API socket and the vsock socket stay in
|
|
// sync regardless of how the launch request laid them out.
|
|
func (s *Server) exposeJailerSockets(req FirecrackerLaunchRequest) error {
|
|
if req.Jailer == nil {
|
|
return nil
|
|
}
|
|
chrootRoot := firecracker.JailerChrootRoot(req.Jailer.ChrootBaseDir, req.VMID)
|
|
hostAPI := filepath.Join(chrootRoot, strings.TrimPrefix(firecracker.JailerSocketName, "/"))
|
|
if err := atomicSymlink(hostAPI, req.SocketPath); err != nil {
|
|
return fmt.Errorf("api socket symlink: %w", err)
|
|
}
|
|
if strings.TrimSpace(req.VSockPath) != "" {
|
|
hostVSock := filepath.Join(chrootRoot, strings.TrimPrefix(firecracker.JailerVSockName, "/"))
|
|
if err := atomicSymlink(hostVSock, req.VSockPath); err != nil {
|
|
return fmt.Errorf("vsock symlink: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func atomicSymlink(target, link string) error {
|
|
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return os.Symlink(target, link)
|
|
}
|
|
|
|
// chrootDriveName returns the bare filename a drive should appear as inside
|
|
// the chroot. We use the drive ID when present (rootfs, work, …) so the
|
|
// chroot listing is self-explanatory; falling back to the source's basename
|
|
// covers the unnamed case.
|
|
func chrootDriveName(d firecracker.DriveConfig) string {
|
|
if id := strings.TrimSpace(d.ID); id != "" {
|
|
return id
|
|
}
|
|
return filepath.Base(d.Path)
|
|
}
|
|
|
|
func (s *Server) validateLaunchDrivePath(drive firecracker.DriveConfig, stateDir string) error {
|
|
if err := s.validateManagedPath(drive.Path, stateDir); err == nil {
|
|
return nil
|
|
}
|
|
if drive.IsRoot {
|
|
if err := validateDMDevicePath(drive.Path); err == nil {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("path %q is outside banger-managed directories", drive.Path)
|
|
}
|
|
|
|
func (s *Server) ensureSocketAccess(ctx context.Context, socketPath, label string) error {
|
|
return fcproc.New(s.runner, fcproc.Config{}, s.logger).EnsureSocketAccessFor(ctx, socketPath, label, s.meta.OwnerUID, s.meta.OwnerGID)
|
|
}
|
|
|
|
func (s *Server) validateManagedPath(path string, roots ...string) error {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return errors.New("path is required")
|
|
}
|
|
if !filepath.IsAbs(path) {
|
|
return fmt.Errorf("path %q must be absolute", path)
|
|
}
|
|
cleaned := filepath.Clean(path)
|
|
for _, root := range roots {
|
|
root = strings.TrimSpace(root)
|
|
if root == "" {
|
|
continue
|
|
}
|
|
root = filepath.Clean(root)
|
|
if cleaned == root || strings.HasPrefix(cleaned, root+string(os.PathSeparator)) {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("path %q is outside banger-managed directories", path)
|
|
}
|
|
|
|
func validateTapName(tapName string) error {
|
|
tapName = strings.TrimSpace(tapName)
|
|
if strings.HasPrefix(tapName, vmTapPrefix) || strings.HasPrefix(tapName, tapPoolPrefix) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("tap %q is outside banger-managed naming", tapName)
|
|
}
|
|
|
|
func validateDMName(dmName string) error {
|
|
dmName = strings.TrimSpace(dmName)
|
|
if strings.HasPrefix(dmName, rootfsDMNamePrefix) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("dm target %q is outside banger-managed naming", dmName)
|
|
}
|
|
|
|
func validateDMDevicePath(path string) error {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return errors.New("dm device path is required")
|
|
}
|
|
if !filepath.IsAbs(path) {
|
|
return fmt.Errorf("dm device path %q must be absolute", path)
|
|
}
|
|
cleaned := filepath.Clean(path)
|
|
if filepath.Dir(cleaned) != "/dev/mapper" {
|
|
return fmt.Errorf("dm device path %q is outside /dev/mapper", path)
|
|
}
|
|
return validateDMName(filepath.Base(cleaned))
|
|
}
|
|
|
|
func validateRootExecutable(path string) error {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return fmt.Errorf("firecracker binary %q is not a regular file", path)
|
|
}
|
|
if info.Mode().Perm()&0o111 == 0 {
|
|
return fmt.Errorf("firecracker binary %q is not executable", path)
|
|
}
|
|
if info.Mode().Perm()&0o022 != 0 {
|
|
return fmt.Errorf("firecracker binary %q must not be group/world writable", path)
|
|
}
|
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
return fmt.Errorf("inspect owner for %q: unsupported file metadata", path)
|
|
}
|
|
if stat.Uid != 0 {
|
|
return fmt.Errorf("firecracker binary %q must be root-owned in system mode", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func marshalResultOrError(v any, err error) rpc.Response {
|
|
if err != nil {
|
|
return rpc.NewError("operation_failed", err.Error())
|
|
}
|
|
resp, marshalErr := rpc.NewResult(v)
|
|
if marshalErr != nil {
|
|
return rpc.NewError("marshal_failed", marshalErr.Error())
|
|
}
|
|
return resp
|
|
}
|