diff --git a/README.md b/README.md index 76b2e8c..3171451 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ This app works similarly to the `http` app. You define servers, and each server Current matchers: +- **layer4.matchers.clock** - matches connections on the time they are wrapped/matched. - **layer4.matchers.http** - matches connections that start with HTTP requests. In addition, any [`http.matchers` modules](https://caddyserver.com/docs/modules/) can be used for matching on HTTP-specific properties of requests, such as header or path. Note that only the first request of each connection can be used for matching. - **layer4.matchers.tls** - matches connections that start with TLS handshakes. In addition, any [`tls.handshake_match` modules](https://caddyserver.com/docs/modules/) can be used for matching on TLS-specific properties of the ClientHello, such as ServerName (SNI). - **layer4.matchers.ssh** - matches connections that look like SSH connections. diff --git a/imports.go b/imports.go index 80c6681..0ae54ce 100644 --- a/imports.go +++ b/imports.go @@ -17,6 +17,7 @@ package caddyl4 import ( // plugging in the standard modules for the layer4 app _ "github.com/mholt/caddy-l4/layer4" + _ "github.com/mholt/caddy-l4/modules/l4clock" _ "github.com/mholt/caddy-l4/modules/l4echo" _ "github.com/mholt/caddy-l4/modules/l4http" _ "github.com/mholt/caddy-l4/modules/l4postgres" diff --git a/integration/caddyfile_adapt/gd_matcher_clock.caddytest b/integration/caddyfile_adapt/gd_matcher_clock.caddytest new file mode 100644 index 0000000..a15e1a0 --- /dev/null +++ b/integration/caddyfile_adapt/gd_matcher_clock.caddytest @@ -0,0 +1,220 @@ +{ + layer4 { + :8080 { + @night_m clock before 05:00:00 + @morning clock 05:00:00 12:00:00 + @afternoon clock 12:00:00 17:00:00 + @evening clock 17:00:00 21:00:00 + @night_e clock after 21:00:00 + route @night_m @night_e { + proxy 00.upstream.local:8080 + } + route @morning { + proxy 01.upstream.local:8080 02.upstream.local:8080 + } + route @afternoon { + proxy 03.upstream.local:8080 04.upstream.local:8080 05.upstream.local:8080 + } + route @evening { + proxy 06.upstream.local:8080 07.upstream.local:8080 + } + } + :8888 { + @la_is_awake clock 08:00:00 20:00:00 America/Los_Angeles + route @la_is_awake { + proxy existing.machine.local:8888 + } + @la_is_asleep not clock 08:00:00 20:00:00 America/Los_Angeles + route @la_is_asleep { + proxy non-existing.machine.local:8888 + } + } + } +} +---------- +{ + "apps": { + "layer4": { + "servers": { + "srv0": { + "listen": [ + ":8080" + ], + "routes": [ + { + "match": [ + { + "clock": { + "after": "00:00:00", + "before": "05:00:00" + } + }, + { + "clock": { + "after": "21:00:00", + "before": "00:00:00" + } + } + ], + "handle": [ + { + "handler": "proxy", + "upstreams": [ + { + "dial": [ + "00.upstream.local:8080" + ] + } + ] + } + ] + }, + { + "match": [ + { + "clock": { + "after": "05:00:00", + "before": "12:00:00" + } + } + ], + "handle": [ + { + "handler": "proxy", + "upstreams": [ + { + "dial": [ + "01.upstream.local:8080" + ] + }, + { + "dial": [ + "02.upstream.local:8080" + ] + } + ] + } + ] + }, + { + "match": [ + { + "clock": { + "after": "12:00:00", + "before": "17:00:00" + } + } + ], + "handle": [ + { + "handler": "proxy", + "upstreams": [ + { + "dial": [ + "03.upstream.local:8080" + ] + }, + { + "dial": [ + "04.upstream.local:8080" + ] + }, + { + "dial": [ + "05.upstream.local:8080" + ] + } + ] + } + ] + }, + { + "match": [ + { + "clock": { + "after": "17:00:00", + "before": "21:00:00" + } + } + ], + "handle": [ + { + "handler": "proxy", + "upstreams": [ + { + "dial": [ + "06.upstream.local:8080" + ] + }, + { + "dial": [ + "07.upstream.local:8080" + ] + } + ] + } + ] + } + ] + }, + "srv1": { + "listen": [ + ":8888" + ], + "routes": [ + { + "match": [ + { + "clock": { + "after": "08:00:00", + "before": "20:00:00", + "timezone": "America/Los_Angeles" + } + } + ], + "handle": [ + { + "handler": "proxy", + "upstreams": [ + { + "dial": [ + "existing.machine.local:8888" + ] + } + ] + } + ] + }, + { + "match": [ + { + "not": [ + { + "clock": { + "after": "08:00:00", + "before": "20:00:00", + "timezone": "America/Los_Angeles" + } + } + ] + } + ], + "handle": [ + { + "handler": "proxy", + "upstreams": [ + { + "dial": [ + "non-existing.machine.local:8888" + ] + } + ] + } + ] + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/layer4/connection.go b/layer4/connection.go index 7bd0184..faae99f 100644 --- a/layer4/connection.go +++ b/layer4/connection.go @@ -19,6 +19,7 @@ import ( "errors" "net" "sync" + "time" "github.com/caddyserver/caddy/v2" "go.uber.org/zap" @@ -33,6 +34,7 @@ func WrapConnection(underlying net.Conn, buf []byte, logger *zap.Logger) *Connec repl := caddy.NewReplacer() repl.Set("l4.conn.remote_addr", underlying.RemoteAddr()) repl.Set("l4.conn.local_addr", underlying.LocalAddr()) + repl.Set("l4.conn.wrap_time", time.Now().UTC()) ctx := context.Background() ctx = context.WithValue(ctx, VarsCtxKey, make(map[string]interface{})) diff --git a/modules/l4clock/matcher.go b/modules/l4clock/matcher.go new file mode 100644 index 0000000..c9069e4 --- /dev/null +++ b/modules/l4clock/matcher.go @@ -0,0 +1,194 @@ +// Copyright 2024 VNXME +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package l4clock + +import ( + "strings" + "time" + _ "time/tzdata" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + + "github.com/mholt/caddy-l4/layer4" +) + +func init() { + caddy.RegisterModule(&MatchClock{}) +} + +// MatchClock is able to match any connections using the time when they are wrapped/matched. +type MatchClock struct { + // After is a mandatory field that must have a value in 15:04:05 format representing the lowest valid time point. + // Placeholders are supported and evaluated at provision. If Before is lower than After, their values are swapped + // at provision. + After string `json:"after,omitempty"` + // Before is a mandatory field that must have a value in 15:04:05 format representing the highest valid time point + // plus one second. Placeholders are supported and evaluated at provision. 00:00:00 is treated here as 24:00:00. + // If Before is lower than After, their values are swapped at provision. + Before string `json:"before,omitempty"` + // Timezone is an optional field that may be an IANA time zone location (e.g. America/Los_Angeles), a fixed offset + // to the east of UTC (e.g. +02, -03:30, or even +12:34:56) or Local (to use the system's local time zone). + // If Timezone is empty, UTC is used by default. + Timezone string `json:"timezone,omitempty"` + + location *time.Location + secondsAfter int + secondsBefore int +} + +// CaddyModule returns the Caddy module information. +func (m *MatchClock) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "layer4.matchers.clock", + New: func() caddy.Module { return new(MatchClock) }, + } +} + +// Match returns true if the connection wrapping/matching occurs within m's time points. +func (m *MatchClock) Match(cx *layer4.Connection) (bool, error) { + repl := cx.Context.Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + t, known := repl.Get(timeKey) + if !known { + t = time.Now().UTC() + repl.Set(timeKey, t) + } + secondsNow := timeToSeconds(t.(time.Time).In(m.location)) + if secondsNow >= m.secondsAfter && secondsNow < m.secondsBefore { + return true, nil + } + return false, nil +} + +// Provision parses m's time points and a time zone (UTC is used by default). +func (m *MatchClock) Provision(_ caddy.Context) (err error) { + repl := caddy.NewReplacer() + + after := repl.ReplaceAll(m.After, "") + if m.secondsAfter, err = timeParseSeconds(after, 0); err != nil { + return + } + + before := repl.ReplaceAll(m.Before, "") + if m.secondsBefore, err = timeParseSeconds(before, 0); err != nil { + return + } + + // Treat secondsBefore of 00:00:00 as 24:00:00 + if m.secondsBefore == 0 { + m.secondsBefore = 86400 + } + + // Swap time points, if secondsAfter is greater than secondsBefore + if m.secondsBefore < m.secondsAfter { + m.secondsAfter, m.secondsBefore = m.secondsBefore, m.secondsAfter + } + + timezone := repl.ReplaceAll(m.Timezone, "") + for _, layout := range tzLayouts { + if len(layout) != len(timezone) { + continue + } + if t, e := time.Parse(layout, timezone); e == nil { + _, offset := t.Zone() + m.location = time.FixedZone(timezone, offset) + break + } + } + if m.location == nil { + if m.location, err = time.LoadLocation(timezone); err != nil { + return + } + } + + return nil +} + +// UnmarshalCaddyfile sets up the MatchClock from Caddyfile tokens. Syntax: +// +// clock [] +// clock [] +// clock [] +// +// Note: MatchClock checks if time_now is greater than or equal to time_after AND less than time_before. +// The lowest value is 00:00:00. If time_before equals 00:00:00, it is treated as 24:00:00. If time_after is greater +// than time_before, they are swapped. Both "after 00:00:00" and "before 00:00:00" match all day. An IANA time zone +// location should be used as a value for time_zone. The system's local time zone may be used with "Local" value. +// If time_zone is empty, UTC is used. +func (m *MatchClock) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + _, wrapper := d.Next(), d.Val() // consume wrapper name + + // Only two or three same-line arguments are supported + if d.CountRemainingArgs() < 2 || d.CountRemainingArgs() > 3 { + return d.ArgErr() + } + + _, first, _, second := d.NextArg(), d.Val(), d.NextArg(), d.Val() + switch strings.ToLower(first) { + case "before", "till", "to", "until": + first = timeMin + break + case "after", "from": + first = timeMax + second, first = first, second + break + } + m.After, m.Before = first, second + + if d.NextArg() { + m.Timezone = d.Val() + } + + // No blocks are supported + if d.NextBlock(d.Nesting()) { + return d.Errf("malformed %s matcher: blocks are not supported", wrapper) + } + + return nil +} + +const ( + timeKey = "l4.conn.wrap_time" + timeLayout = time.TimeOnly + timeMax = "00:00:00" + timeMin = "00:00:00" +) + +var tzLayouts = [...]string{"-07", "-07:00", "-07:00:00"} + +// Interface guards +var ( + _ caddy.Provisioner = (*MatchClock)(nil) + _ caddyfile.Unmarshaler = (*MatchClock)(nil) + _ layer4.ConnMatcher = (*MatchClock)(nil) +) + +// timeToSeconds gets time and returns the number of seconds passed from the beginning of the current day. +func timeToSeconds(t time.Time) int { + hh, mm, ss := t.Clock() + return hh*3600 + mm*60 + ss +} + +// timeParseSeconds parses time string and returns seconds passed from the beginning of the current day. +func timeParseSeconds(src string, def int) (int, error) { + if len(src) == 0 { + return def, nil + } + t, err := time.Parse(timeLayout, src) + if err != nil { + return def, err + } + return timeToSeconds(t), nil +} diff --git a/modules/l4clock/matcher_test.go b/modules/l4clock/matcher_test.go new file mode 100644 index 0000000..c889664 --- /dev/null +++ b/modules/l4clock/matcher_test.go @@ -0,0 +1,91 @@ +// Copyright 2024 VNXME +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package l4clock + +import ( + "context" + "io" + "net" + "testing" + "time" + + "github.com/caddyserver/caddy/v2" + "go.uber.org/zap" + + "github.com/mholt/caddy-l4/layer4" +) + +func assertNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("Unexpected error: %s\n", err) + } +} + +func Test_MatchClock_Match(t *testing.T) { + type test struct { + matcher *MatchClock + data []byte + shouldMatch bool + } + + tNowMinus5Minutes := time.Now().Add(time.Minute * 5 * (-1)).Format(time.TimeOnly) + tNowPlus5Minutes := time.Now().Add(time.Minute * 5).Format(time.TimeOnly) + + tests := []test{ + {matcher: &MatchClock{}, data: []byte{}, shouldMatch: true}, + {matcher: &MatchClock{After: tNowMinus5Minutes}, data: []byte{}, shouldMatch: true}, + {matcher: &MatchClock{Before: tNowPlus5Minutes}, data: []byte{}, shouldMatch: true}, + {matcher: &MatchClock{After: tNowMinus5Minutes, Before: tNowPlus5Minutes}, data: []byte{}, shouldMatch: true}, + {matcher: &MatchClock{After: tNowPlus5Minutes, Before: tNowMinus5Minutes}, data: []byte{}, shouldMatch: true}, + + {matcher: &MatchClock{After: tNowPlus5Minutes}, data: []byte{}, shouldMatch: false}, + {matcher: &MatchClock{Before: tNowMinus5Minutes}, data: []byte{}, shouldMatch: false}, + } + + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) + defer cancel() + + for i, tc := range tests { + func() { + err := tc.matcher.Provision(ctx) + assertNoError(t, err) + + in, out := net.Pipe() + defer func() { + _, _ = io.Copy(io.Discard, out) + _ = out.Close() + }() + + cx := layer4.WrapConnection(out, []byte{}, zap.NewNop()) + go func() { + _, err := in.Write(tc.data) + assertNoError(t, err) + _ = in.Close() + }() + + matched, err := tc.matcher.Match(cx) + assertNoError(t, err) + + if matched != tc.shouldMatch { + if tc.shouldMatch { + t.Fatalf("test %d: matcher did not match | %+v\n", i, tc.matcher) + } else { + t.Fatalf("test %d: matcher should not match | %+v\n", i, tc.matcher) + } + } + }() + } +}