A cell reads allow, block or partly (some new connections let through, the rest not), with its exceptions counted by what they do (+3 -1), and what they let through: services with the addresses they are limited to, under the grid in hc matrix and in each cell on the Matrix page. A zone to itself is marked: only traffic routed between its interfaces passes the firewall. The details list each rule's hits. hc matrix pads the grid by visible width: the colours' escape codes had thrown it out of line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
659 lines
17 KiB
Go
659 lines
17 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/netip"
|
|
"sort"
|
|
"strings"
|
|
|
|
udiff "github.com/aymanbagabas/go-udiff"
|
|
|
|
"housecarl/internal/api"
|
|
"housecarl/internal/auth"
|
|
"housecarl/internal/engine"
|
|
"housecarl/internal/matrix"
|
|
"housecarl/internal/model"
|
|
"housecarl/internal/nft"
|
|
"housecarl/internal/store"
|
|
)
|
|
|
|
// change makes an operation: at once through the engine, or, with a
|
|
// changeset named (?changeset=ID or X-Housecarl-Changeset), staged in it.
|
|
// A network change starts (or joins) the probation.
|
|
func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principal, op model.Op) error {
|
|
if err := require(p, auth.ResConfig, true); err != nil {
|
|
return err
|
|
}
|
|
if op.Op == model.OpCreate && op.ID == 0 {
|
|
ids, err := s.store.AllocateIDs(r.Context(), 1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
op.ID = ids[0]
|
|
}
|
|
csID, err := changesetParam(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if csID != 0 {
|
|
return s.stage(w, r, p, csID, op)
|
|
}
|
|
var id int64
|
|
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, func(st *model.State, _ func() int64) error {
|
|
var err error
|
|
id, err = applyOp(st, &op)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
s.writeErr(w, r, err, res)
|
|
return nil
|
|
}
|
|
s.afterChange(r.Context(), p, res)
|
|
out := s.mutation(res, objectOf(&op, id))
|
|
out.ConfirmToken = s.issueCheck(w, r, p, res)
|
|
writeJSON(w, s.mutationStatus(res, op.Op == model.OpCreate), out)
|
|
return nil
|
|
}
|
|
|
|
// mutation is the answer to a change: what changed, warnings, the object
|
|
// (as it is after the change), the probation, and for a dry run the
|
|
// ruleset's diff.
|
|
func (s *Server) mutation(res *engine.Result, object func(st *model.State) any) api.MutationResponse {
|
|
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, HistoryID: res.HistoryID, Changes: res.Changes, Warnings: res.Warnings}
|
|
if out.Changes == nil {
|
|
out.Changes = []model.Change{}
|
|
}
|
|
if pr := s.probationStatus(); res.Applied && (len(pr.Pending) > 0 || pr.Takeovers > 0) {
|
|
out.ConfirmBy = &pr.Deadline
|
|
}
|
|
if object != nil && res.After != nil && (res.Applied || res.DryRun) {
|
|
if v := object(res.After); v != nil {
|
|
out.Object, _ = json.Marshal(v)
|
|
}
|
|
}
|
|
if res.DryRun && len(res.Changes) > 0 {
|
|
out.Diff = s.rulesetDiff(res.Before, res.After)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mutationStatus is 201 for an applied create, else 200.
|
|
func (s *Server) mutationStatus(res *engine.Result, creates bool) int {
|
|
if res.Applied && creates {
|
|
return http.StatusCreated
|
|
}
|
|
return http.StatusOK
|
|
}
|
|
|
|
// respondChange answers a change made without an operation (undo).
|
|
func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, p *auth.Principal, res *engine.Result, object func(st *model.State) any, creates bool) {
|
|
out := s.mutation(res, object)
|
|
out.ConfirmToken = s.issueCheck(w, r, p, res)
|
|
writeJSON(w, s.mutationStatus(res, creates), out)
|
|
}
|
|
|
|
// rulesetDiff is the unified diff of the rulesets two configurations
|
|
// make: the file housecarl-ruleset.service loads, now and after.
|
|
func (s *Server) rulesetDiff(before, after *model.State) string {
|
|
if before == nil || after == nil {
|
|
return ""
|
|
}
|
|
return udiff.Unified("ruleset.nft (now)", "ruleset.nft (after)", s.fw.Render(before).Text, s.fw.Render(after).Text)
|
|
}
|
|
|
|
// kindOf maps the API's plural path names to kinds.
|
|
var kindOf = map[string]string{
|
|
"zones": model.KindZone, "sets": model.KindSet, "services": model.KindService, "rules": model.KindRule,
|
|
"forwards": model.KindForward, "snats": model.KindSNAT, "redirects": model.KindRedirect,
|
|
}
|
|
|
|
func pathKind(r *http.Request) (string, error) {
|
|
k, ok := kindOf[r.PathValue("kind")]
|
|
if !ok {
|
|
return "", &httpError{status: http.StatusNotFound, code: "not_found", msg: "no such API endpoint: " + r.URL.Path}
|
|
}
|
|
return k, nil
|
|
}
|
|
|
|
func findObject(st *model.State, kind, ref string) (model.Object, error) {
|
|
if o := st.Find(kind, ref); o != nil {
|
|
return o, nil
|
|
}
|
|
return nil, notFound("no %s %s", kind, ref)
|
|
}
|
|
|
|
// ---- objects
|
|
|
|
func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cur, st, draft, err := s.viewState(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := []any{}
|
|
c := r.URL.Query().Get("chain")
|
|
if draft {
|
|
// Marked new, changed or deleted.
|
|
for _, o := range withPending(cur, st, kind) {
|
|
if c != "" && kind == model.KindRule && o.(map[string]any)["chain"] != c {
|
|
continue
|
|
}
|
|
out = append(out, o)
|
|
}
|
|
} else {
|
|
for _, o := range st.Objects(kind) {
|
|
if c != "" {
|
|
if rule, ok := o.(*model.Rule); ok && rule.Chain != c {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, o)
|
|
}
|
|
}
|
|
// Services: the built-in ones too, unless overridden.
|
|
if kind == model.KindService && !queryBool(r, "own") {
|
|
for _, b := range model.BuiltinServices() {
|
|
if mine := st.Find(kind, b.Name); mine == nil {
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
name := func(x any) string {
|
|
if m, ok := x.(map[string]any); ok {
|
|
n, _ := m["name"].(string)
|
|
return n
|
|
}
|
|
return x.(*model.Service).Name
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool { return name(out[i]) < name(out[j]) })
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) getObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, st, _, err := s.viewState(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
o, err := findObject(st, kind, r.PathValue("ref"))
|
|
if err != nil && kind == model.KindService {
|
|
if b := model.BuiltinService(r.PathValue("ref")); b != nil {
|
|
writeJSON(w, http.StatusOK, b)
|
|
return nil
|
|
}
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, o)
|
|
return nil
|
|
}
|
|
|
|
// decodeObject reads a kind's object from JSON (unknown fields refused).
|
|
func decodeObject(kind string, data []byte) (model.Object, error) {
|
|
var o model.Object
|
|
switch kind {
|
|
case model.KindZone:
|
|
o = &model.Zone{}
|
|
case model.KindSet:
|
|
o = &model.AddressSet{}
|
|
case model.KindService:
|
|
o = &model.Service{}
|
|
case model.KindRule:
|
|
o = &model.Rule{}
|
|
case model.KindForward:
|
|
o = &model.PortForward{}
|
|
case model.KindSNAT:
|
|
o = &model.SourceNAT{}
|
|
case model.KindRedirect:
|
|
o = &model.Redirect{}
|
|
}
|
|
dec := json.NewDecoder(strings.NewReader(string(data)))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(o); err != nil {
|
|
return nil, badRequest("%s: %v", kind, err)
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
// lastPosition is after the last rule of the chain (or NAT rule of the
|
|
// kind).
|
|
func lastPosition(st *model.State, kind, chain string) float64 {
|
|
max := 0.0
|
|
for _, o := range st.Objects(kind) {
|
|
var pos float64
|
|
switch x := o.(type) {
|
|
case *model.Rule:
|
|
if x.Chain != chain {
|
|
continue
|
|
}
|
|
pos = x.Position
|
|
case *model.PortForward:
|
|
pos = x.Position
|
|
case *model.SourceNAT:
|
|
pos = x.Position
|
|
case *model.Redirect:
|
|
pos = x.Position
|
|
}
|
|
if pos > max {
|
|
max = pos
|
|
}
|
|
}
|
|
return max + 1
|
|
}
|
|
|
|
func setID(o model.Object, id int64) {
|
|
switch x := o.(type) {
|
|
case *model.Zone:
|
|
x.ID = id
|
|
case *model.AddressSet:
|
|
x.ID = id
|
|
case *model.Service:
|
|
x.ID = id
|
|
case *model.Rule:
|
|
x.ID = id
|
|
case *model.PortForward:
|
|
x.ID = id
|
|
case *model.SourceNAT:
|
|
x.ID = id
|
|
case *model.Redirect:
|
|
x.ID = id
|
|
}
|
|
}
|
|
|
|
func position(o model.Object) *float64 {
|
|
switch x := o.(type) {
|
|
case *model.Rule:
|
|
return &x.Position
|
|
case *model.PortForward:
|
|
return &x.Position
|
|
case *model.SourceNAT:
|
|
return &x.Position
|
|
case *model.Redirect:
|
|
return &x.Position
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func chainOf(o model.Object) string {
|
|
if r, ok := o.(*model.Rule); ok {
|
|
return r.Chain
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Server) createObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var raw json.RawMessage
|
|
if err := decodeJSON(r, &raw); err != nil {
|
|
return err
|
|
}
|
|
// Refuse a bad object now, not when it is applied.
|
|
if _, err := decodeObject(kind, raw); err != nil {
|
|
return err
|
|
}
|
|
return s.change(w, r, p, model.Op{Op: model.OpCreate, Kind: kind, Data: raw})
|
|
}
|
|
|
|
func (s *Server) updateObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var patch map[string]json.RawMessage
|
|
if err := decodeJSON(r, &patch); err != nil {
|
|
return err
|
|
}
|
|
data, _ := json.Marshal(patch)
|
|
return s.change(w, r, p, model.Op{Op: model.OpUpdate, Kind: kind, Ref: r.PathValue("ref"), Data: data})
|
|
}
|
|
|
|
func (s *Server) deleteObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.change(w, r, p, model.Op{Op: model.OpDelete, Kind: kind, Ref: r.PathValue("ref")})
|
|
}
|
|
|
|
// usedBy names an object that refers to o (zones, sets and services are
|
|
// referred to by name), or "".
|
|
func usedBy(st *model.State, kind string, o model.Object) string {
|
|
name := o.Label()
|
|
has := func(list []string) bool {
|
|
for _, x := range list {
|
|
if strings.EqualFold(x, name) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
uses := func(m model.Match) bool {
|
|
switch kind {
|
|
case model.KindZone:
|
|
return has(m.InZones) || has(m.OutZones)
|
|
case model.KindSet:
|
|
return has(m.Sources) || has(m.Destinations)
|
|
case model.KindService:
|
|
return has(m.Services)
|
|
}
|
|
return false
|
|
}
|
|
for _, x := range st.Rules {
|
|
if uses(x.Match) {
|
|
return x.Label()
|
|
}
|
|
}
|
|
for _, x := range st.SNATs {
|
|
if uses(x.Match) {
|
|
return x.Label()
|
|
}
|
|
}
|
|
for _, x := range st.Redirects {
|
|
if uses(x.Match) {
|
|
return x.Label()
|
|
}
|
|
}
|
|
if kind == model.KindSet {
|
|
for _, f := range st.Forwards {
|
|
if has(f.Sources) || has(f.PublicAddresses) || has(f.HairpinSources) {
|
|
return f.Label()
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// renameRefs makes every object that refers to a zone, set or service by
|
|
// its old name use the new one: renaming it changes them in the same
|
|
// change, so the history shows (and undoes) them together.
|
|
func renameRefs(st *model.State, kind, old, now string) {
|
|
fix := func(list []string) {
|
|
for i, x := range list {
|
|
if strings.EqualFold(x, old) {
|
|
list[i] = now
|
|
}
|
|
}
|
|
}
|
|
match := func(m *model.Match) {
|
|
switch kind {
|
|
case model.KindZone:
|
|
fix(m.InZones)
|
|
fix(m.OutZones)
|
|
case model.KindSet:
|
|
fix(m.Sources)
|
|
fix(m.Destinations)
|
|
case model.KindService:
|
|
fix(m.Services)
|
|
}
|
|
}
|
|
for _, x := range st.Rules {
|
|
match(&x.Match)
|
|
}
|
|
for _, x := range st.SNATs {
|
|
match(&x.Match)
|
|
}
|
|
for _, x := range st.Redirects {
|
|
match(&x.Match)
|
|
}
|
|
if kind == model.KindSet {
|
|
for _, f := range st.Forwards {
|
|
fix(f.Sources)
|
|
fix(f.PublicAddresses)
|
|
fix(f.HairpinSources)
|
|
}
|
|
}
|
|
}
|
|
|
|
// moveObject reorders a rule or NAT rule (moveOp).
|
|
func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var in api.MoveInput
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
data, _ := json.Marshal(in)
|
|
return s.change(w, r, p, model.Op{Op: model.OpMove, Kind: kind, Ref: r.PathValue("ref"), Data: data})
|
|
}
|
|
|
|
// ---- settings
|
|
|
|
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
_, st, _, err := s.viewState(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, st.Settings)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
var patch map[string]json.RawMessage
|
|
if err := decodeJSON(r, &patch); err != nil {
|
|
return err
|
|
}
|
|
data, _ := json.Marshal(patch)
|
|
return s.change(w, r, p, model.Op{Op: model.OpSettings, Data: data})
|
|
}
|
|
|
|
// ---- the zone matrix
|
|
|
|
// zoneMatrix answers what the rules do to new connections between zones
|
|
// (internal/matrix); with a changeset named, as it will make them.
|
|
func (s *Server) zoneMatrix(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
_, st, _, err := s.viewState(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m := matrix.Build(st, matrix.Options{Connected: s.connected(st)})
|
|
if t, err := s.fw.Table(r.Context()); err == nil {
|
|
matrixHits(&m, t.Counters)
|
|
}
|
|
writeJSON(w, http.StatusOK, m)
|
|
return nil
|
|
}
|
|
|
|
// matrixHits fills in the hits of the matrix's rules, built-ins and
|
|
// policies from the counters.
|
|
func matrixHits(m *api.Matrix, counters map[string]nft.Counter) {
|
|
hits := func(marks []string) *uint64 {
|
|
var n uint64
|
|
found := false
|
|
for _, mk := range marks {
|
|
if c, ok := counters[mk]; ok {
|
|
n, found = n+c.Packets, true
|
|
}
|
|
}
|
|
if !found {
|
|
return nil
|
|
}
|
|
return &n
|
|
}
|
|
for i := range m.Cells {
|
|
c := &m.Cells[i]
|
|
c.Hits = hits(c.Marks)
|
|
for j := range c.Exceptions {
|
|
c.Exceptions[j].Hits = hits(c.Exceptions[j].Marks)
|
|
}
|
|
}
|
|
}
|
|
|
|
// connected maps the networks on the firewall's interfaces (link-local
|
|
// and loopback aside) to their zones.
|
|
func (s *Server) connected(st *model.State) map[netip.Prefix]string {
|
|
out := map[netip.Prefix]string{}
|
|
for _, i := range s.hostInterfaces(st) {
|
|
if i.Zone == "" {
|
|
continue
|
|
}
|
|
for _, a := range i.Addresses {
|
|
p, err := netip.ParsePrefix(a)
|
|
if err != nil || p.Addr().IsLinkLocalUnicast() || p.Addr().IsLoopback() {
|
|
continue
|
|
}
|
|
out[p.Masked()] = i.Zone
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---- the ruleset
|
|
|
|
func (s *Server) ruleset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
_, st, _, err := s.viewState(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rs := s.fw.Render(st)
|
|
// In shadow mode: what would be loaded once it is off.
|
|
if queryBool(r, "enforcing") {
|
|
rs = s.fw.RenderEnforcing(st)
|
|
}
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Write([]byte(rs.Text))
|
|
return nil
|
|
}
|
|
|
|
// ---- history
|
|
|
|
func (s *Server) listHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
hs, err := s.store.ListHistory(r.Context(), queryInt(r, "limit", 100), int64(queryInt(r, "before", 0)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, hs)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) getHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h, err := s.store.GetHistory(r.Context(), id)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return notFound("history entry %d not found", id)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, h)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, true); err != nil {
|
|
return err
|
|
}
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Staged, an undo is an operation of its own, reviewed like any.
|
|
if csID, err := changesetParam(r); err != nil {
|
|
return err
|
|
} else if csID != 0 {
|
|
h, err := s.store.GetHistory(r.Context(), id)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return notFound("history entry %d not found", id)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if h.RevertedBy != 0 {
|
|
return conflict("history entry %d was already undone by entry %d", id, h.RevertedBy)
|
|
}
|
|
data, _ := json.Marshal(h)
|
|
return s.stage(w, r, p, csID, model.Op{Op: model.OpRevert, ID: id, Data: data})
|
|
}
|
|
res, err := s.eng.Revert(r.Context(), p, id, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)})
|
|
if err != nil {
|
|
s.writeErr(w, r, err, res)
|
|
return nil
|
|
}
|
|
s.afterChange(r.Context(), p, res)
|
|
s.respondChange(w, r, p, res, nil, false)
|
|
return nil
|
|
}
|
|
|
|
// ---- probation
|
|
|
|
func (s *Server) getProbation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, s.probationStatus())
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) confirmChanges(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, true); err != nil {
|
|
return err
|
|
}
|
|
var in api.ConfirmInput
|
|
if r.ContentLength != 0 {
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
confirm := func() (int, error) { return s.confirm(r.Context(), p) }
|
|
if in.Token != "" {
|
|
confirm = func() (int, error) { return s.confirmToken(r, p, in.Token) }
|
|
}
|
|
n, err := confirm()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]int{"confirmed": n})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) rollbackChanges(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, true); err != nil {
|
|
return err
|
|
}
|
|
ps := s.probationStatus()
|
|
n := len(ps.Pending) + ps.Takeovers
|
|
if n > 0 {
|
|
s.rollback("rolled back by " + p.Name())
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]int{"rolled_back": n})
|
|
return nil
|
|
}
|