Shadow mode: Housecarl beside the firewall in force, changing nothing
shadow.enabled in housecarl.yaml loads Housecarl's ruleset so that it blocks, rejects and translates nothing: drops and rejects count and accept (an accept only ends Housecarl's chain), policies accept and log what they would drop, NAT only counts, MSS clamping and the flowtable are left out, and raw rules that do more than accept, drop or reject are left out with a warning. Comments mark what would happen ([would drop]). Filter chains run after the other firewall's (+10) or before (-10); NAT chains always first. Every change is checked by nftables in both forms. No probation; takeovers and closing connections are refused. The boot fallback filters nothing. internal/tablewatch puts the table back after a flush ruleset. Mode in status, healthz, whoami, the web UI, the log and the tracer; hc ruleset --enforcing. docs/shadow.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1e6c085ef9
commit
de075edd5d
48 files changed
+1345
-93
No files matched your search
@@ -275,7 +275,7 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
||||
}
|
||||
|
||||
func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami {
|
||||
out := api.Whoami{User: p.User, Admin: p.IsAdmin(), Method: p.Method, Permissions: p.Permissions(), Build: version.Info()}
|
||||
out := api.Whoami{User: p.User, Admin: p.IsAdmin(), Method: p.Method, Permissions: p.Permissions(), Build: version.Info(), Mode: s.mode()}
|
||||
if se != nil {
|
||||
out.CSRFToken = se.CSRFToken
|
||||
}
|
||||
|
||||
@@ -505,6 +505,10 @@ func (s *Server) ruleset(w http.ResponseWriter, r *http.Request, p *auth.Princip
|
||||
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
|
||||
|
||||
@@ -19,6 +19,8 @@ type health struct {
|
||||
OK bool `json:"ok"`
|
||||
Version string `json:"version"`
|
||||
Firewall string `json:"firewall"` // applied, unconfigured, fallback, missing
|
||||
// Mode is enforcing, or shadow: the ruleset blocks nothing.
|
||||
Mode string `json:"mode"`
|
||||
// Probation: changes waiting to be confirmed, rolled back otherwise.
|
||||
Probation int `json:"probation"`
|
||||
// Warnings are the codes of the machine's problems (nftables_service).
|
||||
@@ -49,7 +51,7 @@ func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) checkHealth(r *http.Request) health {
|
||||
out := health{Version: version.String(), Warnings: []string{}}
|
||||
out := health{Version: version.String(), Warnings: []string{}, Mode: s.mode()}
|
||||
t, err := s.fw.Table(r.Context())
|
||||
switch {
|
||||
case err != nil && s.eng.State().Empty():
|
||||
@@ -63,7 +65,12 @@ func (s *Server) checkHealth(r *http.Request) health {
|
||||
}
|
||||
pr := s.probationStatus()
|
||||
out.Probation = len(pr.Pending) + pr.Takeovers
|
||||
for _, p := range s.hostProblems(r.Context(), nil) {
|
||||
// The other tables matter in shadow mode (shadow_unprotected).
|
||||
var ruleset []byte
|
||||
if s.cfg.Shadow {
|
||||
ruleset, _ = s.fw.ListRuleset(r.Context())
|
||||
}
|
||||
for _, p := range s.hostProblems(r.Context(), ruleset) {
|
||||
out.Warnings = append(out.Warnings, p.Code)
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -53,7 +53,8 @@ func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Princ
|
||||
s.afterChange(r.Context(), p, er)
|
||||
if er.Applied {
|
||||
// Until it is taken over, the old table still applies too: the
|
||||
// status warns (takeover_pending).
|
||||
// status warns (takeover_pending; not in shadow mode, where that
|
||||
// is the point).
|
||||
s.store.SetMeta(r.Context(), importedTableKey, res.Table)
|
||||
}
|
||||
out.MutationResponse = api.MutationResponse{Applied: er.Applied, DryRun: er.DryRun, HistoryID: er.HistoryID, Changes: er.Changes, Warnings: er.Warnings}
|
||||
@@ -117,6 +118,9 @@ func (s *Server) takeover(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
||||
if err := notStaged(r, "a takeover"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.refuseInShadow("delete other tables (the firewall in force)"); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.TakeoverInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
|
||||
@@ -27,7 +27,10 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
||||
}
|
||||
st := s.eng.State()
|
||||
out := api.Status{Build: version.Info(), Started: s.started, Problems: s.eng.Problems(), Probation: s.probationStatus(),
|
||||
Interfaces: s.hostInterfaces(st), Counts: map[string]int{}, OtherTables: []string{}}
|
||||
Interfaces: s.hostInterfaces(st), Counts: map[string]int{}, OtherTables: []string{}, Mode: s.mode()}
|
||||
if s.cfg.Shadow {
|
||||
out.ShadowPosition = s.cfg.ShadowPosition
|
||||
}
|
||||
if out.Problems == nil {
|
||||
out.Problems = []validate.Problem{}
|
||||
}
|
||||
@@ -46,10 +49,11 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
||||
} else {
|
||||
out.Applied = true
|
||||
}
|
||||
if data, err := s.fw.ListRuleset(r.Context()); err == nil {
|
||||
data, err := s.fw.ListRuleset(r.Context())
|
||||
if err == nil {
|
||||
out.OtherTables = otherTables(data)
|
||||
}
|
||||
out.Problems = append(out.Problems, s.hostProblems(r.Context(), out.OtherTables)...)
|
||||
out.Problems = append(out.Problems, s.hostProblems(r.Context(), data)...)
|
||||
out.Problems = append(out.Problems, s.statsProblems()...)
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
@@ -57,6 +61,20 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
||||
|
||||
const importedTableKey = "imported_table"
|
||||
|
||||
// shadowProblems are shadow mode's warnings about the machine.
|
||||
func (s *Server) shadowProblems(ruleset []byte) []validate.Problem {
|
||||
var out []validate.Problem
|
||||
if ruleset != nil && len(inputFilters(ruleset)) == 0 {
|
||||
out = append(out, validate.Problem{Severity: validate.Warning, Code: "shadow_unprotected", Object: "shadow mode",
|
||||
Message: "housecarld is in shadow mode, where its ruleset blocks nothing, and no other table filters what reaches this machine (none has a filter chain on the input hook): nothing protects it; load your firewall, or leave shadow mode"})
|
||||
}
|
||||
for _, r := range render.ShadowLeftOut(s.eng.State()) {
|
||||
out = append(out, validate.Problem{Severity: validate.Warning, Code: "shadow_raw_left_out", Object: r.Label(),
|
||||
Message: "shadow mode leaves this raw rule out: it does more to packets than accept, drop or reject (NAT, setting a mark or field, a jump, a verdict map…), so its hits aren't counted and later rules see what it would have taken"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nftablesUnitLinks are where an enabled nftables.service shows (Debian
|
||||
// enables it for sysinit.target).
|
||||
var nftablesUnitLinks = []string{
|
||||
@@ -66,8 +84,15 @@ var nftablesUnitLinks = []string{
|
||||
|
||||
// hostProblems are warnings about the machine rather than the
|
||||
// configuration: another ruleset that still loads at boot, or the table
|
||||
// an import came from, still in force beside Housecarl's.
|
||||
func (s *Server) hostProblems(ctx context.Context, others []string) []validate.Problem {
|
||||
// an import came from, still in force beside Housecarl's. ruleset is the
|
||||
// machine's (nft -j list ruleset; nil: not read). In shadow mode those
|
||||
// are expected; the warnings are that nothing else filters, and the raw
|
||||
// rules left out.
|
||||
func (s *Server) hostProblems(ctx context.Context, ruleset []byte) []validate.Problem {
|
||||
if s.cfg.Shadow {
|
||||
return s.shadowProblems(ruleset)
|
||||
}
|
||||
others := otherTables(ruleset)
|
||||
var out []validate.Problem
|
||||
for _, link := range s.unitLinks {
|
||||
if _, err := os.Lstat(link); err == nil {
|
||||
@@ -300,6 +325,7 @@ func (l *logRing) Latest(n int, after time.Time, keep func(api.LogEntry) bool) [
|
||||
// LogPacket records a packet the firewall logged (internal/nflog calls
|
||||
// it).
|
||||
func (s *Server) LogPacket(e api.LogEntry) {
|
||||
e.Shadow = s.cfg.Shadow
|
||||
s.logs.Add(e)
|
||||
if s.stats != nil {
|
||||
s.stats.Packet(e)
|
||||
|
||||
@@ -94,6 +94,9 @@ func (s *Server) closeConnections(w http.ResponseWriter, r *http.Request, p *aut
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.refuseInShadow("close connections"); err != nil {
|
||||
return err
|
||||
}
|
||||
ct, err := s.conntrack()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -227,6 +230,12 @@ func (s *Server) traceStatus() api.TraceStatus {
|
||||
p.Steps[j].Label = markLabel(st, p.Steps[j])
|
||||
}
|
||||
p.DecidedBy.Label = markLabel(st, p.DecidedBy)
|
||||
if p.WouldDrop != nil {
|
||||
// A copy: the tracer still holds the step.
|
||||
wd := *p.WouldDrop
|
||||
wd.Label = markLabel(st, wd)
|
||||
p.WouldDrop = &wd
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@ func (s *Server) startProbation(ctx context.Context, p *auth.Principal, historyI
|
||||
pr := &s.probation
|
||||
pr.mu.Lock()
|
||||
defer pr.mu.Unlock()
|
||||
if p.Method == "socket" && len(pr.pending) == 0 && len(pr.restores) == 0 {
|
||||
// In shadow mode a change can't cut anyone off: it blocks nothing.
|
||||
if s.cfg.Shadow || p.Method == "socket" && len(pr.pending) == 0 && len(pr.restores) == 0 {
|
||||
s.saveBoot()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,6 +8,29 @@ import (
|
||||
|
||||
// otherTables lists the tables in an `nft -j list ruleset` other than
|
||||
// Housecarl's ("inet main").
|
||||
// inputFilters lists the tables other than Housecarl's with a filter
|
||||
// chain on the input hook: what filters the machine in shadow mode.
|
||||
func inputFilters(data []byte) []string {
|
||||
var doc struct {
|
||||
Nftables []map[string]json.RawMessage `json:"nftables"`
|
||||
}
|
||||
out := []string{}
|
||||
if json.Unmarshal(data, &doc) != nil {
|
||||
return out
|
||||
}
|
||||
for _, item := range doc.Nftables {
|
||||
raw, ok := item["chain"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var c struct{ Family, Table, Type, Hook string }
|
||||
if json.Unmarshal(raw, &c) == nil && c.Type == "filter" && c.Hook == "input" && !(c.Family == "inet" && (c.Table == "housecarl" || c.Table == trace.Table)) {
|
||||
out = append(out, c.Family+" "+c.Table)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func otherTables(data []byte) []string {
|
||||
var doc struct {
|
||||
Nftables []map[string]json.RawMessage `json:"nftables"`
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/certs"
|
||||
"housecarl/internal/engine"
|
||||
@@ -45,6 +46,11 @@ type Config struct {
|
||||
// ACME keeps the certificate housecarld obtains itself (tls.acme; nil
|
||||
// when off).
|
||||
ACME *certs.Manager
|
||||
// Shadow is shadow mode (docs/shadow.md): nothing goes on probation,
|
||||
// takeovers and closing connections are refused. ShadowPosition is
|
||||
// after or before, for the status.
|
||||
Shadow bool
|
||||
ShadowPosition string
|
||||
}
|
||||
|
||||
// Interfaces lists the host's interfaces with their addresses, and
|
||||
@@ -58,6 +64,8 @@ type Interfaces interface {
|
||||
// engine (internal/nft.Firewall).
|
||||
type Firewall interface {
|
||||
Render(st *model.State) *render.Ruleset
|
||||
// RenderEnforcing is the ruleset outside shadow mode.
|
||||
RenderEnforcing(st *model.State) *render.Ruleset
|
||||
Table(ctx context.Context) (*nft.Table, error)
|
||||
AddElement(ctx context.Context, set, addr string, timeout int) error
|
||||
DeleteElement(ctx context.Context, set, addr string) error
|
||||
@@ -131,6 +139,22 @@ func New(cfg Config, eng *engine.Engine, fw Firewall, ifaces Interfaces, log *sl
|
||||
return s
|
||||
}
|
||||
|
||||
// mode is housecarld's mode: enforcing, or shadow.
|
||||
func (s *Server) mode() string {
|
||||
if s.cfg.Shadow {
|
||||
return api.ModeShadow
|
||||
}
|
||||
return api.ModeEnforcing
|
||||
}
|
||||
|
||||
// refuseInShadow refuses what would change traffic in shadow mode.
|
||||
func (s *Server) refuseInShadow(what string) error {
|
||||
if !s.cfg.Shadow {
|
||||
return nil
|
||||
}
|
||||
return conflict("housecarld is in shadow mode, where it changes nothing about traffic, so it doesn't %s; leave shadow mode first (shadow.enabled in housecarl.yaml, docs/shadow.md)", what)
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler with all middleware.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.recoverer(s.securityHeaders(s.logRequests(s.mux)))
|
||||
|
||||
@@ -41,6 +41,8 @@ type fakeFW struct {
|
||||
traced string
|
||||
traceLines []string
|
||||
fallback bool // the boot fallback is in force
|
||||
shadow bool
|
||||
ruleset string // nft -j list ruleset ("": a default)
|
||||
}
|
||||
|
||||
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
|
||||
@@ -58,7 +60,12 @@ func (f *fakeFW) last() *model.State {
|
||||
defer f.mu.Unlock()
|
||||
return f.applied[len(f.applied)-1]
|
||||
}
|
||||
func (f *fakeFW) Render(st *model.State) *render.Ruleset { return render.Render(st, render.Options{}) }
|
||||
func (f *fakeFW) Render(st *model.State) *render.Ruleset {
|
||||
return render.Render(st, render.Options{Shadow: f.shadow})
|
||||
}
|
||||
func (f *fakeFW) RenderEnforcing(st *model.State) *render.Ruleset {
|
||||
return render.Render(st, render.Options{})
|
||||
}
|
||||
func (f *fakeFW) Table(context.Context) (*nft.Table, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -86,6 +93,9 @@ func (f *fakeFW) DeleteElement(_ context.Context, set, addr string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeFW) ListRuleset(context.Context) ([]byte, error) {
|
||||
if f.ruleset != "" {
|
||||
return []byte(f.ruleset), nil
|
||||
}
|
||||
return []byte(`{"nftables":[{"table":{"family":"inet","name":"main"}},{"table":{"family":"inet","name":"housecarl"}}]}`), nil
|
||||
}
|
||||
func (f *fakeFW) DeleteTable(_ context.Context, fam, name string) error {
|
||||
@@ -142,6 +152,11 @@ type env struct {
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
return newEnvWith(t, Config{})
|
||||
}
|
||||
|
||||
func newEnvWith(t *testing.T, cfg Config) *env {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(":memory:")
|
||||
@@ -159,10 +174,10 @@ func newEnv(t *testing.T) *env {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw := &fakeFW{elements: map[string][]render.Element{}}
|
||||
fw := &fakeFW{elements: map[string][]render.Element{}, shadow: cfg.Shadow}
|
||||
eng.Firewall = fw
|
||||
ifs := hostIfaces{"wan": {netip.MustParsePrefix("217.155.8.194/32")}, "eth1": {netip.MustParsePrefix("10.0.10.1/24")}}
|
||||
srv := New(Config{}, eng, fw, ifs, nil, nil)
|
||||
srv := New(cfg, eng, fw, ifs, nil, nil)
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return &env{t: t, ts: ts, srv: srv, eng: eng, fw: fw, token: tok}
|
||||
@@ -1060,3 +1075,105 @@ func TestStatsEndpoints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShadowMode: in shadow mode changes aren't on probation, what would
|
||||
// change traffic is refused, and the status, health and log say so.
|
||||
func TestShadowMode(t *testing.T) {
|
||||
e := newEnvWith(t, Config{Shadow: true, ShadowPosition: "after"})
|
||||
e.srv.SetConntrack(&fakeConns{})
|
||||
link := t.TempDir() + "/nftables.service"
|
||||
os.Symlink("/usr/lib/systemd/system/nftables.service", link)
|
||||
e.srv.unitLinks = []string{link}
|
||||
e.srv.store.SetMeta(context.Background(), importedTableKey, "inet main")
|
||||
var mr api.MutationResponse
|
||||
if code := e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, &mr); code != 201 || mr.ConfirmToken != "" || mr.ConfirmBy != nil {
|
||||
t.Errorf("a change: %d %+v", code, mr)
|
||||
}
|
||||
var pr api.Probation
|
||||
e.call(e.token, "GET", "/probation", nil, &pr)
|
||||
if len(pr.Pending) != 0 || e.fw.boot == nil {
|
||||
t.Errorf("on probation in shadow mode: %+v (saved for the boot: %v)", pr, e.fw.boot != nil)
|
||||
}
|
||||
var er api.ErrorBody
|
||||
if code := e.call(e.token, "POST", "/takeover", api.TakeoverInput{Tables: []string{"inet main"}}, &er); code != 409 || !strings.Contains(er.Error.Message, "shadow mode") || len(e.fw.deleted) != 0 {
|
||||
t.Errorf("takeover: %d %+v %v", code, er, e.fw.deleted)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/connections/close", api.CloseConnections{Search: "tcp"}, &er); code != 409 {
|
||||
t.Errorf("closing connections: %d", code)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/rules", model.Rule{Chain: model.ChainInput, Raw: "meta mark set 1"}, &er); code != 201 {
|
||||
t.Fatalf("a raw rule: %d %+v", code, er)
|
||||
}
|
||||
var st api.Status
|
||||
e.call(e.token, "GET", "/status", nil, &st)
|
||||
var codes []string
|
||||
for _, p := range st.Problems {
|
||||
codes = append(codes, p.Code)
|
||||
}
|
||||
c := strings.Join(codes, " ")
|
||||
if st.Mode != api.ModeShadow || st.ShadowPosition != "after" {
|
||||
t.Errorf("status mode: %q %q", st.Mode, st.ShadowPosition)
|
||||
}
|
||||
// Expected in shadow mode: the other firewall, and its service.
|
||||
if strings.Contains(c, "nftables_service") || strings.Contains(c, "takeover_pending") || !strings.Contains(c, "shadow_unprotected") || !strings.Contains(c, "shadow_raw_left_out") {
|
||||
t.Errorf("warnings: %s", c)
|
||||
}
|
||||
e.fw.ruleset = `{"nftables":[{"chain":{"family":"inet","table":"main","name":"input","type":"filter","hook":"input","prio":0,"policy":"drop"}}]}`
|
||||
e.call(e.token, "GET", "/status", nil, &st)
|
||||
for _, p := range st.Problems {
|
||||
if p.Code == "shadow_unprotected" {
|
||||
t.Error("unprotected while inet main filters")
|
||||
}
|
||||
}
|
||||
var h health
|
||||
resp, err := http.Get(e.ts.URL + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&h)
|
||||
resp.Body.Close()
|
||||
if h.Mode != api.ModeShadow || !h.OK {
|
||||
t.Errorf("healthz: %+v", h)
|
||||
}
|
||||
var who api.Whoami
|
||||
e.call(e.token, "GET", "/auth/whoami", nil, &who)
|
||||
if who.Mode != api.ModeShadow {
|
||||
t.Errorf("whoami: %+v", who)
|
||||
}
|
||||
e.srv.LogPacket(api.LogEntry{Time: time.Now(), Prefix: "hc input policy drop: ", Verdict: "drop"})
|
||||
var logs []api.LogEntry
|
||||
e.call(e.token, "GET", "/log", nil, &logs)
|
||||
if len(logs) != 1 || !logs[0].Shadow {
|
||||
t.Errorf("log: %+v", logs)
|
||||
}
|
||||
shadow := e.text("/ruleset")
|
||||
enforcing := e.text("/ruleset?enforcing=1")
|
||||
if !strings.Contains(shadow, "Shadow mode") || strings.Contains(enforcing, "Shadow mode") {
|
||||
t.Errorf("rulesets:\n%s\n---\n%s", shadow, enforcing)
|
||||
}
|
||||
// Enforcing, the same change goes on probation.
|
||||
n := newEnv(t)
|
||||
n.call(n.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
|
||||
n.call(n.token, "GET", "/probation", nil, &pr)
|
||||
if len(pr.Pending) != 1 {
|
||||
t.Errorf("not on probation enforcing: %+v", pr)
|
||||
}
|
||||
n.call(n.token, "GET", "/status", nil, &st)
|
||||
if st.Mode != api.ModeEnforcing {
|
||||
t.Errorf("enforcing status mode: %q", st.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// text GETs a plain-text answer.
|
||||
func (e *env) text(path string) string {
|
||||
e.t.Helper()
|
||||
req, _ := http.NewRequest("GET", e.ts.URL+api.Prefix+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+e.token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return string(b)
|
||||
}
|
||||
Reference in new issue
Block a user