mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-08 00:50:20 +09:00
app: switch from gorilla to chi mux
Replace gorilla/mux with go-chi/chi as the HTTP router and add a custom zerolog-based request logger to replace chi's default stdlib-based middleware.Logger, consistent with the rest of the application. Updates #1850
This commit is contained in:
@@ -27,7 +27,7 @@
|
|||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${prev.stdenv.hostPlatform.system};
|
pkgs = nixpkgs.legacyPackages.${prev.stdenv.hostPlatform.system};
|
||||||
buildGo = pkgs.buildGo126Module;
|
buildGo = pkgs.buildGo126Module;
|
||||||
vendorHash = "sha256-9BvphYDAxzwooyVokI3l+q1wRuRsWn/qM+NpWUgqJH0=";
|
vendorHash = "sha256-oUN53ELb3+xn4yA7lEfXyT2c7NxbQC6RtbkGVq6+RLU=";
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
headscale = buildGo {
|
headscale = buildGo {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ require (
|
|||||||
github.com/docker/docker v28.5.2+incompatible
|
github.com/docker/docker v28.5.2+incompatible
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
|
github.com/go-chi/metrics v0.1.1
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5
|
github.com/go-gormigrate/gormigrate/v2 v2.1.5
|
||||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e
|
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e
|
||||||
github.com/gofrs/uuid/v5 v5.4.0
|
github.com/gofrs/uuid/v5 v5.4.0
|
||||||
|
|||||||
@@ -181,6 +181,10 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
|
|||||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
|
github.com/go-chi/metrics v0.1.1 h1:CXhbnkAVVjb0k73EBRQ6Z2YdWFnbXZgNtg1Mboguibk=
|
||||||
|
github.com/go-chi/metrics v0.1.1/go.mod h1:mcGTM1pPalP7WCtb+akNYFO/lwNwBBLCuedepqjoPn4=
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8=
|
github.com/go-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8=
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M=
|
github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M=
|
||||||
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
|
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
|
||||||
|
|||||||
+89
-31
@@ -20,7 +20,9 @@ import (
|
|||||||
|
|
||||||
"github.com/cenkalti/backoff/v5"
|
"github.com/cenkalti/backoff/v5"
|
||||||
"github.com/davecgh/go-spew/spew"
|
"github.com/davecgh/go-spew/spew"
|
||||||
"github.com/gorilla/mux"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/go-chi/metrics"
|
||||||
grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||||
"github.com/juanfont/headscale"
|
"github.com/juanfont/headscale"
|
||||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||||
@@ -457,50 +459,57 @@ func (h *Headscale) ensureUnixSocketIsAbsent() error {
|
|||||||
return os.Remove(h.cfg.UnixSocket)
|
return os.Remove(h.cfg.UnixSocket)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *mux.Router {
|
func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *chi.Mux {
|
||||||
router := mux.NewRouter()
|
r := chi.NewRouter()
|
||||||
router.Use(prometheusMiddleware)
|
r.Use(metrics.Collector(metrics.CollectorOpts{
|
||||||
|
Host: false,
|
||||||
|
Proto: true,
|
||||||
|
Skip: func(r *http.Request) bool {
|
||||||
|
return r.Method != http.MethodOptions
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(middleware.RequestLogger(&zerologRequestLogger{}))
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
router.HandleFunc(ts2021UpgradePath, h.NoiseUpgradeHandler).
|
r.Post(ts2021UpgradePath, h.NoiseUpgradeHandler)
|
||||||
Methods(http.MethodPost, http.MethodGet)
|
|
||||||
|
|
||||||
router.HandleFunc("/robots.txt", h.RobotsHandler).Methods(http.MethodGet)
|
r.Get("/robots.txt", h.RobotsHandler)
|
||||||
router.HandleFunc("/health", h.HealthHandler).Methods(http.MethodGet)
|
r.Get("/health", h.HealthHandler)
|
||||||
router.HandleFunc("/version", h.VersionHandler).Methods(http.MethodGet)
|
r.Get("/version", h.VersionHandler)
|
||||||
router.HandleFunc("/key", h.KeyHandler).Methods(http.MethodGet)
|
r.Get("/key", h.KeyHandler)
|
||||||
router.HandleFunc("/register/{registration_id}", h.authProvider.RegisterHandler).
|
r.Get("/register/{registration_id}", h.authProvider.RegisterHandler)
|
||||||
Methods(http.MethodGet)
|
|
||||||
|
|
||||||
if provider, ok := h.authProvider.(*AuthProviderOIDC); ok {
|
if provider, ok := h.authProvider.(*AuthProviderOIDC); ok {
|
||||||
router.HandleFunc("/oidc/callback", provider.OIDCCallbackHandler).Methods(http.MethodGet)
|
r.Get("/oidc/callback", provider.OIDCCallbackHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
router.HandleFunc("/apple", h.AppleConfigMessage).Methods(http.MethodGet)
|
r.Get("/apple", h.AppleConfigMessage)
|
||||||
router.HandleFunc("/apple/{platform}", h.ApplePlatformConfig).
|
r.Get("/apple/{platform}", h.ApplePlatformConfig)
|
||||||
Methods(http.MethodGet)
|
r.Get("/windows", h.WindowsConfigMessage)
|
||||||
router.HandleFunc("/windows", h.WindowsConfigMessage).Methods(http.MethodGet)
|
|
||||||
|
|
||||||
// TODO(kristoffer): move swagger into a package
|
// TODO(kristoffer): move swagger into a package
|
||||||
router.HandleFunc("/swagger", headscale.SwaggerUI).Methods(http.MethodGet)
|
r.Get("/swagger", headscale.SwaggerUI)
|
||||||
router.HandleFunc("/swagger/v1/openapiv2.json", headscale.SwaggerAPIv1).
|
r.Get("/swagger/v1/openapiv2.json", headscale.SwaggerAPIv1)
|
||||||
Methods(http.MethodGet)
|
|
||||||
|
|
||||||
router.HandleFunc("/verify", h.VerifyHandler).Methods(http.MethodPost)
|
r.Post("/verify", h.VerifyHandler)
|
||||||
|
|
||||||
if h.cfg.DERP.ServerEnabled {
|
if h.cfg.DERP.ServerEnabled {
|
||||||
router.HandleFunc("/derp", h.DERPServer.DERPHandler)
|
r.HandleFunc("/derp", h.DERPServer.DERPHandler)
|
||||||
router.HandleFunc("/derp/probe", derpServer.DERPProbeHandler)
|
r.HandleFunc("/derp/probe", derpServer.DERPProbeHandler)
|
||||||
router.HandleFunc("/derp/latency-check", derpServer.DERPProbeHandler)
|
r.HandleFunc("/derp/latency-check", derpServer.DERPProbeHandler)
|
||||||
router.HandleFunc("/bootstrap-dns", derpServer.DERPBootstrapDNSHandler(h.state.DERPMap()))
|
r.HandleFunc("/bootstrap-dns", derpServer.DERPBootstrapDNSHandler(h.state.DERPMap()))
|
||||||
}
|
}
|
||||||
|
|
||||||
apiRouter := router.PathPrefix("/api").Subrouter()
|
r.Route("/api", func(r chi.Router) {
|
||||||
apiRouter.Use(h.httpAuthenticationMiddleware)
|
r.Use(h.httpAuthenticationMiddleware)
|
||||||
apiRouter.PathPrefix("/v1/").HandlerFunc(grpcMux.ServeHTTP)
|
r.HandleFunc("/v1/*", grpcMux.ServeHTTP)
|
||||||
router.HandleFunc("/favicon.ico", FaviconHandler)
|
})
|
||||||
router.PathPrefix("/").HandlerFunc(BlankHandler)
|
r.Get("/favicon.ico", FaviconHandler)
|
||||||
|
r.Get("/", BlankHandler)
|
||||||
|
|
||||||
return router
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve launches the HTTP and gRPC server service Headscale and the API.
|
// Serve launches the HTTP and gRPC server service Headscale and the API.
|
||||||
@@ -1083,3 +1092,52 @@ func (l *acmeLogger) RoundTrip(req *http.Request) (*http.Response, error) {
|
|||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// zerologRequestLogger implements chi's middleware.LogFormatter
|
||||||
|
// to route HTTP request logs through zerolog.
|
||||||
|
type zerologRequestLogger struct{}
|
||||||
|
|
||||||
|
func (z *zerologRequestLogger) NewLogEntry(
|
||||||
|
r *http.Request,
|
||||||
|
) middleware.LogEntry {
|
||||||
|
return &zerologLogEntry{
|
||||||
|
method: r.Method,
|
||||||
|
path: r.URL.Path,
|
||||||
|
proto: r.Proto,
|
||||||
|
remote: r.RemoteAddr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type zerologLogEntry struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
proto string
|
||||||
|
remote string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *zerologLogEntry) Write(
|
||||||
|
status, bytes int,
|
||||||
|
header http.Header,
|
||||||
|
elapsed time.Duration,
|
||||||
|
extra any,
|
||||||
|
) {
|
||||||
|
log.Info().
|
||||||
|
Str("method", e.method).
|
||||||
|
Str("path", e.path).
|
||||||
|
Str("proto", e.proto).
|
||||||
|
Str("remote", e.remote).
|
||||||
|
Int("status", status).
|
||||||
|
Int("bytes", bytes).
|
||||||
|
Dur("elapsed", elapsed).
|
||||||
|
Msg("http request")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *zerologLogEntry) Panic(
|
||||||
|
v any,
|
||||||
|
stack []byte,
|
||||||
|
) {
|
||||||
|
log.Error().
|
||||||
|
Interface("panic", v).
|
||||||
|
Bytes("stack", stack).
|
||||||
|
Msg("http handler panic")
|
||||||
|
}
|
||||||
|
|||||||
+83
-23
@@ -8,7 +8,9 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/go-chi/metrics"
|
||||||
"github.com/juanfont/headscale/hscontrol/capver"
|
"github.com/juanfont/headscale/hscontrol/capver"
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
@@ -69,7 +71,7 @@ func (h *Headscale) NoiseUpgradeHandler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
noiseServer := noiseServer{
|
ns := noiseServer{
|
||||||
headscale: h,
|
headscale: h,
|
||||||
challenge: key.NewChallenge(),
|
challenge: key.NewChallenge(),
|
||||||
}
|
}
|
||||||
@@ -79,42 +81,88 @@ func (h *Headscale) NoiseUpgradeHandler(
|
|||||||
writer,
|
writer,
|
||||||
req,
|
req,
|
||||||
*h.noisePrivateKey,
|
*h.noisePrivateKey,
|
||||||
noiseServer.earlyNoise,
|
ns.earlyNoise,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(writer, fmt.Errorf("upgrading noise connection: %w", err))
|
httpError(writer, fmt.Errorf("upgrading noise connection: %w", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
noiseServer.conn = noiseConn
|
ns.conn = noiseConn
|
||||||
noiseServer.machineKey = noiseServer.conn.Peer()
|
ns.machineKey = ns.conn.Peer()
|
||||||
noiseServer.protocolVersion = noiseServer.conn.ProtocolVersion()
|
ns.protocolVersion = ns.conn.ProtocolVersion()
|
||||||
|
|
||||||
// This router is served only over the Noise connection, and exposes only the new API.
|
// This router is served only over the Noise connection, and exposes only the new API.
|
||||||
//
|
//
|
||||||
// The HTTP2 server that exposes this router is created for
|
// The HTTP2 server that exposes this router is created for
|
||||||
// a single hijacked connection from /ts2021, using netutil.NewOneConnListener
|
// a single hijacked connection from /ts2021, using netutil.NewOneConnListener
|
||||||
router := mux.NewRouter()
|
|
||||||
router.Use(prometheusMiddleware)
|
|
||||||
|
|
||||||
router.HandleFunc("/machine/register", noiseServer.NoiseRegistrationHandler).
|
r := chi.NewRouter()
|
||||||
Methods(http.MethodPost)
|
r.Use(metrics.Collector(metrics.CollectorOpts{
|
||||||
|
Host: false,
|
||||||
|
Proto: true,
|
||||||
|
Skip: func(r *http.Request) bool {
|
||||||
|
return r.Method != http.MethodOptions
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
// Endpoints outside of the register endpoint must use getAndValidateNode to
|
r.Handle("/metrics", metrics.Handler())
|
||||||
// get the node to ensure that the MachineKey matches the Node setting up the
|
|
||||||
// connection.
|
|
||||||
router.HandleFunc("/machine/map", noiseServer.NoisePollNetMapHandler)
|
|
||||||
|
|
||||||
noiseServer.httpBaseConfig = &http.Server{
|
r.Route("/machine", func(r chi.Router) {
|
||||||
Handler: router,
|
r.Post("/register", ns.RegistrationHandler)
|
||||||
|
r.Post("/map", ns.PollNetMapHandler)
|
||||||
|
|
||||||
|
// Not implemented yet
|
||||||
|
//
|
||||||
|
// /whoami is a debug endpoint to validate that the client can communicate over the connection,
|
||||||
|
// not clear if there is a specific response, it looks like it is just logged.
|
||||||
|
// https://github.com/tailscale/tailscale/blob/dfba01ca9bd8c4df02c3c32f400d9aeb897c5fc7/cmd/tailscale/cli/debug.go#L1138
|
||||||
|
r.Get("/whoami", ns.NotImplementedHandler)
|
||||||
|
|
||||||
|
// client sends a [tailcfg.SetDNSRequest] to this endpoints and expect
|
||||||
|
// the server to create or update this DNS record "somewhere".
|
||||||
|
// It is typically a TXT record for an ACME challenge.
|
||||||
|
r.Post("/set-dns", ns.NotImplementedHandler)
|
||||||
|
|
||||||
|
// A patch of [tailcfg.SetDeviceAttributesRequest] to update device attributes.
|
||||||
|
// We currently do not support device attributes.
|
||||||
|
r.Patch("/set-device-attr", ns.NotImplementedHandler)
|
||||||
|
|
||||||
|
// A [tailcfg.AuditLogRequest] to send audit log entries to the server.
|
||||||
|
// The server is expected to store them "somewhere".
|
||||||
|
// We currently do not support device attributes.
|
||||||
|
r.Post("/audit-log", ns.NotImplementedHandler)
|
||||||
|
|
||||||
|
// handles requests to get an OIDC ID token. Receives a [tailcfg.TokenRequest].
|
||||||
|
r.Post("/id-token", ns.NotImplementedHandler)
|
||||||
|
|
||||||
|
// Asks the server if a feature is available and receive information about how to enable it.
|
||||||
|
// Gets a [tailcfg.QueryFeatureRequest] and returns a [tailcfg.QueryFeatureResponse].
|
||||||
|
r.Post("/feature/query", ns.NotImplementedHandler)
|
||||||
|
|
||||||
|
r.Post("/update-health", ns.NotImplementedHandler)
|
||||||
|
|
||||||
|
r.Route("/webclient", func(r chi.Router) {})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Post("/c2n", ns.NotImplementedHandler)
|
||||||
|
|
||||||
|
r.Get("/ssh-action", ns.SSHAction)
|
||||||
|
|
||||||
|
ns.httpBaseConfig = &http.Server{
|
||||||
|
Handler: r,
|
||||||
ReadHeaderTimeout: types.HTTPTimeout,
|
ReadHeaderTimeout: types.HTTPTimeout,
|
||||||
}
|
}
|
||||||
noiseServer.http2Server = &http2.Server{}
|
ns.http2Server = &http2.Server{}
|
||||||
|
|
||||||
noiseServer.http2Server.ServeConn(
|
ns.http2Server.ServeConn(
|
||||||
noiseConn,
|
noiseConn,
|
||||||
&http2.ServeConnOpts{
|
&http2.ServeConnOpts{
|
||||||
BaseConfig: noiseServer.httpBaseConfig,
|
BaseConfig: ns.httpBaseConfig,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -189,7 +237,19 @@ func rejectUnsupported(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoisePollNetMapHandler takes care of /machine/:id/map using the Noise protocol
|
func (ns *noiseServer) NotImplementedHandler(writer http.ResponseWriter, req *http.Request) {
|
||||||
|
d, _ := io.ReadAll(req.Body)
|
||||||
|
log.Trace().Caller().Str("path", req.URL.String()).Bytes("body", d).Msgf("not implemented handler hit")
|
||||||
|
http.Error(writer, "Not implemented yet", http.StatusNotImplemented)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHAction handles the /ssh-action endpoint, it returns a [tailcfg.SSHAction]
|
||||||
|
// to the client with the verdict of an SSH access request.
|
||||||
|
func (ns *noiseServer) SSHAction(writer http.ResponseWriter, req *http.Request) {
|
||||||
|
log.Trace().Caller().Str("path", req.URL.String()).Msg("got SSH action request")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollNetMapHandler takes care of /machine/:id/map using the Noise protocol
|
||||||
//
|
//
|
||||||
// This is the busiest endpoint, as it keeps the HTTP long poll that updates
|
// This is the busiest endpoint, as it keeps the HTTP long poll that updates
|
||||||
// the clients when something in the network changes.
|
// the clients when something in the network changes.
|
||||||
@@ -198,7 +258,7 @@ func rejectUnsupported(
|
|||||||
// only after their first request (marked with the ReadOnly field).
|
// only after their first request (marked with the ReadOnly field).
|
||||||
//
|
//
|
||||||
// At this moment the updates are sent in a quite horrendous way, but they kinda work.
|
// At this moment the updates are sent in a quite horrendous way, but they kinda work.
|
||||||
func (ns *noiseServer) NoisePollNetMapHandler(
|
func (ns *noiseServer) PollNetMapHandler(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
req *http.Request,
|
req *http.Request,
|
||||||
) {
|
) {
|
||||||
@@ -237,8 +297,8 @@ func regErr(err error) *tailcfg.RegisterResponse {
|
|||||||
return &tailcfg.RegisterResponse{Error: err.Error()}
|
return &tailcfg.RegisterResponse{Error: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoiseRegistrationHandler handles the actual registration process of a node.
|
// RegistrationHandler handles the actual registration process of a node.
|
||||||
func (ns *noiseServer) NoiseRegistrationHandler(
|
func (ns *noiseServer) RegistrationHandler(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
req *http.Request,
|
req *http.Request,
|
||||||
) {
|
) {
|
||||||
|
|||||||
Reference in New Issue
Block a user