Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions client/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,26 @@ transport:
secret: SS_SECRET
```

In case the count of users is needed to have an exact estimate of the sessions, a reporting server can be used for that. This feature is currently experimental.

```yaml
transport:
$type: tcpudp
tcp:
<<: &shared
$type: shadowsocks
endpoint: ss.example.com:4321
cipher: chacha20-ietf-poly1305
secret: SECRET
prefix: "POST "
udp: *shared

reporter:
$type: http
url: https://your-callback-server.com/outline_callback
interval: 24h
```

## Tunnels

### <a id=TunnelConfig></a>TunnelConfig
Expand Down
6 changes: 6 additions & 0 deletions client/go/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ tasks:
TARGET_DIR: "{{.OUT_DIR}}/android/org/getoutline/client/tun2socks/0.0.1"
# ANDROID_API must match the minSdkVersion that the Android client supports.
ANDROID_API: 26
env:
# Bug workaround as per https://github.com/golang/go/issues/71827#issuecomment-2669425491
GODEBUG: gotypesalias=0
preconditions:
- sh: '[[ -d "$ANDROID_HOME" ]]'
msg: "Must set ANDROID_HOME"
Expand All @@ -104,6 +107,9 @@ tasks:
MACOSX_DEPLOYMENT_TARGET: 12.0
# TARGET_IOS_VERSION must be at least 13.1 for macCatalyst and match the version set in the XCode project.
TARGET_IOS_VERSION: 15.5
env:
# Bug workaround as per https://github.com/golang/go/issues/71827#issuecomment-2669425491
GODEBUG: gotypesalias=0
cmds:
- rm -rf "{{.TARGET_DIR}}" && mkdir -p "{{.TARGET_DIR}}"
- export MACOSX_DEPLOYMENT_TARGET={{.MACOSX_DEPLOYMENT_TARGET}}; {{.GOMOBILE_BIND_CMD}} -target=ios,iossimulator,maccatalyst -iosversion={{.TARGET_IOS_VERSION}} -bundleid org.outline.tun2socks -o '{{.TARGET_DIR}}/Tun2socks.xcframework' '{{.TASKFILE_DIR}}/outline/platerrors' '{{.TASKFILE_DIR}}/outline/tun2socks' '{{.TASKFILE_DIR}}/outline'
Expand Down
50 changes: 47 additions & 3 deletions client/go/outline/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@ package outline
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"strings"

"github.com/Jigsaw-Code/outline-apps/client/go/configyaml"
"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"
)
Expand All @@ -36,8 +40,10 @@ import (
// to handle that.
// - Refactor so that StartSession returns a Client
type Client struct {
sd *config.Dialer[transport.StreamConn]
pl *config.PacketListener
sd *config.Dialer[transport.StreamConn]
pl *config.PacketListener
reporter reporting.Reporter
sessionCancel context.CancelFunc
}

func (c *Client) DialStream(ctx context.Context, address string) (transport.StreamConn, error) {
Expand All @@ -50,17 +56,22 @@ func (c *Client) ListenPacket(ctx context.Context) (net.PacketConn, error) {

func (c *Client) StartSession() error {
slog.Debug("Starting session")
var sessionCtx context.Context
sessionCtx, c.sessionCancel = context.WithCancel(context.Background())
go c.reporter.Run(sessionCtx)
return nil
}

func (c *Client) EndSession() error {
slog.Debug("Ending session")
c.sessionCancel()
return nil
}

// ClientConfig is used to create the Client.
type ClientConfig struct {
Transport configyaml.ConfigNode
Reporter configyaml.ConfigNode
}

// NewClientResult represents the result of [NewClientAndReturnError].
Expand Down Expand Up @@ -124,5 +135,38 @@ func NewClientWithBaseDialers(clientConfigText string, tcpDialer transport.Strea
}
}

return &Client{sd: transportPair.StreamDialer, pl: transportPair.PacketListener}, nil
client := &Client{sd: transportPair.StreamDialer, pl: transportPair.PacketListener}
if clientConfig.Reporter != nil {
httpClient := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
if strings.HasPrefix(network, "tcp") {
return client.DialStream(ctx, addr)
} else {
return nil, fmt.Errorf("protocol not supported: %v", network)
}
},
},
}
reporter, err := NewReporterParser(httpClient).Parse(context.Background(), clientConfig.Reporter)
if err != nil {
return nil, &platerrors.PlatformError{
Code: platerrors.InvalidConfig,
Message: "invalid reporter config",
Cause: platerrors.ToPlatformError(err),
}
}
client.reporter = reporter
}

return client, nil
}

func NewReporterParser(httpClient *http.Client) *configyaml.TypeParser[reporting.Reporter] {
parser := configyaml.NewTypeParser(func(ctx context.Context, input configyaml.ConfigNode) (reporting.Reporter, error) {
return nil, errors.New("parser not specified")
})
parser.RegisterSubParser("first-supported", config.NewFirstSupportedSubParser(parser.Parse))
parser.RegisterSubParser("http", reporting.NewHTTPReporterSubParser(httpClient))
return parser
}
63 changes: 63 additions & 0 deletions client/go/outline/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@
package outline

import (
"context"
"net/http"
"testing"
"time"

"github.com/Jigsaw-Code/outline-apps/client/go/configyaml"
"github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors"
"github.com/Jigsaw-Code/outline-apps/client/go/outline/reporting"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -303,3 +308,61 @@ func Test_NewClientFromJSON_Errors(t *testing.T) {
})
}
}

func Test_UsageReporting(t *testing.T) {
config := `
transport:
$type: tcpudp
tcp:
$type: shadowsocks
endpoint: example.com:80
<<: &cipher
cipher: chacha20-ietf-poly1305
secret: SECRET
prefix: "POST "
udp:
$type: shadowsocks
endpoint: example.com:53
<<: *cipher
reporter:
$type: http
url: https://your-callback-server.com/outline_callback
interval: 24h`

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)
require.NotNil(t, result.Client.reporter, "Reporter is nil")
require.Equal(t, "https://your-callback-server.com/outline_callback", result.Client.reporter.(*reporting.HTTPReporter).URL.String())
require.Equal(t, 24*time.Hour, result.Client.reporter.(*reporting.HTTPReporter).Interval)
}

func Test_ParseReporter(t *testing.T) {
config := `
$type: http
url: https://your-callback-server.com/outline_callback
interval: 24h`
yamlNode, err := configyaml.ParseConfigYAML(config)
require.NoError(t, err)
httpClient := &http.Client{}
reporter, err := NewReporterParser(httpClient).Parse(context.Background(), yamlNode)
require.NoError(t, err)
require.NotNil(t, reporter)
require.Equal(t, "https://your-callback-server.com/outline_callback", reporter.(*reporting.HTTPReporter).URL.String())
require.Equal(t, 24*time.Hour, reporter.(*reporting.HTTPReporter).Interval)
require.Equal(t, httpClient, reporter.(*reporting.HTTPReporter).HttpClient)
}

func Test_ParseReporter_NoInterval(t *testing.T) {
config := `
$type: http
url: https://your-callback-server.com/outline_callback`
yamlNode, err := configyaml.ParseConfigYAML(config)
require.NoError(t, err)
reporter, err := NewReporterParser(http.DefaultClient).Parse(context.Background(), yamlNode)
require.NoError(t, err)
require.NotNil(t, reporter)
require.Equal(t, "https://your-callback-server.com/outline_callback", reporter.(*reporting.HTTPReporter).URL.String())
require.Equal(t, time.Duration(0), reporter.(*reporting.HTTPReporter).Interval)
}
4 changes: 2 additions & 2 deletions client/go/outline/config/config_websocket.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ import (
"net"
"net/http"
"net/url"
"runtime"

"github.com/Jigsaw-Code/outline-apps/client/go/configyaml"
"github.com/Jigsaw-Code/outline-apps/client/go/outline/useragent"
"github.com/Jigsaw-Code/outline-sdk/transport"
"github.com/Jigsaw-Code/outline-sdk/x/websocket"
)
Expand Down Expand Up @@ -77,7 +77,7 @@ func parseWebsocketEndpoint[ConnType any](ctx context.Context, configMap map[str
}

headers := http.Header(map[string][]string{
"User-Agent": {fmt.Sprintf("Outline (%s; %s; %s)", runtime.GOOS, runtime.GOARCH, runtime.Version())},
"User-Agent": {useragent.GetOutlineUserAgent()},
})
connect, err := newWE(url.String(), transport.FuncStreamEndpoint(se.Connect), websocket.WithHTTPHeaders(headers))
if err != nil {
Expand Down
59 changes: 59 additions & 0 deletions client/go/outline/reporting/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// 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 reporting

import (
"context"
"fmt"
"net/http"
"net/url"
"time"

"github.com/Jigsaw-Code/outline-apps/client/go/configyaml"
)

// HTTPReporterConfig is the format for the HTTPReporter config.
type HTTPReporterConfig struct {
URL string
Interval string
}

func NewHTTPReporterSubParser(httpClient *http.Client) func(ctx context.Context, input map[string]any) (Reporter, error) {
return func(ctx context.Context, input map[string]any) (Reporter, error) {
var config HTTPReporterConfig
if err := configyaml.MapToAny(input, &config); err != nil {
return nil, fmt.Errorf("invalid config format: %w", err)
}

collectorURL, err := url.Parse(config.URL)
if err != nil {
return nil, fmt.Errorf("failed to parse the report collector URL: %w", err)
}
reporter := &HTTPReporter{URL: *collectorURL, HttpClient: httpClient}

if config.Interval != "" {
interval, err := time.ParseDuration(config.Interval)
if err != nil {
return nil, fmt.Errorf("failed to parse interval: %w", err)
}
if interval < 1*time.Hour {
return nil, fmt.Errorf("interval must be at least 1h")
}
reporter.Interval = interval
}

return reporter, nil
}
}
87 changes: 87 additions & 0 deletions client/go/outline/reporting/reporter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// 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 reporting

import (
"context"
"fmt"
"log/slog"
"net/http"
"net/url"
"time"

"github.com/Jigsaw-Code/outline-apps/client/go/outline/useragent"
)

// Reporter is used to register reports.
type Reporter interface {
// Run blocks until the session context is done.
Run(sessionCtx context.Context)
}

type HTTPReporter struct {
URL url.URL
Interval time.Duration
HttpClient *http.Client
}

func (r *HTTPReporter) Run(sessionCtx context.Context) {
r.reportAndLogError()
if r.Interval == 0 {
return
}
// Only run the loop if we specified an interval.
ticker := time.NewTicker(r.Interval)
defer ticker.Stop()
for {
select {
case <-sessionCtx.Done():
return
case _, ok := <-ticker.C:
if !ok {
return
}
r.reportAndLogError()
}
}
}

func (r *HTTPReporter) reportAndLogError() {
slog.Debug("Sending report", "url", r.URL)
err := r.Report()
if err != nil {
slog.Warn("Failed to report", "err", err)
}
}

func (r *HTTPReporter) Report() error {
req, err := http.NewRequest("POST", r.URL.String(), nil)
if err != nil {
return fmt.Errorf("failed to create report HTTP request: %w", err)
}
req.Close = true
// TODO: Add Outline Client version.
req.Header.Add("User-Agent", useragent.GetOutlineUserAgent())

resp, err := r.HttpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send report: %w", err)
}
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("report failed with status code %d", resp.StatusCode)
}
return nil
}
Loading
Loading