mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-14 20:48:17 +08:00
233 lines
7.6 KiB
Go
233 lines
7.6 KiB
Go
// Package control is the local control protocol: the one way the CLI and the
|
|
// LuCI bridge ask the daemon to do something.
|
|
//
|
|
// It is a Unix socket, never TCP. The wire format is one UTF-8 JSON object per
|
|
// line, one request and one response per connection. That is deliberately dull:
|
|
// the surface an attacker who reaches the socket can reason about is a fixed
|
|
// list of method names and typed parameters, and nothing on this path ever
|
|
// turns a string into a command to execute.
|
|
package control
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
|
|
"github.com/matthewlu070111/smart-srun/core/internal/domain"
|
|
)
|
|
|
|
// Version is the protocol version. A client that sends anything else is
|
|
// refused rather than served a guess at what it meant.
|
|
const Version = 1
|
|
|
|
// Wire limits. They bound allocation before it happens: the reader stops at the
|
|
// limit instead of reading an unbounded line and checking its size afterwards.
|
|
const (
|
|
// Backup JSON is transported intact inside a JSON string (up to 2x escaping).
|
|
MaxRequestBytes = 1024*1024 + 4096
|
|
MaxResponseBytes = 1024 * 1024
|
|
)
|
|
|
|
// SocketPath and its directory. The directory is 0700 and the socket 0600, so
|
|
// reaching the protocol at all requires already being root on the device.
|
|
const (
|
|
RuntimeDir = "/var/run/smart-srun"
|
|
SocketPath = RuntimeDir + "/control.sock"
|
|
|
|
RuntimeDirMode fs.FileMode = 0o700
|
|
SocketFileMode fs.FileMode = 0o600
|
|
)
|
|
|
|
// Request is one call.
|
|
//
|
|
// Params stays raw so each method decodes it into its own type with unknown
|
|
// fields refused. A shared map would let a caller attach a parameter one method
|
|
// honours to another method that ignores it.
|
|
type Request struct {
|
|
RPCVersion int `json:"rpc_version"`
|
|
RequestID string `json:"request_id"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params,omitempty"`
|
|
}
|
|
|
|
// Response is one reply. Exactly one of Result and Error is populated.
|
|
type Response struct {
|
|
RPCVersion int `json:"rpc_version"`
|
|
RequestID string `json:"request_id"`
|
|
OK bool `json:"ok"`
|
|
Result json.RawMessage `json:"result"`
|
|
Error *ErrorPayload `json:"error"`
|
|
}
|
|
|
|
// ErrorPayload is the failure envelope.
|
|
//
|
|
// Code is stable and machine-readable; Message is Chinese and written for a
|
|
// person. Retryable is decided from the code, not guessed from a transport
|
|
// status: "the gateway returned 503" and "this configuration is invalid" are
|
|
// not the same kind of failure even when both arrive over HTTP.
|
|
type ErrorPayload struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Details map[string]string `json:"details,omitempty"`
|
|
Retryable bool `json:"retryable"`
|
|
}
|
|
|
|
// detailKeys is the whole set of keys a response may carry in Details.
|
|
//
|
|
// A whitelist rather than a filter: an error built anywhere in the program can
|
|
// only ever surface these, so no future call site can leak a password, a URL
|
|
// with credentials or a device identifier by attaching it as "context".
|
|
var detailKeys = map[string]struct{}{
|
|
"field": {},
|
|
"expected_revision": {},
|
|
"actual_revision": {},
|
|
"action_id": {},
|
|
"job_id": {},
|
|
"retry_after_seconds": {},
|
|
"limit": {},
|
|
"capability": {},
|
|
"interface": {},
|
|
}
|
|
|
|
// AllowedDetailKeys lists the whitelist, for tests and documentation.
|
|
func AllowedDetailKeys() []string {
|
|
out := make([]string, 0, len(detailKeys))
|
|
for key := range detailKeys {
|
|
out = append(out, key)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sanitizeDetails drops anything not on the whitelist.
|
|
func sanitizeDetails(details map[string]string) map[string]string {
|
|
if len(details) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(details))
|
|
for key, value := range details {
|
|
if _, ok := detailKeys[key]; ok {
|
|
out[key] = value
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// retryableCodes are the failures where the same request, unchanged, could
|
|
// succeed later. Everything else needs the caller to change something first.
|
|
var retryableCodes = map[domain.ErrorCode]bool{
|
|
domain.CodeBusy: true,
|
|
domain.CodeDeadlineExceeded: true,
|
|
domain.CodeServiceStopped: true,
|
|
domain.CodeBindingUnavailable: true,
|
|
domain.CodeBindingChanged: true,
|
|
domain.CodeDNSFailure: true,
|
|
domain.CodeTransportFailure: true,
|
|
domain.CodeTLSFailure: true,
|
|
}
|
|
|
|
// Retryable reports whether a code describes a transient failure.
|
|
func Retryable(code domain.ErrorCode) bool { return retryableCodes[code] }
|
|
|
|
// NewErrorPayload renders an error for the wire.
|
|
//
|
|
// Only messages this program wrote are shown to the user. An error from
|
|
// anywhere else -- the standard library, a dependency, a bug -- gets a generic
|
|
// one: its text was written for a developer reading a log, has never been
|
|
// checked for a password or an address, and is in English.
|
|
//
|
|
// Wrapped causes stay inside the daemon for the same reason. They exist to be
|
|
// logged under the operator's own redaction rules, not handed to a browser.
|
|
func NewErrorPayload(err error) *ErrorPayload {
|
|
code := domain.CodeInternal
|
|
message := "服务内部错误"
|
|
details := map[string]string{}
|
|
|
|
switch set, single := asErrorSet(err), asError(err); {
|
|
// The set is checked first: it unwraps to its items, so looking for a
|
|
// single error would find the first problem and report only that one, and a
|
|
// form with five mistakes would reveal them one save at a time.
|
|
case set != nil:
|
|
code, message = set.Code, set.Error()
|
|
if fields := set.Fields(); len(fields) > 0 {
|
|
details["field"] = fields[0]
|
|
}
|
|
case single != nil:
|
|
code, message = single.Code, single.Message
|
|
if single.Field != "" {
|
|
details["field"] = single.Field
|
|
}
|
|
// Only reached when nothing classified the failure, so a timeout that a
|
|
// handler forgot to describe still arrives as a timeout rather than as a
|
|
// complaint about the request.
|
|
case errors.Is(err, context.Canceled):
|
|
code, message = domain.CodeCancelled, "操作已取消"
|
|
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, os.ErrDeadlineExceeded):
|
|
code, message = domain.CodeDeadlineExceeded, "操作超时"
|
|
}
|
|
|
|
if code == "" {
|
|
code = domain.CodeInternal
|
|
}
|
|
return &ErrorPayload{
|
|
Code: string(code),
|
|
Message: message,
|
|
Details: sanitizeDetails(details),
|
|
Retryable: Retryable(code),
|
|
}
|
|
}
|
|
|
|
// asErrorSet and asError find this program's own error types anywhere in the
|
|
// chain. A handler that adds context with %w must not lose the code the UI
|
|
// branches on.
|
|
func asErrorSet(err error) *domain.Errors {
|
|
if set, ok := errors.AsType[*domain.Errors](err); ok {
|
|
return set
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func asError(err error) *domain.Error {
|
|
if single, ok := errors.AsType[*domain.Error](err); ok {
|
|
return single
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NewSuccess builds an ok response. The result is marshalled here so a method
|
|
// that returns an unencodable value fails as an error rather than a truncated
|
|
// success.
|
|
func NewSuccess(requestID string, result any) (Response, error) {
|
|
encoded := json.RawMessage("null")
|
|
if result != nil {
|
|
data, err := json.Marshal(result)
|
|
if err != nil {
|
|
return Response{}, domain.Errorf(domain.CodeInvalidArgument,
|
|
"结果无法序列化").Wrap(err)
|
|
}
|
|
encoded = data
|
|
}
|
|
return Response{
|
|
RPCVersion: Version,
|
|
RequestID: requestID,
|
|
OK: true,
|
|
Result: encoded,
|
|
}, nil
|
|
}
|
|
|
|
// NewFailure builds an error response. Result stays null so a client cannot
|
|
// read a half-result from a failed call.
|
|
func NewFailure(requestID string, err error) Response {
|
|
return Response{
|
|
RPCVersion: Version,
|
|
RequestID: requestID,
|
|
OK: false,
|
|
Result: json.RawMessage("null"),
|
|
Error: NewErrorPayload(err),
|
|
}
|
|
}
|