Skip to content

Commit 9992735

Browse files
authored
refactor: move to a new "Service" config format (#182)
* Create a new config format so we can expand listener configuration for proxy protocol. * Remove unused `fakeAddr`. * Split `startPort` up between TCP and UDP. * Use listeners to configure TCP and/or UDP services as needed. * Remove commented out line. * Use `ElementsMatch` to compare the services irrespective of element ordering. * Do not ignore the `keys` field if `services` is used as well. * Add some more tests for failure scenarios and empty files. * Remove unused `GetPort()`. * Move `ResolveAddr` to config.go. * Remove use of `net.Addr` type. * Pull listener creation into its own function. * Move listener validation/creation to `config.go`. * Use a custom type for listener type. * Fix accept handler. * Add doc comment. * Fix tests still supplying the port. * Move old config parsing to `loadConfig`. * Lowercase `readConfig`. * Use `Config` suffix for config types. * Remove the IP version specifiers from the `newListener` config handling. * refactor: remove use of port in proving metric * Fix tests. * Add a TODO comment to allow short-form direct listener config. * Make legacy key config name consistent with type. * Move config validation out of the `loadConfig` function. * Remove unused port from bad merge. * Add comment describing keys. * Move validation of listeners to config's `Validate()` function. * Introduce a `NetworkAdd` to centralize parsing and creation of listeners. * Use `net.ListenConfig` to listen. * Simplify how we create new listeners. This does not yet deal with reused sockets. * Do not use `io.Closer`. * Use an inline error check. * Use shared listeners and packet connections. This allows us to reload a config while the existing one is still running. They share the same underlying listener, which is actually closed when the last user closes it. * Close existing listeners once the new ones are serving. * Elevate failure to stop listeners to `ERROR` level. * Be more lenient in config validation to allow empty listeners or keys. * Ensure the address is an IP address. * Use `yaml.v3`. * Move file reading back to `main.go`. * Do not embed the `net.Listener` type. * Use a `Service` object to abstract away some of the complex logic of managing listeners. * Fix how we deal with legacy services. * Remove commented out lines. * Use `tcp` and `udp` types for direct listeners. * Use a `ListenerManager` instead of globals to manage listener state. * Add validation check that no two services have the same listener. * Use channels to notify shared listeners they need to stop acceoting. * Pass TCP timeout to service. * Move go routine call up. * Allow inserting single elements directly into the cipher list. * Add the concept of a listener set to track existing listeners and close them all. * Refactor how we create listeners. We introduce shared listeners that allow us to keep an old config running while we set up a new config. This is done by keeping track of the usage of the listeners and only closing them when the last user is done with the shared listener. * Update comments. * `go mod tidy`. * refactor: don't link the TCP handler to a specific listener * Protect new cipher handling methods with mutex. * Move `listeners.go` under `/service`. * Use callback instead of passing in key and manager. * Move config start into a go routine for easier cleanup. * Make a `StreamListener` type. * Rename `closeFunc` to `onCloseFunc`. * Rename `globalListener`. * Don't track usage in the shared listeners. * Add `getAddr()` to avoid some duplicate code. * Move listener set creation out of the inner function. * Remove `PushBack()` from `CipherList`. * Move listener set to `main.go`. * Close the accept channel with an atomic value. * Update comment. * Address review comments. * Close before deleting key. * `server.Stop()` does not return a value * Add a comment for `StreamListener`. * Do not delete the listener from the manager until the last user has closed it. * Consolidate usage counting inside a `listenAddress` type. * Remove `atomic.Value`. * Add some missing comments. * address review comments * Add type guard for `sharedListener`. * Stop the existing config in a goroutine. * Add a TODO to wait for all handlers to be stopped. * Run `stopConfig` in a goroutine in `Stop()` as well. * Create a `TCPListener` that implements a `StreamListener`. * Track close functions instead of the entire listener, which is not needed. * Delegate usage tracking to a reference counter. * Remove the `Get()` method from `refCount`. * Return immediately. * Rename `shared` to `virtual` as they are not actually shared. * Simplify `listenAddr`. * Fix use of the ref count. * Add simple test case for early closing of stream listener. * Add tests for creating stream listeners. * Create handlers on demand. * Refactor create methods. * Address review comments. * Use a mutex to ensure another user doesn't acquire a new closer while we're closing it. * Move mutex up. * Manage the ref counting next to the listener creation. * Do the lazy initialization inside an anonymous function. * Fix concurrent access to `acceptCh` and `closeCh`. * Use `/` in key instead of `-`. * Return error from stopping listeners. * Use channels to ensure `virtualPacketConn`s get closed. * Add more test cases for packet listeners. * Only log errors from stopping old configs. * Remove the `closed` field from the virtual listeners. * Remove the `RefCount`. * Implement channel-based packet read for virtual connections. * Use a done channel. * Set listeners and `onCloseFunc`'s to nil when closing. * Set `onCloseFunc`'s to nil when closing. * Fix race condition. * Add some benchmarks for listener manager. * Add license header.
1 parent 55e8d0c commit 9992735

File tree

6 files changed

+412
-44
lines changed

6 files changed

+412
-44
lines changed

cmd/outline-ss-server/config.go

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright 2024 Jigsaw Operations LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"fmt"
19+
"net"
20+
21+
"gopkg.in/yaml.v3"
22+
)
23+
24+
type ServiceConfig struct {
25+
Listeners []ListenerConfig
26+
Keys []KeyConfig
27+
}
28+
29+
type ListenerType string
30+
31+
const listenerTypeTCP ListenerType = "tcp"
32+
const listenerTypeUDP ListenerType = "udp"
33+
34+
type ListenerConfig struct {
35+
Type ListenerType
36+
Address string
37+
}
38+
39+
type KeyConfig struct {
40+
ID string
41+
Cipher string
42+
Secret string
43+
}
44+
45+
type LegacyKeyServiceConfig struct {
46+
KeyConfig `yaml:",inline"`
47+
Port int
48+
}
49+
50+
type Config struct {
51+
Services []ServiceConfig
52+
53+
// Deprecated: `keys` exists for backward compatibility. Prefer to configure
54+
// using the newer `services` format.
55+
Keys []LegacyKeyServiceConfig
56+
}
57+
58+
// Validate checks that the config is valid.
59+
func (c *Config) Validate() error {
60+
existingListeners := make(map[string]bool)
61+
for _, serviceConfig := range c.Services {
62+
for _, lnConfig := range serviceConfig.Listeners {
63+
// TODO: Support more listener types.
64+
if lnConfig.Type != listenerTypeTCP && lnConfig.Type != listenerTypeUDP {
65+
return fmt.Errorf("unsupported listener type: %s", lnConfig.Type)
66+
}
67+
host, _, err := net.SplitHostPort(lnConfig.Address)
68+
if err != nil {
69+
return fmt.Errorf("invalid listener address `%s`: %v", lnConfig.Address, err)
70+
}
71+
if ip := net.ParseIP(host); ip == nil {
72+
return fmt.Errorf("address must be IP, found: %s", host)
73+
}
74+
key := string(lnConfig.Type) + "/" + lnConfig.Address
75+
if _, exists := existingListeners[key]; exists {
76+
return fmt.Errorf("listener of type %s with address %s already exists.", lnConfig.Type, lnConfig.Address)
77+
}
78+
existingListeners[key] = true
79+
}
80+
}
81+
return nil
82+
}
83+
84+
// readConfig attempts to read a config from a filename and parses it as a [Config].
85+
func readConfig(configData []byte) (*Config, error) {
86+
config := Config{}
87+
if err := yaml.Unmarshal(configData, &config); err != nil {
88+
return nil, fmt.Errorf("failed to parse config: %w", err)
89+
}
90+
return &config, nil
91+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Copyright 2024 The Outline Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
keys:
16+
- id: user-0
17+
port: 9000
18+
cipher: chacha20-ietf-poly1305
19+
secret: Secret0
20+
21+
- id: user-1
22+
port: 9000
23+
cipher: chacha20-ietf-poly1305
24+
secret: Secret1
25+
26+
- id: user-2
27+
port: 9001
28+
cipher: chacha20-ietf-poly1305
29+
secret: Secret2

cmd/outline-ss-server/config_example.yml

+24-14
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,28 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
keys:
16-
- id: user-0
17-
port: 9000
18-
cipher: chacha20-ietf-poly1305
19-
secret: Secret0
15+
services:
16+
- listeners:
17+
# TODO(sbruens): Allow a string-based listener config, as a convenient short-form
18+
# to create a direct listener, e.g. `- tcp/[::]:9000`.
19+
- type: tcp
20+
address: "[::]:9000"
21+
- type: udp
22+
address: "[::]:9000"
23+
keys:
24+
- id: user-0
25+
cipher: chacha20-ietf-poly1305
26+
secret: Secret0
27+
- id: user-1
28+
cipher: chacha20-ietf-poly1305
29+
secret: Secret1
2030

21-
- id: user-1
22-
port: 9000
23-
cipher: chacha20-ietf-poly1305
24-
secret: Secret1
25-
26-
- id: user-2
27-
port: 9001
28-
cipher: chacha20-ietf-poly1305
29-
secret: Secret2
31+
- listeners:
32+
- type: tcp
33+
address: "[::]:9001"
34+
- type: udp
35+
address: "[::]:9001"
36+
keys:
37+
- id: user-2
38+
cipher: chacha20-ietf-poly1305
39+
secret: Secret2

cmd/outline-ss-server/config_test.go

+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright 2024 Jigsaw Operations LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"os"
19+
"testing"
20+
21+
"github.com/stretchr/testify/require"
22+
)
23+
24+
func TestValidateConfigFails(t *testing.T) {
25+
tests := []struct {
26+
name string
27+
cfg *Config
28+
}{
29+
{
30+
name: "WithUnknownListenerType",
31+
cfg: &Config{
32+
Services: []ServiceConfig{
33+
ServiceConfig{
34+
Listeners: []ListenerConfig{
35+
ListenerConfig{Type: "foo", Address: "[::]:9000"},
36+
},
37+
},
38+
},
39+
},
40+
},
41+
{
42+
name: "WithInvalidListenerAddress",
43+
cfg: &Config{
44+
Services: []ServiceConfig{
45+
ServiceConfig{
46+
Listeners: []ListenerConfig{
47+
ListenerConfig{Type: listenerTypeTCP, Address: "tcp/[::]:9000"},
48+
},
49+
},
50+
},
51+
},
52+
},
53+
{
54+
name: "WithHostnameAddress",
55+
cfg: &Config{
56+
Services: []ServiceConfig{
57+
ServiceConfig{
58+
Listeners: []ListenerConfig{
59+
ListenerConfig{Type: listenerTypeTCP, Address: "example.com:9000"},
60+
},
61+
},
62+
},
63+
},
64+
},
65+
{
66+
name: "WithDuplicateListeners",
67+
cfg: &Config{
68+
Services: []ServiceConfig{
69+
ServiceConfig{
70+
Listeners: []ListenerConfig{
71+
ListenerConfig{Type: listenerTypeTCP, Address: "[::]:9000"},
72+
},
73+
},
74+
ServiceConfig{
75+
Listeners: []ListenerConfig{
76+
ListenerConfig{Type: listenerTypeTCP, Address: "[::]:9000"},
77+
},
78+
},
79+
},
80+
},
81+
},
82+
}
83+
84+
for _, tc := range tests {
85+
t.Run(tc.name, func(t *testing.T) {
86+
err := tc.cfg.Validate()
87+
require.Error(t, err)
88+
})
89+
}
90+
}
91+
92+
func TestReadConfig(t *testing.T) {
93+
config, err := readConfigFile("./config_example.yml")
94+
95+
require.NoError(t, err)
96+
expected := Config{
97+
Services: []ServiceConfig{
98+
ServiceConfig{
99+
Listeners: []ListenerConfig{
100+
ListenerConfig{Type: listenerTypeTCP, Address: "[::]:9000"},
101+
ListenerConfig{Type: listenerTypeUDP, Address: "[::]:9000"},
102+
},
103+
Keys: []KeyConfig{
104+
KeyConfig{"user-0", "chacha20-ietf-poly1305", "Secret0"},
105+
KeyConfig{"user-1", "chacha20-ietf-poly1305", "Secret1"},
106+
},
107+
},
108+
ServiceConfig{
109+
Listeners: []ListenerConfig{
110+
ListenerConfig{Type: listenerTypeTCP, Address: "[::]:9001"},
111+
ListenerConfig{Type: listenerTypeUDP, Address: "[::]:9001"},
112+
},
113+
Keys: []KeyConfig{
114+
KeyConfig{"user-2", "chacha20-ietf-poly1305", "Secret2"},
115+
},
116+
},
117+
},
118+
}
119+
require.Equal(t, expected, *config)
120+
}
121+
122+
func TestReadConfigParsesDeprecatedFormat(t *testing.T) {
123+
config, err := readConfigFile("./config_example.deprecated.yml")
124+
125+
require.NoError(t, err)
126+
expected := Config{
127+
Keys: []LegacyKeyServiceConfig{
128+
LegacyKeyServiceConfig{
129+
KeyConfig: KeyConfig{ID: "user-0", Cipher: "chacha20-ietf-poly1305", Secret: "Secret0"},
130+
Port: 9000,
131+
},
132+
LegacyKeyServiceConfig{
133+
KeyConfig: KeyConfig{ID: "user-1", Cipher: "chacha20-ietf-poly1305", Secret: "Secret1"},
134+
Port: 9000,
135+
},
136+
LegacyKeyServiceConfig{
137+
KeyConfig: KeyConfig{ID: "user-2", Cipher: "chacha20-ietf-poly1305", Secret: "Secret2"},
138+
Port: 9001,
139+
},
140+
},
141+
}
142+
require.Equal(t, expected, *config)
143+
}
144+
145+
func TestReadConfigFromEmptyFile(t *testing.T) {
146+
file, _ := os.CreateTemp("", "empty.yaml")
147+
148+
config, err := readConfigFile(file.Name())
149+
150+
require.NoError(t, err)
151+
require.ElementsMatch(t, Config{}, config)
152+
}
153+
154+
func TestReadConfigFromIncorrectFormatFails(t *testing.T) {
155+
file, _ := os.CreateTemp("", "empty.yaml")
156+
file.WriteString("foo")
157+
158+
config, err := readConfigFile(file.Name())
159+
160+
require.Error(t, err)
161+
require.ElementsMatch(t, Config{}, config)
162+
}
163+
164+
func readConfigFile(filename string) (*Config, error) {
165+
configData, _ := os.ReadFile(filename)
166+
return readConfig(configData)
167+
}

0 commit comments

Comments
 (0)