-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(client): add usage reporting #2369
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
58484de
304748c
ec80311
bd4c0f9
b5ce091
2716f8b
85e688f
321868f
e44ced5
3ad060e
0ea80b0
337695c
b4e2c51
43a9b80
ecaedf2
eb0316c
fb2080e
5b9acb1
225b9da
8ba5d02
ca66235
fc21f96
21dd88f
2e46848
56d5cfe
3aca920
3e1da1f
6718e50
72a72ff
981b656
e6a06fe
23d8230
65a7245
defff32
0ad54a5
2873ce6
32fe879
d747c6a
303a555
14e3719
812c32e
e7a7784
63061e7
f8aaa80
42a20de
341455e
5c1185b
a1c3bed
ed0d9d4
78449ea
e6f0175
0bf84ae
edc200a
5d9a16c
b7f529c
c7dbf80
b14c48a
896c7b9
b33ec07
557972b
ecdcef1
4e75174
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -7,7 +7,7 @@ | |||||
// 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, | ||||||
// distributed under the License is distributed on an "AS IS BASIS, | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Plz revert
Suggested change
|
||||||
// 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. | ||||||
|
@@ -17,10 +17,12 @@ package outline | |||||
import ( | ||||||
"context" | ||||||
"errors" | ||||||
"fmt" | ||||||
"net" | ||||||
|
||||||
"github.com/Jigsaw-Code/outline-apps/client/go/outline/config" | ||||||
"github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" | ||||||
"github.com/Jigsaw-Code/outline-apps/client/go/outline/reporting" | ||||||
"github.com/Jigsaw-Code/outline-sdk/transport" | ||||||
"github.com/goccy/go-yaml" | ||||||
) | ||||||
|
@@ -30,8 +32,10 @@ import ( | |||||
// It's used by the connectivity test and the tun2socks handlers. | ||||||
// TODO: Rename to Transport. Needs to update per-platform code. | ||||||
type Client struct { | ||||||
sd *config.Dialer[transport.StreamConn] | ||||||
pl *config.PacketListener | ||||||
sd *config.Dialer[transport.StreamConn] | ||||||
pl *config.PacketListener | ||||||
ur *config.UsageReporter | ||||||
cancel context.CancelFunc // Used to stop reporting | ||||||
} | ||||||
|
||||||
func (c *Client) DialStream(ctx context.Context, address string) (transport.StreamConn, error) { | ||||||
|
@@ -55,18 +59,35 @@ type NewClientResult struct { | |||||
Error *platerrors.PlatformError | ||||||
} | ||||||
|
||||||
func (c *Client) StartReporting() { | ||||||
if c.ur == nil { | ||||||
return | ||||||
} | ||||||
ctx, cancel := context.WithCancel(context.Background()) | ||||||
c.cancel = cancel // Store the cancel function to stop reporting later | ||||||
|
||||||
go reporting.StartReporting(ctx, c, c.ur) | ||||||
} | ||||||
|
||||||
func (c *Client) StopReporting() { | ||||||
if c.cancel != nil { | ||||||
c.cancel() // Signal the context to stop reporting | ||||||
c.cancel = nil | ||||||
} | ||||||
} | ||||||
|
||||||
// NewClient creates a new Outline client from a configuration string. | ||||||
func NewClient(clientConfig string) *NewClientResult { | ||||||
func NewClient(clientConfig string, sessionConfig string) *NewClientResult { | ||||||
tcpDialer := transport.TCPDialer{Dialer: net.Dialer{KeepAlive: -1}} | ||||||
udpDialer := transport.UDPDialer{} | ||||||
client, err := NewClientWithBaseDialers(clientConfig, &tcpDialer, &udpDialer) | ||||||
client, err := NewClientWithBaseDialers(clientConfig, sessionConfig, &tcpDialer, &udpDialer) | ||||||
if err != nil { | ||||||
return &NewClientResult{Error: platerrors.ToPlatformError(err)} | ||||||
} | ||||||
return &NewClientResult{Client: client} | ||||||
} | ||||||
|
||||||
func NewClientWithBaseDialers(clientConfigText string, tcpDialer transport.StreamDialer, udpDialer transport.PacketDialer) (*Client, error) { | ||||||
func NewClientWithBaseDialers(clientConfigText string, sessionConfig string, tcpDialer transport.StreamDialer, udpDialer transport.PacketDialer) (*Client, error) { | ||||||
var clientConfig ClientConfig | ||||||
err := yaml.Unmarshal([]byte(clientConfigText), &clientConfig) | ||||||
if err != nil { | ||||||
|
@@ -108,5 +129,31 @@ func NewClientWithBaseDialers(clientConfigText string, tcpDialer transport.Strea | |||||
} | ||||||
} | ||||||
|
||||||
return &Client{sd: transportPair.StreamDialer, pl: transportPair.PacketListener}, nil | ||||||
usageReportYAML, err := config.ParseConfigYAML(sessionConfig) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the easiest is to move the parsing of the clientConfig to the config module, which already takes care of this. And it allows you to use first_supported |
||||||
if err != nil { | ||||||
return nil, &platerrors.PlatformError{ | ||||||
Code: platerrors.InvalidConfig, | ||||||
Message: "client config is not valid YAML", | ||||||
Cause: platerrors.ToPlatformError(err), | ||||||
} | ||||||
} | ||||||
usageReporter, err := config.NewUsageReportProvider().Parse(context.Background(), usageReportYAML) | ||||||
if err != nil { | ||||||
if errors.Is(err, errors.ErrUnsupported) { | ||||||
return nil, &platerrors.PlatformError{ | ||||||
Code: platerrors.InvalidConfig, | ||||||
Message: "unsupported client config", | ||||||
Cause: platerrors.ToPlatformError(err), | ||||||
} | ||||||
} else { | ||||||
return nil, &platerrors.PlatformError{ | ||||||
Code: platerrors.InvalidConfig, | ||||||
Message: "failed to create usage report", | ||||||
Cause: platerrors.ToPlatformError(err), | ||||||
} | ||||||
} | ||||||
} | ||||||
fmt.Println("usageReporter", usageReporter) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this for debugging purpose or logging purpose? |
||||||
|
||||||
return &Client{sd: transportPair.StreamDialer, pl: transportPair.PacketListener, ur: usageReporter}, nil | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -25,7 +25,7 @@ func Test_NewTransport_SS_URL(t *testing.T) { | |
config := "transport: ss://[email protected]:4321/" | ||
firstHop := "example.com:4321" | ||
|
||
result := NewClient(config) | ||
result := NewClient(config, "") | ||
require.Nil(t, result.Error, "Got %v", result.Error) | ||
require.Equal(t, firstHop, result.Client.sd.FirstHop) | ||
require.Equal(t, firstHop, result.Client.pl.FirstHop) | ||
|
@@ -41,7 +41,7 @@ transport: { | |
}` | ||
firstHop := "example.com:4321" | ||
|
||
result := NewClient(config) | ||
result := NewClient(config, "") | ||
require.Nil(t, result.Error, "Got %v", result.Error) | ||
require.Equal(t, firstHop, result.Client.sd.FirstHop) | ||
require.Equal(t, firstHop, result.Client.pl.FirstHop) | ||
|
@@ -58,7 +58,7 @@ transport: { | |
}` | ||
firstHop := "example.com:4321" | ||
|
||
result := NewClient(config) | ||
result := NewClient(config, "") | ||
require.Nil(t, result.Error, "Got %v", result.Error) | ||
require.Equal(t, firstHop, result.Client.sd.FirstHop) | ||
require.Equal(t, firstHop, result.Client.pl.FirstHop) | ||
|
@@ -74,7 +74,7 @@ transport: | |
password: SECRET` | ||
firstHop := "example.com:4321" | ||
|
||
result := NewClient(config) | ||
result := NewClient(config, "") | ||
require.Nil(t, result.Error, "Got %v", result.Error) | ||
require.Equal(t, firstHop, result.Client.sd.FirstHop) | ||
require.Equal(t, firstHop, result.Client.pl.FirstHop) | ||
|
@@ -90,7 +90,7 @@ transport: | |
secret: SECRET` | ||
firstHop := "example.com:4321" | ||
|
||
result := NewClient(config) | ||
result := NewClient(config, "") | ||
require.Nil(t, result.Error, "Got %v", result.Error) | ||
require.Equal(t, firstHop, result.Client.sd.FirstHop) | ||
require.Equal(t, firstHop, result.Client.pl.FirstHop) | ||
|
@@ -107,7 +107,7 @@ transport: | |
secret: SECRET` | ||
firstHop := "entry.example.com:4321" | ||
|
||
result := NewClient(config) | ||
result := NewClient(config, "") | ||
require.Nil(t, result.Error, "Got %v", result.Error) | ||
require.Equal(t, firstHop, result.Client.sd.FirstHop) | ||
require.Equal(t, firstHop, result.Client.pl.FirstHop) | ||
|
@@ -128,7 +128,7 @@ transport: | |
secret: EXIT_SECRET` | ||
firstHop := "entry.example.com:4321" | ||
|
||
result := NewClient(config) | ||
result := NewClient(config, "") | ||
require.Nil(t, result.Error, "Got %v", result.Error) | ||
require.Equal(t, firstHop, result.Client.sd.FirstHop) | ||
require.Equal(t, firstHop, result.Client.pl.FirstHop) | ||
|
@@ -150,12 +150,38 @@ transport: | |
cipher: chacha20-ietf-poly1305 | ||
secret: SECRET` | ||
|
||
result := NewClient(config) | ||
result := NewClient(config, "") | ||
require.Nil(t, result.Error, "Got %v", result.Error) | ||
require.Equal(t, "example.com:80", result.Client.sd.FirstHop) | ||
require.Equal(t, "example.com:53", result.Client.pl.FirstHop) | ||
} | ||
|
||
func Test_SessionReport(t *testing.T) { | ||
transportConfig := ` | ||
$type: tcpudp | ||
tcp: | ||
$type: shadowsocks | ||
endpoint: example.com:80 | ||
cipher: chacha20-ietf-poly1305 | ||
secret: SECRET | ||
prefix: "POST " | ||
udp: | ||
$type: shadowsocks | ||
endpoint: example.com:53 | ||
cipher: chacha20-ietf-poly1305 | ||
secret: SECRET` | ||
sessionConfig := ` | ||
$type: sessionreport | ||
url: https://your-callback-server.com/outline_callback | ||
interval: 24h | ||
enable_cookies: true` | ||
|
||
client := NewClient(transportConfig, sessionConfig) | ||
require.Nil(t, client.Error, "Got %v", client.Error) | ||
require.NotNil(t, client.Client.ur, "UsageReporter is nil") | ||
require.Equal(t, "https://your-callback-server.com/outline_callback", client.Client.ur.Url) | ||
} | ||
|
||
func Test_NewTransport_YAML_Reuse(t *testing.T) { | ||
config := ` | ||
transport: | ||
|
@@ -170,7 +196,7 @@ transport: | |
prefix: "POST "` | ||
firstHop := "example.com:4321" | ||
|
||
result := NewClient(config) | ||
result := NewClient(config, "") | ||
require.Nil(t, result.Error, "Got %v", result.Error) | ||
require.Equal(t, firstHop, result.Client.sd.FirstHop) | ||
require.Equal(t, firstHop, result.Client.pl.FirstHop) | ||
|
@@ -192,15 +218,15 @@ transport: | |
endpoint: example.com:53 | ||
<<: *cipher` | ||
|
||
result := NewClient(config) | ||
result := NewClient(config, "") | ||
require.Nil(t, result.Error, "Got %v", result.Error) | ||
require.Equal(t, "example.com:80", result.Client.sd.FirstHop) | ||
require.Equal(t, "example.com:53", result.Client.pl.FirstHop) | ||
} | ||
|
||
func Test_NewTransport_Unsupported(t *testing.T) { | ||
config := `transport: {$type: unsupported}` | ||
result := NewClient(config) | ||
result := NewClient(config, "") | ||
require.Error(t, result.Error, "Got %v", result.Error) | ||
require.Equal(t, "unsupported config", result.Error.Message) | ||
} | ||
|
@@ -223,7 +249,7 @@ transport: | |
url: https://entrypoint.cdn.example.com/udp` | ||
firstHop := "entrypoint.cdn.example.com:443" | ||
|
||
result := NewClient(config) | ||
result := NewClient(config, "") | ||
require.Nil(t, result.Error, "Got %v", result.Error) | ||
require.Equal(t, firstHop, result.Client.sd.FirstHop) | ||
require.Equal(t, firstHop, result.Client.pl.FirstHop) | ||
|
@@ -235,7 +261,7 @@ transport: | |
$type: tcpudp | ||
tcp: | ||
udp:` | ||
result := NewClient(config) | ||
result := NewClient(config, "") | ||
require.Error(t, result.Error, "Got %v", result.Error) | ||
perr := &platerrors.PlatformError{} | ||
require.ErrorAs(t, result.Error, &perr) | ||
|
@@ -295,7 +321,7 @@ func Test_NewClientFromJSON_Errors(t *testing.T) { | |
} | ||
for _, tt := range tests { | ||
t.Run(tt.name, func(t *testing.T) { | ||
got := NewClient(tt.input) | ||
got := NewClient(tt.input, "") | ||
if got.Error == nil || got.Client != nil { | ||
t.Errorf("NewClientFromJSON() expects an error, got = %v", got.Client) | ||
return | ||
|
62w71st marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
// Copyright 2025 The Outline Authors | ||
// | ||
// 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 config | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"time" | ||
) | ||
|
||
type UsageReporter struct { | ||
Interval time.Duration | ||
Url string | ||
EnableCookies bool | ||
} | ||
|
||
// UsageReporterConfig is the format for the Usage Reporter config. | ||
type UsageReporterConfig struct { | ||
Interval string | ||
Url string | ||
EnableCookies bool `json:"enable_cookies"` | ||
} | ||
|
||
func parseUsageReporterConfig(ctx context.Context, configMap map[string]any) (*UsageReporter, error) { | ||
var config UsageReporterConfig | ||
if err := mapToAny(configMap, &config); err != nil { | ||
return nil, fmt.Errorf("invalid config format: %w", err) | ||
} | ||
|
||
duration, err := time.ParseDuration(config.Interval) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to parse interval: %w", err) | ||
} | ||
|
||
return &UsageReporter{ | ||
Interval: duration, | ||
Url: config.Url, | ||
EnableCookies: config.EnableCookies, | ||
}, nil | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -142,3 +142,19 @@ func NewDefaultTransportProvider(tcpDialer transport.StreamDialer, udpDialer tra | |
|
||
return transports | ||
} | ||
|
||
func NewUsageReportProvider() *TypeParser[*UsageReporter] { | ||
usageReporting := NewTypeParser(func(ctx context.Context, input ConfigNode) (*UsageReporter, error) { | ||
switch input.(type) { | ||
// An absent config is acceptable. | ||
case nil: | ||
return nil, nil | ||
default: | ||
return nil, errors.New("parser not specified") | ||
} | ||
Comment on lines
+148
to
+154
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What if we simplify |
||
}) | ||
|
||
usageReporting.RegisterSubParser("sessionreport", parseUsageReporterConfig) | ||
|
||
return usageReporting | ||
} |
Uh oh!
There was an error while loading. Please reload this page.