aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNikolay Govorov <me@govorov.online>2026-04-02 02:22:47 +0100
committerNikolay Govorov <me@govorov.online>2026-04-02 02:22:47 +0100
commit5bdc4e0bfcc0ff1df9ba73a2f13d820d1a974015 (patch)
treeac08367a15109a7ff88602af62a45f3d3706f0be
parentf2bbe8c4e97e108c48d366a4018a73c66ca96026 (diff)
downloadtar
tar.gz
tar.bz2
tar.lz
tar.xz
tar.zst
zip
Add daemon CLI with users management
Diffstat
-rw-r--r--Taskfile.yml2+1 −1
-rw-r--r--cmd/mirumd/config.go10+8 −2
-rw-r--r--cmd/mirumd/main.go160+146 −14
-rw-r--r--cmd/mirumd/server_admin.go46+46 −0
-rw-r--r--go.mod5+4 −1
-rw-r--r--go.sum8+8 −0
-rw-r--r--internal/database/database.go107+102 −5
-rw-r--r--pkg/mirumd.yaml2+2 −0
-rw-r--r--proto/admin.proto36+36 −0
9 files changed, 353 insertions, 23 deletions
diff --git a/Taskfile.yml b/Taskfile.yml
index ba31fe4..2373b8b 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -16,7 +16,7 @@ tasks:
generates:
- internal/protocol/pb/*.pb.go
cmds:
- - protoc --go_out=. --go-grpc_out=. proto/mirum.proto
+ - protoc --go_out=. --go-grpc_out=. proto/mirum.proto proto/admin.proto
test:
desc: Run all tests
diff --git a/cmd/mirumd/config.go b/cmd/mirumd/config.go
index f124896..c856d01 100644
--- a/cmd/mirumd/config.go
+++ b/cmd/mirumd/config.go
@@ -13,8 +13,10 @@ import (
type config struct {
WwwAddr string `yaml:"www_addr"`
GrpcAddr string `yaml:"grpc_addr"`
+ AdminSocket string `yaml:"admin_socket"`
DatabaseUri string `yaml:"database_uri"`
WorkerSecret string `yaml:"secret"`
+ Pepper string `yaml:"pepper"`
GitHubToken string `yaml:"token"`
WebhookSecret string `yaml:"webhook_secret"`
@@ -22,8 +24,9 @@ type config struct {
func getConfig(filename string) (*config, error) {
cfg := &config{
- GrpcAddr: ":2026",
- WwwAddr: ":3000",
+ GrpcAddr: ":2026",
+ WwwAddr: ":3000",
+ AdminSocket: "/run/mirum/admin.sock",
}
data, err := os.ReadFile(filename)
@@ -44,6 +47,9 @@ func getConfig(filename string) (*config, error) {
if cfg.WebhookSecret == "" {
return nil, fmt.Errorf("error: webhook_secret is required")
}
+ if cfg.Pepper == "" {
+ return nil, fmt.Errorf("error: pepper is required")
+ }
return cfg, nil
}
diff --git a/cmd/mirumd/main.go b/cmd/mirumd/main.go
index 86fe007..1a048d7 100644
--- a/cmd/mirumd/main.go
+++ b/cmd/mirumd/main.go
@@ -5,7 +5,6 @@ package main
import (
"context"
- "flag"
"fmt"
"log/slog"
"net"
@@ -19,24 +18,91 @@ import (
"dimidiumlabs/mirum/internal/supervisor"
"github.com/coreos/go-systemd/v22/activation"
+ "github.com/spf13/cobra"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
)
func main() {
- configFile := flag.String("config", "", "path to config file")
- flag.Parse()
+ var socketPath string
- if *configFile == "" {
- slog.Error("-config required")
+ root := &cobra.Command{Use: "mirumd", Short: "Mirum CI server"}
+ root.PersistentFlags().StringVar(&socketPath, "socket", "", "admin socket path (default from config or /run/mirum/admin.sock)")
+
+ daemonCmd := &cobra.Command{
+ Use: "daemon",
+ Short: "Start the server",
+ Run: func(cmd *cobra.Command, args []string) {
+ configFile, _ := cmd.Flags().GetString("config")
+ daemon(configFile, socketPath)
+ },
+ }
+ root.AddCommand(daemonCmd)
+ daemonCmd.Flags().String("config", "", "path to config file")
+ _ = daemonCmd.MarkFlagRequired("config")
+
+ userCmd := &cobra.Command{Use: "user", Short: "Manage users"}
+ root.AddCommand(userCmd)
+
+ userCreateCmd := &cobra.Command{
+ Use: "create",
+ Short: "Create a user",
+ Run: func(cmd *cobra.Command, args []string) {
+ email, _ := cmd.Flags().GetString("email")
+ password, _ := cmd.Flags().GetString("password")
+ userCreate(socketPath, email, password)
+ },
+ }
+ userCmd.AddCommand(userCreateCmd)
+ userCreateCmd.Flags().String("email", "", "user email")
+ userCreateCmd.Flags().String("password", "", "user password")
+ _ = userCreateCmd.MarkFlagRequired("email")
+ _ = userCreateCmd.MarkFlagRequired("password")
+
+ setPasswordCmd := &cobra.Command{
+ Use: "set-password",
+ Short: "Set user password",
+ Run: func(cmd *cobra.Command, args []string) {
+ email, _ := cmd.Flags().GetString("email")
+ password, _ := cmd.Flags().GetString("password")
+ userSetPassword(socketPath, email, password)
+ },
+ }
+ userCmd.AddCommand(setPasswordCmd)
+ setPasswordCmd.Flags().String("email", "", "user email")
+ setPasswordCmd.Flags().String("password", "", "new password")
+ _ = setPasswordCmd.MarkFlagRequired("email")
+ _ = setPasswordCmd.MarkFlagRequired("password")
+
+ deleteUserCmd := &cobra.Command{
+ Use: "delete",
+ Short: "Delete a user",
+ Run: func(cmd *cobra.Command, args []string) {
+ email, _ := cmd.Flags().GetString("email")
+ userDelete(socketPath, email)
+ },
+ }
+ userCmd.AddCommand(deleteUserCmd)
+ deleteUserCmd.Flags().String("email", "", "user email")
+ _ = deleteUserCmd.MarkFlagRequired("email")
+
+ if err := root.Execute(); err != nil {
os.Exit(1)
}
+}
- cfg, err := getConfig(*configFile)
+func daemon(configFile, socketFlag string) {
+ cfg, err := getConfig(configFile)
if err != nil {
slog.Error("config parsing failed", "err", err)
os.Exit(1)
}
- slog.Info("config loaded", "configfile", *configFile)
+ if socketFlag != "" {
+ cfg.AdminSocket = socketFlag
+ }
+
+ slog.Info("config loaded", "configfile", configFile)
db, err := database.Open(context.Background(), cfg.DatabaseUri)
if err != nil {
@@ -64,14 +130,15 @@ func main() {
wwwSrv := NewWwwServer(ctx, srv)
grpcSrv := NewGrpcServer(ctx, srv, []byte(cfg.WorkerSecret))
+ adminSrv := NewAdminServer(srv)
- grpcLn, webLn, err := listeners(cfg)
+ grpcLn, webLn, adminLn, err := listeners(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
- slog.Info("listening", "grpc", grpcLn.Addr(), "web", webLn.Addr())
+ slog.Info("listening", "grpc", grpcLn.Addr(), "web", webLn.Addr(), "admin", cfg.AdminSocket)
go func() {
if err := wwwSrv.Serve(webLn); err != nil && err != http.ErrServerClosed {
@@ -85,6 +152,12 @@ func main() {
os.Exit(1)
}
}()
+ go func() {
+ if err := adminSrv.Serve(adminLn); err != nil {
+ slog.Error("admin server failed", "err", err)
+ os.Exit(1)
+ }
+ }()
sup.Ready()
go sup.StartWatchdog(ctx)
@@ -101,6 +174,7 @@ func main() {
srv.Close()
+ adminSrv.GracefulStop()
wwwSrv.Shutdown(ctx)
grpcSrv.GracefulStop()
}
@@ -108,16 +182,16 @@ func main() {
// listeners returns gRPC and HTTP listeners.
// With systemd socket activation it expects two named fds: "grpc" and "http".
// Without socket activation it falls back to configured addresses.
-func listeners(cfg *config) (grpcLn, webLn net.Listener, err error) {
+func listeners(cfg *config) (grpcLn, webLn, adminLn net.Listener, err error) {
named, err := activation.ListenersWithNames()
if err != nil {
- return nil, nil, fmt.Errorf("socket activation: %w", err)
+ return nil, nil, nil, fmt.Errorf("socket activation: %w", err)
}
if lns := named["grpc"]; len(lns) > 0 {
grpcLn = lns[0]
} else if grpcLn, err = net.Listen("tcp", cfg.GrpcAddr); err != nil {
- return nil, nil, err
+ return nil, nil, nil, err
}
defer func() {
if err != nil {
@@ -128,7 +202,7 @@ func listeners(cfg *config) (grpcLn, webLn net.Listener, err error) {
if lns := named["web"]; len(lns) > 0 {
webLn = lns[0]
} else if webLn, err = net.Listen("tcp", cfg.WwwAddr); err != nil {
- return nil, nil, err
+ return nil, nil, nil, err
}
defer func() {
if err != nil {
@@ -136,5 +210,63 @@ func listeners(cfg *config) (grpcLn, webLn net.Listener, err error) {
}
}()
- return grpcLn, webLn, nil
+ if adminLn, err = net.Listen("unix", cfg.AdminSocket); err != nil {
+ return nil, nil, nil, err
+ }
+ defer func() {
+ if err != nil {
+ _ = adminLn.Close()
+ }
+ }()
+
+ return grpcLn, webLn, adminLn, nil
+}
+
+func adminClient(socketPath string) pb.AdminClient {
+ if socketPath == "" {
+ socketPath = "/run/mirum/admin.sock"
+ }
+ conn, err := grpc.NewClient("unix://"+socketPath,
+ grpc.WithTransportCredentials(insecure.NewCredentials()),
+ )
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ return pb.NewAdminClient(conn)
+}
+
+func userCreate(socketPath, email, password string) {
+ resp, err := adminClient(socketPath).CreateUser(context.Background(), &pb.CreateUserRequest{
+ Email: email,
+ Password: password,
+ })
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ fmt.Println(resp.Id)
+}
+
+func userSetPassword(socketPath, email, password string) {
+ _, err := adminClient(socketPath).SetPassword(context.Background(), &pb.SetPasswordRequest{
+ Email: email,
+ Password: password,
+ })
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ fmt.Println("ok")
+}
+
+func userDelete(socketPath, email string) {
+ _, err := adminClient(socketPath).DeleteUser(context.Background(), &pb.DeleteUserRequest{
+ Email: email,
+ })
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ fmt.Println("ok")
}
diff --git a/cmd/mirumd/server_admin.go b/cmd/mirumd/server_admin.go
new file mode 100644
--- /dev/null
+++ b/cmd/mirumd/server_admin.go
@@ -0,0 +1,46 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package main
+
+import (
+ "context"
+
+ "dimidiumlabs/mirum/internal/protocol/pb"
+
+ "google.golang.org/grpc"
+)
+
+func NewAdminServer(srv *server) *grpc.Server {
+ as := &adminService{srv: srv}
+ s := grpc.NewServer()
+ pb.RegisterAdminServer(s, as)
+ return s
+}
+
+type adminService struct {
+ pb.UnimplementedAdminServer
+ srv *server
+}
+
+func (a *adminService) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
+ id, err := a.srv.db.CreateUser(ctx, req.Email, req.Password, []byte(a.srv.cfg.Pepper))
+ if err != nil {
+ return nil, err
+ }
+ return &pb.CreateUserResponse{Id: id}, nil
+}
+
+func (a *adminService) SetPassword(ctx context.Context, req *pb.SetPasswordRequest) (*pb.SetPasswordResponse, error) {
+ if err := a.srv.db.SetPassword(ctx, req.Email, req.Password, []byte(a.srv.cfg.Pepper)); err != nil {
+ return nil, err
+ }
+ return &pb.SetPasswordResponse{}, nil
+}
+
+func (a *adminService) DeleteUser(ctx context.Context, req *pb.DeleteUserRequest) (*pb.DeleteUserResponse, error) {
+ if err := a.srv.db.DeleteUser(ctx, req.Email); err != nil {
+ return nil, err
+ }
+ return &pb.DeleteUserResponse{}, nil
+}
diff --git a/go.mod b/go.mod
index 13f91a3..5237b98 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,9 @@ require (
github.com/coreos/go-systemd/v22 v22.7.0
github.com/jackc/pgx/v5 v5.9.1
github.com/jackc/tern/v2 v2.3.6
+ github.com/spf13/cobra v1.8.0
go.starlark.net v0.0.0-20260326113308-fadfc96def35
+ golang.org/x/crypto v0.49.0
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
@@ -19,6 +21,7 @@ require (
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -26,7 +29,7 @@ require (
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
- golang.org/x/crypto v0.49.0 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
diff --git a/go.sum b/go.sum
index 9d587a7..12406b8 100644
--- a/go.sum
+++ b/go.sum
@@ -10,6 +10,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
+github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -27,6 +28,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -49,10 +52,15 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
+github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
diff --git a/internal/database/database.go b/internal/database/database.go
index 52d915d..1f88692 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -5,17 +5,34 @@ package database
import (
"context"
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
"errors"
+ "fmt"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/tern/v2/migrate"
+ "golang.org/x/crypto/argon2"
+)
+
+const (
+ argonMemory = 64 * 1024 // 64 MB
+ argonTime = 3
+ argonThreads = 2
+ argonKeyLen = 32
+ saltLen = 16
)
var (
- errOpen = errors.New("database: failed to open")
- errPing = errors.New("database: failed to ping")
- errAcquire = errors.New("database: failed to acquire connection")
- errMigrate = errors.New("database: failed to create migrator")
+ errOpen = errors.New("database: failed to open")
+ errPing = errors.New("database: failed to ping")
+ errAcquire = errors.New("database: failed to acquire connection")
+ errMigrate = errors.New("database: failed to create migrator")
+ errCreateUser = errors.New("database: failed to create user")
+ errSetPassword = errors.New("database: failed to set password")
+ errDeleteUser = errors.New("database: failed to delete user")
)
// DB wraps a pgx connection pool.
@@ -59,10 +76,90 @@ func (db *DB) Migrate(ctx context.Context) error {
id UUID PRIMARY KEY DEFAULT uuidv7(),
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ
)`,
`DROP TABLE users`,
)
return migrator.Migrate(ctx)
}
+
+// CreateUser hashes the password with argon2id and inserts a new user.
+// The pepper is a server-side secret not stored in the database.
+func (db *DB) CreateUser(ctx context.Context, email, password string, pepper []byte) (string, error) {
+ hash, err := hashPassword(password, pepper)
+ if err != nil {
+ return "", err
+ }
+
+ var id string
+ err = db.Pool.QueryRow(ctx,
+ `INSERT INTO users (email, password) VALUES ($1, $2) RETURNING id`,
+ email, hash,
+ ).Scan(&id)
+ if err != nil {
+ return "", errors.Join(errCreateUser, err)
+ }
+
+ return id, nil
+}
+
+// SetPassword updates the password for a user identified by email.
+func (db *DB) SetPassword(ctx context.Context, email, password string, pepper []byte) error {
+ hash, err := hashPassword(password, pepper)
+ if err != nil {
+ return err
+ }
+
+ tag, err := db.Pool.Exec(ctx,
+ `UPDATE users SET password = $1 WHERE email = $2`,
+ hash, email,
+ )
+ if err != nil {
+ return errors.Join(errSetPassword, err)
+ }
+ if tag.RowsAffected() == 0 {
+ return fmt.Errorf("user not found: %s", email)
+ }
+
+ return nil
+}
+
+// DeleteUser clears all fields but keeps the row to preserve the id.
+func (db *DB) DeleteUser(ctx context.Context, email string) error {
+ tag, err := db.Pool.Exec(ctx,
+ `UPDATE users SET email = '<invalid>', password = '<invalid>', deleted_at = now() WHERE email = $1`,
+ email,
+ )
+ if err != nil {
+ return errors.Join(errDeleteUser, err)
+ }
+ if tag.RowsAffected() == 0 {
+ return fmt.Errorf("user not found: %s", email)
+ }
+ return nil
+}
+
+// hashPassword produces a PHC-format string:
+// $argon2id$v=19$m=65536,t=3,p=2$<salt>$<hash>
+func hashPassword(password string, pepper []byte) (string, error) {
+ salt := make([]byte, saltLen)
+ if _, err := rand.Read(salt); err != nil {
+ return "", err
+ }
+
+ // Apply pepper: HMAC-SHA256(pepper, password)
+ mac := hmac.New(sha256.New, pepper)
+ mac.Write([]byte(password))
+ peppered := mac.Sum(nil)
+
+ key := argon2.IDKey(peppered, salt, argonTime, argonMemory, argonThreads, argonKeyLen)
+
+ return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
+ argon2.Version,
+ argonMemory, argonTime, argonThreads,
+ base64.RawStdEncoding.EncodeToString(salt),
+ base64.RawStdEncoding.EncodeToString(key),
+ ), nil
+}
diff --git a/pkg/mirumd.yaml b/pkg/mirumd.yaml
index c3041b2..f140100 100644
--- a/pkg/mirumd.yaml
+++ b/pkg/mirumd.yaml
@@ -7,5 +7,7 @@ grpc_addr: :2026
www_addr: :3000
secret: ""
token: ""
+pepper: ""
dsn: ""
+admin_socket: /run/mirum/admin.sock
script: .mirum/main.star
diff --git a/proto/admin.proto b/proto/admin.proto
new file mode 100644
--- /dev/null
+++ b/proto/admin.proto
@@ -0,0 +1,36 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+syntax = "proto3";
+
+package mirum;
+
+option go_package = "internal/protocol/pb";
+
+service Admin {
+ rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
+ rpc SetPassword(SetPasswordRequest) returns (SetPasswordResponse);
+ rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse);
+}
+
+message CreateUserRequest {
+ string email = 1;
+ string password = 2;
+}
+
+message CreateUserResponse {
+ string id = 1;
+}
+
+message SetPasswordRequest {
+ string email = 1;
+ string password = 2;
+}
+
+message SetPasswordResponse {}
+
+message DeleteUserRequest {
+ string email = 1;
+}
+
+message DeleteUserResponse {}