2019-11-19 10:00:20 -07:00
|
|
|
package nebula
|
|
|
|
|
|
|
|
import (
|
2021-11-02 12:14:26 -06:00
|
|
|
"context"
|
2019-11-19 10:00:20 -07:00
|
|
|
"encoding/binary"
|
|
|
|
"fmt"
|
|
|
|
"net"
|
|
|
|
"time"
|
2020-06-30 16:53:30 -06:00
|
|
|
|
|
|
|
"github.com/sirupsen/logrus"
|
2021-11-03 19:54:04 -06:00
|
|
|
"github.com/slackhq/nebula/config"
|
2021-11-10 20:52:26 -07:00
|
|
|
"github.com/slackhq/nebula/overlay"
|
2020-06-30 16:53:30 -06:00
|
|
|
"github.com/slackhq/nebula/sshd"
|
2021-11-03 19:54:04 -06:00
|
|
|
"github.com/slackhq/nebula/udp"
|
2021-11-10 20:47:38 -07:00
|
|
|
"github.com/slackhq/nebula/util"
|
2020-06-30 16:53:30 -06:00
|
|
|
"gopkg.in/yaml.v2"
|
2019-11-19 10:00:20 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
type m map[string]interface{}
|
|
|
|
|
2021-11-03 19:54:04 -06:00
|
|
|
func Main(c *config.C, configTest bool, buildVersion string, logger *logrus.Logger, tunFd *int) (retcon *Control, reterr error) {
|
2021-11-02 12:14:26 -06:00
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
// Automatically cancel the context if Main returns an error, to signal all created goroutines to quit.
|
|
|
|
defer func() {
|
|
|
|
if reterr != nil {
|
|
|
|
cancel()
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
2021-03-26 08:46:30 -06:00
|
|
|
l := logger
|
2019-11-19 10:00:20 -07:00
|
|
|
l.Formatter = &logrus.TextFormatter{
|
|
|
|
FullTimestamp: true,
|
|
|
|
}
|
|
|
|
|
|
|
|
// Print the config if in test, the exit comes later
|
|
|
|
if configTest {
|
2021-11-03 19:54:04 -06:00
|
|
|
b, err := yaml.Marshal(c.Settings)
|
2019-11-19 10:00:20 -07:00
|
|
|
if err != nil {
|
2020-09-18 08:20:09 -06:00
|
|
|
return nil, err
|
2019-11-19 10:00:20 -07:00
|
|
|
}
|
2020-06-30 12:48:58 -06:00
|
|
|
|
|
|
|
// Print the final config
|
2019-11-19 10:00:20 -07:00
|
|
|
l.Println(string(b))
|
|
|
|
}
|
|
|
|
|
2021-11-03 19:54:04 -06:00
|
|
|
err := configLogger(l, c)
|
2019-11-19 10:00:20 -07:00
|
|
|
if err != nil {
|
2023-08-14 20:32:40 -06:00
|
|
|
return nil, util.ContextualizeIfNeeded("Failed to configure the logger", err)
|
2019-11-19 10:00:20 -07:00
|
|
|
}
|
|
|
|
|
2021-11-03 19:54:04 -06:00
|
|
|
c.RegisterReloadCallback(func(c *config.C) {
|
|
|
|
err := configLogger(l, c)
|
2019-11-19 10:00:20 -07:00
|
|
|
if err != nil {
|
|
|
|
l.WithError(err).Error("Failed to configure the logger")
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
2023-08-14 20:32:40 -06:00
|
|
|
pki, err := NewPKIFromConfig(l, c)
|
2019-11-19 10:00:20 -07:00
|
|
|
if err != nil {
|
2023-08-14 20:32:40 -06:00
|
|
|
return nil, util.ContextualizeIfNeeded("Failed to load PKI from config", err)
|
2019-11-19 10:00:20 -07:00
|
|
|
}
|
|
|
|
|
2023-08-14 20:32:40 -06:00
|
|
|
certificate := pki.GetCertState().Certificate
|
|
|
|
fw, err := NewFirewallFromConfig(l, certificate, c)
|
2019-11-19 10:00:20 -07:00
|
|
|
if err != nil {
|
2023-08-14 20:32:40 -06:00
|
|
|
return nil, util.ContextualizeIfNeeded("Error while loading firewall rules", err)
|
2019-11-19 10:00:20 -07:00
|
|
|
}
|
|
|
|
l.WithField("firewallHash", fw.GetRuleHash()).Info("Firewall started")
|
|
|
|
|
|
|
|
// TODO: make sure mask is 4 bytes
|
2023-08-14 20:32:40 -06:00
|
|
|
tunCidr := certificate.Details.Ips[0]
|
2019-11-19 10:00:20 -07:00
|
|
|
|
|
|
|
ssh, err := sshd.NewSSHServer(l.WithField("subsystem", "sshd"))
|
2021-11-03 19:54:04 -06:00
|
|
|
wireSSHReload(l, ssh, c)
|
2021-04-16 09:34:28 -06:00
|
|
|
var sshStart func()
|
2021-11-03 19:54:04 -06:00
|
|
|
if c.GetBool("sshd.enabled", false) {
|
|
|
|
sshStart, err = configSSH(l, ssh, c)
|
2019-11-19 10:00:20 -07:00
|
|
|
if err != nil {
|
2023-08-14 20:32:40 -06:00
|
|
|
return nil, util.ContextualizeIfNeeded("Error while configuring the sshd", err)
|
2019-11-19 10:00:20 -07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
// All non system modifying configuration consumption should live above this line
|
|
|
|
// tun config, listeners, anything modifying the computer should be below
|
|
|
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
|
2021-02-25 13:01:14 -07:00
|
|
|
var routines int
|
|
|
|
|
|
|
|
// If `routines` is set, use that and ignore the specific values
|
2021-11-03 19:54:04 -06:00
|
|
|
if routines = c.GetInt("routines", 0); routines != 0 {
|
2021-02-25 13:01:14 -07:00
|
|
|
if routines < 1 {
|
|
|
|
routines = 1
|
|
|
|
}
|
|
|
|
if routines > 1 {
|
|
|
|
l.WithField("routines", routines).Info("Using multiple routines")
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// deprecated and undocumented
|
2021-11-03 19:54:04 -06:00
|
|
|
tunQueues := c.GetInt("tun.routines", 1)
|
|
|
|
udpQueues := c.GetInt("listen.routines", 1)
|
2021-02-25 13:01:14 -07:00
|
|
|
if tunQueues > udpQueues {
|
|
|
|
routines = tunQueues
|
|
|
|
} else {
|
|
|
|
routines = udpQueues
|
|
|
|
}
|
|
|
|
if routines != 1 {
|
|
|
|
l.WithField("routines", routines).Warn("Setting tun.routines and listen.routines is deprecated. Use `routines` instead")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-03-01 17:52:17 -07:00
|
|
|
// EXPERIMENTAL
|
|
|
|
// Intentionally not documented yet while we do more testing and determine
|
|
|
|
// a good default value.
|
2021-11-03 19:54:04 -06:00
|
|
|
conntrackCacheTimeout := c.GetDuration("firewall.conntrack.routine_cache_timeout", 0)
|
|
|
|
if routines > 1 && !c.IsSet("firewall.conntrack.routine_cache_timeout") {
|
2021-03-01 17:52:17 -07:00
|
|
|
// Use a different default if we are running with multiple routines
|
|
|
|
conntrackCacheTimeout = 1 * time.Second
|
|
|
|
}
|
|
|
|
if conntrackCacheTimeout > 0 {
|
|
|
|
l.WithField("duration", conntrackCacheTimeout).Info("Using routine-local conntrack cache")
|
|
|
|
}
|
|
|
|
|
2021-11-10 20:52:26 -07:00
|
|
|
var tun overlay.Device
|
2020-04-06 12:35:32 -06:00
|
|
|
if !configTest {
|
2021-11-03 19:54:04 -06:00
|
|
|
c.CatchHUP(ctx)
|
2020-04-06 12:35:32 -06:00
|
|
|
|
2021-11-12 10:19:28 -07:00
|
|
|
tun, err = overlay.NewDeviceFromConfig(c, l, tunCidr, tunFd, routines)
|
2020-04-06 12:35:32 -06:00
|
|
|
if err != nil {
|
2023-08-14 20:32:40 -06:00
|
|
|
return nil, util.ContextualizeIfNeeded("Failed to get a tun/tap device", err)
|
2020-04-06 12:35:32 -06:00
|
|
|
}
|
2019-11-19 10:00:20 -07:00
|
|
|
|
2021-12-06 12:06:16 -07:00
|
|
|
defer func() {
|
|
|
|
if reterr != nil {
|
|
|
|
tun.Close()
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
}
|
2021-11-02 12:14:26 -06:00
|
|
|
|
2019-11-19 10:00:20 -07:00
|
|
|
// set up our UDP listener
|
2023-06-14 09:48:52 -06:00
|
|
|
udpConns := make([]udp.Conn, routines)
|
2021-11-03 19:54:04 -06:00
|
|
|
port := c.GetInt("listen.port", 0)
|
2020-04-06 12:35:32 -06:00
|
|
|
|
|
|
|
if !configTest {
|
2023-04-05 10:29:26 -06:00
|
|
|
rawListenHost := c.GetString("listen.host", "0.0.0.0")
|
|
|
|
var listenHost *net.IPAddr
|
|
|
|
if rawListenHost == "[::]" {
|
|
|
|
// Old guidance was to provide the literal `[::]` in `listen.host` but that won't resolve.
|
|
|
|
listenHost = &net.IPAddr{IP: net.IPv6zero}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
listenHost, err = net.ResolveIPAddr("ip", rawListenHost)
|
|
|
|
if err != nil {
|
2023-08-14 20:32:40 -06:00
|
|
|
return nil, util.ContextualizeIfNeeded("Failed to resolve listen.host", err)
|
2023-04-05 10:29:26 -06:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-02-25 13:01:14 -07:00
|
|
|
for i := 0; i < routines; i++ {
|
2023-04-05 10:29:26 -06:00
|
|
|
udpServer, err := udp.NewListener(l, listenHost.IP, port, routines > 1, c.GetInt("listen.batch", 64))
|
2021-02-25 13:01:14 -07:00
|
|
|
if err != nil {
|
2021-11-10 20:47:38 -07:00
|
|
|
return nil, util.NewContextualError("Failed to open udp listener", m{"queue": i}, err)
|
2021-02-25 13:01:14 -07:00
|
|
|
}
|
2021-11-03 19:54:04 -06:00
|
|
|
udpServer.ReloadConfig(c)
|
2021-02-25 13:01:14 -07:00
|
|
|
udpConns[i] = udpServer
|
2020-04-06 12:35:32 -06:00
|
|
|
}
|
2019-11-19 10:00:20 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
// Set up my internal host map
|
|
|
|
var preferredRanges []*net.IPNet
|
2021-11-03 19:54:04 -06:00
|
|
|
rawPreferredRanges := c.GetStringSlice("preferred_ranges", []string{})
|
2019-11-19 10:00:20 -07:00
|
|
|
// First, check if 'preferred_ranges' is set and fallback to 'local_range'
|
|
|
|
if len(rawPreferredRanges) > 0 {
|
|
|
|
for _, rawPreferredRange := range rawPreferredRanges {
|
|
|
|
_, preferredRange, err := net.ParseCIDR(rawPreferredRange)
|
|
|
|
if err != nil {
|
2023-08-14 20:32:40 -06:00
|
|
|
return nil, util.ContextualizeIfNeeded("Failed to parse preferred ranges", err)
|
2019-11-19 10:00:20 -07:00
|
|
|
}
|
|
|
|
preferredRanges = append(preferredRanges, preferredRange)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// local_range was superseded by preferred_ranges. If it is still present,
|
|
|
|
// merge the local_range setting into preferred_ranges. We will probably
|
|
|
|
// deprecate local_range and remove in the future.
|
2021-11-03 19:54:04 -06:00
|
|
|
rawLocalRange := c.GetString("local_range", "")
|
2019-11-19 10:00:20 -07:00
|
|
|
if rawLocalRange != "" {
|
|
|
|
_, localRange, err := net.ParseCIDR(rawLocalRange)
|
|
|
|
if err != nil {
|
2023-08-14 20:32:40 -06:00
|
|
|
return nil, util.ContextualizeIfNeeded("Failed to parse local_range", err)
|
2019-11-19 10:00:20 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
// Check if the entry for local_range was already specified in
|
|
|
|
// preferred_ranges. Don't put it into the slice twice if so.
|
|
|
|
var found bool
|
|
|
|
for _, r := range preferredRanges {
|
|
|
|
if r.String() == localRange.String() {
|
|
|
|
found = true
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if !found {
|
|
|
|
preferredRanges = append(preferredRanges, localRange)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-24 11:37:52 -06:00
|
|
|
hostMap := NewHostMap(l, tunCidr, preferredRanges)
|
2021-11-03 19:54:04 -06:00
|
|
|
hostMap.metricsEnabled = c.GetBool("stats.message_metrics", false)
|
2019-12-12 09:34:17 -07:00
|
|
|
|
2023-01-23 12:05:35 -07:00
|
|
|
l.
|
|
|
|
WithField("network", hostMap.vpnCIDR.String()).
|
|
|
|
WithField("preferredRanges", hostMap.preferredRanges).
|
|
|
|
Info("Main HostMap created")
|
2019-11-19 10:00:20 -07:00
|
|
|
|
2022-03-14 11:35:13 -06:00
|
|
|
punchy := NewPunchyFromConfig(l, c)
|
2023-05-09 09:22:08 -06:00
|
|
|
lightHouse, err := NewLightHouseFromConfig(ctx, l, c, tunCidr, udpConns[0], punchy)
|
2023-08-14 20:32:40 -06:00
|
|
|
if err != nil {
|
|
|
|
return nil, util.ContextualizeIfNeeded("Failed to initialize lighthouse handler", err)
|
2019-11-23 14:46:45 -07:00
|
|
|
}
|
|
|
|
|
2020-06-26 11:45:48 -06:00
|
|
|
var messageMetrics *MessageMetrics
|
2021-11-03 19:54:04 -06:00
|
|
|
if c.GetBool("stats.message_metrics", false) {
|
2020-06-26 11:45:48 -06:00
|
|
|
messageMetrics = newMessageMetrics()
|
|
|
|
} else {
|
|
|
|
messageMetrics = newMessageMetricsOnlyRecvError()
|
|
|
|
}
|
|
|
|
|
2022-06-21 12:35:23 -06:00
|
|
|
useRelays := c.GetBool("relay.use_relays", DefaultUseRelays) && !c.GetBool("relay.am_relay", false)
|
|
|
|
|
2020-02-21 14:25:11 -07:00
|
|
|
handshakeConfig := HandshakeConfig{
|
2021-11-03 19:54:04 -06:00
|
|
|
tryInterval: c.GetDuration("handshakes.try_interval", DefaultHandshakeTryInterval),
|
|
|
|
retries: c.GetInt("handshakes.retries", DefaultHandshakeRetries),
|
|
|
|
triggerBuffer: c.GetInt("handshakes.trigger_buffer", DefaultHandshakeTriggerBuffer),
|
2022-06-21 12:35:23 -06:00
|
|
|
useRelays: useRelays,
|
2020-06-26 11:45:48 -06:00
|
|
|
|
|
|
|
messageMetrics: messageMetrics,
|
2020-02-21 14:25:11 -07:00
|
|
|
}
|
|
|
|
|
2023-08-21 17:51:45 -06:00
|
|
|
handshakeManager := NewHandshakeManager(l, hostMap, lightHouse, udpConns[0], handshakeConfig)
|
2020-07-22 08:35:10 -06:00
|
|
|
lightHouse.handshakeTrigger = handshakeManager.trigger
|
2019-11-19 10:00:20 -07:00
|
|
|
|
2021-04-16 12:33:56 -06:00
|
|
|
serveDns := false
|
2021-11-03 19:54:04 -06:00
|
|
|
if c.GetBool("lighthouse.serve_dns", false) {
|
|
|
|
if c.GetBool("lighthouse.am_lighthouse", false) {
|
2021-04-16 12:33:56 -06:00
|
|
|
serveDns = true
|
|
|
|
} else {
|
|
|
|
l.Warn("DNS server refusing to run because this host is not a lighthouse.")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-11-03 19:54:04 -06:00
|
|
|
checkInterval := c.GetInt("timers.connection_alive_interval", 5)
|
|
|
|
pendingDeletionInterval := c.GetInt("timers.pending_deletion_interval", 10)
|
2023-08-08 12:26:41 -06:00
|
|
|
|
2019-11-19 10:00:20 -07:00
|
|
|
ifConfig := &InterfaceConfig{
|
2019-11-23 09:50:36 -07:00
|
|
|
HostMap: hostMap,
|
|
|
|
Inside: tun,
|
2021-02-25 13:01:14 -07:00
|
|
|
Outside: udpConns[0],
|
2023-08-14 20:32:40 -06:00
|
|
|
pki: pki,
|
2021-11-03 19:54:04 -06:00
|
|
|
Cipher: c.GetString("cipher", "aes"),
|
2019-11-23 09:50:36 -07:00
|
|
|
Firewall: fw,
|
|
|
|
ServeDns: serveDns,
|
|
|
|
HandshakeManager: handshakeManager,
|
|
|
|
lightHouse: lightHouse,
|
2023-03-31 14:45:05 -06:00
|
|
|
checkInterval: time.Second * time.Duration(checkInterval),
|
|
|
|
pendingDeletionInterval: time.Second * time.Duration(pendingDeletionInterval),
|
2023-08-08 12:26:41 -06:00
|
|
|
tryPromoteEvery: c.GetUint32("counters.try_promote", defaultPromoteEvery),
|
|
|
|
reQueryEvery: c.GetUint32("counters.requery_every_packets", defaultReQueryEvery),
|
|
|
|
reQueryWait: c.GetDuration("timers.requery_wait_duration", defaultReQueryWait),
|
2021-11-03 19:54:04 -06:00
|
|
|
DropLocalBroadcast: c.GetBool("tun.drop_local_broadcast", false),
|
|
|
|
DropMulticast: c.GetBool("tun.drop_multicast", false),
|
2021-02-25 13:01:14 -07:00
|
|
|
routines: routines,
|
2020-06-26 11:45:48 -06:00
|
|
|
MessageMetrics: messageMetrics,
|
2020-09-18 08:20:09 -06:00
|
|
|
version: buildVersion,
|
2021-11-03 19:54:04 -06:00
|
|
|
disconnectInvalid: c.GetBool("pki.disconnect_invalid", false),
|
2022-06-21 12:35:23 -06:00
|
|
|
relayManager: NewRelayManager(ctx, l, hostMap, c),
|
2023-03-31 14:45:05 -06:00
|
|
|
punchy: punchy,
|
2021-03-01 17:52:17 -07:00
|
|
|
|
|
|
|
ConntrackCacheTimeout: conntrackCacheTimeout,
|
2021-03-26 08:46:30 -06:00
|
|
|
l: l,
|
2019-11-19 10:00:20 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
switch ifConfig.Cipher {
|
|
|
|
case "aes":
|
2020-03-30 12:23:55 -06:00
|
|
|
noiseEndianness = binary.BigEndian
|
2019-11-19 10:00:20 -07:00
|
|
|
case "chachapoly":
|
2020-03-30 12:23:55 -06:00
|
|
|
noiseEndianness = binary.LittleEndian
|
2019-11-19 10:00:20 -07:00
|
|
|
default:
|
2020-09-18 08:20:09 -06:00
|
|
|
return nil, fmt.Errorf("unknown cipher: %v", ifConfig.Cipher)
|
2019-11-19 10:00:20 -07:00
|
|
|
}
|
|
|
|
|
2020-04-06 12:35:32 -06:00
|
|
|
var ifce *Interface
|
|
|
|
if !configTest {
|
2021-11-02 12:14:26 -06:00
|
|
|
ifce, err = NewInterface(ctx, ifConfig)
|
2020-04-06 12:35:32 -06:00
|
|
|
if err != nil {
|
2020-09-18 08:20:09 -06:00
|
|
|
return nil, fmt.Errorf("failed to initialize interface: %s", err)
|
2020-04-06 12:35:32 -06:00
|
|
|
}
|
2019-11-19 10:00:20 -07:00
|
|
|
|
2021-02-25 13:01:14 -07:00
|
|
|
// TODO: Better way to attach these, probably want a new interface in InterfaceConfig
|
|
|
|
// I don't want to make this initial commit too far-reaching though
|
|
|
|
ifce.writers = udpConns
|
2023-07-27 13:38:10 -06:00
|
|
|
lightHouse.ifce = ifce
|
2021-02-25 13:01:14 -07:00
|
|
|
|
2021-11-03 19:54:04 -06:00
|
|
|
ifce.RegisterConfigChangeCallbacks(c)
|
2022-06-27 10:37:54 -06:00
|
|
|
ifce.reloadSendRecvError(c)
|
|
|
|
|
2023-08-21 17:51:45 -06:00
|
|
|
handshakeManager.f = ifce
|
|
|
|
go handshakeManager.Run(ctx)
|
2020-04-06 12:35:32 -06:00
|
|
|
}
|
2019-11-19 10:00:20 -07:00
|
|
|
|
2021-11-02 12:14:26 -06:00
|
|
|
// TODO - stats third-party modules start uncancellable goroutines. Update those libs to accept
|
|
|
|
// a context so that they can exit when the context is Done.
|
2021-11-03 19:54:04 -06:00
|
|
|
statsStart, err := startStats(l, c, buildVersion, configTest)
|
2019-11-19 10:00:20 -07:00
|
|
|
if err != nil {
|
2023-08-14 20:32:40 -06:00
|
|
|
return nil, util.ContextualizeIfNeeded("Failed to start stats emitter", err)
|
2019-11-19 10:00:20 -07:00
|
|
|
}
|
|
|
|
|
2020-04-06 12:35:32 -06:00
|
|
|
if configTest {
|
2020-09-18 08:20:09 -06:00
|
|
|
return nil, nil
|
2020-04-06 12:35:32 -06:00
|
|
|
}
|
|
|
|
|
2019-11-19 10:00:20 -07:00
|
|
|
//TODO: check if we _should_ be emitting stats
|
2021-11-03 19:54:04 -06:00
|
|
|
go ifce.emitStats(ctx, c.GetDuration("stats.interval", time.Second*10))
|
2019-11-19 10:00:20 -07:00
|
|
|
|
2023-07-24 11:37:52 -06:00
|
|
|
attachCommands(l, c, ssh, ifce)
|
2019-11-19 10:00:20 -07:00
|
|
|
|
2019-12-11 18:42:55 -07:00
|
|
|
// Start DNS server last to allow using the nebula IP as lighthouse.dns.host
|
2021-04-16 09:34:28 -06:00
|
|
|
var dnsStart func()
|
2022-03-14 11:35:13 -06:00
|
|
|
if lightHouse.amLighthouse && serveDns {
|
2019-12-11 18:42:55 -07:00
|
|
|
l.Debugln("Starting dns server")
|
2021-11-03 19:54:04 -06:00
|
|
|
dnsStart = dnsMain(l, hostMap, c)
|
2019-12-11 18:42:55 -07:00
|
|
|
}
|
|
|
|
|
2023-07-27 13:38:10 -06:00
|
|
|
return &Control{
|
|
|
|
ifce,
|
|
|
|
l,
|
|
|
|
cancel,
|
|
|
|
sshStart,
|
|
|
|
statsStart,
|
|
|
|
dnsStart,
|
|
|
|
lightHouse.StartUpdateWorker,
|
|
|
|
}, nil
|
2019-11-19 10:00:20 -07:00
|
|
|
}
|