2017-06-16 10:52:03 -05:00
|
|
|
// Copyright 2017 Vector Creations Ltd
|
|
|
|
//
|
|
|
|
// 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 (
|
|
|
|
"bytes"
|
|
|
|
"crypto/sha256"
|
|
|
|
"encoding/pem"
|
|
|
|
"fmt"
|
|
|
|
"io/ioutil"
|
|
|
|
"path/filepath"
|
|
|
|
"strings"
|
|
|
|
"time"
|
2017-07-18 07:40:03 -05:00
|
|
|
|
|
|
|
"github.com/matrix-org/gomatrixserverlib"
|
|
|
|
"golang.org/x/crypto/ed25519"
|
|
|
|
"gopkg.in/yaml.v2"
|
2017-06-16 10:52:03 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
// Version is the current version of the config format.
|
|
|
|
// This will change whenever we make breaking changes to the config format.
|
2017-07-05 07:10:28 -05:00
|
|
|
const Version = 0
|
2017-06-16 10:52:03 -05:00
|
|
|
|
|
|
|
// Dendrite contains all the config used by a dendrite process.
|
|
|
|
// Relative paths are resolved relative to the current working directory
|
|
|
|
type Dendrite struct {
|
|
|
|
// The version of the configuration file.
|
|
|
|
// If the version in a file doesn't match the current dendrite config
|
|
|
|
// version then we can give a clear error message telling the user
|
|
|
|
// to update their config file to the current version.
|
|
|
|
// The version of the file should only be different if there has
|
|
|
|
// been a breaking change to the config file format.
|
2017-07-05 07:10:28 -05:00
|
|
|
Version int `yaml:"version"`
|
2017-06-16 10:52:03 -05:00
|
|
|
|
|
|
|
// The configuration required for a matrix server.
|
|
|
|
Matrix struct {
|
|
|
|
// The name of the server. This is usually the domain name, e.g 'matrix.org', 'localhost'.
|
|
|
|
ServerName gomatrixserverlib.ServerName `yaml:"server_name"`
|
|
|
|
// Path to the private key which will be used to sign requests and events.
|
|
|
|
PrivateKeyPath Path `yaml:"private_key"`
|
|
|
|
// The private key which will be used to sign requests and events.
|
|
|
|
PrivateKey ed25519.PrivateKey `yaml:"-"`
|
|
|
|
// An arbitrary string used to uniquely identify the PrivateKey. Must start with the
|
|
|
|
// prefix "ed25519:".
|
|
|
|
KeyID gomatrixserverlib.KeyID `yaml:"-"`
|
|
|
|
// List of paths to X509 certificates used by the external federation listeners.
|
|
|
|
// These are used to calculate the TLS fingerprints to publish for this server.
|
|
|
|
// Other matrix servers talking to this server will expect the x509 certificate
|
|
|
|
// to match one of these certificates.
|
|
|
|
// The certificates should be in PEM format.
|
|
|
|
FederationCertificatePaths []Path `yaml:"federation_certificates"`
|
|
|
|
// A list of SHA256 TLS fingerprints for the X509 certificates used by the
|
|
|
|
// federation listener for this server.
|
|
|
|
TLSFingerPrints []gomatrixserverlib.TLSFingerprint `yaml:"-"`
|
|
|
|
// How long a remote server can cache our server key for before requesting it again.
|
|
|
|
// Increasing this number will reduce the number of requests made by remote servers
|
|
|
|
// for our key, but increases the period a compromised key will be considered valid
|
|
|
|
// by remote servers.
|
|
|
|
// Defaults to 24 hours.
|
|
|
|
KeyValidityPeriod time.Duration `yaml:"key_validity_period"`
|
|
|
|
} `yaml:"matrix"`
|
|
|
|
|
|
|
|
// The configuration specific to the media repostitory.
|
|
|
|
Media struct {
|
|
|
|
// The base path to where the media files will be stored. May be relative or absolute.
|
|
|
|
BasePath Path `yaml:"base_path"`
|
|
|
|
// The absolute base path to where media files will be stored.
|
|
|
|
AbsBasePath Path `yaml:"-"`
|
|
|
|
// The maximum file size in bytes that is allowed to be stored on this server.
|
|
|
|
// Note: if max_file_size_bytes is set to 0, the size is unlimited.
|
|
|
|
// Note: if max_file_size_bytes is not set, it will default to 10485760 (10MB)
|
|
|
|
MaxFileSizeBytes *FileSizeBytes `yaml:"max_file_size_bytes,omitempty"`
|
|
|
|
// Whether to dynamically generate thumbnails on-the-fly if the requested resolution is not already generated
|
|
|
|
DynamicThumbnails bool `yaml:"dynamic_thumbnails"`
|
|
|
|
// The maximum number of simultaneous thumbnail generators. default: 10
|
|
|
|
MaxThumbnailGenerators int `yaml:"max_thumbnail_generators"`
|
|
|
|
// A list of thumbnail sizes to be pre-generated for downloaded remote / uploaded content
|
|
|
|
ThumbnailSizes []ThumbnailSize `yaml:"thumbnail_sizes"`
|
|
|
|
} `yaml:"media"`
|
|
|
|
|
|
|
|
// The configuration for talking to kafka.
|
|
|
|
Kafka struct {
|
|
|
|
// A list of kafka addresses to connect to.
|
|
|
|
Addresses []string `yaml:"addresses"`
|
2017-08-16 07:36:41 -05:00
|
|
|
// Whether to use naffka instead of kafka.
|
|
|
|
// Naffka can only be used when running dendrite as a single monolithic server.
|
|
|
|
// Kafka can be used both with a monolithic server and when running the
|
|
|
|
// components as separate servers.
|
|
|
|
UseNaffka bool `yaml:"use_naffka,omitempty"`
|
2017-06-16 10:52:03 -05:00
|
|
|
// The names of the topics to use when reading and writing from kafka.
|
|
|
|
Topics struct {
|
|
|
|
// Topic for roomserver/api.OutputRoomEvent events.
|
|
|
|
OutputRoomEvent Topic `yaml:"output_room_event"`
|
2017-08-02 10:21:35 -05:00
|
|
|
// Topic for sending account data from client API to sync API
|
|
|
|
OutputClientData Topic `yaml:"output_client_data"`
|
2017-07-11 08:14:06 -05:00
|
|
|
// Topic for user updates (profile, presence)
|
|
|
|
UserUpdates Topic `yaml:"user_updates"`
|
2017-06-16 10:52:03 -05:00
|
|
|
}
|
|
|
|
} `yaml:"kafka"`
|
|
|
|
|
|
|
|
// Postgres Config
|
|
|
|
Database struct {
|
|
|
|
// The Account database stores the login details and account information
|
|
|
|
// for local users. It is accessed by the ClientAPI.
|
|
|
|
Account DataSource `yaml:"account"`
|
|
|
|
// The Device database stores session information for the devices of logged
|
|
|
|
// in local users. It is accessed by the ClientAPI, the MediaAPI and the SyncAPI.
|
|
|
|
Device DataSource `yaml:"device"`
|
|
|
|
// The MediaAPI database stores information about files uploaded and downloaded
|
|
|
|
// by local users. It is only accessed by the MediaAPI.
|
|
|
|
MediaAPI DataSource `yaml:"media_api"`
|
|
|
|
// The ServerKey database caches the public keys of remote servers.
|
|
|
|
// It may be accessed by the FederationAPI, the ClientAPI, and the MediaAPI.
|
|
|
|
ServerKey DataSource `yaml:"server_key"`
|
|
|
|
// The SyncAPI stores information used by the SyncAPI server.
|
|
|
|
// It is only accessed by the SyncAPI server.
|
|
|
|
SyncAPI DataSource `yaml:"sync_api"`
|
|
|
|
// The RoomServer database stores information about matrix rooms.
|
|
|
|
// It is only accessed by the RoomServer.
|
|
|
|
RoomServer DataSource `yaml:"room_server"`
|
2017-06-28 10:10:17 -05:00
|
|
|
// The FederationSender database stores information used by the FederationSender
|
|
|
|
// It is only accessed by the FederationSender.
|
|
|
|
FederationSender DataSource `yaml:"federation_sender"`
|
2017-08-22 05:12:51 -05:00
|
|
|
// The PublicRoomsAPI database stores information used to compute the public
|
|
|
|
// room directory. It is only accessed by the PublicRoomsAPI server.
|
|
|
|
PublicRoomsAPI DataSource `yaml:"public_rooms_api"`
|
2017-06-16 10:52:03 -05:00
|
|
|
} `yaml:"database"`
|
|
|
|
|
|
|
|
// The internal addresses the components will listen on.
|
|
|
|
// These should not be exposed externally as they expose metrics and debugging APIs.
|
|
|
|
Listen struct {
|
2017-06-28 10:10:17 -05:00
|
|
|
MediaAPI Address `yaml:"media_api"`
|
|
|
|
ClientAPI Address `yaml:"client_api"`
|
|
|
|
FederationAPI Address `yaml:"federation_api"`
|
|
|
|
SyncAPI Address `yaml:"sync_api"`
|
|
|
|
RoomServer Address `yaml:"room_server"`
|
|
|
|
FederationSender Address `yaml:"federation_sender"`
|
2017-08-22 05:12:51 -05:00
|
|
|
PublicRoomsAPI Address `yaml:"public_rooms_api"`
|
2017-06-16 10:52:03 -05:00
|
|
|
} `yaml:"listen"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// A Path on the filesystem.
|
|
|
|
type Path string
|
|
|
|
|
|
|
|
// A DataSource for opening a postgresql database using lib/pq.
|
|
|
|
type DataSource string
|
|
|
|
|
|
|
|
// A Topic in kafka.
|
|
|
|
type Topic string
|
|
|
|
|
|
|
|
// An Address to listen on.
|
|
|
|
type Address string
|
|
|
|
|
|
|
|
// FileSizeBytes is a file size in bytes
|
|
|
|
type FileSizeBytes int64
|
|
|
|
|
|
|
|
// ThumbnailSize contains a single thumbnail size configuration
|
|
|
|
type ThumbnailSize struct {
|
|
|
|
// Maximum width of the thumbnail image
|
|
|
|
Width int `yaml:"width"`
|
|
|
|
// Maximum height of the thumbnail image
|
|
|
|
Height int `yaml:"height"`
|
|
|
|
// ResizeMethod is one of crop or scale.
|
|
|
|
// crop scales to fill the requested dimensions and crops the excess.
|
|
|
|
// scale scales to fit the requested dimensions and one dimension may be smaller than requested.
|
|
|
|
ResizeMethod string `yaml:"method,omitempty"`
|
|
|
|
}
|
|
|
|
|
2017-08-16 07:36:41 -05:00
|
|
|
// Load a yaml config file for a server run as multiple processes.
|
|
|
|
// Checks the config to ensure that it is valid.
|
|
|
|
// The checks are different if the server is run as a monolithic process instead
|
|
|
|
// of being split into multiple components
|
2017-06-16 10:52:03 -05:00
|
|
|
func Load(configPath string) (*Dendrite, error) {
|
|
|
|
configData, err := ioutil.ReadFile(configPath)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
basePath, err := filepath.Abs(".")
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
// Pass the current working directory and ioutil.ReadFile so that they can
|
|
|
|
// be mocked in the tests
|
2017-08-16 07:36:41 -05:00
|
|
|
monolithic := false
|
|
|
|
return loadConfig(basePath, configData, ioutil.ReadFile, monolithic)
|
|
|
|
}
|
|
|
|
|
|
|
|
// LoadMonolithic loads a yaml config file for a server run as a single monolith.
|
|
|
|
// Checks the config to ensure that it is valid.
|
|
|
|
// The checks are different if the server is run as a monolithic process instead
|
|
|
|
// of being split into multiple components
|
|
|
|
func LoadMonolithic(configPath string) (*Dendrite, error) {
|
|
|
|
configData, err := ioutil.ReadFile(configPath)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
basePath, err := filepath.Abs(".")
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
// Pass the current working directory and ioutil.ReadFile so that they can
|
|
|
|
// be mocked in the tests
|
|
|
|
monolithic := true
|
|
|
|
return loadConfig(basePath, configData, ioutil.ReadFile, monolithic)
|
2017-06-16 10:52:03 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
// An Error indicates a problem parsing the config.
|
|
|
|
type Error struct {
|
|
|
|
// List of problems encountered parsing the config.
|
|
|
|
Problems []string
|
|
|
|
}
|
|
|
|
|
|
|
|
func loadConfig(
|
|
|
|
basePath string,
|
|
|
|
configData []byte,
|
|
|
|
readFile func(string) ([]byte, error),
|
2017-08-16 07:36:41 -05:00
|
|
|
monolithic bool,
|
2017-06-16 10:52:03 -05:00
|
|
|
) (*Dendrite, error) {
|
|
|
|
var config Dendrite
|
|
|
|
var err error
|
|
|
|
if err = yaml.Unmarshal(configData, &config); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
config.setDefaults()
|
|
|
|
|
2017-08-16 07:36:41 -05:00
|
|
|
if err = config.check(monolithic); err != nil {
|
2017-06-16 10:52:03 -05:00
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
privateKeyPath := absPath(basePath, config.Matrix.PrivateKeyPath)
|
|
|
|
privateKeyData, err := readFile(privateKeyPath)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
if config.Matrix.KeyID, config.Matrix.PrivateKey, err = readKeyPEM(privateKeyPath, privateKeyData); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, certPath := range config.Matrix.FederationCertificatePaths {
|
|
|
|
absCertPath := absPath(basePath, certPath)
|
|
|
|
pemData, err := readFile(absCertPath)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
fingerprint := fingerprintPEM(pemData)
|
|
|
|
if fingerprint == nil {
|
|
|
|
return nil, fmt.Errorf("no certificate PEM data in %q", absCertPath)
|
|
|
|
}
|
|
|
|
config.Matrix.TLSFingerPrints = append(config.Matrix.TLSFingerPrints, *fingerprint)
|
|
|
|
}
|
|
|
|
|
|
|
|
config.Media.AbsBasePath = Path(absPath(basePath, config.Media.BasePath))
|
|
|
|
|
|
|
|
return &config, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (config *Dendrite) setDefaults() {
|
|
|
|
if config.Matrix.KeyValidityPeriod == 0 {
|
|
|
|
config.Matrix.KeyValidityPeriod = 24 * time.Hour
|
|
|
|
}
|
|
|
|
|
|
|
|
if config.Media.MaxThumbnailGenerators == 0 {
|
|
|
|
config.Media.MaxThumbnailGenerators = 10
|
|
|
|
}
|
|
|
|
|
|
|
|
if config.Media.MaxFileSizeBytes == nil {
|
|
|
|
defaultMaxFileSizeBytes := FileSizeBytes(10485760)
|
|
|
|
config.Media.MaxFileSizeBytes = &defaultMaxFileSizeBytes
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (e Error) Error() string {
|
|
|
|
if len(e.Problems) == 1 {
|
|
|
|
return e.Problems[0]
|
|
|
|
}
|
|
|
|
return fmt.Sprintf(
|
|
|
|
"%s (and %d other problems)", e.Problems[0], len(e.Problems)-1,
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2017-08-16 07:36:41 -05:00
|
|
|
func (config *Dendrite) check(monolithic bool) error {
|
2017-06-16 10:52:03 -05:00
|
|
|
var problems []string
|
|
|
|
|
|
|
|
if config.Version != Version {
|
|
|
|
return Error{[]string{fmt.Sprintf(
|
|
|
|
"unknown config version %q, expected %q", config.Version, Version,
|
|
|
|
)}}
|
|
|
|
}
|
|
|
|
|
|
|
|
checkNotEmpty := func(key string, value string) {
|
|
|
|
if value == "" {
|
|
|
|
problems = append(problems, fmt.Sprintf("missing config key %q", key))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
checkNotZero := func(key string, value int64) {
|
|
|
|
if value == 0 {
|
|
|
|
problems = append(problems, fmt.Sprintf("missing config key %q", key))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
checkPositive := func(key string, value int64) {
|
|
|
|
if value < 0 {
|
|
|
|
problems = append(problems, fmt.Sprintf("invalid value for config key %q: %d", key, value))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
checkNotEmpty("matrix.server_name", string(config.Matrix.ServerName))
|
|
|
|
checkNotEmpty("matrix.private_key", string(config.Matrix.PrivateKeyPath))
|
|
|
|
checkNotZero("matrix.federation_certificates", int64(len(config.Matrix.FederationCertificatePaths)))
|
|
|
|
|
|
|
|
checkNotEmpty("media.base_path", string(config.Media.BasePath))
|
|
|
|
checkPositive("media.max_file_size_bytes", int64(*config.Media.MaxFileSizeBytes))
|
|
|
|
checkPositive("media.max_thumbnail_generators", int64(config.Media.MaxThumbnailGenerators))
|
|
|
|
for i, size := range config.Media.ThumbnailSizes {
|
|
|
|
checkPositive(fmt.Sprintf("media.thumbnail_sizes[%d].width", i), int64(size.Width))
|
|
|
|
checkPositive(fmt.Sprintf("media.thumbnail_sizes[%d].height", i), int64(size.Height))
|
|
|
|
}
|
2017-08-16 07:36:41 -05:00
|
|
|
if config.Kafka.UseNaffka {
|
|
|
|
if !monolithic {
|
|
|
|
problems = append(problems, fmt.Sprintf("naffka can only be used in a monolithic server"))
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// If we aren't using naffka then we need to have at least one kafka
|
|
|
|
// server to talk to.
|
|
|
|
checkNotZero("kafka.addresses", int64(len(config.Kafka.Addresses)))
|
|
|
|
}
|
2017-06-16 10:52:03 -05:00
|
|
|
checkNotEmpty("kafka.topics.output_room_event", string(config.Kafka.Topics.OutputRoomEvent))
|
2017-08-02 10:21:35 -05:00
|
|
|
checkNotEmpty("kafka.topics.output_client_data", string(config.Kafka.Topics.OutputClientData))
|
2017-08-16 07:36:41 -05:00
|
|
|
checkNotEmpty("kafka.topics.user_updates", string(config.Kafka.Topics.UserUpdates))
|
2017-06-16 10:52:03 -05:00
|
|
|
checkNotEmpty("database.account", string(config.Database.Account))
|
|
|
|
checkNotEmpty("database.device", string(config.Database.Device))
|
|
|
|
checkNotEmpty("database.server_key", string(config.Database.ServerKey))
|
|
|
|
checkNotEmpty("database.media_api", string(config.Database.MediaAPI))
|
|
|
|
checkNotEmpty("database.sync_api", string(config.Database.SyncAPI))
|
|
|
|
checkNotEmpty("database.room_server", string(config.Database.RoomServer))
|
2017-08-16 07:36:41 -05:00
|
|
|
|
|
|
|
if !monolithic {
|
|
|
|
checkNotEmpty("listen.media_api", string(config.Listen.MediaAPI))
|
|
|
|
checkNotEmpty("listen.client_api", string(config.Listen.ClientAPI))
|
|
|
|
checkNotEmpty("listen.federation_api", string(config.Listen.FederationAPI))
|
|
|
|
checkNotEmpty("listen.sync_api", string(config.Listen.SyncAPI))
|
|
|
|
checkNotEmpty("listen.room_server", string(config.Listen.RoomServer))
|
|
|
|
}
|
2017-06-16 10:52:03 -05:00
|
|
|
|
|
|
|
if problems != nil {
|
|
|
|
return Error{problems}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func absPath(dir string, path Path) string {
|
|
|
|
if filepath.IsAbs(string(path)) {
|
|
|
|
// filepath.Join cleans the path so we should clean the absolute paths as well for consistency.
|
|
|
|
return filepath.Clean(string(path))
|
|
|
|
}
|
|
|
|
return filepath.Join(dir, string(path))
|
|
|
|
}
|
|
|
|
|
|
|
|
func readKeyPEM(path string, data []byte) (gomatrixserverlib.KeyID, ed25519.PrivateKey, error) {
|
|
|
|
for {
|
|
|
|
var keyBlock *pem.Block
|
|
|
|
keyBlock, data = pem.Decode(data)
|
|
|
|
if data == nil {
|
|
|
|
return "", nil, fmt.Errorf("no matrix private key PEM data in %q", path)
|
|
|
|
}
|
|
|
|
if keyBlock.Type == "MATRIX PRIVATE KEY" {
|
|
|
|
keyID := keyBlock.Headers["Key-ID"]
|
|
|
|
if keyID == "" {
|
|
|
|
return "", nil, fmt.Errorf("missing key ID in PEM data in %q", path)
|
|
|
|
}
|
|
|
|
if !strings.HasPrefix(keyID, "ed25519:") {
|
|
|
|
return "", nil, fmt.Errorf("key ID %q doesn't start with \"ed25519:\" in %q", keyID, path)
|
|
|
|
}
|
|
|
|
_, privKey, err := ed25519.GenerateKey(bytes.NewReader(keyBlock.Bytes))
|
|
|
|
if err != nil {
|
|
|
|
return "", nil, err
|
|
|
|
}
|
|
|
|
return gomatrixserverlib.KeyID(keyID), privKey, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func fingerprintPEM(data []byte) *gomatrixserverlib.TLSFingerprint {
|
|
|
|
for {
|
|
|
|
var certDERBlock *pem.Block
|
|
|
|
certDERBlock, data = pem.Decode(data)
|
|
|
|
if data == nil {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
if certDERBlock.Type == "CERTIFICATE" {
|
|
|
|
digest := sha256.Sum256(certDERBlock.Bytes)
|
|
|
|
return &gomatrixserverlib.TLSFingerprint{digest[:]}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2017-06-19 09:21:04 -05:00
|
|
|
|
|
|
|
// RoomServerURL returns an HTTP URL for where the roomserver is listening.
|
|
|
|
func (config *Dendrite) RoomServerURL() string {
|
|
|
|
// Hard code the roomserver to talk HTTP for now.
|
|
|
|
// If we support HTTPS we need to think of a practical way to do certificate validation.
|
|
|
|
// People setting up servers shouldn't need to get a certificate valid for the public
|
|
|
|
// internet for an internal API.
|
|
|
|
return "http://" + string(config.Listen.RoomServer)
|
|
|
|
}
|