forked from cwtch.im/server
adding 'servers' interface to manage multiple servers and support for encrypted configs
This commit is contained in:
parent
e7499ee9fb
commit
d361d71a2a
|
@ -14,6 +14,8 @@ The app takes the following arguments
|
||||||
The app takes the following environment variables
|
The app takes the following environment variables
|
||||||
- CWTCH_HOME: sets the config dir for the app
|
- CWTCH_HOME: sets the config dir for the app
|
||||||
|
|
||||||
|
`env CONFIG_HOME=./conf ./app`
|
||||||
|
|
||||||
## Using the Server
|
## Using the Server
|
||||||
|
|
||||||
When run the app will output standard log lines, one of which will contain the `tofubundle` in purple. This is the part you need to capture and import into a Cwtch client app so you can use the server for hosting groups
|
When run the app will output standard log lines, one of which will contain the `tofubundle` in purple. This is the part you need to capture and import into a Cwtch client app so you can use the server for hosting groups
|
||||||
|
|
40
app/main.go
40
app/main.go
|
@ -2,10 +2,8 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"cwtch.im/cwtch/model"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
|
||||||
cwtchserver "git.openprivacy.ca/cwtch.im/server"
|
cwtchserver "git.openprivacy.ca/cwtch.im/server"
|
||||||
"git.openprivacy.ca/cwtch.im/tapir/primitives"
|
"git.openprivacy.ca/cwtch.im/tapir/primitives"
|
||||||
"git.openprivacy.ca/openprivacy/connectivity/tor"
|
"git.openprivacy.ca/openprivacy/connectivity/tor"
|
||||||
|
@ -19,10 +17,6 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
serverConfigFile = "serverConfig.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flagDebug := flag.Bool("debug", false, "Enable debug logging")
|
flagDebug := flag.Bool("debug", false, "Enable debug logging")
|
||||||
flagExportTofu := flag.Bool("exportTofuBundle", false, "Export the tofubundle to a file called tofubundle")
|
flagExportTofu := flag.Bool("exportTofuBundle", false, "Export the tofubundle to a file called tofubundle")
|
||||||
|
@ -52,19 +46,25 @@ func main() {
|
||||||
ReportingGroupID: "",
|
ReportingGroupID: "",
|
||||||
ReportingServerAddr: "",
|
ReportingServerAddr: "",
|
||||||
}
|
}
|
||||||
config.Save(".", "serverConfig.json")
|
config.ConfigDir = "."
|
||||||
|
config.FilePath = cwtchserver.ServerConfigFile
|
||||||
|
config.Encrypted = false
|
||||||
|
config.Save()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
serverConfig := cwtchserver.LoadConfig(configDir, serverConfigFile)
|
serverConfig, err := cwtchserver.LoadCreateDefaultConfigFile(configDir, cwtchserver.ServerConfigFile, false, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Could not load/create config file: %s\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
// we don't need real randomness for the port, just to avoid a possible conflict...
|
// we don't need real randomness for the port, just to avoid a possible conflict...
|
||||||
mrand.Seed(int64(time.Now().Nanosecond()))
|
mrand.Seed(int64(time.Now().Nanosecond()))
|
||||||
controlPort := mrand.Intn(1000) + 9052
|
controlPort := mrand.Intn(1000) + 9052
|
||||||
|
|
||||||
// generate a random password
|
// generate a random password
|
||||||
key := make([]byte, 64)
|
key := make([]byte, 64)
|
||||||
_, err := rand.Read(key)
|
_, err = rand.Read(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
@ -79,25 +79,15 @@ func main() {
|
||||||
}
|
}
|
||||||
defer acn.Close()
|
defer acn.Close()
|
||||||
|
|
||||||
server := new(cwtchserver.Server)
|
server := cwtchserver.NewServer(serverConfig)
|
||||||
log.Infoln("starting cwtch server...")
|
log.Infoln("starting cwtch server...")
|
||||||
|
log.Infof("Server 'hash name': %s\n", server.HashName())
|
||||||
|
|
||||||
server.Setup(serverConfig)
|
log.Infof("Server bundle (import into client to use server): %s\n", log.Magenta(server.Server()))
|
||||||
|
|
||||||
// TODO create a random group for testing
|
|
||||||
group, _ := model.NewGroup(tor.GetTorV3Hostname(serverConfig.PublicKey))
|
|
||||||
invite, err := group.Invite()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bundle := server.KeyBundle().Serialize()
|
|
||||||
tofubundle := fmt.Sprintf("tofubundle:server:%s||%s", base64.StdEncoding.EncodeToString(bundle), invite)
|
|
||||||
log.Infof("Server Tofu Bundle (import into client to use server): %s", log.Magenta(tofubundle))
|
|
||||||
log.Infof("Server Config: server address:%s", base64.StdEncoding.EncodeToString(bundle))
|
|
||||||
|
|
||||||
if *flagExportTofu {
|
if *flagExportTofu {
|
||||||
ioutil.WriteFile(path.Join(serverConfig.ConfigDir, "tofubundle"), []byte(tofubundle), 0600)
|
// Todo: change all to server export
|
||||||
|
ioutil.WriteFile(path.Join(serverConfig.ConfigDir, "tofubundle"), []byte(server.TofuBundle()), 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful Shutdown
|
// Graceful Shutdown
|
||||||
|
|
119
serverConfig.go
119
serverConfig.go
|
@ -2,15 +2,22 @@ package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
v1 "cwtch.im/cwtch/storage/v1"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"git.openprivacy.ca/cwtch.im/tapir/primitives"
|
"git.openprivacy.ca/cwtch.im/tapir/primitives"
|
||||||
"git.openprivacy.ca/openprivacy/log"
|
"git.openprivacy.ca/openprivacy/log"
|
||||||
"github.com/gtank/ristretto255"
|
"github.com/gtank/ristretto255"
|
||||||
"golang.org/x/crypto/ed25519"
|
"golang.org/x/crypto/ed25519"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SaltFile is the standard filename to store an encrypted config's SALT under beside it
|
||||||
|
SaltFile = "SALT"
|
||||||
|
)
|
||||||
|
|
||||||
// Reporting is a struct for storing a the config a server needs to be a peer, and connect to a group to report
|
// Reporting is a struct for storing a the config a server needs to be a peer, and connect to a group to report
|
||||||
type Reporting struct {
|
type Reporting struct {
|
||||||
LogMetricsToFile bool `json:"logMetricsToFile"`
|
LogMetricsToFile bool `json:"logMetricsToFile"`
|
||||||
|
@ -22,7 +29,9 @@ type Reporting struct {
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ConfigDir string `json:"-"`
|
ConfigDir string `json:"-"`
|
||||||
FilePath string `json:"-"`
|
FilePath string `json:"-"`
|
||||||
MaxBufferLines int `json:"maxBufferLines"`
|
Encrypted bool `json:"-"`
|
||||||
|
key [32]byte
|
||||||
|
MaxBufferLines int `json:"maxBufferLines"`
|
||||||
|
|
||||||
PublicKey ed25519.PublicKey `json:"publicKey"`
|
PublicKey ed25519.PublicKey `json:"publicKey"`
|
||||||
PrivateKey ed25519.PrivateKey `json:"privateKey"`
|
PrivateKey ed25519.PrivateKey `json:"privateKey"`
|
||||||
|
@ -46,17 +55,8 @@ func (config *Config) TokenServiceIdentity() primitives.Identity {
|
||||||
return primitives.InitializeIdentity("", &config.TokenServerPrivateKey, &config.TokenServerPublicKey)
|
return primitives.InitializeIdentity("", &config.TokenServerPrivateKey, &config.TokenServerPublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save dumps the latest version of the config to a json file given by filename
|
func initDefaultConfig(configDir, filename string, encrypted bool) Config {
|
||||||
func (config *Config) Save(dir, filename string) {
|
config := Config{Encrypted: encrypted, ConfigDir: configDir, FilePath: filename}
|
||||||
log.Infof("Saving config to %s\n", path.Join(dir, filename))
|
|
||||||
bytes, _ := json.MarshalIndent(config, "", "\t")
|
|
||||||
ioutil.WriteFile(path.Join(dir, filename), bytes, 0600)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadConfig loads a Config from a json file specified by filename
|
|
||||||
func LoadConfig(configDir, filename string) Config {
|
|
||||||
log.Infof("Loading config from %s\n", path.Join(configDir, filename))
|
|
||||||
config := Config{}
|
|
||||||
|
|
||||||
id, pk := primitives.InitializeEphemeralIdentity()
|
id, pk := primitives.InitializeEphemeralIdentity()
|
||||||
tid, tpk := primitives.InitializeEphemeralIdentity()
|
tid, tpk := primitives.InitializeEphemeralIdentity()
|
||||||
|
@ -71,8 +71,6 @@ func LoadConfig(configDir, filename string) Config {
|
||||||
ReportingServerAddr: "",
|
ReportingServerAddr: "",
|
||||||
}
|
}
|
||||||
config.AutoStart = false
|
config.AutoStart = false
|
||||||
config.ConfigDir = configDir
|
|
||||||
config.FilePath = filename
|
|
||||||
|
|
||||||
k := new(ristretto255.Scalar)
|
k := new(ristretto255.Scalar)
|
||||||
b := make([]byte, 64)
|
b := make([]byte, 64)
|
||||||
|
@ -83,16 +81,87 @@ func LoadConfig(configDir, filename string) Config {
|
||||||
}
|
}
|
||||||
k.FromUniformBytes(b)
|
k.FromUniformBytes(b)
|
||||||
config.TokenServiceK = *k
|
config.TokenServiceK = *k
|
||||||
|
|
||||||
raw, err := ioutil.ReadFile(path.Join(configDir, filename))
|
|
||||||
if err == nil {
|
|
||||||
err = json.Unmarshal(raw, &config)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("reading config: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Always save (first time generation, new version with new variables populated)
|
|
||||||
config.Save(configDir, filename)
|
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadCreateDefaultConfigFile loads a Config from or creates a default config and saves it to a json file specified by filename
|
||||||
|
// if the encrypted flag is true the config is store encrypted by password
|
||||||
|
func LoadCreateDefaultConfigFile(configDir, filename string, encrypted bool, password string) (*Config, error) {
|
||||||
|
if _, err := os.Stat(path.Join(configDir, filename)); os.IsNotExist(err) {
|
||||||
|
return CreateConfig(configDir, filename, encrypted, password)
|
||||||
|
}
|
||||||
|
return LoadConfig(configDir, filename, encrypted, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateConfig creates a default config and saves it to a json file specified by filename
|
||||||
|
// if the encrypted flag is true the config is store encrypted by password
|
||||||
|
func CreateConfig(configDir, filename string, encrypted bool, password string) (*Config, error) {
|
||||||
|
os.Mkdir(configDir, 0700)
|
||||||
|
config := initDefaultConfig(configDir, filename, encrypted)
|
||||||
|
if encrypted {
|
||||||
|
key, _, err := v1.InitV1Directory(configDir, password)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Could not create server directory: %s", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
config.key = key
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Save()
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig loads a Config from a json file specified by filename
|
||||||
|
func LoadConfig(configDir, filename string, encrypted bool, password string) (*Config, error) {
|
||||||
|
log.Infof("Loading config from %s\n", path.Join(configDir, filename))
|
||||||
|
|
||||||
|
config := initDefaultConfig(configDir, filename, encrypted)
|
||||||
|
|
||||||
|
raw, err := ioutil.ReadFile(path.Join(configDir, filename))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if encrypted {
|
||||||
|
salt, err := ioutil.ReadFile(path.Join(configDir, SaltFile))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
key := v1.CreateKey(password, salt)
|
||||||
|
settingsStore := v1.NewFileStore(configDir, ServerConfigFile, key)
|
||||||
|
raw, err = settingsStore.Read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = json.Unmarshal(raw, &config); err != nil {
|
||||||
|
log.Errorf("reading config: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always save (first time generation, new version with new variables populated)
|
||||||
|
config.Save()
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save dumps the latest version of the config to a json file given by filename
|
||||||
|
func (config *Config) Save() error {
|
||||||
|
log.Infof("Saving config to %s\n", path.Join(config.ConfigDir, config.FilePath))
|
||||||
|
bytes, _ := json.MarshalIndent(config, "", "\t")
|
||||||
|
if config.Encrypted {
|
||||||
|
settingStore := v1.NewFileStore(config.ConfigDir, config.FilePath, config.key)
|
||||||
|
return settingStore.Write(bytes)
|
||||||
|
}
|
||||||
|
return ioutil.WriteFile(path.Join(config.ConfigDir, config.FilePath), bytes, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPassword returns true if the given password produces the same key as the current stored key, otherwise false.
|
||||||
|
func (config *Config) CheckPassword(checkpass string) bool {
|
||||||
|
salt, err := ioutil.ReadFile(path.Join(config.ConfigDir, SaltFile))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
oldkey := v1.CreateKey(checkpass, salt[:])
|
||||||
|
return oldkey == config.key
|
||||||
|
}
|
||||||
|
|
|
@ -50,14 +50,14 @@ func (ta *TokenboardServer) Listen() {
|
||||||
for {
|
for {
|
||||||
data := ta.connection.Expect()
|
data := ta.connection.Expect()
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
log.Debugf("Server Closing Connection")
|
log.Debugf("server Closing Connection")
|
||||||
ta.connection.Close()
|
ta.connection.Close()
|
||||||
return // connection is closed
|
return // connection is closed
|
||||||
}
|
}
|
||||||
|
|
||||||
var message groups.Message
|
var message groups.Message
|
||||||
if err := json.Unmarshal(data, &message); err != nil {
|
if err := json.Unmarshal(data, &message); err != nil {
|
||||||
log.Debugf("Server Closing Connection Because of Malformed Client Packet %v", err)
|
log.Debugf("server Closing Connection Because of Malformed Client Packet %v", err)
|
||||||
ta.connection.Close()
|
ta.connection.Close()
|
||||||
return // connection is closed
|
return // connection is closed
|
||||||
}
|
}
|
||||||
|
@ -69,7 +69,7 @@ func (ta *TokenboardServer) Listen() {
|
||||||
log.Debugf("Received a Post Message Request: %v", ta.connection.Hostname())
|
log.Debugf("Received a Post Message Request: %v", ta.connection.Hostname())
|
||||||
ta.postMessageRequest(postrequest)
|
ta.postMessageRequest(postrequest)
|
||||||
} else {
|
} else {
|
||||||
log.Debugf("Server Closing Connection Because of PostRequestMessage Client Packet")
|
log.Debugf("server Closing Connection Because of PostRequestMessage Client Packet")
|
||||||
ta.connection.Close()
|
ta.connection.Close()
|
||||||
return // connection is closed
|
return // connection is closed
|
||||||
}
|
}
|
||||||
|
@ -97,7 +97,7 @@ func (ta *TokenboardServer) Listen() {
|
||||||
ta.connection.Send(data)
|
ta.connection.Send(data)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Debugf("Server Closing Connection Because of Malformed ReplayRequestMessage Packet")
|
log.Debugf("server Closing Connection Because of Malformed ReplayRequestMessage Packet")
|
||||||
ta.connection.Close()
|
ta.connection.Close()
|
||||||
return // connection is closed
|
return // connection is closed
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,134 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cwtch.im/cwtch/model"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"git.openprivacy.ca/openprivacy/connectivity"
|
||||||
|
"io/ioutil"
|
||||||
|
"path"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Servers is an interface to manage multiple Cwtch servers
|
||||||
|
// Unlike a standalone server, server's dirs will be under one "$CwtchDir/servers" and use a cwtch style localID to obscure
|
||||||
|
// what servers are hosted. Users are of course free to use a default password. This means Config file will be encrypted
|
||||||
|
// with cwtch/storage/v1/file_enc and monitor files will not be generated
|
||||||
|
type Servers interface {
|
||||||
|
LoadServers(password string) ([]string, error)
|
||||||
|
CreateServer(password string) (Server, error)
|
||||||
|
|
||||||
|
GetServer(onion string) Server
|
||||||
|
ListServers() []string
|
||||||
|
DeleteServer(onion string, currentPassword string) error
|
||||||
|
|
||||||
|
LaunchServers()
|
||||||
|
ShutdownServer(string)
|
||||||
|
Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
type servers struct {
|
||||||
|
lock sync.Mutex
|
||||||
|
servers map[string]Server
|
||||||
|
directory string
|
||||||
|
acn connectivity.ACN
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServers returns a Servers interface to manage a collection of servers
|
||||||
|
// expecting directory: $CWTCH_HOME/servers
|
||||||
|
func NewServers(acn connectivity.ACN, directory string) Servers {
|
||||||
|
return &servers{acn: acn, directory: directory}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadServers will attempt to load any servers in the servers directory that are encrypted with the supplied password
|
||||||
|
// returns a list of onions identifiers for servers loaded or an error
|
||||||
|
func (s *servers) LoadServers(password string) ([]string, error) {
|
||||||
|
s.lock.Lock()
|
||||||
|
defer s.lock.Unlock()
|
||||||
|
|
||||||
|
dirs, err := ioutil.ReadDir(s.directory)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error: cannot read server directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedServers := []string{}
|
||||||
|
for _, dir := range dirs {
|
||||||
|
newConfig, err := LoadConfig(path.Join(s.directory, dir.Name()), ServerConfigFile, true, password)
|
||||||
|
if err == nil {
|
||||||
|
server := NewServer(newConfig)
|
||||||
|
s.servers[server.Onion()] = server
|
||||||
|
loadedServers = append(loadedServers, server.Onion())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return loadedServers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateServer creates a new server and stores it, also returns an interface to it
|
||||||
|
func (s *servers) CreateServer(password string) (Server, error) {
|
||||||
|
newLocalID := model.GenerateRandomID()
|
||||||
|
directory := path.Join(s.directory, newLocalID)
|
||||||
|
config, err := CreateConfig(directory, ServerConfigFile, true, password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
server := NewServer(config)
|
||||||
|
s.lock.Lock()
|
||||||
|
defer s.lock.Unlock()
|
||||||
|
s.servers[server.Onion()] = server
|
||||||
|
return server, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServer returns a server interface for the supplied onion
|
||||||
|
func (s *servers) GetServer(onion string) Server {
|
||||||
|
s.lock.Lock()
|
||||||
|
defer s.lock.Unlock()
|
||||||
|
return s.servers[onion]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListServers returns a list of server onion identifies this servers struct is managing
|
||||||
|
func (s *servers) ListServers() []string {
|
||||||
|
s.lock.Lock()
|
||||||
|
defer s.lock.Unlock()
|
||||||
|
list := []string{}
|
||||||
|
for onion := range s.servers {
|
||||||
|
list = append(list, onion)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteServer delete's the requested server (assuming the passwords match
|
||||||
|
func (s *servers) DeleteServer(onion string, password string) error {
|
||||||
|
s.lock.Lock()
|
||||||
|
defer s.lock.Unlock()
|
||||||
|
server := s.servers[onion]
|
||||||
|
if server != nil {
|
||||||
|
server.Shutdown()
|
||||||
|
err := server.Delete(password)
|
||||||
|
delete(s.servers, onion)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errors.New("Server not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LaunchServers Run() all loaded servers
|
||||||
|
func (s *servers) LaunchServers() {
|
||||||
|
s.lock.Lock()
|
||||||
|
defer s.lock.Unlock()
|
||||||
|
for _, server := range s.servers {
|
||||||
|
server.Run(s.acn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShutdownServer Shutsdown the specified server
|
||||||
|
func (s *servers) ShutdownServer(onion string) {
|
||||||
|
s.servers[onion].Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown shutsdown all the servers
|
||||||
|
func (s *servers) Shutdown() {
|
||||||
|
s.lock.Lock()
|
||||||
|
defer s.lock.Unlock()
|
||||||
|
for _, server := range s.servers {
|
||||||
|
server.Shutdown()
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,10 +2,10 @@ package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cwtch.im/cwtch/protocol/groups"
|
"cwtch.im/cwtch/protocol/groups"
|
||||||
"git.openprivacy.ca/cwtch.im/server/metrics"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"git.openprivacy.ca/cwtch.im/server/metrics"
|
||||||
"git.openprivacy.ca/openprivacy/log"
|
"git.openprivacy.ca/openprivacy/log"
|
||||||
_ "github.com/mattn/go-sqlite3" // sqlite3 driver
|
_ "github.com/mattn/go-sqlite3" // sqlite3 driver
|
||||||
)
|
)
|
||||||
|
|
|
@ -2,8 +2,8 @@ package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cwtch.im/cwtch/protocol/groups"
|
"cwtch.im/cwtch/protocol/groups"
|
||||||
"git.openprivacy.ca/cwtch.im/server/metrics"
|
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"git.openprivacy.ca/cwtch.im/server/metrics"
|
||||||
"git.openprivacy.ca/openprivacy/log"
|
"git.openprivacy.ca/openprivacy/log"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
Loading…
Reference in New Issue