Skip to content

Commit

Permalink
refactor: move to a new "Service" config format (#182)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
sbruens authored Sep 4, 2024
1 parent 55e8d0c commit 9992735
Show file tree
Hide file tree
Showing 6 changed files with 412 additions and 44 deletions.
91 changes: 91 additions & 0 deletions cmd/outline-ss-server/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright 2024 Jigsaw Operations LLC
//
// 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
//
// https://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 main

import (
"fmt"
"net"

"gopkg.in/yaml.v3"
)

type ServiceConfig struct {
Listeners []ListenerConfig
Keys []KeyConfig
}

type ListenerType string

const listenerTypeTCP ListenerType = "tcp"
const listenerTypeUDP ListenerType = "udp"

type ListenerConfig struct {
Type ListenerType
Address string
}

type KeyConfig struct {
ID string
Cipher string
Secret string
}

type LegacyKeyServiceConfig struct {
KeyConfig `yaml:",inline"`
Port int
}

type Config struct {
Services []ServiceConfig

// Deprecated: `keys` exists for backward compatibility. Prefer to configure
// using the newer `services` format.
Keys []LegacyKeyServiceConfig
}

// Validate checks that the config is valid.
func (c *Config) Validate() error {
existingListeners := make(map[string]bool)
for _, serviceConfig := range c.Services {
for _, lnConfig := range serviceConfig.Listeners {
// TODO: Support more listener types.
if lnConfig.Type != listenerTypeTCP && lnConfig.Type != listenerTypeUDP {
return fmt.Errorf("unsupported listener type: %s", lnConfig.Type)
}
host, _, err := net.SplitHostPort(lnConfig.Address)
if err != nil {
return fmt.Errorf("invalid listener address `%s`: %v", lnConfig.Address, err)
}
if ip := net.ParseIP(host); ip == nil {
return fmt.Errorf("address must be IP, found: %s", host)
}
key := string(lnConfig.Type) + "/" + lnConfig.Address
if _, exists := existingListeners[key]; exists {
return fmt.Errorf("listener of type %s with address %s already exists.", lnConfig.Type, lnConfig.Address)
}
existingListeners[key] = true
}
}
return nil
}

// readConfig attempts to read a config from a filename and parses it as a [Config].
func readConfig(configData []byte) (*Config, error) {
config := Config{}
if err := yaml.Unmarshal(configData, &config); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
return &config, nil
}
29 changes: 29 additions & 0 deletions cmd/outline-ss-server/config_example.deprecated.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright 2024 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.

keys:
- id: user-0
port: 9000
cipher: chacha20-ietf-poly1305
secret: Secret0

- id: user-1
port: 9000
cipher: chacha20-ietf-poly1305
secret: Secret1

- id: user-2
port: 9001
cipher: chacha20-ietf-poly1305
secret: Secret2
38 changes: 24 additions & 14 deletions cmd/outline-ss-server/config_example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,28 @@
# See the License for the specific language governing permissions and
# limitations under the License.

keys:
- id: user-0
port: 9000
cipher: chacha20-ietf-poly1305
secret: Secret0
services:
- listeners:
# TODO(sbruens): Allow a string-based listener config, as a convenient short-form
# to create a direct listener, e.g. `- tcp/[::]:9000`.
- type: tcp
address: "[::]:9000"
- type: udp
address: "[::]:9000"
keys:
- id: user-0
cipher: chacha20-ietf-poly1305
secret: Secret0
- id: user-1
cipher: chacha20-ietf-poly1305
secret: Secret1

- id: user-1
port: 9000
cipher: chacha20-ietf-poly1305
secret: Secret1

- id: user-2
port: 9001
cipher: chacha20-ietf-poly1305
secret: Secret2
- listeners:
- type: tcp
address: "[::]:9001"
- type: udp
address: "[::]:9001"
keys:
- id: user-2
cipher: chacha20-ietf-poly1305
secret: Secret2
167 changes: 167 additions & 0 deletions cmd/outline-ss-server/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// Copyright 2024 Jigsaw Operations LLC
//
// 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
//
// https://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 main

import (
"os"
"testing"

"github.com/stretchr/testify/require"
)

func TestValidateConfigFails(t *testing.T) {
tests := []struct {
name string
cfg *Config
}{
{
name: "WithUnknownListenerType",
cfg: &Config{
Services: []ServiceConfig{
ServiceConfig{
Listeners: []ListenerConfig{
ListenerConfig{Type: "foo", Address: "[::]:9000"},
},
},
},
},
},
{
name: "WithInvalidListenerAddress",
cfg: &Config{
Services: []ServiceConfig{
ServiceConfig{
Listeners: []ListenerConfig{
ListenerConfig{Type: listenerTypeTCP, Address: "tcp/[::]:9000"},
},
},
},
},
},
{
name: "WithHostnameAddress",
cfg: &Config{
Services: []ServiceConfig{
ServiceConfig{
Listeners: []ListenerConfig{
ListenerConfig{Type: listenerTypeTCP, Address: "example.com:9000"},
},
},
},
},
},
{
name: "WithDuplicateListeners",
cfg: &Config{
Services: []ServiceConfig{
ServiceConfig{
Listeners: []ListenerConfig{
ListenerConfig{Type: listenerTypeTCP, Address: "[::]:9000"},
},
},
ServiceConfig{
Listeners: []ListenerConfig{
ListenerConfig{Type: listenerTypeTCP, Address: "[::]:9000"},
},
},
},
},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.cfg.Validate()
require.Error(t, err)
})
}
}

func TestReadConfig(t *testing.T) {
config, err := readConfigFile("./config_example.yml")

require.NoError(t, err)
expected := Config{
Services: []ServiceConfig{
ServiceConfig{
Listeners: []ListenerConfig{
ListenerConfig{Type: listenerTypeTCP, Address: "[::]:9000"},
ListenerConfig{Type: listenerTypeUDP, Address: "[::]:9000"},
},
Keys: []KeyConfig{
KeyConfig{"user-0", "chacha20-ietf-poly1305", "Secret0"},
KeyConfig{"user-1", "chacha20-ietf-poly1305", "Secret1"},
},
},
ServiceConfig{
Listeners: []ListenerConfig{
ListenerConfig{Type: listenerTypeTCP, Address: "[::]:9001"},
ListenerConfig{Type: listenerTypeUDP, Address: "[::]:9001"},
},
Keys: []KeyConfig{
KeyConfig{"user-2", "chacha20-ietf-poly1305", "Secret2"},
},
},
},
}
require.Equal(t, expected, *config)
}

func TestReadConfigParsesDeprecatedFormat(t *testing.T) {
config, err := readConfigFile("./config_example.deprecated.yml")

require.NoError(t, err)
expected := Config{
Keys: []LegacyKeyServiceConfig{
LegacyKeyServiceConfig{
KeyConfig: KeyConfig{ID: "user-0", Cipher: "chacha20-ietf-poly1305", Secret: "Secret0"},
Port: 9000,
},
LegacyKeyServiceConfig{
KeyConfig: KeyConfig{ID: "user-1", Cipher: "chacha20-ietf-poly1305", Secret: "Secret1"},
Port: 9000,
},
LegacyKeyServiceConfig{
KeyConfig: KeyConfig{ID: "user-2", Cipher: "chacha20-ietf-poly1305", Secret: "Secret2"},
Port: 9001,
},
},
}
require.Equal(t, expected, *config)
}

func TestReadConfigFromEmptyFile(t *testing.T) {
file, _ := os.CreateTemp("", "empty.yaml")

config, err := readConfigFile(file.Name())

require.NoError(t, err)
require.ElementsMatch(t, Config{}, config)
}

func TestReadConfigFromIncorrectFormatFails(t *testing.T) {
file, _ := os.CreateTemp("", "empty.yaml")
file.WriteString("foo")

config, err := readConfigFile(file.Name())

require.Error(t, err)
require.ElementsMatch(t, Config{}, config)
}

func readConfigFile(filename string) (*Config, error) {
configData, _ := os.ReadFile(filename)
return readConfig(configData)
}
Loading

0 comments on commit 9992735

Please sign in to comment.