Skip to content

Commit 848960d

Browse files
authored
Merge pull request #259 from anywherelan/fix/win-gateway-winnat-errors
Improve error reporting: WinNAT diagnostics, API error logging, echo logs
2 parents 9d6437d + 3cea500 commit 848960d

9 files changed

Lines changed: 579 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ The privacy exposure — your IP appearing as the source of another device's tra
327327
- **A "what's my IP" site still shows your own IP after enabling.** Check the gateway status (the **VPN Gateway** card, or `awl cli gateway status`): if it's not connected, awl can't reach the exit node, so nothing is being tunnelled.
328328
- **A site works over IPv6 but not through the gateway.** Expected — IPv6 isn't tunnelled (see the note above). Dual-stack hosts fall back to IPv4 automatically; anything IPv6-only won't work while the gateway is on.
329329
- **Turning on *Serve as VPN Gateway* fails on Windows.** Windows effectively allows one NAT instance per host, and it may already be taken by Docker (Windows containers), WSL2 or Internet Connection Sharing — the error message lists the current holders. Free it up, or share this device over SOCKS5 instead: the SOCKS5 exit node doesn't need NAT.
330+
- **Turning on *Serve as VPN Gateway* on Windows fails with "WinNAT is not available" (HRESULT 0x80041010).** awl's exit-node NAT is built on Windows' own WinNAT, and on this installation the `MSFT_NetNat` WMI class doesn't exist. Windows **Home** editions don't ship WinNAT at all — there is no way to enable it there. On Pro/Enterprise/Server it can also be missing when neither Hyper-V nor RAS components are enabled (turning on the Hyper-V feature registers it) or when the WMI repository is corrupted. If you can't get WinNAT on your machine, share this device over SOCKS5 instead — the SOCKS5 exit node doesn't need it.
330331
- **No IPv6 connectivity after awl crashed (Linux).** If awl is killed (not shut down) with the gateway client on, its IPv6 block stays behind. It is removed automatically on the next awl start (and stop).
331332
- **Devices are reachable only via relay from a Windows machine with multiple network interfaces.** awl on Windows pins its peer-to-peer traffic to the interface that holds the default route, so peers reachable only through a secondary network card may fall back to relayed connections.
332333

api/api.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import (
1515
"github.com/labstack/echo-contrib/echoprometheus"
1616
"github.com/labstack/echo/v4"
1717
"github.com/labstack/echo/v4/middleware"
18+
glog "github.com/labstack/gommon/log"
19+
"go.uber.org/zap"
1820

1921
"github.com/anywherelan/awl/config"
2022
"github.com/anywherelan/awl/p2p"
@@ -95,17 +97,34 @@ func (h *Handler) setupRouter(address string) (*echo.Echo, error) {
9597
e := echo.New()
9698
e.HideBanner = true
9799
e.HidePort = true
100+
// Route echo's internal logs (panic stacks from Recover, response-write
101+
// failures) into the awl logger. StdLogger is the net/http Server.ErrorLog
102+
// ("http: accept error", "http: panic serving"): echo.New built it from
103+
// the default logger's stdout, and configureServer copies it into the
104+
// server at startup, so it must be replaced here, not on e.Server.
105+
e.Logger = newEchoLogger(&h.logger.SugaredLogger)
106+
stdLogger, err := zap.NewStdLogAt(h.logger.Desugar(), zap.ErrorLevel)
107+
if err != nil {
108+
return nil, err
109+
}
110+
e.StdLogger = stdLogger
111+
98112
val := validator.New()
99-
err := val.RegisterValidation("trimmed_str_not_empty", validateTrimmedStringNotEmpty, false)
113+
err = val.RegisterValidation("trimmed_str_not_empty", validateTrimmedStringNotEmpty, false)
100114
if err != nil {
101115
return nil, err
102116
}
103117

104118
e.Validator = &customValidator{validator: val}
105119

106120
// Middleware
121+
// First/outermost, so it also sees errors from the middleware below
122+
// (basic auth 401s, panics recovered to 500s).
123+
e.Use(errorLogMiddleware(h.logger))
107124
if !h.conf.DevMode() {
108-
e.Use(middleware.Recover())
125+
e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{
126+
LogLevel: glog.ERROR,
127+
}))
109128
}
110129

111130
if h.conf.HttpBasicAuth.Password != "" {

api/logger.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package api
2+
3+
import (
4+
"fmt"
5+
"io"
6+
7+
"github.com/labstack/echo/v4"
8+
glog "github.com/labstack/gommon/log"
9+
"go.uber.org/zap"
10+
)
11+
12+
// echoLogger adapts our zap-based logger to the echo.Logger interface, so
13+
// echo's internal logs — recovered panic stack traces, "response already
14+
// committed", failures to send an error response — land in awl's log file and
15+
// ring buffer instead of a console.
16+
//
17+
// Echo core only ever calls Print/Warn/Error/Errorf plus the Output/Prefix/
18+
// SetLevel plumbing; the rest of the interface is implemented for
19+
// completeness.
20+
type echoLogger struct {
21+
l *zap.SugaredLogger
22+
}
23+
24+
var _ echo.Logger = (*echoLogger)(nil)
25+
26+
// newEchoLogger wraps l for echo. The caller-skip makes zap report echo's
27+
// call site instead of this adapter.
28+
func newEchoLogger(l *zap.SugaredLogger) *echoLogger {
29+
return &echoLogger{l: l.WithOptions(zap.AddCallerSkip(1))}
30+
}
31+
32+
// Output/Prefix/level plumbing: filtering and destinations are governed by
33+
// the go-log config, not by echo, so the setters are no-ops. Level reports
34+
// DEBUG so echo never suppresses a record before it reaches zap. Output is
35+
// only used by echo for its startup banner colorer (hidden in awl) and the
36+
// initial StdLogger (overridden in setupRouter).
37+
38+
func (e *echoLogger) Output() io.Writer { return io.Discard }
39+
func (e *echoLogger) SetOutput(_ io.Writer) {}
40+
func (e *echoLogger) Prefix() string { return "" }
41+
func (e *echoLogger) SetPrefix(_ string) {}
42+
func (e *echoLogger) Level() glog.Lvl { return glog.DEBUG }
43+
func (e *echoLogger) SetLevel(_ glog.Lvl) {}
44+
func (e *echoLogger) SetHeader(_ string) {}
45+
46+
func (e *echoLogger) Print(i ...any) { e.l.Info(i...) }
47+
func (e *echoLogger) Printf(format string, args ...any) { e.l.Infof(format, args...) }
48+
func (e *echoLogger) Printj(j glog.JSON) { e.l.Infow("", jsonFields(j)...) }
49+
50+
func (e *echoLogger) Debug(i ...any) { e.l.Debug(i...) }
51+
func (e *echoLogger) Debugf(format string, args ...any) { e.l.Debugf(format, args...) }
52+
func (e *echoLogger) Debugj(j glog.JSON) { e.l.Debugw("", jsonFields(j)...) }
53+
54+
func (e *echoLogger) Info(i ...any) { e.l.Info(i...) }
55+
func (e *echoLogger) Infof(format string, args ...any) { e.l.Infof(format, args...) }
56+
func (e *echoLogger) Infoj(j glog.JSON) { e.l.Infow("", jsonFields(j)...) }
57+
58+
func (e *echoLogger) Warn(i ...any) { e.l.Warn(i...) }
59+
func (e *echoLogger) Warnf(format string, args ...any) { e.l.Warnf(format, args...) }
60+
func (e *echoLogger) Warnj(j glog.JSON) { e.l.Warnw("", jsonFields(j)...) }
61+
62+
func (e *echoLogger) Error(i ...any) { e.l.Error(i...) }
63+
func (e *echoLogger) Errorf(format string, args ...any) { e.l.Errorf(format, args...) }
64+
func (e *echoLogger) Errorj(j glog.JSON) { e.l.Errorw("", jsonFields(j)...) }
65+
66+
// Fatal* log at error level and panic instead of gommon's os.Exit: a library
67+
// must not be able to kill the P2P daemon bypassing deferred cleanup, and a
68+
// panic keeps the "does not return" contract while staying catchable by
69+
// Recover.
70+
71+
func (e *echoLogger) Fatal(i ...any) {
72+
e.l.Error(i...)
73+
panic(fmt.Sprint(i...))
74+
}
75+
76+
func (e *echoLogger) Fatalf(format string, args ...any) {
77+
e.l.Errorf(format, args...)
78+
panic(fmt.Sprintf(format, args...))
79+
}
80+
81+
func (e *echoLogger) Fatalj(j glog.JSON) {
82+
e.l.Errorw("", jsonFields(j)...)
83+
panic(fmt.Sprintf("%v", j))
84+
}
85+
86+
func (e *echoLogger) Panic(i ...any) {
87+
e.l.Error(i...)
88+
panic(fmt.Sprint(i...))
89+
}
90+
91+
func (e *echoLogger) Panicf(format string, args ...any) {
92+
e.l.Errorf(format, args...)
93+
panic(fmt.Sprintf(format, args...))
94+
}
95+
96+
func (e *echoLogger) Panicj(j glog.JSON) {
97+
e.l.Errorw("", jsonFields(j)...)
98+
panic(fmt.Sprintf("%v", j))
99+
}
100+
101+
// jsonFields flattens a gommon structured-log map into zap key-value pairs.
102+
func jsonFields(j glog.JSON) []any {
103+
fields := make([]any, 0, len(j)*2)
104+
for k, v := range j {
105+
fields = append(fields, k, v)
106+
}
107+
return fields
108+
}

api/logger_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/labstack/echo/v4"
9+
"github.com/labstack/echo/v4/middleware"
10+
glog "github.com/labstack/gommon/log"
11+
"github.com/stretchr/testify/require"
12+
"go.uber.org/zap"
13+
"go.uber.org/zap/zapcore"
14+
"go.uber.org/zap/zaptest/observer"
15+
)
16+
17+
func newObservedEchoLogger() (*echoLogger, *observer.ObservedLogs) {
18+
core, logs := observer.New(zapcore.DebugLevel)
19+
return newEchoLogger(zap.New(core).Sugar()), logs
20+
}
21+
22+
func TestEchoLoggerLevels(t *testing.T) {
23+
logger, logs := newObservedEchoLogger()
24+
25+
logger.Debugf("debug %s", "msg")
26+
logger.Infof("info %s", "msg")
27+
logger.Warnf("warn %s", "msg")
28+
logger.Errorf("error %s", "msg")
29+
// gommon's level-less Print maps to info.
30+
logger.Print("print msg")
31+
32+
entries := logs.All()
33+
require.Len(t, entries, 5)
34+
require.Equal(t, zapcore.DebugLevel, entries[0].Level)
35+
require.Equal(t, "debug msg", entries[0].Message)
36+
require.Equal(t, zapcore.InfoLevel, entries[1].Level)
37+
require.Equal(t, zapcore.WarnLevel, entries[2].Level)
38+
require.Equal(t, zapcore.ErrorLevel, entries[3].Level)
39+
require.Equal(t, zapcore.InfoLevel, entries[4].Level)
40+
}
41+
42+
func TestEchoLoggerJSONFields(t *testing.T) {
43+
logger, logs := newObservedEchoLogger()
44+
45+
logger.Errorj(glog.JSON{"file": "app.go", "line": 42})
46+
47+
entries := logs.All()
48+
require.Len(t, entries, 1)
49+
require.Equal(t, zapcore.ErrorLevel, entries[0].Level)
50+
fields := entries[0].ContextMap()
51+
require.Equal(t, "app.go", fields["file"])
52+
require.EqualValues(t, 42, fields["line"])
53+
}
54+
55+
// Fatal must not os.Exit like gommon does — a library must not be able to
56+
// kill the daemon. It logs at error level and panics instead.
57+
func TestEchoLoggerFatalPanics(t *testing.T) {
58+
logger, logs := newObservedEchoLogger()
59+
60+
require.PanicsWithValue(t, "fatal problem", func() {
61+
logger.Fatalf("fatal %s", "problem")
62+
})
63+
require.PanicsWithValue(t, "panic problem", func() {
64+
logger.Panicf("panic %s", "problem")
65+
})
66+
67+
entries := logs.All()
68+
require.Len(t, entries, 2)
69+
require.Equal(t, zapcore.ErrorLevel, entries[0].Level)
70+
require.Equal(t, "fatal problem", entries[0].Message)
71+
require.Equal(t, zapcore.ErrorLevel, entries[1].Level)
72+
}
73+
74+
// The original motivation for the adapter: a panic inside a handler must
75+
// leave a stack trace in awl's logs, not on a console the process may not
76+
// have.
77+
func TestEchoLoggerPanicStackReachesZap(t *testing.T) {
78+
logger, logs := newObservedEchoLogger()
79+
80+
e := echo.New()
81+
e.Logger = logger
82+
e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{
83+
LogLevel: glog.ERROR,
84+
}))
85+
e.GET("/boom", func(c echo.Context) error {
86+
panic("kaboom")
87+
})
88+
89+
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
90+
rec := httptest.NewRecorder()
91+
e.ServeHTTP(rec, req)
92+
require.Equal(t, http.StatusInternalServerError, rec.Code)
93+
94+
entries := logs.FilterLevelExact(zapcore.ErrorLevel).All()
95+
require.Len(t, entries, 1)
96+
require.Contains(t, entries[0].Message, "PANIC RECOVER")
97+
require.Contains(t, entries[0].Message, "kaboom")
98+
require.Contains(t, entries[0].Message, "goroutine")
99+
}

api/middleware.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"net/http"
7+
"strings"
8+
9+
"github.com/ipfs/go-log/v2"
10+
"github.com/labstack/echo/v4"
11+
)
12+
13+
// errorBodyLogLimit caps how much of an error response body is captured for
14+
// logging. Error bodies are small JSON objects; the cap only guards against a
15+
// pathological handler.
16+
const errorBodyLogLimit = 4 * 1024
17+
18+
// errorLogMiddleware logs every error response, which would otherwise exist
19+
// only in the UI banner that showed it: handlers write errors directly with
20+
// c.JSON(4xx/5xx, ...), so from echo's point of view they are successful
21+
// responses and no framework hook sees them. Must be registered first (as the
22+
// outermost middleware) — errors from inner middleware (basic auth 401s,
23+
// panics recovered to 500s) are only visible to middleware outside of them.
24+
//
25+
// 5xx are logged everywhere as errors; 4xx as warnings and only under the API
26+
// prefix — outside it they are browser noise (static file misses, the basic
27+
// auth 401 every browser session starts with).
28+
func errorLogMiddleware(logger *log.ZapEventLogger) echo.MiddlewareFunc {
29+
return errorLogMiddlewareLogf(func(status int, format string, args ...any) {
30+
if status >= http.StatusInternalServerError {
31+
logger.Errorf(format, args...)
32+
} else {
33+
logger.Warnf(format, args...)
34+
}
35+
})
36+
}
37+
38+
// errorLogMiddlewareLogf is errorLogMiddleware with the log destination
39+
// injected, so tests can assert on emitted records.
40+
func errorLogMiddlewareLogf(logf func(status int, format string, args ...any)) echo.MiddlewareFunc {
41+
return func(next echo.HandlerFunc) echo.HandlerFunc {
42+
return func(c echo.Context) error {
43+
w := &errorCaptureWriter{ResponseWriter: c.Response().Writer}
44+
c.Response().Writer = w
45+
46+
err := next(c)
47+
req := c.Request()
48+
49+
// An error heading to echo's HTTPErrorHandler: it is rendered
50+
// after this middleware returns, so the capture below never sees
51+
// its body — log the error value itself. If the response is
52+
// already committed, the write went through the capture writer
53+
// and the branch below reports it instead.
54+
if err != nil && !c.Response().Committed {
55+
status := http.StatusInternalServerError
56+
if httpErr, ok := errors.AsType[*echo.HTTPError](err); ok {
57+
status = httpErr.Code
58+
}
59+
if shouldLogErrorResponse(status, req.URL.Path) {
60+
logf(status, "%s %s: %d: %v", req.Method, req.URL.Path, status, err)
61+
}
62+
return err
63+
}
64+
65+
if w.status >= 400 && shouldLogErrorResponse(w.status, req.URL.Path) {
66+
body := bytes.TrimSpace(w.body.Bytes())
67+
truncated := ""
68+
if w.truncated {
69+
truncated = " (body truncated)"
70+
}
71+
logf(w.status, "%s %s: %d: %s%s", req.Method, req.URL.Path, w.status, body, truncated)
72+
}
73+
return err
74+
}
75+
}
76+
}
77+
78+
func shouldLogErrorResponse(status int, path string) bool {
79+
return status >= http.StatusInternalServerError || strings.HasPrefix(path, V0Prefix)
80+
}
81+
82+
// errorCaptureWriter passes everything through to the underlying
83+
// ResponseWriter and additionally keeps a copy of the body — but only once
84+
// the status is known to be an error, so success responses (log dumps,
85+
// metrics scrapes, pprof profiles) are never buffered.
86+
type errorCaptureWriter struct {
87+
http.ResponseWriter
88+
status int
89+
body bytes.Buffer
90+
truncated bool
91+
}
92+
93+
func (w *errorCaptureWriter) WriteHeader(status int) {
94+
w.status = status
95+
w.ResponseWriter.WriteHeader(status)
96+
}
97+
98+
func (w *errorCaptureWriter) Write(b []byte) (int, error) {
99+
if w.status >= 400 {
100+
free := errorBodyLogLimit - w.body.Len()
101+
if len(b) <= free {
102+
w.body.Write(b)
103+
} else {
104+
w.body.Write(b[:free])
105+
w.truncated = true
106+
}
107+
}
108+
return w.ResponseWriter.Write(b)
109+
}
110+
111+
// Flush keeps streaming endpoints (pprof profiles) working: echo's
112+
// Response.Flush type-asserts the underlying writer to http.Flusher and would
113+
// panic on a wrapper without it.
114+
func (w *errorCaptureWriter) Flush() {
115+
if f, ok := w.ResponseWriter.(http.Flusher); ok {
116+
f.Flush()
117+
}
118+
}
119+
120+
// Unwrap lets http.ResponseController reach the underlying writer's optional
121+
// interfaces.
122+
func (w *errorCaptureWriter) Unwrap() http.ResponseWriter {
123+
return w.ResponseWriter
124+
}

0 commit comments

Comments
 (0)