initial commit

This commit is contained in:
2026-06-06 00:09:35 +02:00
commit 12854e2eca
5 changed files with 287 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/result

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

67
flake.nix Normal file
View File

@@ -0,0 +1,67 @@
{
description = "nas_unlock";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
pname = "nix_unlock";
version = "0.1.0";
app = pkgs.buildGoModule {
inherit pname version;
src = ./.;
vendorHash = null;
meta = with pkgs.lib; {
description = "Unlock your NAS via http";
license = licenses.asl20;
mainProgram = pname;
};
};
in
{
packages.default = app;
packages.${pname} = app;
defaultPackage = app;
apps.default = flake-utils.lib.mkApp { drv = app; };
devShells.default = pkgs.mkShell {
name = "${pname}-dev";
packages = with pkgs; [
go
gopls
gotools
golangci-lint
delve
];
env = {
GOPATH = "${placeholder "out"}/go";
CGO_ENABLED = "0";
};
shellHook = ''
echo "Go version: $(go version | awk '{print $3}')"
'';
};
devShell = self.devShells.${system}.default;
}
);
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module git.kapelle.org/niklas/nas_unlock
go 1.25.8

155
main.go Normal file
View File

@@ -0,0 +1,155 @@
package main
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"flag"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
)
var unlockCommand string
var unlockMutex sync.Mutex
type unlockPayload struct {
Passphrase string `json:"passphrase"`
}
func apiKeyMiddleware(apiKey string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
providedKey := r.Header.Get("X-API-Key")
match := subtle.ConstantTimeCompare([]byte(providedKey), []byte(apiKey))
if match != 1 {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func unlockZFSHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if r.Header.Get("Content-Type") != "application/json" {
http.Error(w, "Unsupported media type", http.StatusUnsupportedMediaType)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
var payload unlockPayload
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 10*1024))
err := decoder.Decode(&payload)
if err != nil {
http.Error(w, "Malformed json", http.StatusBadRequest)
return
}
unlockMutex.Lock()
defer unlockMutex.Unlock()
cmd := exec.CommandContext(ctx, unlockCommand)
cmd.Stdin = strings.NewReader(payload.Passphrase)
output, err := cmd.CombinedOutput()
if err != nil {
http.Error(w, "Failed to unlock dataset", http.StatusInternalServerError)
log.Printf("Error: %v", err)
log.Printf("Output: %s", output)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("Dataset unlocked and mounted successfully.\n"))
}
func validateUnlockCommand(command string) bool {
if !filepath.IsAbs(command) {
log.Printf("Unlock command path is not absolute\n")
return false
}
_, err := os.Stat(command)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
log.Printf("Command does not exist\n")
}
return false
}
return true
}
func main() {
unlockCommandFlag := flag.String("command", "", "what script to execute to unlock the volume")
flag.Parse()
if *unlockCommandFlag == "" {
log.Fatalln("No script provided")
}
unlockCommand = *unlockCommandFlag
if !validateUnlockCommand(unlockCommand) {
log.Fatalf("Unlock command validation failed\n")
}
apiKey := os.Getenv("API_KEY")
if apiKey == "" {
log.Fatalln("No API_KEY provided")
}
if len(apiKey) <= 14 {
log.Fatalln("API_KEY must be at least 14 characters long")
}
mux := http.NewServeMux()
mux.Handle("/api/unlock", apiKeyMiddleware(apiKey, http.HandlerFunc(unlockZFSHandler)))
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 31 * time.Second,
IdleTimeout: 31 * time.Second,
}
shutdownChan := make(chan os.Signal, 1)
signal.Notify(shutdownChan, os.Interrupt, syscall.SIGTERM)
go func() {
log.Printf("Server listening on %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server startup failed: %v", err)
}
}()
<-shutdownChan
log.Println("Shutdown signal received. Shutting down gracefully...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("Server exited properly")
}