CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Implements PLAN.md Phase 10. Simplified Sliding Sync (MSC4186) is served at the unstable org.matrix.simplified_msc3575 endpoint and advertised in /versions. Lists are sorted by activity and filtered by DM, invite, encryption, room type, space and tag; room subscriptions, required_state with wildcards, $ME and $LAZY, and the to_device, e2ee, account_data, receipts and typing extensions are supported. Per-connection state is kept in memory and an unknown pos answers M_UNKNOWN_POS. Threads and relations: m.relates_to is indexed on roomserver_events (migration 0012), /relations (with recursion) and /threads are served, and every event a timeline read returns carries its m.thread, m.replace and m.reference summaries in unsigned.m.relations. Notifications carry a thread ID (migration 0013): threaded receipts clear only their thread, and sync reports unread_thread_notifications when the filter asks. Spaces: /hierarchy walks a space breadth first, asking other servers about rooms this one is not in through the new federation hierarchy endpoint; room_summary describes one room. /search runs on FTS5 (sqlite, pocketbase) or tsvector (postgres), bounded to what each user may see, with context, profiles, state, grouping and paging. The PocketBase driver is real: the sqlite schema and store on an embedded PocketBase app's database, with media in its filesystem, local or S3 (database.pocketbase.s3). It runs the full storage test suite. Ops: OpenTelemetry tracing over OTLP/HTTP (tracing.*), with server spans per route, component spans, and trace propagation on federation, push and application service calls. New metrics for persisted events, waiting syncs, sliding sync connections and the database pool, with a Grafana dashboard. deploy/ adds a Compose setup (PostgreSQL, Prometheus, Grafana, Jaeger) and a Helm chart; cmd/warp-loadtest loads a server and reports request and delivery latencies. The load tester found a long-standing bug, fixed here: on a server where no account data had been written, every incremental /sync re-sent the push rules and returned at once, and an all-zero token was taken for an initial sync, so long polls became busy loops. The PostgreSQL migrations and search dialect compile but were not run against a live PostgreSQL in this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
5.0 KiB
Go
170 lines
5.0 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
|
|
"golang.org/x/term"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/config"
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
|
)
|
|
|
|
// runCreateAccount implements `warp create-account`.
|
|
//
|
|
// It talks to the database directly rather than to a running server, so the
|
|
// first account on a fresh install can be made before anything is listening
|
|
// and without turning registration on. That is the whole point of it: a
|
|
// private homeserver with registration off still needs a first admin.
|
|
func runCreateAccount(ctx context.Context, args []string) error {
|
|
fs := newFlagSet("create-account")
|
|
loadConfig := addConfigFlag(fs)
|
|
username := fs.String("username", "", "localpart of the new user, e.g. alice")
|
|
password := fs.String("password", "", "password (prompted for if omitted)")
|
|
passwordFile := fs.String("password-file", "", "read the password from this file instead of prompting")
|
|
displayName := fs.String("display-name", "", "profile display name (default: none)")
|
|
admin := fs.Bool("admin", false, "give the account the server administrator flag")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *username == "" {
|
|
return errors.New("create-account: --username is required")
|
|
}
|
|
if *password != "" && *passwordFile != "" {
|
|
return errors.New("create-account: pass either --password or --password-file, not both")
|
|
}
|
|
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log, err := newLogger(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
slog.SetDefault(log)
|
|
|
|
secret, err := resolvePassword(*password, *passwordFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
db, err := openDatabase(ctx, cfg, log)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
// The schema has to be there before an account can be written. Running
|
|
// the migrations here rather than erroring out means the documented
|
|
// first-run sequence works whatever order the operator does it in.
|
|
if err := db.Migrate(ctx); err != nil {
|
|
return fmt.Errorf("migrate database: %w", err)
|
|
}
|
|
|
|
users, err := userapi.New(userapi.Options{
|
|
ServerName: cfg.Global.ServerName,
|
|
Store: db.Users(),
|
|
Logger: log,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
account, err := users.CreateAccount(ctx, userapi.NewAccountRequest{
|
|
Localpart: *username,
|
|
Password: secret,
|
|
DisplayName: *displayName,
|
|
Admin: *admin,
|
|
})
|
|
switch {
|
|
case errors.Is(err, userapi.ErrUserExists):
|
|
return fmt.Errorf("%s already exists", users.UserID(userapi.NormaliseLocalpart(*username)))
|
|
case err != nil:
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("created %s\n", users.UserID(account.Localpart))
|
|
if account.Admin {
|
|
fmt.Println(" server administrator: yes")
|
|
}
|
|
if secret == "" {
|
|
fmt.Println(" no password set: this account cannot log in until one is")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// openDatabase opens the storage driver named in the config.
|
|
func openDatabase(ctx context.Context, cfg *config.Config, log *slog.Logger) (storage.Database, error) {
|
|
return storage.Open(ctx, cfg.Database.Storage(), log)
|
|
}
|
|
|
|
// resolvePassword settles where the password comes from.
|
|
//
|
|
// An interactive prompt is the default because a password on the command
|
|
// line is a password in the shell history and in the process list of every
|
|
// other user on the machine. --password is kept for scripts that have
|
|
// already dealt with that, and --password-file for the ones that have not.
|
|
func resolvePassword(flagValue, file string) (string, error) {
|
|
if flagValue != "" {
|
|
return flagValue, nil
|
|
}
|
|
if file != "" {
|
|
data, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read password file: %w", err)
|
|
}
|
|
secret := strings.TrimRight(string(data), "\r\n")
|
|
if secret == "" {
|
|
return "", fmt.Errorf("password file %s is empty", file)
|
|
}
|
|
return secret, nil
|
|
}
|
|
return promptForPassword()
|
|
}
|
|
|
|
// promptForPassword reads a password twice without echoing it.
|
|
//
|
|
// When stdin is not a terminal — a pipe in a container entrypoint, say —
|
|
// one line is read instead, with no confirmation, because there is nobody
|
|
// there to mistype it.
|
|
func promptForPassword() (string, error) {
|
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
|
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
|
if err != nil && line == "" {
|
|
return "", fmt.Errorf("read password from standard input: %w", err)
|
|
}
|
|
secret := strings.TrimRight(line, "\r\n")
|
|
if secret == "" {
|
|
return "", errors.New("no password given on standard input")
|
|
}
|
|
return secret, nil
|
|
}
|
|
|
|
fmt.Print("Password: ")
|
|
first, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
fmt.Println()
|
|
if err != nil {
|
|
return "", fmt.Errorf("read password: %w", err)
|
|
}
|
|
fmt.Print("Confirm: ")
|
|
second, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
fmt.Println()
|
|
if err != nil {
|
|
return "", fmt.Errorf("read password: %w", err)
|
|
}
|
|
if string(first) != string(second) {
|
|
return "", errors.New("the passwords do not match")
|
|
}
|
|
if len(first) == 0 {
|
|
return "", errors.New("the password must not be empty")
|
|
}
|
|
return string(first), nil
|
|
}
|