Index: /trunk/Makefile
===================================================================
--- /trunk/Makefile	(revision 800)
+++ /trunk/Makefile	(revision 801)
@@ -7,24 +7,24 @@
 MANDIR ?= share/man
 
-all: soju sojuctl doc/soju.1
+all: suika suikactl doc/suika.1
 
-soju:
-	$(GO) build $(GOFLAGS) ./cmd/soju
-sojuctl:
-	$(GO) build $(GOFLAGS) ./cmd/sojuctl
-soju-znc-import:
-	$(GO) build $(GOFLAGS) ./cmd/soju-znc-import
-doc/soju.1: doc/soju.1.scd
-	$(SCDOC) <doc/soju.1.scd >doc/soju.1
+suika:
+	$(GO) build $(GOFLAGS) ./cmd/suika
+suikactl:
+	$(GO) build $(GOFLAGS) ./cmd/suikactl
+suika-znc-import:
+	$(GO) build $(GOFLAGS) ./cmd/suika-znc-import
+doc/suika.1: doc/suika.1.scd
+	$(SCDOC) <doc/suika.1.scd >doc/suika.1
 clean:
-	$(RM) -f soju sojuctl doc/soju.1
+	$(RM) -f suika suikactl doc/suika.1
 install:
 	mkdir -p $(DESTDIR)$(PREFIX)/$(BINDIR)
 	mkdir -p $(DESTDIR)$(PREFIX)/$(MANDIR)/man1
-	mkdir -p $(DESTDIR)/etc/soju
-	mkdir -p $(DESTDIR)/var/lib/soju
-	cp -f soju sojuctl $(DESTDIR)$(PREFIX)/$(BINDIR)
-	cp -f doc/soju.1 $(DESTDIR)$(PREFIX)/$(MANDIR)/man1
-	[ -f $(DESTDIR)/etc/soju/config ] || cp -f config.in $(DESTDIR)/etc/soju/config
+	mkdir -p $(DESTDIR)/etc/suika
+	mkdir -p $(DESTDIR)/var/lib/suika
+	cp -f suika suikactl $(DESTDIR)$(PREFIX)/$(BINDIR)
+	cp -f doc/suika.1 $(DESTDIR)$(PREFIX)/$(MANDIR)/man1
+	[ -f $(DESTDIR)/etc/suika/config ] || cp -f config.in $(DESTDIR)/etc/suika/config
 
-.PHONY: soju sojuctl soju-znc-import clean install
+.PHONY: suika suikactl suika-znc-import clean install
Index: /trunk/README.md
===================================================================
--- /trunk/README.md	(revision 800)
+++ /trunk/README.md	(revision 801)
@@ -1,7 +1,5 @@
-# [soju]
+# suika
 
-[![builds.sr.ht status](https://builds.sr.ht/~emersion/soju/commits.svg)](https://builds.sr.ht/~emersion/soju/commits?)
-
-A user-friendly IRC bouncer.
+A user-friendly IRC bouncer. Hard-fork of the 0.3 series of [soju](https://soju.im), named after [Suika Ibuki](https://en.touhouwiki.net/wiki/Suika_Ibuki) from [Touhou 7.5: Immaterial and Missing Power](https://en.touhouwiki.net/wiki/Immaterial_and_Missing_Power)
 
 - Multi-user
@@ -10,9 +8,4 @@
 - Support connecting to multiple upstream servers via a single IRC connection
   to the bouncer
-
-## Usage
-
-* [Getting started]
-* [Man page]
 
 ## Building and installing
@@ -28,14 +21,9 @@
 
     make
-    sudo make install
+    doas make install
 
-For development, you can use `go run ./cmd/soju` as usual.
+For development, you can use `go run ./cmd/suika` as usual.
 
 To link with the system libsqlite3, set `GOFLAGS="-tags=libsqlite3"`.
-
-## Contributing
-
-Send patches on the [mailing list] or on [GitHub], report bugs on the
-[issue tracker]. Discuss in [#soju on Libera Chat][IRC channel].
 
 ## License
@@ -44,10 +32,4 @@
 
 Copyright (C) 2020 The soju Contributors
+Copyright (C) 2023-present Aoi K.
 
-[soju]: https://soju.im
-[Getting started]: doc/getting-started.md
-[Man page]: https://soju.im/doc/soju.1.html
-[mailing list]: https://lists.sr.ht/~emersion/soju-dev
-[GitHub]: https://github.com/emersion/soju
-[issue tracker]: https://todo.sr.ht/~emersion/soju
-[IRC channel]: ircs://irc.libera.chat/#soju
Index: unk/cmd/soju-znc-import/main.go
===================================================================
--- /trunk/cmd/soju-znc-import/main.go	(revision 800)
+++ 	(revision )
@@ -1,474 +1,0 @@
-package main
-
-import (
-	"bufio"
-	"context"
-	"flag"
-	"fmt"
-	"io"
-	"log"
-	"net/url"
-	"os"
-	"strings"
-	"unicode"
-
-	"git.chaotic.ninja/koizumi.aoi/soju-retro"
-	"git.chaotic.ninja/koizumi.aoi/soju-retro/config"
-)
-
-const usage = `usage: soju-znc-import [options...] <znc config path>
-
-Imports configuration from a ZNC file. Users and networks are merged if they
-already exist in the soju database. ZNC settings overwrite existing soju
-settings.
-
-Options:
-
-  -help             Show this help message
-  -config <path>    Path to soju config file
-  -user <username>  Limit import to username (may be specified multiple times)
-  -network <name>   Limit import to network (may be specified multiple times)
-`
-
-func init() {
-	flag.Usage = func() {
-		fmt.Fprintf(flag.CommandLine.Output(), usage)
-	}
-}
-
-func main() {
-	var configPath string
-	users := make(map[string]bool)
-	networks := make(map[string]bool)
-	flag.StringVar(&configPath, "config", "", "path to configuration file")
-	flag.Var((*stringSetFlag)(&users), "user", "")
-	flag.Var((*stringSetFlag)(&networks), "network", "")
-	flag.Parse()
-
-	zncPath := flag.Arg(0)
-	if zncPath == "" {
-		flag.Usage()
-		os.Exit(1)
-	}
-
-	var cfg *config.Server
-	if configPath != "" {
-		var err error
-		cfg, err = config.Load(configPath)
-		if err != nil {
-			log.Fatalf("failed to load config file: %v", err)
-		}
-	} else {
-		cfg = config.Defaults()
-	}
-
-	ctx := context.Background()
-
-	db, err := soju.OpenDB(cfg.SQLDriver, cfg.SQLSource)
-	if err != nil {
-		log.Fatalf("failed to open database: %v", err)
-	}
-	defer db.Close()
-
-	f, err := os.Open(zncPath)
-	if err != nil {
-		log.Fatalf("failed to open ZNC configuration file: %v", err)
-	}
-	defer f.Close()
-
-	zp := zncParser{bufio.NewReader(f), 1}
-	root, err := zp.sectionBody("", "")
-	if err != nil {
-		log.Fatalf("failed to parse %q: line %v: %v", zncPath, zp.line, err)
-	}
-
-	l, err := db.ListUsers(ctx)
-	if err != nil {
-		log.Fatalf("failed to list users in DB: %v", err)
-	}
-	existingUsers := make(map[string]*soju.User, len(l))
-	for i, u := range l {
-		existingUsers[u.Username] = &l[i]
-	}
-
-	usersCreated := 0
-	usersImported := 0
-	networksImported := 0
-	channelsImported := 0
-	root.ForEach("User", func(section *zncSection) {
-		username := section.Name
-		if len(users) > 0 && !users[username] {
-			return
-		}
-		usersImported++
-
-		u, ok := existingUsers[username]
-		if ok {
-			log.Printf("user %q: updating existing user", username)
-		} else {
-			// "!!" is an invalid crypt format, thus disables password auth
-			u = &soju.User{Username: username, Password: "!!"}
-			usersCreated++
-			log.Printf("user %q: creating new user", username)
-		}
-
-		u.Admin = section.Values.Get("Admin") == "true"
-
-		if err := db.StoreUser(ctx, u); err != nil {
-			log.Fatalf("failed to store user %q: %v", username, err)
-		}
-		userID := u.ID
-
-		l, err := db.ListNetworks(ctx, userID)
-		if err != nil {
-			log.Fatalf("failed to list networks for user %q: %v", username, err)
-		}
-		existingNetworks := make(map[string]*soju.Network, len(l))
-		for i, n := range l {
-			existingNetworks[n.GetName()] = &l[i]
-		}
-
-		nick := section.Values.Get("Nick")
-		realname := section.Values.Get("RealName")
-		ident := section.Values.Get("Ident")
-
-		section.ForEach("Network", func(section *zncSection) {
-			netName := section.Name
-			if len(networks) > 0 && !networks[netName] {
-				return
-			}
-			networksImported++
-
-			logPrefix := fmt.Sprintf("user %q: network %q: ", username, netName)
-			logger := log.New(os.Stderr, logPrefix, log.LstdFlags|log.Lmsgprefix)
-
-			netNick := section.Values.Get("Nick")
-			if netNick == "" {
-				netNick = nick
-			}
-			netRealname := section.Values.Get("RealName")
-			if netRealname == "" {
-				netRealname = realname
-			}
-			netIdent := section.Values.Get("Ident")
-			if netIdent == "" {
-				netIdent = ident
-			}
-
-			for _, name := range section.Values["LoadModule"] {
-				switch name {
-				case "sasl":
-					logger.Printf("warning: SASL credentials not imported")
-				case "nickserv":
-					logger.Printf("warning: NickServ credentials not imported")
-				case "perform":
-					logger.Printf("warning: \"perform\" plugin commands not imported")
-				}
-			}
-
-			u, pass, err := importNetworkServer(section.Values.Get("Server"))
-			if err != nil {
-				logger.Fatalf("failed to import server %q: %v", section.Values.Get("Server"), err)
-			}
-
-			n, ok := existingNetworks[netName]
-			if ok {
-				logger.Printf("updating existing network")
-			} else {
-				n = &soju.Network{Name: netName}
-				logger.Printf("creating new network")
-			}
-
-			n.Addr = u.String()
-			n.Nick = netNick
-			n.Username = netIdent
-			n.Realname = netRealname
-			n.Pass = pass
-			n.Enabled = section.Values.Get("IRCConnectEnabled") != "false"
-
-			if err := db.StoreNetwork(ctx, userID, n); err != nil {
-				logger.Fatalf("failed to store network: %v", err)
-			}
-
-			l, err := db.ListChannels(ctx, n.ID)
-			if err != nil {
-				logger.Fatalf("failed to list channels: %v", err)
-			}
-			existingChannels := make(map[string]*soju.Channel, len(l))
-			for i, ch := range l {
-				existingChannels[ch.Name] = &l[i]
-			}
-
-			section.ForEach("Chan", func(section *zncSection) {
-				chName := section.Name
-
-				if section.Values.Get("Disabled") == "true" {
-					logger.Printf("skipping import of disabled channel %q", chName)
-					return
-				}
-
-				channelsImported++
-
-				ch, ok := existingChannels[chName]
-				if ok {
-					logger.Printf("channel %q: updating existing channel", chName)
-				} else {
-					ch = &soju.Channel{Name: chName}
-					logger.Printf("channel %q: creating new channel", chName)
-				}
-
-				ch.Key = section.Values.Get("Key")
-				ch.Detached = section.Values.Get("Detached") == "true"
-
-				if err := db.StoreChannel(ctx, n.ID, ch); err != nil {
-					logger.Printf("channel %q: failed to store channel: %v", chName, err)
-				}
-			})
-		})
-	})
-
-	if err := db.Close(); err != nil {
-		log.Printf("failed to close database: %v", err)
-	}
-
-	if usersCreated > 0 {
-		log.Printf("warning: user passwords haven't been imported, please set them with `sojuctl change-password <username>`")
-	}
-
-	log.Printf("imported %v users, %v networks and %v channels", usersImported, networksImported, channelsImported)
-}
-
-func importNetworkServer(s string) (u *url.URL, pass string, err error) {
-	parts := strings.Fields(s)
-	if len(parts) < 2 {
-		return nil, "", fmt.Errorf("expected space-separated host and port")
-	}
-
-	scheme := "irc+insecure"
-	host := parts[0]
-	port := parts[1]
-	if strings.HasPrefix(port, "+") {
-		port = port[1:]
-		scheme = "ircs"
-	}
-
-	if len(parts) > 2 {
-		pass = parts[2]
-	}
-
-	u = &url.URL{
-		Scheme: scheme,
-		Host:   host + ":" + port,
-	}
-	return u, pass, nil
-}
-
-type zncSection struct {
-	Type     string
-	Name     string
-	Values   zncValues
-	Children []zncSection
-}
-
-func (s *zncSection) ForEach(typ string, f func(*zncSection)) {
-	for _, section := range s.Children {
-		if section.Type == typ {
-			f(&section)
-		}
-	}
-}
-
-type zncValues map[string][]string
-
-func (zv zncValues) Get(k string) string {
-	if len(zv[k]) == 0 {
-		return ""
-	}
-	return zv[k][0]
-}
-
-type zncParser struct {
-	br   *bufio.Reader
-	line int
-}
-
-func (zp *zncParser) readByte() (byte, error) {
-	b, err := zp.br.ReadByte()
-	if b == '\n' {
-		zp.line++
-	}
-	return b, err
-}
-
-func (zp *zncParser) readRune() (rune, int, error) {
-	r, n, err := zp.br.ReadRune()
-	if r == '\n' {
-		zp.line++
-	}
-	return r, n, err
-}
-
-func (zp *zncParser) sectionBody(typ, name string) (*zncSection, error) {
-	section := &zncSection{Type: typ, Name: name, Values: make(zncValues)}
-
-Loop:
-	for {
-		if err := zp.skipSpace(); err != nil {
-			return nil, err
-		}
-
-		b, err := zp.br.Peek(2)
-		if err == io.EOF {
-			break
-		} else if err != nil {
-			return nil, err
-		}
-
-		switch b[0] {
-		case '<':
-			if b[1] == '/' {
-				break Loop
-			} else {
-				childType, childName, err := zp.sectionHeader()
-				if err != nil {
-					return nil, err
-				}
-				child, err := zp.sectionBody(childType, childName)
-				if err != nil {
-					return nil, err
-				}
-				if footerType, err := zp.sectionFooter(); err != nil {
-					return nil, err
-				} else if footerType != childType {
-					return nil, fmt.Errorf("invalid section footer: expected type %q, got %q", childType, footerType)
-				}
-				section.Children = append(section.Children, *child)
-			}
-		case '/':
-			if b[1] == '/' {
-				if err := zp.skipComment(); err != nil {
-					return nil, err
-				}
-				break
-			}
-			fallthrough
-		default:
-			k, v, err := zp.keyValuePair()
-			if err != nil {
-				return nil, err
-			}
-			section.Values[k] = append(section.Values[k], v)
-		}
-	}
-
-	return section, nil
-}
-
-func (zp *zncParser) skipSpace() error {
-	for {
-		r, _, err := zp.readRune()
-		if err == io.EOF {
-			return nil
-		} else if err != nil {
-			return err
-		}
-
-		if !unicode.IsSpace(r) {
-			zp.br.UnreadRune()
-			return nil
-		}
-	}
-}
-
-func (zp *zncParser) skipComment() error {
-	if err := zp.expectRune('/'); err != nil {
-		return err
-	}
-	if err := zp.expectRune('/'); err != nil {
-		return err
-	}
-
-	for {
-		b, err := zp.readByte()
-		if err == io.EOF {
-			return nil
-		} else if err != nil {
-			return err
-		}
-
-		if b == '\n' {
-			return nil
-		}
-	}
-}
-
-func (zp *zncParser) sectionHeader() (string, string, error) {
-	if err := zp.expectRune('<'); err != nil {
-		return "", "", err
-	}
-	typ, err := zp.readWord(' ')
-	if err != nil {
-		return "", "", err
-	}
-	name, err := zp.readWord('>')
-	return typ, name, err
-}
-
-func (zp *zncParser) sectionFooter() (string, error) {
-	if err := zp.expectRune('<'); err != nil {
-		return "", err
-	}
-	if err := zp.expectRune('/'); err != nil {
-		return "", err
-	}
-	return zp.readWord('>')
-}
-
-func (zp *zncParser) keyValuePair() (string, string, error) {
-	k, err := zp.readWord('=')
-	if err != nil {
-		return "", "", err
-	}
-	v, err := zp.readWord('\n')
-	return strings.TrimSpace(k), strings.TrimSpace(v), err
-}
-
-func (zp *zncParser) expectRune(expected rune) error {
-	r, _, err := zp.readRune()
-	if err != nil {
-		return err
-	} else if r != expected {
-		return fmt.Errorf("expected %q, got %q", expected, r)
-	}
-	return nil
-}
-
-func (zp *zncParser) readWord(delim byte) (string, error) {
-	var sb strings.Builder
-	for {
-		b, err := zp.readByte()
-		if err != nil {
-			return "", err
-		}
-
-		if b == delim {
-			return sb.String(), nil
-		}
-		if b == '\n' {
-			return "", fmt.Errorf("expected %q before newline", delim)
-		}
-
-		sb.WriteByte(b)
-	}
-}
-
-type stringSetFlag map[string]bool
-
-func (v *stringSetFlag) String() string {
-	return fmt.Sprint(map[string]bool(*v))
-}
-
-func (v *stringSetFlag) Set(s string) error {
-	(*v)[s] = true
-	return nil
-}
Index: unk/cmd/soju/main.go
===================================================================
--- /trunk/cmd/soju/main.go	(revision 800)
+++ 	(revision )
@@ -1,362 +1,0 @@
-package main
-
-import (
-	"context"
-	"crypto/tls"
-	"flag"
-	"fmt"
-	"io/ioutil"
-	"log"
-	"net"
-	"net/http"
-	_ "net/http/pprof"
-	"net/url"
-	"os"
-	"os/signal"
-	"strings"
-	"sync/atomic"
-	"syscall"
-	"time"
-
-	"github.com/pires/go-proxyproto"
-	"github.com/prometheus/client_golang/prometheus"
-	"github.com/prometheus/client_golang/prometheus/promhttp"
-
-	"git.chaotic.ninja/koizumi.aoi/soju-retro"
-	"git.chaotic.ninja/koizumi.aoi/soju-retro/config"
-)
-
-// TCP keep-alive interval for downstream TCP connections
-const downstreamKeepAlive = 1 * time.Hour
-
-type stringSliceFlag []string
-
-func (v *stringSliceFlag) String() string {
-	return fmt.Sprint([]string(*v))
-}
-
-func (v *stringSliceFlag) Set(s string) error {
-	*v = append(*v, s)
-	return nil
-}
-
-func bumpOpenedFileLimit() error {
-	var rlimit syscall.Rlimit
-	if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlimit); err != nil {
-		return fmt.Errorf("failed to get RLIMIT_NOFILE: %v", err)
-	}
-	rlimit.Cur = rlimit.Max
-	if err := syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rlimit); err != nil {
-		return fmt.Errorf("failed to set RLIMIT_NOFILE: %v", err)
-	}
-	return nil
-}
-
-var (
-	configPath string
-	debug      bool
-
-	tlsCert atomic.Value // *tls.Certificate
-)
-
-func loadConfig() (*config.Server, *soju.Config, error) {
-	var raw *config.Server
-	if configPath != "" {
-		var err error
-		raw, err = config.Load(configPath)
-		if err != nil {
-			return nil, nil, fmt.Errorf("failed to load config file: %v", err)
-		}
-	} else {
-		raw = config.Defaults()
-	}
-
-	var motd string
-	if raw.MOTDPath != "" {
-		b, err := ioutil.ReadFile(raw.MOTDPath)
-		if err != nil {
-			return nil, nil, fmt.Errorf("failed to load MOTD: %v", err)
-		}
-		motd = strings.TrimSuffix(string(b), "\n")
-	}
-
-	if raw.TLS != nil {
-		cert, err := tls.LoadX509KeyPair(raw.TLS.CertPath, raw.TLS.KeyPath)
-		if err != nil {
-			return nil, nil, fmt.Errorf("failed to load TLS certificate and key: %v", err)
-		}
-		tlsCert.Store(&cert)
-	}
-
-	cfg := &soju.Config{
-		Hostname:        raw.Hostname,
-		Title:           raw.Title,
-		LogPath:         raw.LogPath,
-		HTTPOrigins:     raw.HTTPOrigins,
-		AcceptProxyIPs:  raw.AcceptProxyIPs,
-		MaxUserNetworks: raw.MaxUserNetworks,
-		MultiUpstream:   raw.MultiUpstream,
-		UpstreamUserIPs: raw.UpstreamUserIPs,
-		MOTD:            motd,
-	}
-	return raw, cfg, nil
-}
-
-func main() {
-	var listen []string
-	flag.Var((*stringSliceFlag)(&listen), "listen", "listening address")
-	flag.StringVar(&configPath, "config", "", "path to configuration file")
-	flag.BoolVar(&debug, "debug", false, "enable debug logging")
-	flag.Parse()
-
-	cfg, serverCfg, err := loadConfig()
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	cfg.Listen = append(cfg.Listen, listen...)
-	if len(cfg.Listen) == 0 {
-		cfg.Listen = []string{":6697"}
-	}
-
-	if err := bumpOpenedFileLimit(); err != nil {
-		log.Printf("failed to bump max number of opened files: %v", err)
-	}
-
-	db, err := soju.OpenDB(cfg.SQLDriver, cfg.SQLSource)
-	if err != nil {
-		log.Fatalf("failed to open database: %v", err)
-	}
-
-	var tlsCfg *tls.Config
-	if cfg.TLS != nil {
-		tlsCfg = &tls.Config{
-			GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
-				return tlsCert.Load().(*tls.Certificate), nil
-			},
-		}
-	}
-
-	srv := soju.NewServer(db)
-	srv.SetConfig(serverCfg)
-	srv.Logger = soju.NewLogger(log.Writer(), debug)
-
-	for _, listen := range cfg.Listen {
-		listen := listen // copy
-		listenURI := listen
-		if !strings.Contains(listenURI, ":/") {
-			// This is a raw domain name, make it an URL with an empty scheme
-			listenURI = "//" + listenURI
-		}
-		u, err := url.Parse(listenURI)
-		if err != nil {
-			log.Fatalf("failed to parse listen URI %q: %v", listen, err)
-		}
-
-		switch u.Scheme {
-		case "ircs", "":
-			if tlsCfg == nil {
-				log.Fatalf("failed to listen on %q: missing TLS configuration", listen)
-			}
-			host := u.Host
-			if _, _, err := net.SplitHostPort(host); err != nil {
-				host = host + ":6697"
-			}
-			ircsTLSCfg := tlsCfg.Clone()
-			ircsTLSCfg.NextProtos = []string{"irc"}
-			lc := net.ListenConfig{
-				KeepAlive: downstreamKeepAlive,
-			}
-			l, err := lc.Listen(context.Background(), "tcp", host)
-			if err != nil {
-				log.Fatalf("failed to start TLS listener on %q: %v", listen, err)
-			}
-			ln := tls.NewListener(l, ircsTLSCfg)
-			ln = proxyProtoListener(ln, srv)
-			go func() {
-				if err := srv.Serve(ln); err != nil {
-					log.Printf("serving %q: %v", listen, err)
-				}
-			}()
-		case "irc+insecure":
-			host := u.Host
-			if _, _, err := net.SplitHostPort(host); err != nil {
-				host = host + ":6667"
-			}
-			lc := net.ListenConfig{
-				KeepAlive: downstreamKeepAlive,
-			}
-			ln, err := lc.Listen(context.Background(), "tcp", host)
-			if err != nil {
-				log.Fatalf("failed to start listener on %q: %v", listen, err)
-			}
-			ln = proxyProtoListener(ln, srv)
-			go func() {
-				if err := srv.Serve(ln); err != nil {
-					log.Printf("serving %q: %v", listen, err)
-				}
-			}()
-		case "unix":
-			ln, err := net.Listen("unix", u.Path)
-			if err != nil {
-				log.Fatalf("failed to start listener on %q: %v", listen, err)
-			}
-			ln = proxyProtoListener(ln, srv)
-			go func() {
-				if err := srv.Serve(ln); err != nil {
-					log.Printf("serving %q: %v", listen, err)
-				}
-			}()
-		case "wss":
-			if tlsCfg == nil {
-				log.Fatalf("failed to listen on %q: missing TLS configuration", listen)
-			}
-			addr := u.Host
-			if _, _, err := net.SplitHostPort(addr); err != nil {
-				addr = addr + ":https"
-			}
-			httpSrv := http.Server{
-				Addr:      addr,
-				TLSConfig: tlsCfg,
-				Handler:   srv,
-			}
-			go func() {
-				if err := httpSrv.ListenAndServeTLS("", ""); err != nil {
-					log.Fatalf("serving %q: %v", listen, err)
-				}
-			}()
-		case "ws+insecure":
-			addr := u.Host
-			if _, _, err := net.SplitHostPort(addr); err != nil {
-				addr = addr + ":http"
-			}
-			httpSrv := http.Server{
-				Addr:    addr,
-				Handler: srv,
-			}
-			go func() {
-				if err := httpSrv.ListenAndServe(); err != nil {
-					log.Fatalf("serving %q: %v", listen, err)
-				}
-			}()
-		case "ident":
-			if srv.Identd == nil {
-				srv.Identd = soju.NewIdentd()
-			}
-
-			host := u.Host
-			if _, _, err := net.SplitHostPort(host); err != nil {
-				host = host + ":113"
-			}
-			ln, err := net.Listen("tcp", host)
-			if err != nil {
-				log.Fatalf("failed to start listener on %q: %v", listen, err)
-			}
-			ln = proxyProtoListener(ln, srv)
-			go func() {
-				if err := srv.Identd.Serve(ln); err != nil {
-					log.Printf("serving %q: %v", listen, err)
-				}
-			}()
-		case "http+prometheus":
-			if srv.MetricsRegistry == nil {
-				srv.MetricsRegistry = prometheus.DefaultRegisterer
-			}
-
-			// Only allow localhost as listening host for security reasons.
-			// Users can always explicitly setup reverse proxies if desirable.
-			hostname, _, err := net.SplitHostPort(u.Host)
-			if err != nil {
-				log.Fatalf("invalid host in URI %q: %v", listen, err)
-			} else if hostname != "localhost" {
-				log.Fatalf("Prometheus listening host must be localhost")
-			}
-
-			metricsHandler := promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{
-				MaxRequestsInFlight: 10,
-				Timeout:             10 * time.Second,
-				EnableOpenMetrics:   true,
-			})
-			metricsHandler = promhttp.InstrumentMetricHandler(prometheus.DefaultRegisterer, metricsHandler)
-
-			httpSrv := http.Server{
-				Addr:    u.Host,
-				Handler: metricsHandler,
-			}
-			go func() {
-				if err := httpSrv.ListenAndServe(); err != nil {
-					log.Fatalf("serving %q: %v", listen, err)
-				}
-			}()
-		case "http+pprof":
-			// Only allow localhost as listening host for security reasons.
-			// Users can always explicitly setup reverse proxies if desirable.
-			hostname, _, err := net.SplitHostPort(u.Host)
-			if err != nil {
-				log.Fatalf("invalid host in URI %q: %v", listen, err)
-			} else if hostname != "localhost" {
-				log.Fatalf("pprof listening host must be localhost")
-			}
-
-			// net/http/pprof registers its handlers in http.DefaultServeMux
-			httpSrv := http.Server{
-				Addr:    u.Host,
-				Handler: http.DefaultServeMux,
-			}
-			go func() {
-				if err := httpSrv.ListenAndServe(); err != nil {
-					log.Fatalf("serving %q: %v", listen, err)
-				}
-			}()
-		default:
-			log.Fatalf("failed to listen on %q: unsupported scheme", listen)
-		}
-
-		log.Printf("server listening on %q", listen)
-	}
-
-	if db, ok := db.(soju.MetricsCollectorDatabase); ok && srv.MetricsRegistry != nil {
-		srv.MetricsRegistry.MustRegister(db.MetricsCollector())
-	}
-
-	sigCh := make(chan os.Signal, 1)
-	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
-
-	if err := srv.Start(); err != nil {
-		log.Fatal(err)
-	}
-
-	for sig := range sigCh {
-		switch sig {
-		case syscall.SIGHUP:
-			log.Print("reloading configuration")
-			_, serverCfg, err := loadConfig()
-			if err != nil {
-				log.Printf("failed to reloading configuration: %v", err)
-			} else {
-				srv.SetConfig(serverCfg)
-			}
-		case syscall.SIGINT, syscall.SIGTERM:
-			log.Print("shutting down server")
-			srv.Shutdown()
-			return
-		}
-	}
-}
-
-func proxyProtoListener(ln net.Listener, srv *soju.Server) net.Listener {
-	return &proxyproto.Listener{
-		Listener: ln,
-		Policy: func(upstream net.Addr) (proxyproto.Policy, error) {
-			tcpAddr, ok := upstream.(*net.TCPAddr)
-			if !ok {
-				return proxyproto.IGNORE, nil
-			}
-			if srv.Config().AcceptProxyIPs.Contains(tcpAddr.IP) {
-				return proxyproto.USE, nil
-			}
-			return proxyproto.IGNORE, nil
-		},
-		ReadHeaderTimeout: 5 * time.Second,
-	}
-}
Index: unk/cmd/sojuctl/main.go
===================================================================
--- /trunk/cmd/sojuctl/main.go	(revision 800)
+++ 	(revision )
@@ -1,148 +1,0 @@
-package main
-
-import (
-	"bufio"
-	"context"
-	"flag"
-	"fmt"
-	"io"
-	"log"
-	"os"
-
-	"git.chaotic.ninja/koizumi.aoi/soju-retro"
-	"git.chaotic.ninja/koizumi.aoi/soju-retro/config"
-	"golang.org/x/crypto/bcrypt"
-	"golang.org/x/crypto/ssh/terminal"
-)
-
-const usage = `usage: sojuctl [-config path] <action> [options...]
-
-  create-user <username> [-admin]  Create a new user
-  change-password <username>       Change password for a user
-  help                             Show this help message
-`
-
-func init() {
-	flag.Usage = func() {
-		fmt.Fprintf(flag.CommandLine.Output(), usage)
-	}
-}
-
-func main() {
-	var configPath string
-	flag.StringVar(&configPath, "config", "", "path to configuration file")
-	flag.Parse()
-
-	var cfg *config.Server
-	if configPath != "" {
-		var err error
-		cfg, err = config.Load(configPath)
-		if err != nil {
-			log.Fatalf("failed to load config file: %v", err)
-		}
-	} else {
-		cfg = config.Defaults()
-	}
-
-	db, err := soju.OpenDB(cfg.SQLDriver, cfg.SQLSource)
-	if err != nil {
-		log.Fatalf("failed to open database: %v", err)
-	}
-
-	ctx := context.Background()
-
-	switch cmd := flag.Arg(0); cmd {
-	case "create-user":
-		username := flag.Arg(1)
-		if username == "" {
-			flag.Usage()
-			os.Exit(1)
-		}
-
-		fs := flag.NewFlagSet("", flag.ExitOnError)
-		admin := fs.Bool("admin", false, "make the new user admin")
-		fs.Parse(flag.Args()[2:])
-
-		password, err := readPassword()
-		if err != nil {
-			log.Fatalf("failed to read password: %v", err)
-		}
-
-		hashed, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
-		if err != nil {
-			log.Fatalf("failed to hash password: %v", err)
-		}
-
-		user := soju.User{
-			Username: username,
-			Password: string(hashed),
-			Admin:    *admin,
-		}
-		if err := db.StoreUser(ctx, &user); err != nil {
-			log.Fatalf("failed to create user: %v", err)
-		}
-	case "change-password":
-		username := flag.Arg(1)
-		if username == "" {
-			flag.Usage()
-			os.Exit(1)
-		}
-
-		user, err := db.GetUser(ctx, username)
-		if err != nil {
-			log.Fatalf("failed to get user: %v", err)
-		}
-
-		password, err := readPassword()
-		if err != nil {
-			log.Fatalf("failed to read password: %v", err)
-		}
-
-		hashed, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
-		if err != nil {
-			log.Fatalf("failed to hash password: %v", err)
-		}
-
-		user.Password = string(hashed)
-		if err := db.StoreUser(ctx, user); err != nil {
-			log.Fatalf("failed to update password: %v", err)
-		}
-	default:
-		flag.Usage()
-		if cmd != "help" {
-			os.Exit(1)
-		}
-	}
-}
-
-func readPassword() ([]byte, error) {
-	var password []byte
-	var err error
-	fd := int(os.Stdin.Fd())
-
-	if terminal.IsTerminal(fd) {
-		fmt.Printf("Password: ")
-		password, err = terminal.ReadPassword(int(os.Stdin.Fd()))
-		if err != nil {
-			return nil, err
-		}
-		fmt.Printf("\n")
-	} else {
-		fmt.Fprintf(os.Stderr, "Warning: Reading password from stdin.\n")
-		// TODO: the buffering messes up repeated calls to readPassword
-		scanner := bufio.NewScanner(os.Stdin)
-		if !scanner.Scan() {
-			if err := scanner.Err(); err != nil {
-				return nil, err
-			}
-			return nil, io.ErrUnexpectedEOF
-		}
-		password = scanner.Bytes()
-
-		if len(password) == 0 {
-			return nil, fmt.Errorf("zero length password")
-		}
-	}
-
-	return password, nil
-}
Index: /trunk/cmd/suika-znc-import/main.go
===================================================================
--- /trunk/cmd/suika-znc-import/main.go	(revision 801)
+++ /trunk/cmd/suika-znc-import/main.go	(revision 801)
@@ -0,0 +1,474 @@
+package main
+
+import (
+	"bufio"
+	"context"
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"net/url"
+	"os"
+	"strings"
+	"unicode"
+
+	"git.chaotic.ninja/koizumi.aoi/suika"
+	"git.chaotic.ninja/koizumi.aoi/suika/config"
+)
+
+const usage = `usage: soju-znc-import [options...] <znc config path>
+
+Imports configuration from a ZNC file. Users and networks are merged if they
+already exist in the soju database. ZNC settings overwrite existing soju
+settings.
+
+Options:
+
+  -help             Show this help message
+  -config <path>    Path to soju config file
+  -user <username>  Limit import to username (may be specified multiple times)
+  -network <name>   Limit import to network (may be specified multiple times)
+`
+
+func init() {
+	flag.Usage = func() {
+		fmt.Fprintf(flag.CommandLine.Output(), usage)
+	}
+}
+
+func main() {
+	var configPath string
+	users := make(map[string]bool)
+	networks := make(map[string]bool)
+	flag.StringVar(&configPath, "config", "", "path to configuration file")
+	flag.Var((*stringSetFlag)(&users), "user", "")
+	flag.Var((*stringSetFlag)(&networks), "network", "")
+	flag.Parse()
+
+	zncPath := flag.Arg(0)
+	if zncPath == "" {
+		flag.Usage()
+		os.Exit(1)
+	}
+
+	var cfg *config.Server
+	if configPath != "" {
+		var err error
+		cfg, err = config.Load(configPath)
+		if err != nil {
+			log.Fatalf("failed to load config file: %v", err)
+		}
+	} else {
+		cfg = config.Defaults()
+	}
+
+	ctx := context.Background()
+
+	db, err := soju.OpenDB(cfg.SQLDriver, cfg.SQLSource)
+	if err != nil {
+		log.Fatalf("failed to open database: %v", err)
+	}
+	defer db.Close()
+
+	f, err := os.Open(zncPath)
+	if err != nil {
+		log.Fatalf("failed to open ZNC configuration file: %v", err)
+	}
+	defer f.Close()
+
+	zp := zncParser{bufio.NewReader(f), 1}
+	root, err := zp.sectionBody("", "")
+	if err != nil {
+		log.Fatalf("failed to parse %q: line %v: %v", zncPath, zp.line, err)
+	}
+
+	l, err := db.ListUsers(ctx)
+	if err != nil {
+		log.Fatalf("failed to list users in DB: %v", err)
+	}
+	existingUsers := make(map[string]*soju.User, len(l))
+	for i, u := range l {
+		existingUsers[u.Username] = &l[i]
+	}
+
+	usersCreated := 0
+	usersImported := 0
+	networksImported := 0
+	channelsImported := 0
+	root.ForEach("User", func(section *zncSection) {
+		username := section.Name
+		if len(users) > 0 && !users[username] {
+			return
+		}
+		usersImported++
+
+		u, ok := existingUsers[username]
+		if ok {
+			log.Printf("user %q: updating existing user", username)
+		} else {
+			// "!!" is an invalid crypt format, thus disables password auth
+			u = &soju.User{Username: username, Password: "!!"}
+			usersCreated++
+			log.Printf("user %q: creating new user", username)
+		}
+
+		u.Admin = section.Values.Get("Admin") == "true"
+
+		if err := db.StoreUser(ctx, u); err != nil {
+			log.Fatalf("failed to store user %q: %v", username, err)
+		}
+		userID := u.ID
+
+		l, err := db.ListNetworks(ctx, userID)
+		if err != nil {
+			log.Fatalf("failed to list networks for user %q: %v", username, err)
+		}
+		existingNetworks := make(map[string]*soju.Network, len(l))
+		for i, n := range l {
+			existingNetworks[n.GetName()] = &l[i]
+		}
+
+		nick := section.Values.Get("Nick")
+		realname := section.Values.Get("RealName")
+		ident := section.Values.Get("Ident")
+
+		section.ForEach("Network", func(section *zncSection) {
+			netName := section.Name
+			if len(networks) > 0 && !networks[netName] {
+				return
+			}
+			networksImported++
+
+			logPrefix := fmt.Sprintf("user %q: network %q: ", username, netName)
+			logger := log.New(os.Stderr, logPrefix, log.LstdFlags|log.Lmsgprefix)
+
+			netNick := section.Values.Get("Nick")
+			if netNick == "" {
+				netNick = nick
+			}
+			netRealname := section.Values.Get("RealName")
+			if netRealname == "" {
+				netRealname = realname
+			}
+			netIdent := section.Values.Get("Ident")
+			if netIdent == "" {
+				netIdent = ident
+			}
+
+			for _, name := range section.Values["LoadModule"] {
+				switch name {
+				case "sasl":
+					logger.Printf("warning: SASL credentials not imported")
+				case "nickserv":
+					logger.Printf("warning: NickServ credentials not imported")
+				case "perform":
+					logger.Printf("warning: \"perform\" plugin commands not imported")
+				}
+			}
+
+			u, pass, err := importNetworkServer(section.Values.Get("Server"))
+			if err != nil {
+				logger.Fatalf("failed to import server %q: %v", section.Values.Get("Server"), err)
+			}
+
+			n, ok := existingNetworks[netName]
+			if ok {
+				logger.Printf("updating existing network")
+			} else {
+				n = &soju.Network{Name: netName}
+				logger.Printf("creating new network")
+			}
+
+			n.Addr = u.String()
+			n.Nick = netNick
+			n.Username = netIdent
+			n.Realname = netRealname
+			n.Pass = pass
+			n.Enabled = section.Values.Get("IRCConnectEnabled") != "false"
+
+			if err := db.StoreNetwork(ctx, userID, n); err != nil {
+				logger.Fatalf("failed to store network: %v", err)
+			}
+
+			l, err := db.ListChannels(ctx, n.ID)
+			if err != nil {
+				logger.Fatalf("failed to list channels: %v", err)
+			}
+			existingChannels := make(map[string]*soju.Channel, len(l))
+			for i, ch := range l {
+				existingChannels[ch.Name] = &l[i]
+			}
+
+			section.ForEach("Chan", func(section *zncSection) {
+				chName := section.Name
+
+				if section.Values.Get("Disabled") == "true" {
+					logger.Printf("skipping import of disabled channel %q", chName)
+					return
+				}
+
+				channelsImported++
+
+				ch, ok := existingChannels[chName]
+				if ok {
+					logger.Printf("channel %q: updating existing channel", chName)
+				} else {
+					ch = &soju.Channel{Name: chName}
+					logger.Printf("channel %q: creating new channel", chName)
+				}
+
+				ch.Key = section.Values.Get("Key")
+				ch.Detached = section.Values.Get("Detached") == "true"
+
+				if err := db.StoreChannel(ctx, n.ID, ch); err != nil {
+					logger.Printf("channel %q: failed to store channel: %v", chName, err)
+				}
+			})
+		})
+	})
+
+	if err := db.Close(); err != nil {
+		log.Printf("failed to close database: %v", err)
+	}
+
+	if usersCreated > 0 {
+		log.Printf("warning: user passwords haven't been imported, please set them with `sojuctl change-password <username>`")
+	}
+
+	log.Printf("imported %v users, %v networks and %v channels", usersImported, networksImported, channelsImported)
+}
+
+func importNetworkServer(s string) (u *url.URL, pass string, err error) {
+	parts := strings.Fields(s)
+	if len(parts) < 2 {
+		return nil, "", fmt.Errorf("expected space-separated host and port")
+	}
+
+	scheme := "irc+insecure"
+	host := parts[0]
+	port := parts[1]
+	if strings.HasPrefix(port, "+") {
+		port = port[1:]
+		scheme = "ircs"
+	}
+
+	if len(parts) > 2 {
+		pass = parts[2]
+	}
+
+	u = &url.URL{
+		Scheme: scheme,
+		Host:   host + ":" + port,
+	}
+	return u, pass, nil
+}
+
+type zncSection struct {
+	Type     string
+	Name     string
+	Values   zncValues
+	Children []zncSection
+}
+
+func (s *zncSection) ForEach(typ string, f func(*zncSection)) {
+	for _, section := range s.Children {
+		if section.Type == typ {
+			f(&section)
+		}
+	}
+}
+
+type zncValues map[string][]string
+
+func (zv zncValues) Get(k string) string {
+	if len(zv[k]) == 0 {
+		return ""
+	}
+	return zv[k][0]
+}
+
+type zncParser struct {
+	br   *bufio.Reader
+	line int
+}
+
+func (zp *zncParser) readByte() (byte, error) {
+	b, err := zp.br.ReadByte()
+	if b == '\n' {
+		zp.line++
+	}
+	return b, err
+}
+
+func (zp *zncParser) readRune() (rune, int, error) {
+	r, n, err := zp.br.ReadRune()
+	if r == '\n' {
+		zp.line++
+	}
+	return r, n, err
+}
+
+func (zp *zncParser) sectionBody(typ, name string) (*zncSection, error) {
+	section := &zncSection{Type: typ, Name: name, Values: make(zncValues)}
+
+Loop:
+	for {
+		if err := zp.skipSpace(); err != nil {
+			return nil, err
+		}
+
+		b, err := zp.br.Peek(2)
+		if err == io.EOF {
+			break
+		} else if err != nil {
+			return nil, err
+		}
+
+		switch b[0] {
+		case '<':
+			if b[1] == '/' {
+				break Loop
+			} else {
+				childType, childName, err := zp.sectionHeader()
+				if err != nil {
+					return nil, err
+				}
+				child, err := zp.sectionBody(childType, childName)
+				if err != nil {
+					return nil, err
+				}
+				if footerType, err := zp.sectionFooter(); err != nil {
+					return nil, err
+				} else if footerType != childType {
+					return nil, fmt.Errorf("invalid section footer: expected type %q, got %q", childType, footerType)
+				}
+				section.Children = append(section.Children, *child)
+			}
+		case '/':
+			if b[1] == '/' {
+				if err := zp.skipComment(); err != nil {
+					return nil, err
+				}
+				break
+			}
+			fallthrough
+		default:
+			k, v, err := zp.keyValuePair()
+			if err != nil {
+				return nil, err
+			}
+			section.Values[k] = append(section.Values[k], v)
+		}
+	}
+
+	return section, nil
+}
+
+func (zp *zncParser) skipSpace() error {
+	for {
+		r, _, err := zp.readRune()
+		if err == io.EOF {
+			return nil
+		} else if err != nil {
+			return err
+		}
+
+		if !unicode.IsSpace(r) {
+			zp.br.UnreadRune()
+			return nil
+		}
+	}
+}
+
+func (zp *zncParser) skipComment() error {
+	if err := zp.expectRune('/'); err != nil {
+		return err
+	}
+	if err := zp.expectRune('/'); err != nil {
+		return err
+	}
+
+	for {
+		b, err := zp.readByte()
+		if err == io.EOF {
+			return nil
+		} else if err != nil {
+			return err
+		}
+
+		if b == '\n' {
+			return nil
+		}
+	}
+}
+
+func (zp *zncParser) sectionHeader() (string, string, error) {
+	if err := zp.expectRune('<'); err != nil {
+		return "", "", err
+	}
+	typ, err := zp.readWord(' ')
+	if err != nil {
+		return "", "", err
+	}
+	name, err := zp.readWord('>')
+	return typ, name, err
+}
+
+func (zp *zncParser) sectionFooter() (string, error) {
+	if err := zp.expectRune('<'); err != nil {
+		return "", err
+	}
+	if err := zp.expectRune('/'); err != nil {
+		return "", err
+	}
+	return zp.readWord('>')
+}
+
+func (zp *zncParser) keyValuePair() (string, string, error) {
+	k, err := zp.readWord('=')
+	if err != nil {
+		return "", "", err
+	}
+	v, err := zp.readWord('\n')
+	return strings.TrimSpace(k), strings.TrimSpace(v), err
+}
+
+func (zp *zncParser) expectRune(expected rune) error {
+	r, _, err := zp.readRune()
+	if err != nil {
+		return err
+	} else if r != expected {
+		return fmt.Errorf("expected %q, got %q", expected, r)
+	}
+	return nil
+}
+
+func (zp *zncParser) readWord(delim byte) (string, error) {
+	var sb strings.Builder
+	for {
+		b, err := zp.readByte()
+		if err != nil {
+			return "", err
+		}
+
+		if b == delim {
+			return sb.String(), nil
+		}
+		if b == '\n' {
+			return "", fmt.Errorf("expected %q before newline", delim)
+		}
+
+		sb.WriteByte(b)
+	}
+}
+
+type stringSetFlag map[string]bool
+
+func (v *stringSetFlag) String() string {
+	return fmt.Sprint(map[string]bool(*v))
+}
+
+func (v *stringSetFlag) Set(s string) error {
+	(*v)[s] = true
+	return nil
+}
Index: /trunk/cmd/suika/main.go
===================================================================
--- /trunk/cmd/suika/main.go	(revision 801)
+++ /trunk/cmd/suika/main.go	(revision 801)
@@ -0,0 +1,362 @@
+package main
+
+import (
+	"context"
+	"crypto/tls"
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"net"
+	"net/http"
+	_ "net/http/pprof"
+	"net/url"
+	"os"
+	"os/signal"
+	"strings"
+	"sync/atomic"
+	"syscall"
+	"time"
+
+	"github.com/pires/go-proxyproto"
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promhttp"
+
+	"git.chaotic.ninja/koizumi.aoi/suika"
+	"git.chaotic.ninja/koizumi.aoi/suika/config"
+)
+
+// TCP keep-alive interval for downstream TCP connections
+const downstreamKeepAlive = 1 * time.Hour
+
+type stringSliceFlag []string
+
+func (v *stringSliceFlag) String() string {
+	return fmt.Sprint([]string(*v))
+}
+
+func (v *stringSliceFlag) Set(s string) error {
+	*v = append(*v, s)
+	return nil
+}
+
+func bumpOpenedFileLimit() error {
+	var rlimit syscall.Rlimit
+	if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlimit); err != nil {
+		return fmt.Errorf("failed to get RLIMIT_NOFILE: %v", err)
+	}
+	rlimit.Cur = rlimit.Max
+	if err := syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rlimit); err != nil {
+		return fmt.Errorf("failed to set RLIMIT_NOFILE: %v", err)
+	}
+	return nil
+}
+
+var (
+	configPath string
+	debug      bool
+
+	tlsCert atomic.Value // *tls.Certificate
+)
+
+func loadConfig() (*config.Server, *soju.Config, error) {
+	var raw *config.Server
+	if configPath != "" {
+		var err error
+		raw, err = config.Load(configPath)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to load config file: %v", err)
+		}
+	} else {
+		raw = config.Defaults()
+	}
+
+	var motd string
+	if raw.MOTDPath != "" {
+		b, err := ioutil.ReadFile(raw.MOTDPath)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to load MOTD: %v", err)
+		}
+		motd = strings.TrimSuffix(string(b), "\n")
+	}
+
+	if raw.TLS != nil {
+		cert, err := tls.LoadX509KeyPair(raw.TLS.CertPath, raw.TLS.KeyPath)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to load TLS certificate and key: %v", err)
+		}
+		tlsCert.Store(&cert)
+	}
+
+	cfg := &soju.Config{
+		Hostname:        raw.Hostname,
+		Title:           raw.Title,
+		LogPath:         raw.LogPath,
+		HTTPOrigins:     raw.HTTPOrigins,
+		AcceptProxyIPs:  raw.AcceptProxyIPs,
+		MaxUserNetworks: raw.MaxUserNetworks,
+		MultiUpstream:   raw.MultiUpstream,
+		UpstreamUserIPs: raw.UpstreamUserIPs,
+		MOTD:            motd,
+	}
+	return raw, cfg, nil
+}
+
+func main() {
+	var listen []string
+	flag.Var((*stringSliceFlag)(&listen), "listen", "listening address")
+	flag.StringVar(&configPath, "config", "", "path to configuration file")
+	flag.BoolVar(&debug, "debug", false, "enable debug logging")
+	flag.Parse()
+
+	cfg, serverCfg, err := loadConfig()
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	cfg.Listen = append(cfg.Listen, listen...)
+	if len(cfg.Listen) == 0 {
+		cfg.Listen = []string{":6697"}
+	}
+
+	if err := bumpOpenedFileLimit(); err != nil {
+		log.Printf("failed to bump max number of opened files: %v", err)
+	}
+
+	db, err := soju.OpenDB(cfg.SQLDriver, cfg.SQLSource)
+	if err != nil {
+		log.Fatalf("failed to open database: %v", err)
+	}
+
+	var tlsCfg *tls.Config
+	if cfg.TLS != nil {
+		tlsCfg = &tls.Config{
+			GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
+				return tlsCert.Load().(*tls.Certificate), nil
+			},
+		}
+	}
+
+	srv := soju.NewServer(db)
+	srv.SetConfig(serverCfg)
+	srv.Logger = soju.NewLogger(log.Writer(), debug)
+
+	for _, listen := range cfg.Listen {
+		listen := listen // copy
+		listenURI := listen
+		if !strings.Contains(listenURI, ":/") {
+			// This is a raw domain name, make it an URL with an empty scheme
+			listenURI = "//" + listenURI
+		}
+		u, err := url.Parse(listenURI)
+		if err != nil {
+			log.Fatalf("failed to parse listen URI %q: %v", listen, err)
+		}
+
+		switch u.Scheme {
+		case "ircs", "":
+			if tlsCfg == nil {
+				log.Fatalf("failed to listen on %q: missing TLS configuration", listen)
+			}
+			host := u.Host
+			if _, _, err := net.SplitHostPort(host); err != nil {
+				host = host + ":6697"
+			}
+			ircsTLSCfg := tlsCfg.Clone()
+			ircsTLSCfg.NextProtos = []string{"irc"}
+			lc := net.ListenConfig{
+				KeepAlive: downstreamKeepAlive,
+			}
+			l, err := lc.Listen(context.Background(), "tcp", host)
+			if err != nil {
+				log.Fatalf("failed to start TLS listener on %q: %v", listen, err)
+			}
+			ln := tls.NewListener(l, ircsTLSCfg)
+			ln = proxyProtoListener(ln, srv)
+			go func() {
+				if err := srv.Serve(ln); err != nil {
+					log.Printf("serving %q: %v", listen, err)
+				}
+			}()
+		case "irc+insecure":
+			host := u.Host
+			if _, _, err := net.SplitHostPort(host); err != nil {
+				host = host + ":6667"
+			}
+			lc := net.ListenConfig{
+				KeepAlive: downstreamKeepAlive,
+			}
+			ln, err := lc.Listen(context.Background(), "tcp", host)
+			if err != nil {
+				log.Fatalf("failed to start listener on %q: %v", listen, err)
+			}
+			ln = proxyProtoListener(ln, srv)
+			go func() {
+				if err := srv.Serve(ln); err != nil {
+					log.Printf("serving %q: %v", listen, err)
+				}
+			}()
+		case "unix":
+			ln, err := net.Listen("unix", u.Path)
+			if err != nil {
+				log.Fatalf("failed to start listener on %q: %v", listen, err)
+			}
+			ln = proxyProtoListener(ln, srv)
+			go func() {
+				if err := srv.Serve(ln); err != nil {
+					log.Printf("serving %q: %v", listen, err)
+				}
+			}()
+		case "wss":
+			if tlsCfg == nil {
+				log.Fatalf("failed to listen on %q: missing TLS configuration", listen)
+			}
+			addr := u.Host
+			if _, _, err := net.SplitHostPort(addr); err != nil {
+				addr = addr + ":https"
+			}
+			httpSrv := http.Server{
+				Addr:      addr,
+				TLSConfig: tlsCfg,
+				Handler:   srv,
+			}
+			go func() {
+				if err := httpSrv.ListenAndServeTLS("", ""); err != nil {
+					log.Fatalf("serving %q: %v", listen, err)
+				}
+			}()
+		case "ws+insecure":
+			addr := u.Host
+			if _, _, err := net.SplitHostPort(addr); err != nil {
+				addr = addr + ":http"
+			}
+			httpSrv := http.Server{
+				Addr:    addr,
+				Handler: srv,
+			}
+			go func() {
+				if err := httpSrv.ListenAndServe(); err != nil {
+					log.Fatalf("serving %q: %v", listen, err)
+				}
+			}()
+		case "ident":
+			if srv.Identd == nil {
+				srv.Identd = soju.NewIdentd()
+			}
+
+			host := u.Host
+			if _, _, err := net.SplitHostPort(host); err != nil {
+				host = host + ":113"
+			}
+			ln, err := net.Listen("tcp", host)
+			if err != nil {
+				log.Fatalf("failed to start listener on %q: %v", listen, err)
+			}
+			ln = proxyProtoListener(ln, srv)
+			go func() {
+				if err := srv.Identd.Serve(ln); err != nil {
+					log.Printf("serving %q: %v", listen, err)
+				}
+			}()
+		case "http+prometheus":
+			if srv.MetricsRegistry == nil {
+				srv.MetricsRegistry = prometheus.DefaultRegisterer
+			}
+
+			// Only allow localhost as listening host for security reasons.
+			// Users can always explicitly setup reverse proxies if desirable.
+			hostname, _, err := net.SplitHostPort(u.Host)
+			if err != nil {
+				log.Fatalf("invalid host in URI %q: %v", listen, err)
+			} else if hostname != "localhost" {
+				log.Fatalf("Prometheus listening host must be localhost")
+			}
+
+			metricsHandler := promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{
+				MaxRequestsInFlight: 10,
+				Timeout:             10 * time.Second,
+				EnableOpenMetrics:   true,
+			})
+			metricsHandler = promhttp.InstrumentMetricHandler(prometheus.DefaultRegisterer, metricsHandler)
+
+			httpSrv := http.Server{
+				Addr:    u.Host,
+				Handler: metricsHandler,
+			}
+			go func() {
+				if err := httpSrv.ListenAndServe(); err != nil {
+					log.Fatalf("serving %q: %v", listen, err)
+				}
+			}()
+		case "http+pprof":
+			// Only allow localhost as listening host for security reasons.
+			// Users can always explicitly setup reverse proxies if desirable.
+			hostname, _, err := net.SplitHostPort(u.Host)
+			if err != nil {
+				log.Fatalf("invalid host in URI %q: %v", listen, err)
+			} else if hostname != "localhost" {
+				log.Fatalf("pprof listening host must be localhost")
+			}
+
+			// net/http/pprof registers its handlers in http.DefaultServeMux
+			httpSrv := http.Server{
+				Addr:    u.Host,
+				Handler: http.DefaultServeMux,
+			}
+			go func() {
+				if err := httpSrv.ListenAndServe(); err != nil {
+					log.Fatalf("serving %q: %v", listen, err)
+				}
+			}()
+		default:
+			log.Fatalf("failed to listen on %q: unsupported scheme", listen)
+		}
+
+		log.Printf("server listening on %q", listen)
+	}
+
+	if db, ok := db.(soju.MetricsCollectorDatabase); ok && srv.MetricsRegistry != nil {
+		srv.MetricsRegistry.MustRegister(db.MetricsCollector())
+	}
+
+	sigCh := make(chan os.Signal, 1)
+	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
+
+	if err := srv.Start(); err != nil {
+		log.Fatal(err)
+	}
+
+	for sig := range sigCh {
+		switch sig {
+		case syscall.SIGHUP:
+			log.Print("reloading configuration")
+			_, serverCfg, err := loadConfig()
+			if err != nil {
+				log.Printf("failed to reloading configuration: %v", err)
+			} else {
+				srv.SetConfig(serverCfg)
+			}
+		case syscall.SIGINT, syscall.SIGTERM:
+			log.Print("shutting down server")
+			srv.Shutdown()
+			return
+		}
+	}
+}
+
+func proxyProtoListener(ln net.Listener, srv *soju.Server) net.Listener {
+	return &proxyproto.Listener{
+		Listener: ln,
+		Policy: func(upstream net.Addr) (proxyproto.Policy, error) {
+			tcpAddr, ok := upstream.(*net.TCPAddr)
+			if !ok {
+				return proxyproto.IGNORE, nil
+			}
+			if srv.Config().AcceptProxyIPs.Contains(tcpAddr.IP) {
+				return proxyproto.USE, nil
+			}
+			return proxyproto.IGNORE, nil
+		},
+		ReadHeaderTimeout: 5 * time.Second,
+	}
+}
Index: /trunk/cmd/suikactl/main.go
===================================================================
--- /trunk/cmd/suikactl/main.go	(revision 801)
+++ /trunk/cmd/suikactl/main.go	(revision 801)
@@ -0,0 +1,148 @@
+package main
+
+import (
+	"bufio"
+	"context"
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"os"
+
+	"git.chaotic.ninja/koizumi.aoi/suika"
+	"git.chaotic.ninja/koizumi.aoi/suika/config"
+	"golang.org/x/crypto/bcrypt"
+	"golang.org/x/crypto/ssh/terminal"
+)
+
+const usage = `usage: sojuctl [-config path] <action> [options...]
+
+  create-user <username> [-admin]  Create a new user
+  change-password <username>       Change password for a user
+  help                             Show this help message
+`
+
+func init() {
+	flag.Usage = func() {
+		fmt.Fprintf(flag.CommandLine.Output(), usage)
+	}
+}
+
+func main() {
+	var configPath string
+	flag.StringVar(&configPath, "config", "", "path to configuration file")
+	flag.Parse()
+
+	var cfg *config.Server
+	if configPath != "" {
+		var err error
+		cfg, err = config.Load(configPath)
+		if err != nil {
+			log.Fatalf("failed to load config file: %v", err)
+		}
+	} else {
+		cfg = config.Defaults()
+	}
+
+	db, err := soju.OpenDB(cfg.SQLDriver, cfg.SQLSource)
+	if err != nil {
+		log.Fatalf("failed to open database: %v", err)
+	}
+
+	ctx := context.Background()
+
+	switch cmd := flag.Arg(0); cmd {
+	case "create-user":
+		username := flag.Arg(1)
+		if username == "" {
+			flag.Usage()
+			os.Exit(1)
+		}
+
+		fs := flag.NewFlagSet("", flag.ExitOnError)
+		admin := fs.Bool("admin", false, "make the new user admin")
+		fs.Parse(flag.Args()[2:])
+
+		password, err := readPassword()
+		if err != nil {
+			log.Fatalf("failed to read password: %v", err)
+		}
+
+		hashed, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
+		if err != nil {
+			log.Fatalf("failed to hash password: %v", err)
+		}
+
+		user := soju.User{
+			Username: username,
+			Password: string(hashed),
+			Admin:    *admin,
+		}
+		if err := db.StoreUser(ctx, &user); err != nil {
+			log.Fatalf("failed to create user: %v", err)
+		}
+	case "change-password":
+		username := flag.Arg(1)
+		if username == "" {
+			flag.Usage()
+			os.Exit(1)
+		}
+
+		user, err := db.GetUser(ctx, username)
+		if err != nil {
+			log.Fatalf("failed to get user: %v", err)
+		}
+
+		password, err := readPassword()
+		if err != nil {
+			log.Fatalf("failed to read password: %v", err)
+		}
+
+		hashed, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
+		if err != nil {
+			log.Fatalf("failed to hash password: %v", err)
+		}
+
+		user.Password = string(hashed)
+		if err := db.StoreUser(ctx, user); err != nil {
+			log.Fatalf("failed to update password: %v", err)
+		}
+	default:
+		flag.Usage()
+		if cmd != "help" {
+			os.Exit(1)
+		}
+	}
+}
+
+func readPassword() ([]byte, error) {
+	var password []byte
+	var err error
+	fd := int(os.Stdin.Fd())
+
+	if terminal.IsTerminal(fd) {
+		fmt.Printf("Password: ")
+		password, err = terminal.ReadPassword(int(os.Stdin.Fd()))
+		if err != nil {
+			return nil, err
+		}
+		fmt.Printf("\n")
+	} else {
+		fmt.Fprintf(os.Stderr, "Warning: Reading password from stdin.\n")
+		// TODO: the buffering messes up repeated calls to readPassword
+		scanner := bufio.NewScanner(os.Stdin)
+		if !scanner.Scan() {
+			if err := scanner.Err(); err != nil {
+				return nil, err
+			}
+			return nil, io.ErrUnexpectedEOF
+		}
+		password = scanner.Bytes()
+
+		if len(password) == 0 {
+			return nil, fmt.Errorf("zero length password")
+		}
+	}
+
+	return password, nil
+}
Index: /trunk/go.mod
===================================================================
--- /trunk/go.mod	(revision 800)
+++ /trunk/go.mod	(revision 801)
@@ -1,3 +1,3 @@
-module git.chaotic.ninja/koizumi.aoi/soju-retro
+module git.chaotic.ninja/koizumi.aoi/suika
 
 go 1.15
Index: /trunk/server.go
===================================================================
--- /trunk/server.go	(revision 800)
+++ /trunk/server.go	(revision 801)
@@ -20,5 +20,5 @@
 	"nhooyr.io/websocket"
 
-	"git.chaotic.ninja/koizumi.aoi/soju-retro/config"
+	"git.chaotic.ninja/koizumi.aoi/suika/config"
 )
 
