Implement monorepo layout
Implemented the nexantic monorepo.
Smalltown code was moved to `core`. From now on all code will live in top level directories named after the projects with the exception for general purpose libraries which should go to `<lang>libs`.
General build and utility folders are underscore prefixed.
The repo name will from now on be rNXT (nexantic). I think this change makes sense since components in this repo will not all be part of Smalltown, the Smalltown brand has been claimed by Signon GmbH so we need to change it anyway and the longer we wait the harder it will be to change/move it.
Test Plan: Launched Smalltown using `./scripts/bin/bazel run //core/scripts:launch`
X-Origin-Diff: phab/D210
GitOrigin-RevId: fa5a7f08143d2ead2cb7206b4c63ab641794162c
diff --git a/core/internal/api/BUILD.bazel b/core/internal/api/BUILD.bazel
new file mode 100644
index 0000000..6e3cb2b
--- /dev/null
+++ b/core/internal/api/BUILD.bazel
@@ -0,0 +1,20 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+ name = "go_default_library",
+ srcs = [
+ "cluster.go",
+ "main.go",
+ "setup.go",
+ ],
+ importpath = "git.monogon.dev/source/nexantic.git/core/internal/api",
+ visibility = ["//core:__subpackages__"],
+ deps = [
+ "//core/api/api:go_default_library",
+ "//core/internal/common:go_default_library",
+ "//core/internal/consensus:go_default_library",
+ "@com_github_casbin_casbin//:go_default_library",
+ "@org_golang_google_grpc//:go_default_library",
+ "@org_uber_go_zap//:go_default_library",
+ ],
+)
diff --git a/core/internal/api/cluster.go b/core/internal/api/cluster.go
new file mode 100644
index 0000000..32a5691
--- /dev/null
+++ b/core/internal/api/cluster.go
@@ -0,0 +1,117 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package api
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ schema "git.monogon.dev/source/nexantic.git/core/generated/api"
+ "git.monogon.dev/source/nexantic.git/core/internal/common"
+
+ "errors"
+
+ "go.uber.org/zap"
+)
+
+var (
+ ErrAttestationFailed = errors.New("attestation_failed")
+)
+
+func (s *Server) AddNode(ctx context.Context, req *schema.AddNodeRequest) (*schema.AddNodeResponse, error) {
+ // Setup API client
+ c, err := common.NewSmalltownAPIClient(fmt.Sprintf("%s:%d", req.Host, req.ApiPort))
+ if err != nil {
+ return nil, err
+ }
+
+ // Check attestation
+ nonce := make([]byte, 20)
+ _, err = rand.Read(nonce)
+ if err != nil {
+ return nil, err
+ }
+ hexNonce := hex.EncodeToString(nonce)
+
+ aRes, err := c.Setup.Attest(ctx, &schema.AttestRequest{
+ Challenge: hexNonce,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ //TODO(hendrik): Verify response
+ if aRes.Response != hexNonce {
+ return nil, ErrAttestationFailed
+ }
+
+ // Provision cluster info locally
+ memberID, err := s.consensusService.AddMember(ctx, req.Name, fmt.Sprintf("http://%s:%d", req.Host, req.ConsensusPort))
+ if err != nil {
+ return nil, err
+ }
+
+ s.Logger.Info("Added new node to consensus cluster; provisioning external node now",
+ zap.String("host", req.Host), zap.Uint32("port", req.ApiPort),
+ zap.Uint32("consensus_port", req.ConsensusPort), zap.String("name", req.Name))
+
+ // Provision cluster info externally
+ _, err = c.Setup.ProvisionCluster(ctx, &schema.ProvisionClusterRequest{
+ InitialCluster: s.consensusService.GetInitialClusterString(),
+ ProvisioningToken: req.Token,
+ ExternalHost: req.Host,
+ NodeName: req.Name,
+ TrustBackend: req.TrustBackend,
+ })
+ if err != nil {
+ // Revert Consensus add member - might fail if consensus cannot be established
+ err2 := s.consensusService.RemoveMember(ctx, memberID)
+ if err2 != nil {
+ return nil, fmt.Errorf("Rollback failed after failed provisioning; err=%v; err_rb=%v", err, err2)
+ }
+ return nil, err
+ }
+ s.Logger.Info("Fully provisioned new node",
+ zap.String("host", req.Host), zap.Uint32("port", req.ApiPort),
+ zap.Uint32("consensus_port", req.ConsensusPort), zap.String("name", req.Name),
+ zap.Uint64("member_id", memberID))
+
+ return &schema.AddNodeResponse{}, nil
+}
+
+func (s *Server) RemoveNode(context.Context, *schema.RemoveNodeRequest) (*schema.RemoveNodeRequest, error) {
+ panic("implement me")
+}
+
+func (s *Server) GetNodes(context.Context, *schema.GetNodesRequest) (*schema.GetNodesResponse, error) {
+ nodes := s.consensusService.GetNodes()
+ resNodes := make([]*schema.Node, len(nodes))
+
+ for i, node := range nodes {
+ resNodes[i] = &schema.Node{
+ Id: node.ID,
+ Name: node.Name,
+ Address: node.Address,
+ Synced: node.Synced,
+ }
+ }
+
+ return &schema.GetNodesResponse{
+ Nodes: resNodes,
+ }, nil
+}
diff --git a/core/internal/api/main.go b/core/internal/api/main.go
new file mode 100644
index 0000000..20c3a3a
--- /dev/null
+++ b/core/internal/api/main.go
@@ -0,0 +1,87 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package api
+
+import (
+ "fmt"
+ schema "git.monogon.dev/source/nexantic.git/core/generated/api"
+ "git.monogon.dev/source/nexantic.git/core/internal/common"
+ "git.monogon.dev/source/nexantic.git/core/internal/consensus"
+ "github.com/casbin/casbin"
+ "go.uber.org/zap"
+ "google.golang.org/grpc"
+ "net"
+)
+
+type (
+ Server struct {
+ *common.BaseService
+
+ ruleEnforcer *casbin.Enforcer
+ setupService common.SetupService
+ grpcServer *grpc.Server
+
+ consensusService *consensus.Service
+
+ config *Config
+ }
+
+ Config struct {
+ Port uint16
+ }
+)
+
+func NewApiServer(config *Config, logger *zap.Logger, setupService common.SetupService, consensusService *consensus.Service) (*Server, error) {
+ s := &Server{
+ config: config,
+ setupService: setupService,
+ consensusService: consensusService,
+ }
+
+ s.BaseService = common.NewBaseService("api", logger, s)
+
+ grpcServer := grpc.NewServer()
+ schema.RegisterClusterManagementServer(grpcServer, s)
+ schema.RegisterSetupServiceServer(grpcServer, s)
+
+ s.grpcServer = grpcServer
+
+ return s, nil
+}
+
+func (s *Server) OnStart() error {
+ listenHost := fmt.Sprintf(":%d", s.config.Port)
+ lis, err := net.Listen("tcp", listenHost)
+ if err != nil {
+ s.Logger.Fatal("failed to listen", zap.Error(err))
+ }
+
+ go func() {
+ err = s.grpcServer.Serve(lis)
+ s.Logger.Error("API server failed", zap.Error(err))
+ }()
+
+ s.Logger.Info("GRPC listening", zap.String("host", listenHost))
+
+ return nil
+}
+
+func (s *Server) OnStop() error {
+ s.grpcServer.Stop()
+
+ return nil
+}
diff --git a/core/internal/api/setup.go b/core/internal/api/setup.go
new file mode 100644
index 0000000..943f203
--- /dev/null
+++ b/core/internal/api/setup.go
@@ -0,0 +1,103 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package api
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ schema "git.monogon.dev/source/nexantic.git/core/generated/api"
+)
+
+const (
+ MinNameLength = 3
+)
+
+var (
+ ErrInvalidProvisioningToken = errors.New("invalid provisioning token")
+ ErrInvalidNameLength = fmt.Errorf("name must be at least %d characters long", MinNameLength)
+)
+
+func (s *Server) Setup(c context.Context, r *schema.SetupRequest) (*schema.SetupResponse, error) {
+
+ switch r.Request.(type) {
+ case *schema.SetupRequest_JoinCluster:
+ token, err := s.enterJoinCluster(r.GetJoinCluster())
+ if err != nil {
+ return nil, err
+ }
+
+ return &schema.SetupResponse{
+ Response: &schema.SetupResponse_JoinCluster{
+ JoinCluster: &schema.JoinClusterResponse{
+ ProvisioningToken: token,
+ },
+ },
+ }, nil
+
+ case *schema.SetupRequest_NewCluster:
+ return &schema.SetupResponse{
+ Response: &schema.SetupResponse_NewCluster{
+ NewCluster: &schema.NewClusterResponse{},
+ },
+ }, s.setupNewCluster(r.GetNewCluster())
+ }
+
+ return &schema.SetupResponse{}, nil
+}
+
+func (s *Server) enterJoinCluster(r *schema.JoinClusterRequest) (string, error) {
+ err := s.setupService.EnterJoinClusterMode()
+ if err != nil {
+ return "", err
+ }
+
+ return s.setupService.GetJoinClusterToken(), nil
+}
+
+func (s *Server) setupNewCluster(r *schema.NewClusterRequest) error {
+ if len(r.NodeName) < MinNameLength {
+ return ErrInvalidNameLength
+ }
+ return s.setupService.SetupNewCluster(r.NodeName, r.ExternalHost)
+}
+
+func (s *Server) ProvisionCluster(ctx context.Context, req *schema.ProvisionClusterRequest) (*schema.ProvisionClusterResponse, error) {
+ if len(req.NodeName) < MinNameLength {
+ return nil, ErrInvalidNameLength
+ }
+
+ // Verify provisioning token
+ if s.setupService.GetJoinClusterToken() != req.ProvisioningToken {
+ return nil, ErrInvalidProvisioningToken
+ }
+
+ // Join cluster
+ err := s.setupService.JoinCluster(req.NodeName, req.InitialCluster, req.ExternalHost)
+ if err != nil {
+ return nil, err
+ }
+
+ return &schema.ProvisionClusterResponse{}, nil
+}
+
+func (s *Server) Attest(c context.Context, r *schema.AttestRequest) (*schema.AttestResponse, error) {
+ // TODO implement
+ return &schema.AttestResponse{
+ Response: r.Challenge,
+ }, nil
+}
diff --git a/core/internal/audit/BUILD.bazel b/core/internal/audit/BUILD.bazel
new file mode 100644
index 0000000..9684d55
--- /dev/null
+++ b/core/internal/audit/BUILD.bazel
@@ -0,0 +1,9 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+ name = "go_default_library",
+ srcs = ["main.go"],
+ importpath = "git.monogon.dev/source/nexantic.git/core/internal/audit",
+ visibility = ["//:__subpackages__"],
+ deps = ["@io_etcd_go_etcd//clientv3:go_default_library"],
+)
diff --git a/core/internal/audit/main.go b/core/internal/audit/main.go
new file mode 100644
index 0000000..2d43dd0
--- /dev/null
+++ b/core/internal/audit/main.go
@@ -0,0 +1,35 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package audit
+
+import "go.etcd.io/etcd/clientv3"
+
+type (
+ Logger struct {
+ kv *clientv3.KV
+ }
+)
+
+func NewAuditLogger(kv *clientv3.KV) (*Logger, error) {
+ return &Logger{
+ kv: kv,
+ }, nil
+}
+
+func Log(user, action, params string) error {
+ return nil
+}
diff --git a/core/internal/common/BUILD.bazel b/core/internal/common/BUILD.bazel
new file mode 100644
index 0000000..4312b88
--- /dev/null
+++ b/core/internal/common/BUILD.bazel
@@ -0,0 +1,20 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+ name = "go_default_library",
+ srcs = [
+ "grpc.go",
+ "service.go",
+ "setup.go",
+ "storage.go",
+ "util.go",
+ ],
+ importpath = "git.monogon.dev/source/nexantic.git/core/internal/common",
+ visibility = ["//core:__subpackages__"],
+ deps = [
+ "//core/api/api:go_default_library",
+ "//core/api/common:go_default_library",
+ "@org_golang_google_grpc//:go_default_library",
+ "@org_uber_go_zap//:go_default_library",
+ ],
+)
diff --git a/core/internal/common/grpc.go b/core/internal/common/grpc.go
new file mode 100644
index 0000000..5512a5c
--- /dev/null
+++ b/core/internal/common/grpc.go
@@ -0,0 +1,52 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package common
+
+import (
+ "git.monogon.dev/source/nexantic.git/core/generated/api"
+
+ "google.golang.org/grpc"
+)
+
+type (
+ SmalltownClient struct {
+ conn *grpc.ClientConn
+
+ Cluster api.ClusterManagementClient
+ Setup api.SetupServiceClient
+ }
+)
+
+func NewSmalltownAPIClient(address string) (*SmalltownClient, error) {
+ s := &SmalltownClient{}
+
+ conn, err := grpc.Dial(address, grpc.WithInsecure())
+ if err != nil {
+ return nil, err
+ }
+ s.conn = conn
+
+ // Setup all client connections
+ s.Cluster = api.NewClusterManagementClient(conn)
+ s.Setup = api.NewSetupServiceClient(conn)
+
+ return s, nil
+}
+
+func (s *SmalltownClient) Close() error {
+ return s.conn.Close()
+}
diff --git a/core/internal/common/service.go b/core/internal/common/service.go
new file mode 100644
index 0000000..3bdc1f9
--- /dev/null
+++ b/core/internal/common/service.go
@@ -0,0 +1,104 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package common
+
+import (
+ "errors"
+ "go.uber.org/zap"
+ "sync"
+)
+
+var (
+ ErrAlreadyRunning = errors.New("service is already running")
+ ErrNotRunning = errors.New("service is not running")
+)
+
+type (
+ // Service represents a subsystem of an application that can be used with a BaseService.
+ Service interface {
+ OnStart() error
+ OnStop() error
+ }
+
+ // BaseService implements utility functionality around a service.
+ BaseService struct {
+ impl Service
+ name string
+
+ Logger *zap.Logger
+
+ mutex sync.Mutex
+ running bool
+ }
+)
+
+func NewBaseService(name string, logger *zap.Logger, impl Service) *BaseService {
+ return &BaseService{
+ Logger: logger,
+ name: name,
+ impl: impl,
+ }
+}
+
+// Start starts the service. This is an atomic operation and should not be called on an already running service.
+func (b *BaseService) Start() error {
+ b.mutex.Lock()
+ defer b.mutex.Unlock()
+
+ if b.running {
+ return ErrAlreadyRunning
+ }
+
+ err := b.impl.OnStart()
+ if err != nil {
+ b.Logger.Error("Failed to start service", zap.String("service", b.name), zap.Error(err))
+ return err
+ }
+
+ b.running = true
+ b.Logger.Info("Started service", zap.String("service", b.name))
+ return nil
+}
+
+// Stop stops the service. THis is an atomic operation and should only be called on a running service.
+func (b *BaseService) Stop() error {
+ b.mutex.Lock()
+ defer b.mutex.Unlock()
+
+ if !b.running {
+ return ErrNotRunning
+ }
+
+ err := b.impl.OnStart()
+ if err != nil {
+ b.Logger.Error("Failed to stop service", zap.String("service", b.name), zap.Error(err))
+
+ return err
+ }
+
+ b.running = false
+ b.Logger.Info("Stopped service", zap.String("service", b.name))
+ return nil
+}
+
+// IsRunning returns whether the service is currently running.
+func (b *BaseService) IsRunning() bool {
+ b.mutex.Lock()
+ defer b.mutex.Unlock()
+
+ return b.running
+}
diff --git a/core/internal/common/setup.go b/core/internal/common/setup.go
new file mode 100644
index 0000000..fd70d0a
--- /dev/null
+++ b/core/internal/common/setup.go
@@ -0,0 +1,35 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package common
+
+type (
+ SetupService interface {
+ CurrentState() SmalltownState
+ GetJoinClusterToken() string
+ SetupNewCluster(name string, externalHost string) error
+ EnterJoinClusterMode() error
+ JoinCluster(name string, clusterString string, externalHost string) error
+ }
+
+ SmalltownState string
+)
+
+const (
+ StateSetupMode SmalltownState = "setup"
+ StateClusterJoinMode SmalltownState = "join"
+ StateConfigured SmalltownState = "configured"
+)
diff --git a/core/internal/common/storage.go b/core/internal/common/storage.go
new file mode 100644
index 0000000..caaa155
--- /dev/null
+++ b/core/internal/common/storage.go
@@ -0,0 +1,37 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package common
+
+import "errors"
+
+type DataPlace uint32
+
+const (
+ PlaceESP DataPlace = 0
+ PlaceData = 1
+)
+
+var (
+ // ErrNotInitialized will be returned when trying to access a place that's not yet initialized
+ ErrNotInitialized = errors.New("This place is not initialized")
+ // ErrUnknownPlace will be returned when trying to access a place that's not known
+ ErrUnknownPlace = errors.New("This place is not known")
+)
+
+type StorageManager interface {
+ GetPathInPlace(place DataPlace, path string) (string, error)
+}
diff --git a/core/internal/common/util.go b/core/internal/common/util.go
new file mode 100644
index 0000000..fc8a72b
--- /dev/null
+++ b/core/internal/common/util.go
@@ -0,0 +1,33 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package common
+
+import "git.monogon.dev/source/nexantic.git/core/generated/common"
+
+func MapToKVs(input map[string]string) []*common.KV {
+ kvs := make([]*common.KV, len(input))
+
+ i := 0
+ for key, item := range input {
+ kvs[i] = &common.KV{
+ Key: key,
+ Value: []byte(item),
+ }
+ }
+
+ return kvs
+}
diff --git a/core/internal/consensus/BUILD.bazel b/core/internal/consensus/BUILD.bazel
new file mode 100644
index 0000000..72d73b4
--- /dev/null
+++ b/core/internal/consensus/BUILD.bazel
@@ -0,0 +1,19 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+ name = "go_default_library",
+ srcs = ["consensus.go"],
+ importpath = "git.monogon.dev/source/nexantic.git/core/internal/consensus",
+ visibility = ["//:__subpackages__"],
+ deps = [
+ "//core/internal/common:go_default_library",
+ "@com_github_pkg_errors//:go_default_library",
+ "@io_etcd_go_etcd//clientv3:go_default_library",
+ "@io_etcd_go_etcd//clientv3/namespace:go_default_library",
+ "@io_etcd_go_etcd//embed:go_default_library",
+ "@io_etcd_go_etcd//etcdserver/api/membership:go_default_library",
+ "@io_etcd_go_etcd//pkg/types:go_default_library",
+ "@io_etcd_go_etcd//proxy/grpcproxy/adapter:go_default_library",
+ "@org_uber_go_zap//:go_default_library",
+ ],
+)
diff --git a/core/internal/consensus/consensus.go b/core/internal/consensus/consensus.go
new file mode 100644
index 0000000..e1f59d6
--- /dev/null
+++ b/core/internal/consensus/consensus.go
@@ -0,0 +1,230 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package consensus
+
+import (
+ "context"
+ "fmt"
+ "git.monogon.dev/source/nexantic.git/core/internal/common"
+ "github.com/pkg/errors"
+ "go.etcd.io/etcd/clientv3"
+ "go.etcd.io/etcd/clientv3/namespace"
+ "go.etcd.io/etcd/embed"
+ "go.etcd.io/etcd/etcdserver/api/membership"
+ "go.etcd.io/etcd/pkg/types"
+ "go.etcd.io/etcd/proxy/grpcproxy/adapter"
+ "go.uber.org/zap"
+ "net/url"
+ "os"
+ "strings"
+)
+
+const (
+ DefaultClusterToken = "SIGNOS"
+ DefaultLogger = "zap"
+)
+
+type (
+ Service struct {
+ *common.BaseService
+
+ etcd *embed.Etcd
+ kv clientv3.KV
+ ready bool
+
+ config *Config
+ }
+
+ Config struct {
+ Name string
+ DataDir string
+ InitialCluster string
+ NewCluster bool
+
+ ExternalHost string
+ ListenHost string
+ ListenPort uint16
+ }
+
+ Member struct {
+ ID uint64
+ Name string
+ Address string
+ Synced bool
+ }
+)
+
+func NewConsensusService(config Config, logger *zap.Logger) (*Service, error) {
+ consensusServer := &Service{
+ config: &config,
+ }
+ consensusServer.BaseService = common.NewBaseService("consensus", logger, consensusServer)
+
+ return consensusServer, nil
+}
+
+func (s *Service) OnStart() error {
+ if s.config == nil {
+ return errors.New("config for consensus is nil")
+ }
+
+ cfg := embed.NewConfig()
+
+ // Reset LCUrls because we don't want to expose any client
+ cfg.LCUrls = nil
+
+ apURL, err := url.Parse(fmt.Sprintf("http://%s:%d", s.config.ExternalHost, s.config.ListenPort))
+ if err != nil {
+ return errors.Wrap(err, "invalid external_host or listen_port")
+ }
+
+ lpURL, err := url.Parse(fmt.Sprintf("http://%s:%d", s.config.ListenHost, s.config.ListenPort))
+ if err != nil {
+ return errors.Wrap(err, "invalid listen_host or listen_port")
+ }
+ cfg.APUrls = []url.URL{*apURL}
+ cfg.LPUrls = []url.URL{*lpURL}
+ cfg.ACUrls = []url.URL{}
+
+ cfg.Dir = s.config.DataDir
+ cfg.InitialClusterToken = DefaultClusterToken
+ cfg.Name = s.config.Name
+
+ // Only relevant if creating or joining a cluster; otherwise settings will be ignored
+ if s.config.NewCluster {
+ cfg.ClusterState = "new"
+ cfg.InitialCluster = cfg.InitialClusterFromName(cfg.Name)
+ } else if s.config.InitialCluster != "" {
+ cfg.ClusterState = "existing"
+ cfg.InitialCluster = s.config.InitialCluster
+ }
+
+ cfg.Logger = DefaultLogger
+
+ server, err := embed.StartEtcd(cfg)
+ if err != nil {
+ return err
+ }
+ s.etcd = server
+
+ // Override the logger
+ //*server.GetLogger() = *s.Logger.With(zap.String("component", "etcd"))
+
+ go func() {
+ s.Logger.Info("waiting for etcd to become ready")
+ <-s.etcd.Server.ReadyNotify()
+ s.ready = true
+ s.Logger.Info("etcd is now ready")
+ }()
+
+ // Inject kv client
+ s.kv = clientv3.NewKVFromKVClient(adapter.KvServerToKvClient(s.etcd.Server), nil)
+
+ return nil
+}
+
+func (s *Service) OnStop() error {
+ s.etcd.Close()
+
+ return nil
+}
+
+// IsProvisioned returns whether the node has been setup before and etcd has a data directory
+func (s *Service) IsProvisioned() bool {
+ _, err := os.Stat(s.config.DataDir)
+
+ return !os.IsNotExist(err)
+}
+
+// IsReady returns whether etcd is ready and synced
+func (s *Service) IsReady() bool {
+ return s.ready
+}
+
+// AddMember adds a new etcd member to the cluster
+func (s *Service) AddMember(ctx context.Context, name string, url string) (uint64, error) {
+ urls, err := types.NewURLs([]string{url})
+ if err != nil {
+ return 0, err
+ }
+
+ member := membership.NewMember(name, urls, DefaultClusterToken, nil)
+
+ _, err = s.etcd.Server.AddMember(ctx, *member)
+ if err != nil {
+ return 0, err
+ }
+
+ return uint64(member.ID), nil
+}
+
+// RemoveMember removes a member from the etcd cluster
+func (s *Service) RemoveMember(ctx context.Context, id uint64) error {
+ _, err := s.etcd.Server.RemoveMember(ctx, id)
+ return err
+}
+
+// Health returns the current cluster health
+func (s *Service) Health() {
+}
+
+// GetConfig returns the current consensus config
+func (s *Service) GetConfig() Config {
+ return *s.config
+}
+
+// SetConfig sets the consensus config. Changes are only applied when the service is restarted.
+func (s *Service) SetConfig(config Config) {
+ s.config = &config
+}
+
+// GetInitialClusterString returns the InitialCluster string that can be used to bootstrap a consensus node
+func (s *Service) GetInitialClusterString() string {
+ members := s.etcd.Server.Cluster().Members()
+ clusterString := strings.Builder{}
+
+ for i, m := range members {
+ if i != 0 {
+ clusterString.WriteString(",")
+ }
+ clusterString.WriteString(m.Name)
+ clusterString.WriteString("=")
+ clusterString.WriteString(m.PickPeerURL())
+ }
+
+ return clusterString.String()
+}
+
+// GetNodes returns a list of consensus nodes
+func (s *Service) GetNodes() []Member {
+ members := s.etcd.Server.Cluster().Members()
+ cMembers := make([]Member, len(members))
+ for i, m := range members {
+ cMembers[i] = Member{
+ ID: uint64(m.ID),
+ Name: m.Name,
+ Address: m.PickPeerURL(),
+ Synced: !m.IsLearner,
+ }
+ }
+
+ return cMembers
+}
+
+func (s *Service) GetStore(module, space string) clientv3.KV {
+ return namespace.NewKV(s.kv, fmt.Sprintf("%s:%s", module, space))
+}
diff --git a/core/internal/iam/BUILD.bazel b/core/internal/iam/BUILD.bazel
new file mode 100644
index 0000000..f737d5c
--- /dev/null
+++ b/core/internal/iam/BUILD.bazel
@@ -0,0 +1,22 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+ name = "go_default_library",
+ srcs = [
+ "capabilities.go",
+ "policies.go",
+ ],
+ importpath = "git.monogon.dev/source/nexantic.git/core/internal/iam",
+ visibility = ["//visibility:private"],
+ deps = [
+ "@com_github_open_policy_agent_opa//ast:go_default_library",
+ "@com_github_open_policy_agent_opa//rego:go_default_library",
+ "@com_github_open_policy_agent_opa//util:go_default_library",
+ ],
+)
+
+go_binary(
+ name = "iam",
+ embed = [":go_default_library"],
+ visibility = ["//:__subpackages__"],
+)
diff --git a/core/internal/iam/README.md b/core/internal/iam/README.md
new file mode 100644
index 0000000..07635ad
--- /dev/null
+++ b/core/internal/iam/README.md
@@ -0,0 +1,102 @@
+## Smalltown IAM
+
+There are 4 kinds of elements in Smalltown's Authorization system
+* Identities
+ * User
+ * Key
+ * Module
+* Objects
+ * Key
+ * Secret
+ * Module
+* Policies
+* Permissions
+
+### Identity
+Identities represent an actor that can execute **actions** like editing or interacting with an object.
+
+Identities possess **permissions** and **properties** which can be accessed by policies.
+
+### Objects
+Objects are things that can be interacted with like keys, secrets or modules.
+
+Each object has a **policy** that handles authorization of **actions** performed on it.
+
+When an object is created a default policy is attached which forwards all decisions to the global policy.
+For the first iteration of the system this policy will not be modifiable.
+
+**WARNING**: by modifying a policy, an object could become inaccessible!
+
+### Permissions
+
+Permissions can be assigned to an identity.
+
+| Property | Description | Example |
+|----------|-------------|---------|
+| Allowed Action | Regex specifying the allowed actions | key:meta:edit |
+| Object | Regex specifying the objects this affects | keys:* |
+| Multisig | Number of approvals required | 2 |
+
+Optionally a permission can have a multisig flag that requires N approvals from identities with the same permission.
+
+### Policies
+
+Policies guard actions that are performed on an object.
+
+By default a global policy governs all objects and global actions using an AWS IAM like model.
+
+Potentially a dynamic model using attachable policies could be implemented in the future to allow
+for highly custom models.
+
+A potential graphical representation of a future policy:
+
+
+
+### Global Default Ruleset
+
+This default global policy defines an AWS IAM like permission system.
+
+The following actions are implemented on objects:
+
+| Category | Action | Description | Note |
+|----------|-------------|---------|---------|
+| Object | object:view | Allow to view the object | Cannot be scripted using the policy builder |
+| Object | object:delete | Allow to delete the object |
+| Object | object:attach:normal | Allow to attach the object to a module slot |
+| Object | object:attach:exclusive | Allow to attach the object to an exclusive module slot |
+| Object | object:policy:view | Allow to view the object's attached policy |
+| Object | object:policy:edit | Allow to edit the object's attached policy |
+| Object | object:audit:view | Allow to view the object's audit log |
+| Object:Key | key:sign:eddsa | Allow to sign using the key |
+| Object:Key | key:sign:ecdsa | Allow to sign using the key |
+| Object:Key | key:sign:rsa | Allow to sign using the key |
+| Object:Key | key:encrypt:rsa | Allow to encrypt using the key |
+| Object:Key | key:encrypt:des | Allow to encrypt using the key |
+| Object:Key | key:encrypt:3des| Allow to encrypt using the key |
+| Object:Key | key:encrypt:aes | Allow to encrypt using the key |
+| Object:Key | key:decrypt:rsa | Allow to decrypt using the key |
+| Object:Key | key:decrypt:des | Allow to decrypt using the key |
+| Object:Key | key:decrypt:3des| Allow to decrypt using the key |
+| Object:Key | key:decrypt:aes | Allow to decrypt using the key |
+| Object:Key | key:auth:hmac | Allow to auth messages using the key |
+| Object:Secret | secret:reveal | Allow to reveal a secret to the identity |
+| Object:Module | module:update | Allow to update a module's bytecode | Updates verify the module signature
+| Object:Module | module:config | Allow to configure a module | Assigning objects to slots requires additional permissions on that object
+| Object:Module | module:call:* | Allow to call a function of the module | Function names are defined in the module and vary between modules
+
+The following actions are implemented globally:
+
+| Category | Action | Description | Note |
+|----------|-------------|---------|---------|
+| Object | g:key:generate | Allow to generate a key |
+| Object | g:key:import | Allow to import a key |
+| Object | g:secret:import | Allow to import a secret |
+| Object | g:module:install | Allow to install a module |
+| Object | g:user:create | Allow to create a user |
+| Object | g:user:permission_remove | Allow to create a user | **Privilege Escalation Risk**: Recommend Multisig
+| Object | g:user:permission_add | Allow to create a user | **Privilege Escalation Risk**: Recommend Multisig
+| Object | g:cluster:view | Allow to view cluster nodes
+| Object | g:cluster:add | Allow to add a node to the cluster | **Dangerous**: Recommend Multisig
+| Object | g:cluster:remove | Allow to remove a node from the cluster | **Dangerous**: Recommend Multisig
+| Object | g:config:edit | Allow to edit the global config
+
diff --git a/core/internal/iam/capabilities.go b/core/internal/iam/capabilities.go
new file mode 100644
index 0000000..1c692a3
--- /dev/null
+++ b/core/internal/iam/capabilities.go
@@ -0,0 +1,23 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+type (
+ Capability struct {
+ Name string
+ }
+)
diff --git a/core/internal/iam/policies.go b/core/internal/iam/policies.go
new file mode 100644
index 0000000..b17b623
--- /dev/null
+++ b/core/internal/iam/policies.go
@@ -0,0 +1,69 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "github.com/open-policy-agent/opa/ast"
+ "github.com/open-policy-agent/opa/rego"
+ "github.com/open-policy-agent/opa/util"
+)
+
+type dataSetProfile struct {
+ numTokens int
+ numPaths int
+}
+
+func main() {
+ ctx := context.Background()
+ compiler := ast.NewCompiler()
+ module := ast.MustParseModule(policy)
+
+ compiler.Compile(map[string]*ast.Module{"": module})
+ if compiler.Failed() {
+ }
+
+ r := rego.New(
+ rego.Compiler(compiler),
+ rego.Input(util.MustUnmarshalJSON([]byte(`{
+ "token_id": "deadbeef",
+ "path": "mna",
+ "method": "GET"
+ }`))),
+ rego.Query("data.restauthz"),
+ )
+
+ rs, err := r.Eval(ctx)
+ if err != nil {
+ panic(err)
+ }
+ fmt.Printf("%v", rs)
+}
+
+const policy = `package restauthz
+
+default allow = false
+
+allow {
+ input.method == "GET"
+}
+
+allow {
+ not input.method == "GET"
+}
+`
diff --git a/core/internal/network/BUILD.bazel b/core/internal/network/BUILD.bazel
new file mode 100644
index 0000000..db6467a
--- /dev/null
+++ b/core/internal/network/BUILD.bazel
@@ -0,0 +1,15 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+ name = "go_default_library",
+ srcs = ["main.go"],
+ importpath = "git.monogon.dev/source/nexantic.git/core/internal/network",
+ visibility = ["//:__subpackages__"],
+ deps = [
+ "//core/internal/common:go_default_library",
+ "@com_github_insomniacslk_dhcp//dhcpv4/nclient4:go_default_library",
+ "@com_github_vishvananda_netlink//:go_default_library",
+ "@org_golang_x_sys//unix:go_default_library",
+ "@org_uber_go_zap//:go_default_library",
+ ],
+)
diff --git a/core/internal/network/main.go b/core/internal/network/main.go
new file mode 100644
index 0000000..ecb0d18
--- /dev/null
+++ b/core/internal/network/main.go
@@ -0,0 +1,154 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package network
+
+import (
+ "context"
+ "fmt"
+ "git.monogon.dev/source/nexantic.git/core/internal/common"
+ "net"
+ "os"
+
+ "github.com/insomniacslk/dhcp/dhcpv4/nclient4"
+ "github.com/vishvananda/netlink"
+ "go.uber.org/zap"
+ "golang.org/x/sys/unix"
+)
+
+const (
+ resolvConfPath = "/etc/resolv.conf"
+ resolvConfSwapPath = "/etc/resolv.conf.new"
+)
+
+type Service struct {
+ *common.BaseService
+ config Config
+ dhcp4Client *nclient4.Client
+}
+
+type Config struct {
+}
+
+func NewNetworkService(config Config, logger *zap.Logger) (*Service, error) {
+ s := &Service{
+ config: config,
+ }
+ s.BaseService = common.NewBaseService("network", logger, s)
+ return s, nil
+}
+
+func setResolvconf(nameservers []net.IP, searchDomains []string) error {
+ os.Mkdir("/etc", 0755) // Error intentionally not checked
+ newResolvConf, err := os.Create(resolvConfSwapPath)
+ if err != nil {
+ return err
+ }
+ defer newResolvConf.Close()
+ defer os.Remove(resolvConfSwapPath)
+ for _, ns := range nameservers {
+ if _, err := newResolvConf.WriteString(fmt.Sprintf("nameserver %v\n", ns)); err != nil {
+ return err
+ }
+ }
+ for _, searchDomain := range searchDomains {
+ if _, err := newResolvConf.WriteString(fmt.Sprintf("search %v", searchDomain)); err != nil {
+ return err
+ }
+ }
+ newResolvConf.Close()
+ // Atomically swap in new config
+ return unix.Rename(resolvConfSwapPath, resolvConfPath)
+}
+
+func addNetworkRoutes(link netlink.Link, addr net.IPNet, gw net.IP) error {
+ if err := netlink.AddrReplace(link, &netlink.Addr{IPNet: &addr}); err != nil {
+ return err
+ }
+ if err := netlink.RouteAdd(&netlink.Route{
+ Dst: &net.IPNet{IP: net.IPv4(0, 0, 0, 0), Mask: net.IPv4Mask(0, 0, 0, 0)},
+ Gw: gw,
+
+ Scope: netlink.SCOPE_UNIVERSE,
+ }); err != nil {
+ return fmt.Errorf("Failed to add default route: %w", err)
+ }
+ return nil
+}
+
+const (
+ stateInitialize = 1
+ stateSelect = 2
+ stateBound = 3
+ stateRenew = 4
+ stateRebind = 5
+)
+
+var dhcpBroadcastAddr = &net.UDPAddr{IP: net.IP{255, 255, 255, 255}, Port: 67}
+
+// TODO(lorenz): This is a super terrible DHCP client, but it works for QEMU slirp
+func (s *Service) dhcpClient(iface netlink.Link) error {
+ client, err := nclient4.New(iface.Attrs().Name)
+ if err != nil {
+ panic(err)
+ }
+ _, ack, err := client.Request(context.Background())
+ if err != nil {
+ panic(err)
+ }
+ s.Logger.Info("Network service got IP", zap.String("ip", ack.YourIPAddr.String()))
+ if err := setResolvconf(ack.DNS(), []string{}); err != nil {
+ s.Logger.Warn("Failed to set resolvconf", zap.Error(err))
+ }
+ if err := addNetworkRoutes(iface, net.IPNet{IP: ack.YourIPAddr, Mask: ack.SubnetMask()}, ack.GatewayIPAddr); err != nil {
+ s.Logger.Warn("Failed to add routes", zap.Error(err))
+ }
+ return nil
+}
+
+func (s *Service) OnStart() error {
+ s.Logger.Info("Starting network service")
+ links, err := netlink.LinkList()
+ if err != nil {
+ s.Logger.Fatal("Failed to list network links", zap.Error(err))
+ }
+ var ethernetLinks []netlink.Link
+ for _, link := range links {
+ attrs := link.Attrs()
+ if link.Type() == "device" && len(attrs.HardwareAddr) > 0 {
+ if len(attrs.HardwareAddr) == 6 { // Ethernet
+ if attrs.Flags&net.FlagUp != net.FlagUp {
+ netlink.LinkSetUp(link) // Attempt to take up all ethernet links
+ }
+ ethernetLinks = append(ethernetLinks, link)
+ } else {
+ s.Logger.Info("Ignoring non-Ethernet interface", zap.String("interface", attrs.Name))
+ }
+ }
+ }
+ if len(ethernetLinks) == 1 {
+ link := ethernetLinks[0]
+ go s.dhcpClient(link)
+
+ } else {
+ s.Logger.Warn("Network service cannot yet handle more than one interface :(")
+ }
+ return nil
+}
+
+func (s *Service) OnStop() error {
+ return nil
+}
diff --git a/core/internal/node/BUILD.bazel b/core/internal/node/BUILD.bazel
new file mode 100644
index 0000000..0596269
--- /dev/null
+++ b/core/internal/node/BUILD.bazel
@@ -0,0 +1,20 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+ name = "go_default_library",
+ srcs = [
+ "main.go",
+ "setup.go",
+ ],
+ importpath = "git.monogon.dev/source/nexantic.git/core/internal/node",
+ visibility = ["//:__subpackages__"],
+ deps = [
+ "//core/internal/api:go_default_library",
+ "//core/internal/common:go_default_library",
+ "//core/internal/consensus:go_default_library",
+ "//core/internal/storage:go_default_library",
+ "@com_github_casbin_casbin//:go_default_library",
+ "@com_github_google_uuid//:go_default_library",
+ "@org_uber_go_zap//:go_default_library",
+ ],
+)
diff --git a/core/internal/node/main.go b/core/internal/node/main.go
new file mode 100644
index 0000000..7494f7a
--- /dev/null
+++ b/core/internal/node/main.go
@@ -0,0 +1,150 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package node
+
+import (
+ "flag"
+ "git.monogon.dev/source/nexantic.git/core/internal/api"
+ "git.monogon.dev/source/nexantic.git/core/internal/common"
+ "git.monogon.dev/source/nexantic.git/core/internal/consensus"
+ "git.monogon.dev/source/nexantic.git/core/internal/storage"
+
+ "github.com/casbin/casbin"
+ "github.com/google/uuid"
+ "go.uber.org/zap"
+)
+
+type (
+ SmalltownNode struct {
+ Api *api.Server
+ Consensus *consensus.Service
+ Storage *storage.Manager
+
+ logger *zap.Logger
+ ruleEnforcer *casbin.Enforcer
+ state common.SmalltownState
+ joinToken string
+ }
+)
+
+func NewSmalltownNode(logger *zap.Logger, apiPort, consensusPort uint16) (*SmalltownNode, error) {
+ flag.Parse()
+ logger.Info("Creating Smalltown node")
+
+ storageManager, err := storage.Initialize(logger.With(zap.String("component", "storage")))
+ if err != nil {
+ logger.Error("Failed to initialize storage manager", zap.Error(err))
+ return nil, err
+ }
+
+ consensusService, err := consensus.NewConsensusService(consensus.Config{
+ Name: "test",
+ ExternalHost: "0.0.0.0",
+ ListenPort: consensusPort,
+ ListenHost: "0.0.0.0",
+ }, logger.With(zap.String("module", "consensus")))
+ if err != nil {
+ return nil, err
+ }
+
+ s := &SmalltownNode{
+ Consensus: consensusService,
+ logger: logger,
+ Storage: storageManager,
+ }
+
+ apiService, err := api.NewApiServer(&api.Config{
+ Port: apiPort,
+ }, logger.With(zap.String("module", "api")), s, s.Consensus)
+ if err != nil {
+ return nil, err
+ }
+
+ s.Api = apiService
+
+ logger.Info("Created SmalltownNode")
+
+ return s, nil
+}
+
+func (s *SmalltownNode) Start() error {
+ s.logger.Info("Starting Smalltown node")
+
+ if s.Consensus.IsProvisioned() {
+ s.logger.Info("Consensus is provisioned")
+ err := s.startFull()
+ if err != nil {
+ return err
+ }
+ } else {
+ s.logger.Info("Consensus is not provisioned")
+ err := s.startForSetup()
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (s *SmalltownNode) startForSetup() error {
+ s.logger.Info("Initializing subsystems for setup mode")
+ s.state = common.StateSetupMode
+ s.joinToken = uuid.New().String()
+
+ err := s.Api.Start()
+ if err != nil {
+ s.logger.Error("Failed to start the API service", zap.Error(err))
+ return err
+ }
+
+ return nil
+}
+
+func (s *SmalltownNode) startFull() error {
+ s.logger.Info("Initializing subsystems for production")
+ s.state = common.StateConfigured
+
+ err := s.SetupBackend()
+ if err != nil {
+ return err
+ }
+
+ err = s.Consensus.Start()
+ if err != nil {
+ return err
+ }
+
+ err = s.Api.Start()
+ if err != nil {
+ s.logger.Error("Failed to start the API service", zap.Error(err))
+ return err
+ }
+
+ return nil
+}
+
+func (s *SmalltownNode) Stop() error {
+ s.logger.Info("Stopping Smalltown node")
+ return nil
+}
+
+func (s *SmalltownNode) SetupBackend() error {
+ s.logger.Debug("Creating trust backend")
+
+ return nil
+}
diff --git a/core/internal/node/setup.go b/core/internal/node/setup.go
new file mode 100644
index 0000000..28585dd
--- /dev/null
+++ b/core/internal/node/setup.go
@@ -0,0 +1,123 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package node
+
+import (
+ "git.monogon.dev/source/nexantic.git/core/internal/common"
+
+ "errors"
+ "go.uber.org/zap"
+)
+
+var (
+ ErrConsensusAlreadyProvisioned = errors.New("consensus is already provisioned; make sure the data folder is empty")
+ ErrAlreadySetup = errors.New("node is already set up")
+ ErrNotInJoinMode = errors.New("node is not in the cluster join mode")
+ ErrTrustNotInitialized = errors.New("trust backend not initialized")
+ ErrStorageNotInitialized = errors.New("storage not initialized")
+)
+
+func (s *SmalltownNode) CurrentState() common.SmalltownState {
+ return s.state
+}
+
+func (s *SmalltownNode) GetJoinClusterToken() string {
+ return s.joinToken
+}
+
+func (s *SmalltownNode) SetupNewCluster(name string, externalHost string) error {
+ if s.state == common.StateConfigured {
+ return ErrAlreadySetup
+ }
+ dataPath, err := s.Storage.GetPathInPlace(common.PlaceData, "etcd")
+ if err == common.ErrNotInitialized {
+ return ErrStorageNotInitialized
+ } else if err != nil {
+ return err
+ }
+
+ s.logger.Info("Setting up a new cluster", zap.String("name", name), zap.String("external_host", externalHost))
+
+ s.logger.Info("Provisioning consensus")
+
+ // Make sure etcd is not yet provisioned
+ if s.Consensus.IsProvisioned() {
+ return ErrConsensusAlreadyProvisioned
+ }
+
+ // Spin up etcd
+ config := s.Consensus.GetConfig()
+ config.NewCluster = true
+ config.Name = name
+ config.ExternalHost = externalHost
+ config.DataDir = dataPath
+ s.Consensus.SetConfig(config)
+
+ err = s.Consensus.Start()
+ if err != nil {
+ return err
+ }
+
+ // Change system state
+ s.state = common.StateConfigured
+
+ s.logger.Info("New Cluster set up. Node is now fully operational")
+
+ return nil
+}
+
+func (s *SmalltownNode) EnterJoinClusterMode() error {
+ if s.state == common.StateConfigured {
+ return ErrAlreadySetup
+ }
+ s.state = common.StateClusterJoinMode
+
+ s.logger.Info("Node is now in the cluster join mode")
+
+ return nil
+}
+
+func (s *SmalltownNode) JoinCluster(name string, clusterString string, externalHost string) error {
+ if s.state != common.StateClusterJoinMode {
+ return ErrNotInJoinMode
+ }
+
+ s.logger.Info("Joining cluster", zap.String("cluster", clusterString), zap.String("name", name))
+
+ err := s.SetupBackend()
+ if err != nil {
+ return err
+ }
+
+ config := s.Consensus.GetConfig()
+ config.Name = name
+ config.InitialCluster = clusterString
+ config.ExternalHost = externalHost
+ s.Consensus.SetConfig(config)
+
+ // Start consensus
+ err = s.Consensus.Start()
+ if err != nil {
+ return err
+ }
+
+ s.state = common.StateConfigured
+
+ s.logger.Info("Joined cluster. Node is now syncing.")
+
+ return nil
+}
diff --git a/core/internal/storage/BUILD.bazel b/core/internal/storage/BUILD.bazel
new file mode 100644
index 0000000..08c27d6
--- /dev/null
+++ b/core/internal/storage/BUILD.bazel
@@ -0,0 +1,22 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+ name = "go_default_library",
+ srcs = [
+ "blockdev.go",
+ "data.go",
+ "find.go",
+ "xfs.go",
+ ],
+ importpath = "git.monogon.dev/source/nexantic.git/core/internal/storage",
+ visibility = ["//:__subpackages__"],
+ deps = [
+ "//core/internal/common:go_default_library",
+ "//core/pkg/devicemapper:go_default_library",
+ "//core/pkg/sysfs:go_default_library",
+ "//core/pkg/tpm:go_default_library",
+ "@com_github_rekby_gpt//:go_default_library",
+ "@org_golang_x_sys//unix:go_default_library",
+ "@org_uber_go_zap//:go_default_library",
+ ],
+)
diff --git a/core/internal/storage/blockdev.go b/core/internal/storage/blockdev.go
new file mode 100644
index 0000000..8bdad12
--- /dev/null
+++ b/core/internal/storage/blockdev.go
@@ -0,0 +1,148 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package storage
+
+import (
+ "encoding/binary"
+ "encoding/hex"
+ "fmt"
+ "git.monogon.dev/source/nexantic.git/core/pkg/devicemapper"
+ "os"
+ "syscall"
+
+ "golang.org/x/sys/unix"
+)
+
+func readDataSectors(path string) (uint64, error) {
+ integrityPartition, err := os.Open(path)
+ if err != nil {
+ return 0, err
+ }
+ defer integrityPartition.Close()
+ // Based on structure defined in
+ // https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/drivers/md/dm-integrity.c#n59
+ if _, err := integrityPartition.Seek(16, 0); err != nil {
+ return 0, err
+ }
+ var providedDataSectors uint64
+ if err := binary.Read(integrityPartition, binary.LittleEndian, &providedDataSectors); err != nil {
+ return 0, err
+ }
+ return providedDataSectors, nil
+}
+
+// MapEncryptedBlockDevice maps an encrypted device (node) at baseName to a
+// decrypted device at /dev/$name using the given encryptionKey
+func MapEncryptedBlockDevice(name string, baseName string, encryptionKey []byte) error {
+ integritySectors, err := readDataSectors(baseName)
+ if err != nil {
+ return fmt.Errorf("failed to read the number of usable sectors on the integrity device: %w", err)
+ }
+
+ integrityDevName := fmt.Sprintf("/dev/%v-integrity", name)
+ integrityDMName := fmt.Sprintf("%v-integrity", name)
+ integrityDev, err := devicemapper.CreateActiveDevice(integrityDMName, []devicemapper.Target{
+ devicemapper.Target{
+ Length: integritySectors,
+ Type: "integrity",
+ Parameters: fmt.Sprintf("%v 0 28 J 1 journal_sectors:1024", baseName),
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("failed to create Integrity device: %w", err)
+ }
+ if err := unix.Mknod(integrityDevName, 0600|unix.S_IFBLK, int(integrityDev)); err != nil {
+ unix.Unlink(integrityDevName)
+ devicemapper.RemoveDevice(integrityDMName)
+ return fmt.Errorf("failed to create integrity device node: %w", err)
+ }
+
+ cryptDevName := fmt.Sprintf("/dev/%v", name)
+ cryptDev, err := devicemapper.CreateActiveDevice(name, []devicemapper.Target{
+ devicemapper.Target{
+ Length: integritySectors,
+ Type: "crypt",
+ Parameters: fmt.Sprintf("capi:gcm(aes)-random %v 0 %v 0 1 integrity:28:aead", hex.EncodeToString(encryptionKey), integrityDevName),
+ },
+ })
+ if err != nil {
+ unix.Unlink(integrityDevName)
+ devicemapper.RemoveDevice(integrityDMName)
+ return fmt.Errorf("failed to create crypt device: %w", err)
+ }
+ if err := unix.Mknod(cryptDevName, 0600|unix.S_IFBLK, int(cryptDev)); err != nil {
+ unix.Unlink(cryptDevName)
+ devicemapper.RemoveDevice(name)
+
+ unix.Unlink(integrityDevName)
+ devicemapper.RemoveDevice(integrityDMName)
+ return fmt.Errorf("failed to create crypt device node: %w", err)
+ }
+ return nil
+}
+
+// InitializeEncryptedBlockDevice initializes a new encrypted block device. This can take a long
+// time since all bytes on the mapped block device need to be zeroed.
+func InitializeEncryptedBlockDevice(name, baseName string, encryptionKey []byte) error {
+ integrityPartition, err := os.OpenFile(baseName, os.O_WRONLY, 0)
+ if err != nil {
+ return err
+ }
+ defer integrityPartition.Close()
+ zeroed512BBuf := make([]byte, 4096)
+ if _, err := integrityPartition.Write(zeroed512BBuf); err != nil {
+ return fmt.Errorf("failed to wipe header: %w", err)
+ }
+ integrityPartition.Close()
+
+ integrityDMName := fmt.Sprintf("%v-integrity", name)
+ _, err = devicemapper.CreateActiveDevice(integrityDMName, []devicemapper.Target{
+ devicemapper.Target{
+ Length: 1,
+ Type: "integrity",
+ Parameters: fmt.Sprintf("%v 0 28 J 1 journal_sectors:1024", baseName),
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("failed to create discovery integrity device: %w", err)
+ }
+ if err := devicemapper.RemoveDevice(integrityDMName); err != nil {
+ return fmt.Errorf("failed to remove discovery integrity device: %w", err)
+ }
+
+ if err := MapEncryptedBlockDevice(name, baseName, encryptionKey); err != nil {
+ return err
+ }
+
+ blkdev, err := os.OpenFile(fmt.Sprintf("/dev/%v", name), unix.O_DIRECT|os.O_WRONLY, 0000)
+ if err != nil {
+ return fmt.Errorf("failed to open new encrypted device for zeroing: %w", err)
+ }
+ defer blkdev.Close()
+ blockSize, err := unix.IoctlGetUint32(int(blkdev.Fd()), unix.BLKSSZGET)
+ zeroedBuf := make([]byte, blockSize*100) // Make it faster
+ for {
+ _, err := blkdev.Write(zeroedBuf)
+ if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC {
+ break
+ }
+ if err != nil {
+ return fmt.Errorf("failed to zero-initalize new encrypted device: %w", err)
+ }
+ }
+ return nil
+}
diff --git a/core/internal/storage/data.go b/core/internal/storage/data.go
new file mode 100644
index 0000000..e6df103
--- /dev/null
+++ b/core/internal/storage/data.go
@@ -0,0 +1,165 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package storage
+
+import (
+ "fmt"
+ "git.monogon.dev/source/nexantic.git/core/internal/common"
+ "git.monogon.dev/source/nexantic.git/core/pkg/tpm"
+ "io/ioutil"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sync"
+
+ "go.uber.org/zap"
+ "golang.org/x/sys/unix"
+)
+
+const (
+ dataMountPath = "/data"
+ espMountPath = "/esp"
+ espDataPath = espMountPath + "/EFI/smalltown"
+ etcdSealedKeyLocation = espDataPath + "/data-key.bin"
+)
+
+type Manager struct {
+ logger *zap.Logger
+ dataReady bool
+ initializationError error
+ mutex sync.RWMutex
+}
+
+func Initialize(logger *zap.Logger) (*Manager, error) {
+ if err := FindPartitions(); err != nil {
+ return nil, err
+ }
+
+ if err := os.Mkdir("/esp", 0755); err != nil {
+ return nil, err
+ }
+
+ // We're mounting ESP sync for reliability, this lowers our chances of getting half-written files
+ if err := unix.Mount(ESPDevicePath, espMountPath, "vfat", unix.MS_NOEXEC|unix.MS_NODEV|unix.MS_SYNC, ""); err != nil {
+ return nil, err
+ }
+
+ manager := &Manager{
+ logger: logger,
+ dataReady: false,
+ }
+
+ manager.mutex.Lock()
+ defer manager.mutex.Unlock()
+
+ sealedKeyFile, err := os.Open(etcdSealedKeyLocation)
+ if os.IsNotExist(err) {
+ logger.Info("Initializing encrypted storage, this might take a while...")
+ go manager.initializeData()
+ } else if err != nil {
+ return nil, err
+ } else {
+ sealedKey, err := ioutil.ReadAll(sealedKeyFile)
+ sealedKeyFile.Close()
+ if err != nil {
+ return nil, err
+ }
+ key, err := tpm.Unseal(sealedKey)
+ if err != nil {
+ return nil, err
+ }
+ if err := MapEncryptedBlockDevice("data", SmalltownDataCryptPath, key); err != nil {
+ return nil, err
+ }
+ if err := manager.mountData(); err != nil {
+ return nil, err
+ }
+ logger.Info("Mounted encrypted storage")
+ }
+ return manager, nil
+}
+
+func (s *Manager) initializeData() {
+ key, err := tpm.GenerateSafeKey(256 / 8)
+ if err != nil {
+ s.logger.Error("Failed to generate master key", zap.Error(err))
+ s.initializationError = fmt.Errorf("Failed to generate master key: %w", err)
+ return
+ }
+ sealedKey, err := tpm.Seal(key, tpm.FullSystemPCRs)
+ if err != nil {
+ s.logger.Error("Failed to seal master key", zap.Error(err))
+ s.initializationError = fmt.Errorf("Failed to seal master key: %w", err)
+ return
+ }
+ if err := InitializeEncryptedBlockDevice("data", SmalltownDataCryptPath, key); err != nil {
+ s.logger.Error("Failed to initialize encrypted block device", zap.Error(err))
+ s.initializationError = fmt.Errorf("Failed to initialize encrypted block device: %w", err)
+ return
+ }
+ mkfsCmd := exec.Command("/bin/mkfs.xfs", "-qf", "/dev/data")
+ if _, err := mkfsCmd.Output(); err != nil {
+ s.logger.Error("Failed to format encrypted block device", zap.Error(err))
+ s.initializationError = fmt.Errorf("Failed to format encrypted block device: %w", err)
+ return
+ }
+ // This file is the marker if the partition has
+ if err := ioutil.WriteFile(etcdSealedKeyLocation, sealedKey, 0600); err != nil {
+ panic(err)
+ }
+
+ if err := s.mountData(); err != nil {
+ s.initializationError = err
+ return
+ }
+
+ s.mutex.Lock()
+ s.dataReady = true
+ s.mutex.Unlock()
+
+ s.logger.Info("Initialized encrypted storage")
+}
+
+func (s *Manager) mountData() error {
+ if err := os.Mkdir("/data", 0755); err != nil {
+ return err
+ }
+
+ if err := unix.Mount("/dev/data", "/data", "xfs", unix.MS_NOEXEC|unix.MS_NODEV, ""); err != nil {
+ return err
+ }
+ return nil
+}
+
+// GetPathInPlace returns a path in the given place
+// It may return ErrNotInitialized if the place you're trying to access
+// is not initialized or ErrUnknownPlace if the place is not known
+func (s *Manager) GetPathInPlace(place common.DataPlace, path string) (string, error) {
+ s.mutex.RLock()
+ defer s.mutex.RUnlock()
+ switch place {
+ case common.PlaceESP:
+ return filepath.Join(espDataPath, path), nil
+ case common.PlaceData:
+ if s.dataReady {
+ return filepath.Join(dataMountPath, path), nil
+ }
+ return "", common.ErrNotInitialized
+ default:
+ return "", common.ErrUnknownPlace
+ }
+}
diff --git a/core/internal/storage/find.go b/core/internal/storage/find.go
new file mode 100644
index 0000000..0ba1ca0
--- /dev/null
+++ b/core/internal/storage/find.go
@@ -0,0 +1,96 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package storage
+
+import (
+ "fmt"
+ "git.monogon.dev/source/nexantic.git/core/pkg/sysfs"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "strconv"
+
+ "github.com/rekby/gpt"
+ "golang.org/x/sys/unix"
+)
+
+// EFIPartitionType is the standardized partition type value for the EFI ESP partition. The human readable GUID is C12A7328-F81F-11D2-BA4B-00A0C93EC93B.
+var EFIPartitionType = gpt.PartType{0x28, 0x73, 0x2a, 0xc1, 0x1f, 0xf8, 0xd2, 0x11, 0xba, 0x4b, 0x00, 0xa0, 0xc9, 0x3e, 0xc9, 0x3b}
+
+// SmalltownDataPartitionType is the partition type value for a Smalltown data partition. The human-readable GUID is 9eeec464-6885-414a-b278-4305c51f7966.
+var SmalltownDataPartitionType = gpt.PartType{0x64, 0xc4, 0xee, 0x9e, 0x85, 0x68, 0x4a, 0x41, 0xb2, 0x78, 0x43, 0x05, 0xc5, 0x1f, 0x79, 0x66}
+
+var ESPDevicePath = "/dev/esp"
+var SmalltownDataCryptPath = "/dev/data-crypt"
+
+// FindPartitions looks for the ESP and the Smalltown data partition and maps them to ESPDevicePath and
+// SmalltownDataCryptPath respectively. This doesn't fail if it doesn't find the partitions, only if
+// something goes catastrophically wrong.
+func FindPartitions() error {
+ blockdevNames, err := ioutil.ReadDir("/sys/class/block")
+ if err != nil {
+ return fmt.Errorf("failed to read sysfs block class: %w", err)
+ }
+ for _, blockdevName := range blockdevNames {
+ ueventData, err := sysfs.ReadUevents(filepath.Join("/sys/class/block", blockdevName.Name(), "uevent"))
+ if err != nil {
+ return fmt.Errorf("failed to read uevent for block device %v: %w", blockdevName.Name(), err)
+ }
+ if ueventData["DEVTYPE"] == "disk" {
+ majorDev, err := strconv.Atoi(ueventData["MAJOR"])
+ if err != nil {
+ return fmt.Errorf("failed to convert uevent: %w", err)
+ }
+ minorDev, err := strconv.Atoi(ueventData["MINOR"])
+ if err != nil {
+ return fmt.Errorf("failed to convert uevent: %w", err)
+ }
+ devNodeName := fmt.Sprintf("/dev/%v", ueventData["DEVNAME"])
+ if err := unix.Mknod(devNodeName, 0600|unix.S_IFBLK, int(unix.Mkdev(uint32(majorDev), uint32(minorDev)))); err != nil {
+ return fmt.Errorf("failed to create block device node: %w", err)
+ }
+ blkdev, err := os.Open(devNodeName)
+ if err != nil {
+ return fmt.Errorf("failed to open block device %v: %w", devNodeName, err)
+ }
+ defer blkdev.Close()
+ blockSize, err := unix.IoctlGetUint32(int(blkdev.Fd()), unix.BLKSSZGET)
+ if err != nil {
+ continue // This is not a regular block device
+ }
+ blkdev.Seek(int64(blockSize), 0)
+ table, err := gpt.ReadTable(blkdev, uint64(blockSize))
+ if err != nil {
+ // Probably just not a GPT-partitioned disk
+ continue
+ }
+ for partNumber, part := range table.Partitions {
+ if part.Type == EFIPartitionType {
+ if err := unix.Mknod(ESPDevicePath, 0600|unix.S_IFBLK, int(unix.Mkdev(uint32(majorDev), uint32(partNumber+1)))); err != nil {
+ return fmt.Errorf("failed to create device node for ESP partition: %w", err)
+ }
+ }
+ if part.Type == SmalltownDataPartitionType {
+ if err := unix.Mknod(SmalltownDataCryptPath, 0600|unix.S_IFBLK, int(unix.Mkdev(uint32(majorDev), uint32(partNumber+1)))); err != nil {
+ return fmt.Errorf("failed to create device node for Smalltown encrypted data partition: %w", err)
+ }
+ }
+ }
+ }
+ }
+ return nil
+}
diff --git a/core/internal/storage/xfs.go b/core/internal/storage/xfs.go
new file mode 100644
index 0000000..30c6686
--- /dev/null
+++ b/core/internal/storage/xfs.go
@@ -0,0 +1,18 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package storage
+