mirror of
https://github.com/caddyserver/caddy.git
synced 2026-08-27 04:07:28 +00:00
Merge branch 'master' into dependabot/go_modules/all-updates-7546fc046b
This commit is contained in:
commit
27248315fa
22 changed files with 116 additions and 40 deletions
|
|
@ -155,7 +155,7 @@ func (l *lexer) next() (bool, error) {
|
|||
// want to keep.
|
||||
if ch == '\n' {
|
||||
if len(val) == 2 {
|
||||
return false, fmt.Errorf("missing opening heredoc marker on line #%d; must contain only alpha-numeric characters, dashes and underscores; got empty string", l.line)
|
||||
return false, fmt.Errorf("missing opening heredoc marker on line #%d; must contain only alphanumeric characters, dashes and underscores; got empty string", l.line)
|
||||
}
|
||||
|
||||
// check if there's too many <
|
||||
|
|
@ -165,7 +165,7 @@ func (l *lexer) next() (bool, error) {
|
|||
|
||||
heredocMarker = string(val[2:])
|
||||
if !heredocMarkerRegexp.Match([]byte(heredocMarker)) {
|
||||
return false, fmt.Errorf("heredoc marker on line #%d must contain only alpha-numeric characters, dashes and underscores; got '%s'", l.line, heredocMarker)
|
||||
return false, fmt.Errorf("heredoc marker on line #%d must contain only alphanumeric characters, dashes and underscores; got '%s'", l.line, heredocMarker)
|
||||
}
|
||||
|
||||
inHeredoc = true
|
||||
|
|
|
|||
|
|
@ -424,7 +424,7 @@ EOF
|
|||
{
|
||||
input: []byte("not-a-heredoc <<\n"),
|
||||
expectErr: true,
|
||||
errorMessage: "missing opening heredoc marker on line #1; must contain only alpha-numeric characters, dashes and underscores; got empty string",
|
||||
errorMessage: "missing opening heredoc marker on line #1; must contain only alphanumeric characters, dashes and underscores; got empty string",
|
||||
},
|
||||
{
|
||||
input: []byte(`heredoc <<<EOF
|
||||
|
|
|
|||
|
|
@ -683,7 +683,7 @@ func (p *parser) directive() error {
|
|||
// openCurlyBrace expects the current token to be an
|
||||
// opening curly brace. This acts like an assertion
|
||||
// because it returns an error if the token is not
|
||||
// a opening curly brace. It does NOT advance the token.
|
||||
// an opening curly brace. It does NOT advance the token.
|
||||
func (p *parser) openCurlyBrace() error {
|
||||
if p.Val() != "{" {
|
||||
if p.valLooksLikeGlobalOptionsAfterImportedSnippets() {
|
||||
|
|
|
|||
|
|
@ -518,7 +518,7 @@ func (tc *Tester) AssertResponseCode(req *http.Request, expectedStatusCode int)
|
|||
return resp
|
||||
}
|
||||
|
||||
// AssertResponse request a URI and assert the status code and the body contains a string
|
||||
// AssertResponse requests a URI and asserts the status code and body.
|
||||
func (tc *Tester) AssertResponse(req *http.Request, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
||||
tc.t.Helper()
|
||||
|
||||
|
|
@ -541,7 +541,7 @@ func (tc *Tester) AssertResponse(req *http.Request, expectedStatusCode int, expe
|
|||
|
||||
// Verb specific test functions
|
||||
|
||||
// AssertGetResponse GET a URI and expect a statusCode and body text
|
||||
// AssertGetResponse requests a URI with GET and expects a status code and body text.
|
||||
func (tc *Tester) AssertGetResponse(requestURI string, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
||||
tc.t.Helper()
|
||||
|
||||
|
|
@ -553,7 +553,7 @@ func (tc *Tester) AssertGetResponse(requestURI string, expectedStatusCode int, e
|
|||
return tc.AssertResponse(req, expectedStatusCode, expectedBody)
|
||||
}
|
||||
|
||||
// AssertDeleteResponse request a URI and expect a statusCode and body text
|
||||
// AssertDeleteResponse requests a URI with DELETE and expects a status code and body text.
|
||||
func (tc *Tester) AssertDeleteResponse(requestURI string, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
||||
tc.t.Helper()
|
||||
|
||||
|
|
@ -565,7 +565,7 @@ func (tc *Tester) AssertDeleteResponse(requestURI string, expectedStatusCode int
|
|||
return tc.AssertResponse(req, expectedStatusCode, expectedBody)
|
||||
}
|
||||
|
||||
// AssertPostResponseBody POST to a URI and assert the response code and body
|
||||
// AssertPostResponseBody requests a URI with POST and asserts the response code and body.
|
||||
func (tc *Tester) AssertPostResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
||||
tc.t.Helper()
|
||||
|
||||
|
|
@ -580,7 +580,7 @@ func (tc *Tester) AssertPostResponseBody(requestURI string, requestHeaders []str
|
|||
return tc.AssertResponse(req, expectedStatusCode, expectedBody)
|
||||
}
|
||||
|
||||
// AssertPutResponseBody PUT to a URI and assert the response code and body
|
||||
// AssertPutResponseBody requests a URI with PUT and asserts the response code and body.
|
||||
func (tc *Tester) AssertPutResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
||||
tc.t.Helper()
|
||||
|
||||
|
|
@ -595,7 +595,7 @@ func (tc *Tester) AssertPutResponseBody(requestURI string, requestHeaders []stri
|
|||
return tc.AssertResponse(req, expectedStatusCode, expectedBody)
|
||||
}
|
||||
|
||||
// AssertPatchResponseBody PATCH to a URI and assert the response code and body
|
||||
// AssertPatchResponseBody requests a URI with PATCH and asserts the response code and body.
|
||||
func (tc *Tester) AssertPatchResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
||||
tc.t.Helper()
|
||||
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ handle {
|
|||
END!
|
||||
}
|
||||
----------
|
||||
heredoc marker on line #4 must contain only alpha-numeric characters, dashes and underscores; got 'END!'
|
||||
heredoc marker on line #4 must contain only alphanumeric characters, dashes and underscores; got 'END!'
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ func testH2ToH2CStreamServeH2C(t *testing.T) *http.Server {
|
|||
}
|
||||
// We only accept HTTP/2!
|
||||
if r.ProtoMajor != 2 {
|
||||
t.Error("Not a HTTP/2 request, rejected!")
|
||||
t.Error("Not an HTTP/2 request, rejected!")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -566,7 +566,7 @@ argument of --directory. If the directory does not exist, it will be created.
|
|||
// following format:
|
||||
//
|
||||
// - lowercase
|
||||
// - alphanumeric and hyphen characters only
|
||||
// - ASCII lowercase letters, digits and hyphens only
|
||||
// - cannot start or end with a hyphen
|
||||
// - hyphen cannot be adjacent to another hyphen
|
||||
//
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ func (m *MatchExpression) UnmarshalJSON(data []byte) error {
|
|||
return json.Unmarshal(data, &m.Expr)
|
||||
}
|
||||
// otherwise, it's a full object, so unmarshal it,
|
||||
// using an temp map to avoid infinite recursion
|
||||
// using a temp map to avoid infinite recursion
|
||||
var tmpJson map[string]any
|
||||
err := json.Unmarshal(data, &tmpJson)
|
||||
*m = MatchExpression{
|
||||
|
|
@ -118,7 +118,7 @@ func (m *MatchExpression) UnmarshalJSON(data []byte) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Provision sets ups m.
|
||||
// Provision sets up m.
|
||||
func (m *MatchExpression) Provision(ctx caddy.Context) error {
|
||||
m.log = ctx.Logger()
|
||||
|
||||
|
|
@ -319,7 +319,7 @@ func (cr celHTTPRequest) Value() any { return cr }
|
|||
|
||||
var pkixNameCELType = cel.ObjectType("pkix.Name", traits.ReceiverType)
|
||||
|
||||
// celPkixName wraps an pkix.Name with
|
||||
// celPkixName wraps a pkix.Name with
|
||||
// methods to satisfy the ref.Val interface.
|
||||
type celPkixName struct{ *pkix.Name }
|
||||
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV
|
|||
wantResult: true,
|
||||
},
|
||||
{
|
||||
name: "header matches an placeholder replaced during the header matcher (MatchHeader)",
|
||||
name: "header matches a placeholder replaced during the header matcher (MatchHeader)",
|
||||
expression: &MatchExpression{
|
||||
Expr: `header({'Field': '\{http.request.uri.path}'})`,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ func (enc *Encode) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyh
|
|||
|
||||
// to comply with RFC 9110 section 8.8.3(.3), we modify the Etag when encoding
|
||||
// by appending a hyphen and the encoder name; the problem is, the client will
|
||||
// send back that Etag in a If-None-Match header, but upstream handlers that set
|
||||
// send back that Etag in an If-None-Match header, but upstream handlers that set
|
||||
// the Etag in the first place don't know that we appended to their Etag! so here
|
||||
// we have to strip our addition so the upstream handlers can still honor client
|
||||
// caches without knowing about our changes...
|
||||
|
|
@ -369,7 +369,7 @@ const sniffLen = 512
|
|||
|
||||
// ReadFrom will try to use sendfile to copy from the reader to the response writer.
|
||||
// It's only used if the response writer implements io.ReaderFrom and the data can't be compressed.
|
||||
// It's based on stdlin http1.1 response writer implementation.
|
||||
// It's based on the standard library HTTP/1.1 response writer implementation.
|
||||
// https://github.com/golang/go/blob/f4e3ec3dbe3b8e04a058d266adf8e048bab563f2/src/net/http/server.go#L586
|
||||
func (rw *responseWriter) ReadFrom(r io.Reader) (int64, error) {
|
||||
rf, ok := rw.ResponseWriter.(io.ReaderFrom)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import (
|
|||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
|
@ -123,7 +124,7 @@ type FileServer struct {
|
|||
// put "hidden" in the list. To hide only ./hidden, put "./hidden" in the list.
|
||||
//
|
||||
// When possible, all paths are resolved to their absolute form before
|
||||
// comparisons are made. For maximum clarity and explictness, use complete,
|
||||
// comparisons are made. For maximum clarity and explicitness, use complete,
|
||||
// absolute paths; or, for greater portability, use relative paths instead.
|
||||
//
|
||||
// Note that hide comparisons are case-sensitive. On case-insensitive
|
||||
|
|
@ -579,7 +580,17 @@ func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, next c
|
|||
// that errors generated by ServeContent are written immediately
|
||||
// to the response, so we cannot handle them (but errors there
|
||||
// are rare)
|
||||
http.ServeContent(w, r, info.Name(), info.ModTime(), file.(io.ReadSeeker))
|
||||
//
|
||||
// There are a few file modification times that aren't useful
|
||||
// to send in Last-Modified headers, but the golang http library only
|
||||
// omits Last-Modified headers for the Unix epoch time. So, force
|
||||
// the modification time to the epoch time if it's not useful.
|
||||
zeroTime := time.Time{}
|
||||
modTime := info.ModTime()
|
||||
if !usefulModTime(modTime) {
|
||||
modTime = zeroTime
|
||||
}
|
||||
http.ServeContent(w, r, info.Name(), modTime, file.(io.ReadSeeker))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -726,6 +737,14 @@ func (fsrv *FileServer) notFound(w http.ResponseWriter, r *http.Request, next ca
|
|||
return caddyhttp.Error(http.StatusNotFound, nil)
|
||||
}
|
||||
|
||||
// Indicates whether a file's modification time is useful for validator
|
||||
// generation purposes (i.e. inclusion in ETag and Last-Modified headers).
|
||||
// See issues #5548 and #7730.
|
||||
func usefulModTime(modTime time.Time) bool {
|
||||
mtimeunix := modTime.Unix()
|
||||
return mtimeunix != 0 && mtimeunix != 1
|
||||
}
|
||||
|
||||
// calculateEtag computes an entity tag using a strong validator
|
||||
// without consuming the contents of the file. It requires the
|
||||
// file info contain the correct size and modification time.
|
||||
|
|
@ -743,8 +762,8 @@ func (fsrv *FileServer) notFound(w http.ResponseWriter, r *http.Request, next ca
|
|||
// which we consider precise enough to qualify as a strong validator.
|
||||
func calculateEtag(d os.FileInfo) string {
|
||||
mtime := d.ModTime()
|
||||
if mtimeUnix := mtime.Unix(); mtimeUnix == 0 || mtimeUnix == 1 {
|
||||
return "" // not useful anyway; see issue #5548
|
||||
if !usefulModTime(mtime) {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteRune('"')
|
||||
|
|
|
|||
|
|
@ -15,10 +15,17 @@
|
|||
package fileserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
)
|
||||
|
||||
func TestFileHidden(t *testing.T) {
|
||||
|
|
@ -128,3 +135,52 @@ func TestFileHidden(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check to make sure that we don't serve ETag and Last-Modified headers
|
||||
// for files with invalid modification times
|
||||
func TestModTimeHeaders(t *testing.T) {
|
||||
check_validator_headers(time.Now(), true, t)
|
||||
check_validator_headers(time.Unix(0, 0), false, t)
|
||||
check_validator_headers(time.Unix(1, 0), false, t)
|
||||
check_validator_headers(time.Unix(2, 0), true, t)
|
||||
}
|
||||
|
||||
func check_validator_headers(modTime time.Time, expect_headers bool, t *testing.T) {
|
||||
f := false
|
||||
fsrv := FileServer{
|
||||
Root: "./testdata",
|
||||
CanonicalURIs: &f,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r, err := http.NewRequest("GET", "/modtime.txt", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repl := caddy.NewReplacer()
|
||||
ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
ctx2, _ := caddy.NewContext(caddy.Context{Context: context.Background()}) // module will be nil by default
|
||||
fsrv.Provision(ctx2)
|
||||
|
||||
path := "testdata/modtime.txt"
|
||||
os.Chtimes(path, modTime, modTime)
|
||||
|
||||
fsrv.ServeHTTP(w, r, nil)
|
||||
|
||||
if expect_headers {
|
||||
if w.Header().Get("ETag") == "" {
|
||||
t.Errorf("Didn't get ETag header for file with valid mod time %s", modTime)
|
||||
}
|
||||
if w.Header().Get("Last-Modified") == "" {
|
||||
t.Errorf("Didn't get Last-Modified header for file with valid mod time %s", modTime)
|
||||
}
|
||||
} else {
|
||||
if w.Header().Get("ETag") != "" {
|
||||
t.Errorf("Got ETag header for file with invalid mod time %s", modTime)
|
||||
}
|
||||
if w.Header().Get("Last-Modified") != "" {
|
||||
t.Errorf("Got Last-Modified header for file with invalid mod time %s", modTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
0
modules/caddyhttp/fileserver/testdata/modtime.txt
vendored
Normal file
0
modules/caddyhttp/fileserver/testdata/modtime.txt
vendored
Normal file
|
|
@ -15,7 +15,7 @@ type connectionStater interface {
|
|||
|
||||
// http2Listener wraps the listener to solve the following problems:
|
||||
// 1. prevent genuine h2c connections from succeeding if h2c is not enabled
|
||||
// and the connection doesn't implment connectionStater or the resulting NegotiatedProtocol
|
||||
// and the connection doesn't implement connectionStater or the resulting NegotiatedProtocol
|
||||
// isn't http2.
|
||||
// This does allow a connection to pass as tls enabled even if it's not, listener wrappers
|
||||
// can do this.
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ type httpRedirectConn struct {
|
|||
|
||||
// Read tries to peek at the first few bytes of the request, and if we get
|
||||
// an error reading the headers, and that error was due to the bytes looking
|
||||
// like an HTTP request, then we perform a HTTP->HTTPS redirect on the same
|
||||
// like an HTTP request, then we perform an HTTP->HTTPS redirect on the same
|
||||
// port as the original connection.
|
||||
func (c *httpRedirectConn) Read(p []byte) (int, error) {
|
||||
if c.once {
|
||||
|
|
|
|||
|
|
@ -135,8 +135,8 @@ type client struct {
|
|||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// Do made the request and returns a io.Reader that translates the data read
|
||||
// from fcgi responder out of fcgi packet before returning it.
|
||||
// Do makes the request and returns an io.Reader that translates the data read
|
||||
// from the FastCGI responder out of FastCGI packets before returning it.
|
||||
func (c *client) Do(p map[string]string, req io.Reader) (r io.Reader, err error) {
|
||||
// check for CONTENT_LENGTH, since the lack of it or wrong value will cause the backend to hang
|
||||
if clStr, ok := p["CONTENT_LENGTH"]; !ok {
|
||||
|
|
@ -179,7 +179,7 @@ func (c *client) Do(p map[string]string, req io.Reader) (r io.Reader, err error)
|
|||
return r, err
|
||||
}
|
||||
|
||||
// clientCloser is a io.ReadCloser. It wraps a io.Reader with a Closer
|
||||
// clientCloser is an io.ReadCloser. It wraps an io.Reader with a Closer
|
||||
// that closes the client connection.
|
||||
type clientCloser struct {
|
||||
rwc net.Conn
|
||||
|
|
@ -208,8 +208,8 @@ func (f clientCloser) Close() error {
|
|||
return f.rwc.Close()
|
||||
}
|
||||
|
||||
// Request returns a HTTP Response with Header and Body
|
||||
// from fcgi responder
|
||||
// Request returns an HTTP response with header and body
|
||||
// from the FastCGI responder.
|
||||
func (c *client) Request(p map[string]string, req io.Reader) (resp *http.Response, err error) {
|
||||
r, err := c.Do(p, req)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -522,7 +522,7 @@ func (h *Handler) doActiveHealthCheck(dialInfo DialInfo, hostAddr string, networ
|
|||
body = io.LimitReader(body, h.HealthChecks.Active.MaxSize)
|
||||
}
|
||||
defer func() {
|
||||
// drain any remaining body so connection could be re-used
|
||||
// drain any remaining body so connection could be reused
|
||||
_, _ = io.Copy(io.Discard, body)
|
||||
resp.Body.Close()
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -568,7 +568,7 @@ func TestQueryHashPolicy(t *testing.T) {
|
|||
pool[1].setHealthy(false)
|
||||
h = queryPolicy.Select(pool, request, nil)
|
||||
if h != nil {
|
||||
t.Error("Expected query policy policy host to be nil.")
|
||||
t.Error("Expected query policy host to be nil.")
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/?foo=aa11&foo=bb22", nil)
|
||||
|
|
@ -630,7 +630,7 @@ func TestURIHashPolicy(t *testing.T) {
|
|||
pool[1].setHealthy(false)
|
||||
h = uriPolicy.Select(pool, request, nil)
|
||||
if h != nil {
|
||||
t.Error("Expected uri policy policy host to be nil.")
|
||||
t.Error("Expected uri policy host to be nil.")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ type AutomationPolicy struct {
|
|||
DisableOCSPStapling bool `json:"disable_ocsp_stapling,omitempty"`
|
||||
|
||||
// Overrides the URLs of OCSP responders embedded in certificates.
|
||||
// Each key is a OCSP server URL to override, and its value is the
|
||||
// Each key is an OCSP server URL to override, and its value is the
|
||||
// replacement. An empty value will disable querying of that server.
|
||||
// EXPERIMENTAL. Subject to change.
|
||||
OCSPOverrides map[string]string `json:"ocsp_overrides,omitempty"`
|
||||
|
|
|
|||
|
|
@ -137,11 +137,10 @@ func (s *SessionTicketService) stayUpdated() {
|
|||
case newKeys := <-keysChan:
|
||||
s.mu.Lock()
|
||||
s.currentKeys = newKeys
|
||||
configs := s.configs
|
||||
s.mu.Unlock()
|
||||
for cfg := range configs {
|
||||
for cfg := range s.configs {
|
||||
cfg.SetSessionTicketKeys(newKeys)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
case <-s.stopChan:
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -440,7 +440,7 @@ func (t *TLS) Start() error {
|
|||
t.EncryptedClientHello.configsMu.Unlock()
|
||||
if err != nil {
|
||||
echLogger.Error("rotating ECH configs failed", zap.Error(err))
|
||||
return
|
||||
continue
|
||||
}
|
||||
err := t.publishECHConfigs(echLogger)
|
||||
if err != nil {
|
||||
|
|
@ -879,6 +879,8 @@ func (t *TLS) getAutomationPolicyForName(name string) *AutomationPolicy {
|
|||
// AllMatchingCertificates returns the list of all certificates in
|
||||
// the cache which could be used to satisfy the given SAN.
|
||||
func AllMatchingCertificates(san string) []certmagic.Certificate {
|
||||
certCacheMu.RLock()
|
||||
defer certCacheMu.RUnlock()
|
||||
return certCache.AllMatchingCertificates(san)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -149,10 +149,10 @@ func (f *ReplaceFilter) Filter(in zapcore.Field) zapcore.Field {
|
|||
// list of IP addresses, where all of the values
|
||||
// will be masked.
|
||||
type IPMaskFilter struct {
|
||||
// The IPv4 mask, as an subnet size CIDR.
|
||||
// The IPv4 mask, as a subnet size CIDR.
|
||||
IPv4MaskRaw int `json:"ipv4_cidr,omitempty"`
|
||||
|
||||
// The IPv6 mask, as an subnet size CIDR.
|
||||
// The IPv6 mask, as a subnet size CIDR.
|
||||
IPv6MaskRaw int `json:"ipv6_cidr,omitempty"`
|
||||
|
||||
v4Mask net.IPMask
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue