rest

package module
v0.0.0-...-db1d587 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 12, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

Documentation

Overview

Package rest is a RouterOS REST client.

It depends on nothing outside the standard library, and holds no package state, so a process may drive many routers concurrently. That is deliberate: the provider it grew out of serializes every router operation behind one process-global mutex, purely because the Terraform provider it wraps keeps the negotiated RouterOS version in a package variable.

The http.Client is supplied by the caller rather than built here. An OpenTelemetry receiver already gets TLS, proxying, compression, connection pooling and auth extensions from confighttp; this package must not fight that.

What RouterOS does that a REST client would not expect

Each of these is verified against CHR 7.23.2, and each has a test.

  • Errors are a JSON object shaped like a record, so they cannot be recognised by decoding alone. "error" is a JSON *number*, which is why unmarshalling a response into map[string]string fails on a 401 before any error check can run.

  • A request from a source address the service does not permit completes the TCP handshake and is then closed. That surfaces as io.EOF: neither a status code nor a refusal, and emphatically not "menu absent" or "router down". It is reported as ErrAddressRejected.

  • Reaching REST at all needs both the "api" and "rest-api" policies. read,rest-api alone answers 500 "std failure: not allowed (9)" on every endpoint, because REST is a JSON wrapper over the binary API.

  • Creation is PUT. POST is the console-command verb, and a POST to a menu path creates nothing while reporting success.

  • A singleton menu returns a bare object; a list menu returns an array.

  • Settings singletons reject PATCH; they are written with POST <path>/set.

  • Filters travel in the JSON body of POST <path>/print, so no caller value is ever URL-encoded. Where a GET filter is used instead, values need RFC 3986 escaping — see escapeQuery, and note that this is a Go stdlib footgun rather than a RouterOS quirk.

  • POST <path>/print with .proplist omits .id, while GET ?.proplist= includes it. Props always requests .id back, because a caller that cannot address the row it just read cannot update it.

  • Every value is a string: {"cpu-load":"0"}, {"running":"true"}, {"rx-byte":"5577"}, {"uptime":"1m20s"}. Package scalar parses them, and Record's accessors add the presence semantics that booleans need.

  • Continuous commands are refused; monitor needs an "once" argument. POST commands are capped at 60s router-side, which the caller's http.Client timeout should account for.

Decoding

Replies are decoded with encoding/json/v2 at its defaults, which reject both duplicate object names and invalid UTF-8. A malformed reply is an error rather than a silently repaired value: neither encoder can return the original bytes, and substituting U+FFFD into a comment — which is durable identity on this device — turns a visible failure into a row that quietly stops matching.

Inspecting failures

A failure the router described in a body arrives as *Error, carrying its numeric code and detail:

if re, ok := errors.AsType[*rest.Error](err); ok && re.Code == 400 {
    // re.Detail is the useful half: "no such command or directory (…)"
}

The sentinels answer the two cases with no useful body: errors.Is(err, rest.ErrNotFound) and errors.Is(err, rest.ErrAddressRejected).

Index

Constants

View Source
const IDField = ".id"

IDField is the router's row identifier. Values look like "*1A" and are reassigned when a row is deleted and recreated, so they address a row for the duration of one exchange and are not an identity to store.

Variables

View Source
var ErrAddressRejected = errors.New("rest: router closed the connection without answering; source address is probably not permitted for the service")

ErrAddressRejected reports that the router accepted the connection and then closed it without answering. RouterOS does this when the source address is not permitted for the www/www-ssl service: the handshake completes, so this is neither a refusal nor a status code, and it must not be read as "menu absent" or "router down".

View Source
var ErrEmptyIgnoreSelector = errors.New("rest: MenuSpec.Ignore contains an empty selector")

ErrEmptyIgnoreSelector prevents a selector that would hide every row.

View Source
var ErrNoUnlistedPolicy = errors.New("rest: MenuSpec.Unlisted must be set to tolerate or prune")

ErrNoUnlistedPolicy is returned for a spec that has not chosen one.

View Source
var ErrNotFound = errors.New("rest: not found")

ErrNotFound reports that a path or row does not exist.

Functions

This section is empty.

Types

type AmbiguousError

type AmbiguousError struct {
	Path  string
	Index int
	Row   Record
	IDs   []string
}

AmbiguousError is a desired row that matched more than one device row.

It is an error rather than a choice on purpose. Picking one would silently manage an arbitrary row and leave its twin drifting, and the twin is usually somebody's hand-added configuration. Without a device-enforced key this is reachable on any router a person has touched: keyprobe found no menu where RouterOS enforces comment uniqueness, so matching on a comment can genuinely hit two rows.

Candidates that differ in nothing but their id are not an error — the choice between them cannot be observed, so they are paired off in listed order.

func (*AmbiguousError) Error

func (e *AmbiguousError) Error() string

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client talks to one router. It holds no package state, so many may exist in one process.

func New

func New(endpoint string, opts ...Option) (*Client, error)

New returns a Client for a router's base URL, e.g. "https://10.0.10.1". A trailing "/rest" is accepted and not doubled.

func (*Client) Apply

func (c *Client) Apply(ctx context.Context, spec MenuSpec, desired []Record) (Plan, error)

Apply runs a plan. It re-plans first, so the reading the plan was computed from is as fresh as it can be.

Steps run in plan order and the first failure stops it: a partially applied menu is visible in the next Plan, whereas continuing past an error can leave order half-corrected, which for a first-match chain is worse than not starting. The plan that was executed is returned so a caller can report what happened.

func (*Client) ApplyChecked

func (c *Client) ApplyChecked(ctx context.Context, spec MenuSpec, desired []Record, approve func(Plan) error) (Plan, error)

ApplyChecked plans once, passes that exact plan to approve, and performs no mutation unless approve returns nil. This closes the read/apply gap for a caller that requires human approval of destructive operations: the approved plan is the one whose structural steps begin executing.

func (*Client) Command

func (c *Client) Command(ctx context.Context, path, cmd string, args Record) ([]Record, error)

Command invokes a console command, e.g. Command(ctx, "/interface", "monitor", Record{"numbers": "ether1", "once": ""}).

REST refuses continuous commands, so anything that streams — monitor above — needs "once". The router caps a POST at 60s, which the http.Client's own timeout should allow for.

func (*Client) Count

func (c *Client) Count(ctx context.Context, path string, opts ...QueryOpt) (int, error)

Count returns how many rows match, without transferring them. The router answers {"ret":"0"} — note that ret is a string.

func (*Client) Create

func (c *Client) Create(ctx context.Context, path string, r Record) (Record, error)

Create adds a row and returns it as stored.

The verb is PUT. POST is the console-command verb: sent to a menu path it creates nothing and reports success anyway.

func (*Client) Delete

func (c *Client) Delete(ctx context.Context, path, id string) error

Delete removes one row, addressed by .id.

func (*Client) Get

func (c *Client) Get(ctx context.Context, path string) (Record, error)

Get reads a settings singleton.

func (*Client) List

func (c *Client) List(ctx context.Context, path string, opts ...QueryOpt) ([]Record, error)

List returns the rows of a menu. Without options it is a plain GET; with them it becomes POST <path>/print carrying a JSON body, so filter values are never URL-encoded.

A singleton menu answers with a bare object rather than an array; it is returned as a single-element slice. Use Get to read one directly.

func (*Client) Plan

func (c *Client) Plan(ctx context.Context, spec MenuSpec, desired []Record) (Plan, error)

Plan computes what it would take to make the menu match desired.

Nothing is written. The device is read once, and the plan is a pure function of that reading and the spec, which is what makes it testable without a router and reviewable before it runs.

func (*Client) Set

func (c *Client) Set(ctx context.Context, path string, r Record) error

Set writes a settings singleton. Singletons reject PATCH, so this is POST <path>/set.

func (*Client) Update

func (c *Client) Update(ctx context.Context, path, id string, r Record) (Record, error)

Update patches one row, addressed by .id.

type Error

type Error struct {
	Op      string // "GET /ip/address"
	Status  int    // HTTP status, 0 when the body alone reported the failure
	Code    int    // RouterOS "error"
	Message string // RouterOS "message", e.g. "Bad Request"
	Detail  string // RouterOS "detail", e.g. "no such command or directory"
}

Error is a failure the router described in the response body.

RouterOS reports errors as a JSON object that decodes exactly like a record, so the body alone cannot be trusted to be data. Code is the router's numeric "error" field, which is why a response cannot simply be unmarshalled into map[string]string: that fails on the number before any check can run.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Is

func (e *Error) Is(target error) bool

Is lets errors.Is(err, ErrNotFound) work for the router's own 404.

type MenuSpec struct {
	// Path is the menu, e.g. "/ip/firewall/filter".
	Path string
	// Ordered is whether position carries meaning. It should come from the
	// IR's class rather than from a guess: a first-match chain is ordered, an
	// address list is not, and reordering the second is pointless churn.
	Ordered bool
	// Unlisted is required. See the type.
	Unlisted Unlisted
	// Key is a field the router itself enforces unique, or empty.
	//
	// It must come from schema.Identity.Tested, meaning a probe watched the
	// device refuse a second row and name this field — never from a field whose
	// name merely looks like an identifier. With a key, a desired row is matched
	// by it and everything else about the row can be updated in place. Without
	// one, matching falls back to the fields the spec sets, which cannot
	// distinguish "this row changed" from "this is a different row", so a change
	// becomes a delete and a create.
	Key string
	// Ignore contains selectors for device-owned rows that must never be
	// matched, updated, deleted, or treated as evidence that this resource owns
	// the complete menu. A row is ignored when it contains every field of any
	// selector. For example {"dynamic":"true"} excludes RouterOS runtime rows
	// while still allowing Prune to own every static row.
	//
	// Empty selectors are rejected because they would silently ignore the
	// entire menu.
	Ignore []Record
}

MenuSpec is the menu being reconciled and the policy for doing so.

type Op

type Op string

Op is what a step does.

const (
	OpCreate Op = "create"
	OpUpdate Op = "update"
	OpDelete Op = "delete"
	OpMove   Op = "move"
)

type Option

type Option func(*Client)

Option configures a Client.

func WithBasicAuth

func WithBasicAuth(user, pass string) Option

WithBasicAuth sets the credentials sent on every request.

The user needs both the "api" and "rest-api" policies. read,rest-api alone answers 500 "std failure: not allowed (9)" on every endpoint, because REST is a JSON wrapper over the binary API.

func WithHTTPClient

func WithHTTPClient(h *http.Client) Option

WithHTTPClient supplies the transport. Callers that already own one — a collector configured through confighttp, say — should pass it rather than let this package build a second policy for TLS, proxying and pooling.

type Plan

type Plan struct {
	Steps []Step
	// Matched pairs a desired row's index with the device row it resolved to,
	// so a caller can report per-row status from a single object.
	Matched map[int]string
}

Plan is what Apply would do. It is computed without writing anything, so a caller can show it, refuse it, or count it.

func (Plan) Counts

func (p Plan) Counts() map[Op]int

Counts summarises a plan by operation, for a log line that does not need the whole thing.

func (Plan) Empty

func (p Plan) Empty() bool

Empty reports that the device already matches the spec.

type QueryOpt

type QueryOpt func(*query)

QueryOpt narrows a listing. Options are applied to a POST <path>/print body, so no caller value is URL-encoded.

func Props

func Props(names ...string) QueryOpt

Props limits the reply to the named fields.

.id is always requested as well. POST <path>/print omits it unless it is asked for by name — unlike GET ?.proplist=, which includes it regardless — and a caller that cannot address the row it just read cannot then update it.

func Where

func Where(field, value string) QueryOpt

Where keeps only rows whose field equals value.

The value is placed in the request body verbatim. Comments containing spaces, ampersands and equals signs round-trip unharmed, which is the reason listings prefer POST over a GET query string.

func WhereRaw

func WhereRaw(term string) QueryOpt

WhereRaw adds a .query term the caller has already composed, for the operators Where does not model.

type Record

type Record map[string]string

Record is one row or one settings singleton. RouterOS returns every value as a string; the accessors below parse them, and they exist on Record rather than in package scalar because a boolean's meaning depends on whether the key was present at all.

func (Record) Bool

func (r Record) Bool(key string) bool

Bool covers the three encodings that coexist in a single record: "true" or "false"; present but empty, which is a set flag; and absent, which is unset. A BGP session carries all three at once — established is "true", ebgp is "", ibgp is missing — so reading "" as false gets eBGP exactly backwards.

func (Record) Duration

func (r Record) Duration(key string) time.Duration

Duration parses a RouterOS interval such as "3d12h12m44s" or "52m34s530ms". An absent or unparseable value is 0; use scalar.Duration to see the error.

func (Record) Float

func (r Record) Float(key string) float64

Float parses a decimal value such as a sensor's "49.2"; an absent or unparseable value is 0.

func (Record) FloatOK

func (r Record) FloatOK(key string) (float64, bool)

FloatOK is Float with the distinction between a reading of zero and no reading at all.

Zero is a real measurement, so a caller that publishes it cannot use the zero value to mean "absent". /system/health is the case that needs this: the router states value's vocabulary as ok/fail/idle/no-input/not-present, so an unplugged sensor reads "no-input" — and reporting that as 0 °C is a fabricated reading, indistinguishable downstream from a genuinely cold room. An empty value is not a reading either. scalar.Float reads "" as 0 without complaint, which is the right shape there — those parsers have nowhere to report and a caller has already decided it wants a number. This is the layer that can say so, so it does.

func (Record) Has

func (r Record) Has(key string) bool

Has reports whether the router sent the key.

func (Record) ID

func (r Record) ID() string

ID returns the row's .id, or "" for a singleton.

func (Record) Int

func (r Record) Int(key string) int64

Int parses a signed integer value; an absent or unparseable value is 0.

func (Record) String

func (r Record) String(key string) string

String returns the raw value, or "" when absent.

func (Record) Uint

func (r Record) Uint(key string) uint64

Uint parses an unsigned integer value, which is what RouterOS's counters are — the traffic generator states their range as 0..18446744073709551615, so a signed reader loses the top half. An absent or unparseable value is 0.

type Step

type Step struct {
	Op Op
	// ID is the row being acted on, for update, delete and move.
	ID string
	// Row is the body to send for create and update. For update it holds only
	// the fields that differ. For delete it is the row observed while planning,
	// which lets a caller preview exactly what destructive work it approves.
	Row Record
	// Order is the rows to place, in the order they should end up, for move.
	Order []string
	// Before is the row they are placed in front of, or empty for the end of
	// the menu.
	Before string
	// Why is a short reason, for logs and for a dry run a human reads.
	Why string
}

Step is one operation in a plan.

type Unlisted

type Unlisted string

Unlisted is what to do with a device row that no desired row claims.

There is deliberately no zero value that means anything: prune deletes configuration a person added by hand the first time a menu resource is applied, and tolerate means the resource never actually converges to its spec. Neither is safe as a default, so ADR 0004 makes the choice required and Plan refuses a spec that has not made it.

const (
	// UnlistedTolerate leaves rows alone. The menu converges to "contains the
	// desired rows, in the desired relative order" rather than "is the desired
	// rows".
	UnlistedTolerate Unlisted = "tolerate"
	// UnlistedPrune deletes them. The menu converges to exactly the spec.
	UnlistedPrune Unlisted = "prune"
)

Directories

Path Synopsis
Package scalar parses the string forms RouterOS returns for every value.
Package scalar parses the string forms RouterOS returns for every value.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL