aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat
-rw-r--r--.github/workflows/build.yml209+209 −0
-rw-r--r--README.md3+3 −0
-rw-r--r--REUSE.toml4+4 −0
-rw-r--r--Taskfile.yml20+19 −1
-rw-r--r--VERSION1+1 −0
-rw-r--r--cmd/mirumd/main.go70+63 −7
-rw-r--r--go.mod5+4 −1
-rw-r--r--go.sum2+2 −0
-rw-r--r--nfpm.yaml62+62 −0
-rw-r--r--pkg/apt-ftparchive.conf29+29 −0
-rw-r--r--pkg/logo.svg25+25 −0
-rw-r--r--pkg/mirumd.service51+51 −0
-rw-r--r--pkg/mirumd.yaml4+4 −0
-rw-r--r--pkg/scripts/postinstall.sh10+10 −0
-rw-r--r--pkg/scripts/preinstall.sh17+17 −0
-rw-r--r--pkg/scripts/preremove.sh10+10 −0
16 files changed, 513 insertions, 9 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,209 @@
+# Copyright (c) 2026 Nikolay Govorov
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+name: Build
+
+on:
+ push:
+ branches: [main]
+ tags: ["v*"]
+ pull_request:
+ branches: [main]
+
+jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+
+ - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ with:
+ go-version-file: go.mod
+
+ - name: Check licenses
+ uses: fsfe/reuse-action@676e2d560c9a403aa252096d99fcab3e1132b0f5 # v6.0.0
+
+ - name: Check formatting
+ run: test -z "$(gofmt -l .)"
+
+ - name: Vet
+ run: go vet ./...
+
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ outputs:
+ channel: ${{ steps.version.outputs.channel }}
+ version: ${{ steps.version.outputs.version }}
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+
+ - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ with:
+ go-version-file: go.mod
+
+ - name: Install tools
+ run: |
+ echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list
+ sudo apt update && sudo apt install nfpm
+ sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin
+
+ - name: Import GPG key
+ uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6.3.0
+ with:
+ passphrase: ${{ secrets.GPG_PASSPHRASE }}
+ gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
+
+ - name: Determine version
+ id: version
+ run: |
+ if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
+ echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
+ echo "channel=stable" >> "$GITHUB_OUTPUT"
+ else
+ echo "version=$(cat VERSION)~nightly.$(git log -1 --format=%ct)" >> "$GITHUB_OUTPUT"
+ echo "channel=nightly" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Build and package
+ run: |
+ printf '%s' "$GPG_PRIVATE_KEY" > /tmp/private.asc
+ chmod 600 /tmp/private.asc
+ export SIGNING_PRIVATE_KEY="/tmp/private.asc"
+ task package VERSION=${{ steps.version.outputs.version }}
+ rm -f /tmp/private.asc
+ env:
+ GPG_KEY_ID: ${{ vars.GPG_KEY_ID }}
+ GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
+ NFPM_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
+
+ - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ with:
+ name: packages
+ path: build/dist/*
+
+ publish:
+ name: Publish
+ runs-on: ubuntu-latest
+ needs: [lint, build]
+ if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - name: Install tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y apt-utils rclone createrepo-c
+
+ - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
+ with:
+ name: packages
+ path: dist
+
+ # Configure GPG keys
+ - name: Import GPG key
+ uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6.3.0
+ with:
+ passphrase: ${{ secrets.GPG_PASSPHRASE }}
+ gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
+ - name: Update nightly tag
+ if: needs.build.outputs.channel == 'nightly'
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git tag -f nightly
+ git push origin --force tag nightly
+
+ - name: Delete old nightly assets
+ if: needs.build.outputs.channel == 'nightly'
+ run: |
+ for asset in $(gh release view nightly --json assets --jq '.assets[].name' 2>/dev/null || true); do
+ gh release delete-asset nightly "$asset" --yes
+ done
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
+ with:
+ name: ${{ needs.build.outputs.channel == 'stable' && needs.build.outputs.version || 'nightly' }}
+ tag_name: ${{ needs.build.outputs.channel == 'stable' && format('v{0}', needs.build.outputs.version) || 'nightly' }}
+ prerelease: ${{ needs.build.outputs.channel == 'nightly' }}
+ files: dist/*
+ body: |
+ **Build**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # Update APT/RPM repos on S3
+ - name: Configure rclone
+ run: |
+ mkdir -p ~/.config/rclone
+ cat > ~/.config/rclone/rclone.conf << EOF
+ [s3]
+ type = s3
+ provider = ${{ vars.S3_PROVIDER || 'Other' }}
+ access_key_id = ${{ secrets.S3_ACCESS_KEY_ID }}
+ secret_access_key = ${{ secrets.S3_SECRET_ACCESS_KEY }}
+ region = ${{ vars.S3_REGION }}
+ endpoint = ${{ vars.S3_ENDPOINT }}
+ EOF
+
+ - name: Upload GPG public key
+ run: |
+ gpg --export --armor "${{ vars.GPG_KEY_ID }}" > public.gpg
+ rclone copyto public.gpg "s3:${{ vars.S3_BUCKET }}/public.gpg"
+
+ - name: Create APT repository
+ run: |
+ CHANNEL=${{ needs.build.outputs.channel }}
+
+ mkdir -p apt-repo
+ rclone copy "s3:${{ vars.S3_BUCKET }}/apt/" apt-repo/
+
+ mkdir -p /tmp/apt-cache
+ for ch in stable nightly; do
+ mkdir -p apt-repo/pool/$ch
+ for arch in amd64 arm64 riscv64 ppc64le; do
+ mkdir -p apt-repo/dists/$ch/main/binary-$arch
+ done
+ done
+ cp dist/*.deb apt-repo/pool/$CHANNEL/
+
+ cd apt-repo
+ apt-ftparchive generate "${{ github.workspace }}/pkg/apt-ftparchive.conf"
+ apt-ftparchive \
+ -o APT::FTPArchive::Release::Codename=$CHANNEL \
+ -o APT::FTPArchive::Release::Components=main \
+ -o APT::FTPArchive::Release::Architectures="amd64 arm64 riscv64 ppc64le" \
+ release dists/$CHANNEL/ > dists/$CHANNEL/Release
+ gpg --default-key="${{ vars.GPG_KEY_ID }}" --batch --yes -abs -o dists/$CHANNEL/Release.gpg dists/$CHANNEL/Release
+ gpg --default-key="${{ vars.GPG_KEY_ID }}" --batch --yes --clearsign -o dists/$CHANNEL/InRelease dists/$CHANNEL/Release
+ cd ..
+
+ rclone copy apt-repo/ "s3:${{ vars.S3_BUCKET }}/apt/" --progress
+
+ - name: Create RPM repository
+ run: |
+ CHANNEL=${{ needs.build.outputs.channel }}
+
+ mkdir -p rpm-repo
+ rclone copy "s3:${{ vars.S3_BUCKET }}/rpm/$CHANNEL/" rpm-repo/
+
+ cp dist/*.rpm rpm-repo/
+
+ createrepo_c --update rpm-repo/
+ gpg --default-key="${{ vars.GPG_KEY_ID }}" --batch --yes --detach-sign --armor rpm-repo/repodata/repomd.xml
+
+ cat > rpm-repo/mirum-${CHANNEL}.repo << EOF
+ [mirum-${CHANNEL}]
+ name=Mirum ${CHANNEL}
+ gpgkey=${{ vars.S3_PUBLIC_URL }}/public.gpg
+ baseurl=${{ vars.S3_PUBLIC_URL }}/rpm/${CHANNEL}/
+ enabled=1
+ gpgcheck=1
+ EOF
+
+ rclone copy rpm-repo/ "s3:${{ vars.S3_BUCKET }}/rpm/$CHANNEL/" --progress
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,3 @@
+# Mirum
+
+An experimental CI built around virtual machines and Starlark
diff --git a/REUSE.toml b/REUSE.toml
index 82918bf..0d40574 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -7,6 +7,10 @@ version = 1
path = [
"go.mod",
"go.sum",
+ "VERSION",
+ "CLA.md",
+ "README.md",
+ "pkg/logo.svg",
]
SPDX-FileCopyrightText = "2026 Nikolay Govorov <me@govorov.online>"
SPDX-License-Identifier = "AGPL-3.0-or-later"
diff --git a/Taskfile.yml b/Taskfile.yml
index 2c923c3..8693c22 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -5,12 +5,30 @@ version: '3'
vars:
BUILD_DIR: build
+ DIST_DIR: build/dist
+ ARCHES: amd64 arm64 riscv64 ppc64le
tasks:
build:
- desc: Build master binary
+ desc: Build for current arch
cmds:
- mkdir -p {{.BUILD_DIR}}
- go build -o {{.BUILD_DIR}}/mirumd ./cmd/mirumd
+ package:
+ desc: Build deb/rpm packages for all architectures
+ requires:
+ vars: [VERSION]
+ env:
+ CGO_ENABLED: "0"
+ VERSION: "{{.VERSION}}"
+ cmds:
+ - mkdir -p {{.DIST_DIR}} {{.BUILD_DIR}}
+ - for: { var: ARCHES }
+ cmd: |
+ GOARCH={{.ITEM}} go build -o {{.BUILD_DIR}}/mirumd-linux-{{.ITEM}} ./cmd/mirumd
+ cp {{.BUILD_DIR}}/mirumd-linux-{{.ITEM}} {{.BUILD_DIR}}/mirumd
+ ARCH={{.ITEM}} nfpm package --packager deb --target {{.DIST_DIR}}/
+ ARCH={{.ITEM}} nfpm package --packager rpm --target {{.DIST_DIR}}/
+
# yaml-language-server: $schema=https://taskfile.dev/schema.json
diff --git a/VERSION b/VERSION
new file mode 100644
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.1.0
diff --git a/cmd/mirumd/main.go b/cmd/mirumd/main.go
index 062d1d7..c3c05a9 100644
--- a/cmd/mirumd/main.go
+++ b/cmd/mirumd/main.go
@@ -5,6 +5,7 @@ package main
import (
"bytes"
+ "context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
@@ -13,13 +14,20 @@ import (
"fmt"
"io"
"log/slog"
+ "net"
"net/http"
"net/url"
"os"
"os/exec"
+ "os/signal"
"path/filepath"
+ "strconv"
"strings"
+ "syscall"
+ "time"
+ "github.com/coreos/go-systemd/v22/activation"
+ "github.com/coreos/go-systemd/v22/daemon"
"go.starlark.net/starlark"
)
@@ -145,11 +153,11 @@ type taskCtx struct {
var _ starlark.HasAttrs = (*taskCtx)(nil)
func (c *taskCtx) String() string { return "ctx" }
-func (c *taskCtx) Type() string { return "ctx" }
-func (c *taskCtx) Freeze() {}
-func (c *taskCtx) Truth() starlark.Bool { return true }
-func (c *taskCtx) Hash() (uint32, error) { return 0, fmt.Errorf("unhashable: ctx") }
-func (c *taskCtx) AttrNames() []string { return []string{"shell"} }
+func (c *taskCtx) Type() string { return "ctx" }
+func (c *taskCtx) Freeze() {}
+func (c *taskCtx) Truth() starlark.Bool { return true }
+func (c *taskCtx) Hash() (uint32, error) { return 0, fmt.Errorf("unhashable: ctx") }
+func (c *taskCtx) AttrNames() []string { return []string{"shell"} }
func (c *taskCtx) Attr(name string) (starlark.Value, error) {
if name == "shell" {
@@ -260,10 +268,58 @@ func main() {
go processPush(push)
})
- slog.Info("listening", "addr", *addr)
+ ln, err := socketActivationListener()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+
+ slog.Info("listening", "addr", ln.Addr())
+
+ srv := &http.Server{Handler: mux}
+
+ go func() {
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
+ <-sig
+
+ slog.Info("shutting down")
+ daemon.SdNotify(false, daemon.SdNotifyStopping)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ srv.Shutdown(ctx)
+ }()
+
+ daemon.SdNotify(false, daemon.SdNotifyReady)
+ go watchdog()
- if err := http.ListenAndServe(*addr, mux); err != nil {
+ if err := srv.Serve(ln); err != http.ErrServerClosed {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+
+func socketActivationListener() (net.Listener, error) {
+ listeners, _ := activation.Listeners()
+ if len(listeners) > 0 {
+ return listeners[0], nil
+ }
+ return net.Listen("tcp", *addr)
+}
+
+func watchdog() {
+ usecStr := os.Getenv("WATCHDOG_USEC")
+ if usecStr == "" {
+ return
+ }
+ usec, err := strconv.ParseInt(usecStr, 10, 64)
+ if err != nil || usec <= 0 {
+ return
+ }
+ interval := time.Duration(usec) * time.Microsecond / 2
+ for {
+ daemon.SdNotify(false, daemon.SdNotifyWatchdog)
+ time.Sleep(interval)
+ }
+}
diff --git a/go.mod b/go.mod
index 632a160..5e5f10b 100644
--- a/go.mod
+++ b/go.mod
@@ -2,6 +2,9 @@ module mrdimidium/mirum
go 1.26.1
-require go.starlark.net v0.0.0-20260326113308-fadfc96def35
+require (
+ github.com/coreos/go-systemd/v22 v22.7.0
+ go.starlark.net v0.0.0-20260326113308-fadfc96def35
+)
require golang.org/x/sys v0.42.0 // indirect
diff --git a/go.sum b/go.sum
index fa78fcb..305c53c 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
go.starlark.net v0.0.0-20260326113308-fadfc96def35 h1:VYAqieSOJNxBDX8KJneTAwvdf4J4zRDE2u+UFXtt9h4=
diff --git a/nfpm.yaml b/nfpm.yaml
new file mode 100644
--- /dev/null
+++ b/nfpm.yaml
@@ -0,0 +1,62 @@
+# Copyright (c) 2026 Nikolay Govorov
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# yaml-language-server: $schema=https://nfpm.goreleaser.com/schema.json
+# vim: set ts=2 sw=2 tw=0 fo=cnqoj
+
+name: mirum
+arch: ${ARCH}
+version: ${VERSION}
+license: AGPL-3.0-or-later
+platform: linux
+maintainer: Nikolay Govorov <me@govorov.online>
+description: Modern CI platform
+
+contents:
+ - src: ./LICENSE
+ dst: /usr/share/doc/mirum/LICENSE
+ - src: ./README.md
+ dst: /usr/share/doc/mirum/README.md
+
+ - src: build/mirumd
+ dst: /usr/local/bin/mirumd
+ file_info:
+ mode: 0755
+
+ - src: pkg/mirumd.yaml
+ dst: /etc/mirumd.yaml
+ type: config|noreplace
+ file_info:
+ mode: 0640
+ owner: root
+ group: mirum
+
+ - src: pkg/mirumd.service
+ dst: /usr/lib/systemd/system/mirumd.service
+ file_info:
+ mode: 0644
+
+ - dst: /var/lib/mirumd
+ type: dir
+ file_info:
+ mode: 0755
+ owner: root
+ group: mirum
+
+
+scripts:
+ preinstall: pkg/scripts/preinstall.sh
+ postinstall: pkg/scripts/postinstall.sh
+ preremove: pkg/scripts/preremove.sh
+
+deb:
+ signature:
+ method: debsign
+ key_id: ${GPG_KEY_ID}
+ key_file: ${SIGNING_PRIVATE_KEY}
+
+rpm:
+ group: System Environment/Daemons
+ signature:
+ key_id: ${GPG_KEY_ID}
+ key_file: ${SIGNING_PRIVATE_KEY}
diff --git a/pkg/apt-ftparchive.conf b/pkg/apt-ftparchive.conf
new file mode 100644
--- /dev/null
+++ b/pkg/apt-ftparchive.conf
@@ -0,0 +1,29 @@
+// Copyright (c) 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+Dir {
+ ArchiveDir ".";
+ CacheDir "/tmp/apt-cache";
+};
+
+Default {
+ Packages::Compress ". gzip";
+ Packages::Extensions ".deb";
+};
+
+TreeDefault {
+ Packages "$(DIST)/$(SECTION)/binary-$(ARCH)/Packages";
+ BinCacheDB "packages-$(DIST)-$(ARCH).db";
+};
+
+Tree "dists/stable" {
+ Sections "main";
+ Architectures "amd64 arm64 riscv64 ppc64le";
+ Directory "pool/stable";
+};
+
+Tree "dists/nightly" {
+ Sections "main";
+ Architectures "amd64 arm64 riscv64 ppc64le";
+ Directory "pool/nightly";
+};
diff --git a/pkg/logo.svg b/pkg/logo.svg
new file mode 100644
--- /dev/null
+++ b/pkg/logo.svg
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg viewBox="-150 -150 800 800" xmlns="http://www.w3.org/2000/svg" fill="#00afaf">
+ <rect x="0" y="100" width="100" height="100"></rect>
+ <rect x="0" y="200" width="100" height="100"></rect>
+ <rect x="0" y="300" width="100" height="100"></rect>
+ <rect x="0" y="400" width="100" height="100"></rect>
+ <rect x="100" y="0" width="100" height="100"></rect>
+ <rect x="100" y="100" width="100" height="100"></rect>
+ <rect x="100" y="300" width="100" height="100"></rect>
+ <rect x="100" y="400" width="100" height="100"></rect>
+ <rect x="200" y="0" width="100" height="100"></rect>
+ <rect x="200" y="100" width="100" height="100"></rect>
+ <rect x="200" y="200" width="100" height="100"></rect>
+ <rect x="200" y="300" width="100" height="100"></rect>
+ <rect x="200" y="400" width="100" height="100"></rect>
+ <rect x="400" y="100" width="100" height="100"></rect>
+ <rect x="400" y="200" width="100" height="100"></rect>
+ <rect x="400" y="300" width="100" height="100"></rect>
+ <rect x="400" y="400" width="100" height="100"></rect>
+ <rect x="300" y="0" width="100" height="100"></rect>
+ <rect x="300" y="100" width="100" height="100"></rect>
+ <rect x="300" y="300" width="100" height="100"></rect>
+ <rect x="300" y="400" width="100" height="100"></rect>
+</svg>
diff --git a/pkg/mirumd.service b/pkg/mirumd.service
new file mode 100644
--- /dev/null
+++ b/pkg/mirumd.service
@@ -0,0 +1,51 @@
+# Copyright (c) 2026 Nikolay Govorov
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+[Unit]
+Description=Mirum daemon (modern CI platform)
+Requires=network-online.target
+After=time-sync.target network-online.target remote-fs.target nss-lookup.target
+Wants=time-sync.target
+
+[Service]
+Type=notify
+User=mirum
+Group=mirum
+Restart=always
+RestartSec=30
+WatchdogSec=30
+NotifyAccess=main
+ExecPaths=/usr/local/bin/mirumd /usr/lib
+ExecStart=/usr/local/bin/mirumd --config=/etc/mirumd.yaml
+LimitCORE=infinity
+LimitNOFILE=500000
+AmbientCapabilities=CAP_NET_BIND_SERVICE
+
+# %p is resolved to the systemd unit name
+LogsDirectory=%p
+StateDirectory=%p
+CacheDirectory=%p
+RuntimeDirectory=%p
+
+UMask=0077
+LockPersonality=yes
+NoNewPrivileges=yes
+PrivateDevices=yes
+PrivateTmp=true
+ProcSubset=pid
+ProtectClock=yes
+ProtectControlGroups=yes
+ProtectHome=yes
+ProtectHostname=yes
+ProtectKernelLogs=yes
+ProtectKernelModules=yes
+ProtectKernelTunables=yes
+ProtectProc=invisible
+ProtectSystem=strict
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+RestrictNamespaces=yes
+RestrictSUIDSGID=yes
+
+[Install]
+# service should not start from the rescue shell (rescue.target).
+WantedBy=multi-user.target
diff --git a/pkg/mirumd.yaml b/pkg/mirumd.yaml
new file mode 100644
--- /dev/null
+++ b/pkg/mirumd.yaml
@@ -0,0 +1,4 @@
+# Copyright (c) 2026 Nikolay Govorov
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+address: :2026
diff --git a/pkg/scripts/postinstall.sh b/pkg/scripts/postinstall.sh
new file mode 100644
--- /dev/null
+++ b/pkg/scripts/postinstall.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+# Copyright (c) 2026 Nikolay Govorov
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+set -e
+
+if [ -x "/bin/systemctl" ] && [ -d /run/systemd/system ] && [ -f /usr/lib/systemd/system/mirumd.service ]; then
+ /bin/systemctl daemon-reload
+ /bin/systemctl enable mirumd
+fi
diff --git a/pkg/scripts/preinstall.sh b/pkg/scripts/preinstall.sh
new file mode 100644
--- /dev/null
+++ b/pkg/scripts/preinstall.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+# Copyright (c) 2026 Nikolay Govorov
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+set -e
+
+PROGRAM=mirum
+MIRUM_USER=${MIRUM_USER:-mirum}
+MIRUM_GROUP=${MIRUM_GROUP:-${MIRUM_USER}}
+
+if ! getent group $MIRUM_GROUP >/dev/null; then
+ groupadd --system $MIRUM_GROUP
+fi
+
+if ! getent passwd $MIRUM_USER >/dev/null; then
+ useradd --system --gid $MIRUM_GROUP --no-create-home --shell /usr/sbin/nologin $MIRUM_USER
+fi
diff --git a/pkg/scripts/preremove.sh b/pkg/scripts/preremove.sh
new file mode 100644
--- /dev/null
+++ b/pkg/scripts/preremove.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+# Copyright (c) 2026 Nikolay Govorov
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+set -e
+
+if [ -x "/bin/systemctl" ] && [ -d /run/systemd/system ] && [ -f /usr/lib/systemd/system/mirumd.service ]; then
+ /bin/systemctl stop mirumd.service || true
+ /bin/systemctl disable mirumd.service || true
+fi