detached 1c01096

feat(pki): add trust store wrapper and iOS mobileconfig generation

commit
1c010968359878613bacf019f676403ce064091a
author
brice · · 2026-03-29 16:41 UTC
parent
b939cf1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +111 -0
M go.mod +2 -0
@@ -15,6 +15,7 @@ require (
1515 github.com/mattn/go-runewidth v0.0.21
1616 github.com/muesli/termenv v0.16.0
1717 github.com/rivo/uniseg v0.4.7
18+ github.com/smallstep/truststore v0.13.0
1819 github.com/spf13/cobra v1.10.2
1920 github.com/spf13/viper v1.21.0
2021 github.com/stretchr/testify v1.11.1
@@ -67,6 +68,7 @@ require (
6768 google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
6869 google.golang.org/grpc v1.79.3 // indirect
6970 google.golang.org/protobuf v1.36.11 // indirect
71+ howett.net/plist v1.0.0 // indirect
7072 )
7173
7274 require (
@@ -15,6 +15,7 @@ require (
15 github.com/mattn/go-runewidth v0.0.2115 github.com/mattn/go-runewidth v0.0.21
16 github.com/muesli/termenv v0.16.016 github.com/muesli/termenv v0.16.0
17 github.com/rivo/uniseg v0.4.717 github.com/rivo/uniseg v0.4.7
18+ github.com/smallstep/truststore v0.13.0
18 github.com/spf13/cobra v1.10.219 github.com/spf13/cobra v1.10.2
19 github.com/spf13/viper v1.21.020 github.com/spf13/viper v1.21.0
20 github.com/stretchr/testify v1.11.121 github.com/stretchr/testify v1.11.1
@@ -67,6 +68,7 @@ require (
67 google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect68 google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
68 google.golang.org/grpc v1.79.3 // indirect69 google.golang.org/grpc v1.79.3 // indirect
69 google.golang.org/protobuf v1.36.11 // indirect70 google.golang.org/protobuf v1.36.11 // indirect
71+ howett.net/plist v1.0.0 // indirect
70 )72 )
71 73
72 require (74 require (
M go.sum +6 -0
@@ -92,6 +92,7 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz
9292 github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
9393 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
9494 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
95+github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
9596 github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
9697 github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
9798 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -151,6 +152,8 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee
151152 github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
152153 github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
153154 github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
155+github.com/smallstep/truststore v0.13.0 h1:90if9htAOblavbMeWlqNLnO9bsjjgVv2hQeQJCi/py4=
156+github.com/smallstep/truststore v0.13.0/go.mod h1:3tmMp2aLKZ/OA/jnFUB0cYPcho402UG2knuJoPh4j7A=
154157 github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
155158 github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
156159 github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -241,7 +244,10 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
241244 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
242245 gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
243246 gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
247+gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
244248 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
245249 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
246250 gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
247251 gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
252+howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
253+howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
@@ -92,6 +92,7 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz
92 github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=92 github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
93 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=93 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
94 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=94 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
95+github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
95 github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=96 github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
96 github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=97 github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
97 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=98 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -151,6 +152,8 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee
151 github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=152 github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
152 github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=153 github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
153 github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=154 github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
155+github.com/smallstep/truststore v0.13.0 h1:90if9htAOblavbMeWlqNLnO9bsjjgVv2hQeQJCi/py4=
156+github.com/smallstep/truststore v0.13.0/go.mod h1:3tmMp2aLKZ/OA/jnFUB0cYPcho402UG2knuJoPh4j7A=
154 github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=157 github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
155 github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=158 github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
156 github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=159 github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -241,7 +244,10 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
241 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=244 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
242 gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=245 gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
243 gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=246 gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
247+gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
244 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=248 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
245 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=249 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
246 gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=250 gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
247 gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=251 gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
252+howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
253+howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
A internal/adapters/out/pki/mobileconfig.go +58 -0
@@ -0,0 +1,58 @@
1+package pki
2+
3+import (
4+ "encoding/base64"
5+ "fmt"
6+
7+ "github.com/google/uuid"
8+)
9+
10+// GenerateMobileconfig generates an iOS configuration profile containing
11+// the root CA certificate for one-tap trust installation.
12+func GenerateMobileconfig(rootDER []byte, rootCN string) []byte {
13+ b64Cert := base64.StdEncoding.EncodeToString(rootDER)
14+ profileUUID := uuid.New().String()
15+ certUUID := uuid.New().String()
16+
17+ profile := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
18+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
19+<plist version="1.0">
20+<dict>
21+ <key>PayloadContent</key>
22+ <array>
23+ <dict>
24+ <key>PayloadCertificateFileName</key>
25+ <string>gordon-ca.crt</string>
26+ <key>PayloadContent</key>
27+ <data>%s</data>
28+ <key>PayloadDescription</key>
29+ <string>Installs the Gordon internal CA root certificate</string>
30+ <key>PayloadDisplayName</key>
31+ <string>%s</string>
32+ <key>PayloadIdentifier</key>
33+ <string>dev.gordon.ca.%s</string>
34+ <key>PayloadType</key>
35+ <string>com.apple.security.root</string>
36+ <key>PayloadUUID</key>
37+ <string>%s</string>
38+ <key>PayloadVersion</key>
39+ <integer>1</integer>
40+ </dict>
41+ </array>
42+ <key>PayloadDescription</key>
43+ <string>Trust Gordon's internal Certificate Authority for HTTPS access</string>
44+ <key>PayloadDisplayName</key>
45+ <string>Gordon CA Trust</string>
46+ <key>PayloadIdentifier</key>
47+ <string>dev.gordon.ca-profile</string>
48+ <key>PayloadType</key>
49+ <string>Configuration</string>
50+ <key>PayloadUUID</key>
51+ <string>%s</string>
52+ <key>PayloadVersion</key>
53+ <integer>1</integer>
54+</dict>
55+</plist>`, b64Cert, rootCN, certUUID, certUUID, profileUUID)
56+
57+ return []byte(profile)
58+}
@@ -0,0 +1,58 @@
1+package pki
2+
3+import (
4+ "encoding/base64"
5+ "fmt"
6+
7+ "github.com/google/uuid"
8+)
9+
10+// GenerateMobileconfig generates an iOS configuration profile containing
11+// the root CA certificate for one-tap trust installation.
12+func GenerateMobileconfig(rootDER []byte, rootCN string) []byte {
13+ b64Cert := base64.StdEncoding.EncodeToString(rootDER)
14+ profileUUID := uuid.New().String()
15+ certUUID := uuid.New().String()
16+
17+ profile := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
18+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
19+<plist version="1.0">
20+<dict>
21+ <key>PayloadContent</key>
22+ <array>
23+ <dict>
24+ <key>PayloadCertificateFileName</key>
25+ <string>gordon-ca.crt</string>
26+ <key>PayloadContent</key>
27+ <data>%s</data>
28+ <key>PayloadDescription</key>
29+ <string>Installs the Gordon internal CA root certificate</string>
30+ <key>PayloadDisplayName</key>
31+ <string>%s</string>
32+ <key>PayloadIdentifier</key>
33+ <string>dev.gordon.ca.%s</string>
34+ <key>PayloadType</key>
35+ <string>com.apple.security.root</string>
36+ <key>PayloadUUID</key>
37+ <string>%s</string>
38+ <key>PayloadVersion</key>
39+ <integer>1</integer>
40+ </dict>
41+ </array>
42+ <key>PayloadDescription</key>
43+ <string>Trust Gordon's internal Certificate Authority for HTTPS access</string>
44+ <key>PayloadDisplayName</key>
45+ <string>Gordon CA Trust</string>
46+ <key>PayloadIdentifier</key>
47+ <string>dev.gordon.ca-profile</string>
48+ <key>PayloadType</key>
49+ <string>Configuration</string>
50+ <key>PayloadUUID</key>
51+ <string>%s</string>
52+ <key>PayloadVersion</key>
53+ <integer>1</integer>
54+</dict>
55+</plist>`, b64Cert, rootCN, certUUID, certUUID, profileUUID)
56+
57+ return []byte(profile)
58+}
A internal/adapters/out/pki/mobileconfig_test.go +22 -0
@@ -0,0 +1,22 @@
1+package pki_test
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/gordon/internal/adapters/out/pki"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestGenerateMobileconfig(t *testing.T) {
13+ dir := t.TempDir()
14+ ca, err := pki.NewCA(dir, testLogger())
15+ require.NoError(t, err)
16+
17+ mc := pki.GenerateMobileconfig(ca.RootCertificateDER(), ca.RootCommonName())
18+ assert.True(t, strings.HasPrefix(string(mc), "<?xml"))
19+ assert.Contains(t, string(mc), "Gordon Internal CA")
20+ assert.Contains(t, string(mc), "<key>PayloadType</key>")
21+ assert.Contains(t, string(mc), "com.apple.security.root")
22+}
@@ -0,0 +1,22 @@
1+package pki_test
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/gordon/internal/adapters/out/pki"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestGenerateMobileconfig(t *testing.T) {
13+ dir := t.TempDir()
14+ ca, err := pki.NewCA(dir, testLogger())
15+ require.NoError(t, err)
16+
17+ mc := pki.GenerateMobileconfig(ca.RootCertificateDER(), ca.RootCommonName())
18+ assert.True(t, strings.HasPrefix(string(mc), "<?xml"))
19+ assert.Contains(t, string(mc), "Gordon Internal CA")
20+ assert.Contains(t, string(mc), "<key>PayloadType</key>")
21+ assert.Contains(t, string(mc), "com.apple.security.root")
22+}
A internal/adapters/out/pki/trust.go +23 -0
@@ -0,0 +1,23 @@
1+package pki
2+
3+import (
4+ "crypto/x509"
5+
6+ "github.com/smallstep/truststore"
7+)
8+
9+// InstallRoot installs the root CA certificate into the system trust store.
10+func InstallRoot(cert *x509.Certificate) error {
11+ return truststore.Install(cert,
12+ truststore.WithFirefox(),
13+ truststore.WithJava(),
14+ )
15+}
16+
17+// UninstallRoot removes the root CA certificate from the system trust store.
18+func UninstallRoot(cert *x509.Certificate) error {
19+ return truststore.Uninstall(cert,
20+ truststore.WithFirefox(),
21+ truststore.WithJava(),
22+ )
23+}
@@ -0,0 +1,23 @@
1+package pki
2+
3+import (
4+ "crypto/x509"
5+
6+ "github.com/smallstep/truststore"
7+)
8+
9+// InstallRoot installs the root CA certificate into the system trust store.
10+func InstallRoot(cert *x509.Certificate) error {
11+ return truststore.Install(cert,
12+ truststore.WithFirefox(),
13+ truststore.WithJava(),
14+ )
15+}
16+
17+// UninstallRoot removes the root CA certificate from the system trust store.
18+func UninstallRoot(cert *x509.Certificate) error {
19+ return truststore.Uninstall(cert,
20+ truststore.WithFirefox(),
21+ truststore.WithJava(),
22+ )
23+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close