;;; GNU Guix --- Functional package management for GNU
;;; Copyright © 2020-2023, 2025 Ludovic Courtès <ludo@gnu.org>
;;; Copyright © 2020 Jan (janneke) Nieuwenhuizen <janneke@gnu.org>
;;;
;;; This file is part of GNU Guix.
;;;
;;; GNU Guix is free software; you can redistribute it and/or modify it
;;; under the terms of the GNU General Public License as published by
;;; the Free Software Foundation; either version 3 of the License, or (at
;;; your option) any later version.
;;;
;;; GNU Guix is distributed in the hope that it will be useful, but
;;; WITHOUT ANY WARRANTY; without even the implied warranty of
;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;;; GNU General Public License for more details.
;;;
;;; You should have received a copy of the GNU General Public License
;;; along with GNU Guix.  If not, see <http://www.gnu.org/licenses/>.

(define-module (gnu build secret-service)
  #:autoload   (fibers io-wakeup) (wait-until-port-readable-operation)
  #:autoload   (fibers operations) (perform-operation
                                    choice-operation
                                    wrap-operation)
  #:autoload   (fibers timers) (sleep-operation)
  #:use-module (guix build utils)
  #:use-module (srfi srfi-26)
  #:use-module (rnrs bytevectors)
  #:use-module (ice-9 binary-ports)
  #:use-module (ice-9 match)
  #:use-module (ice-9 rdelim)

  #:export (secret-service-receive-secrets
            secret-service-send-secrets))

;;; Commentary:
;;;
;;; Utility procedures for copying secrets into a VM.
;;;
;;; Note: This code runs within the 'shepherd' process, hence the use of
;;; Fibers.
;;;
;;; Code:

(define-syntax log
  (lambda (s)
    "Log the given message."
    (syntax-case s ()
      ((_ fmt args ...)
       (with-syntax ((fmt (string-append "secret service: "
                                         (syntax->datum #'fmt))))
         ;; Log to the current output port.  That way, when
         ;; 'secret-service-send-secrets' is called from shepherd, output goes
         ;; to syslog.
         #'(format (current-output-port) fmt args ...))))))

(define (wait-for-readable-fd port timeout)
  "Wait until PORT has data available for reading or TIMEOUT has expired.
Return #t in the former case and #f in the latter case."
  (perform-operation
   (choice-operation
    (wrap-operation (wait-until-port-readable-operation port)
                    (const #t))
    (wrap-operation (sleep-operation timeout)
                    (const #f)))))

(define (socket-address->string address)
  "Return a human-readable representation of ADDRESS, an object as returned by
'make-socket-address'."
  (let ((family (sockaddr:fam address)))
    (cond ((= AF_INET family)
           (string-append (inet-ntop AF_INET (sockaddr:addr address))
                          ":" (number->string (sockaddr:port address))))
          ((= AF_INET6 family)
           (string-append "[" (inet-ntop AF_INET6 (sockaddr:addr address)) "]"
                          ":" (number->string (sockaddr:port address))))
          ((= AF_UNIX family)
           (sockaddr:path address))
          (else
           (object->string address)))))

(define* (secret-service-send-secrets address secret-root
                                      #:key (retry 60)
                                      (handshake-timeout 180))
  "Copy all files under SECRET-ROOT by connecting to secret-service listening
at ADDRESS, an address as returned by 'make-socket-address'.  If connection
fails, sleep 1s and retry RETRY times; once connected, wait for at most
HANDSHAKE-TIMEOUT seconds for handshake to complete.  Return #f on failure."
  (define (file->file+size+mode file-name)
    (let ((stat (stat file-name))
          (target (substring file-name (string-length secret-root))))
      (list target (stat:size stat) (stat:mode stat))))

  (define (send-files sock)
    (let* ((files (if secret-root (find-files secret-root) '()))
           (files-sizes-modes (map file->file+size+mode files))
           (secrets `(secrets
                      (version 0)
                      (files ,files-sizes-modes))))
      (write secrets sock)
      (for-each (lambda (file)
                  (call-with-input-file file
                    (lambda (input)
                      (dump-port input sock))))
                files)))

  (log "sending secrets to ~a~%" (socket-address->string address))

  (let ((sock (socket AF_INET
                      (logior SOCK_CLOEXEC SOCK_NONBLOCK SOCK_STREAM)
                      0))
        (sleep (module-ref (resolve-interface '(fibers)) 'sleep)))
    ;; Connect to QEMU on the forwarded port.  The 'connect' call succeeds as
    ;; soon as QEMU is ready, even if there's no server listening on the
    ;; forward port inside the guest.
    (let loop ((retry retry))
      (catch 'system-error
        (cute connect sock address)
        (lambda (key . args)
          (when (zero? retry)
            (apply throw key args))
          (log "retrying connection [~a attempts left]~%"
               (- retry 1))
          (sleep 1)
          (loop (1- retry)))))

    (log "connected; waiting for handshake...~%")

    ;; Wait for "hello" message from the server.  This is the only way to know
    ;; that we're really connected to the server inside the guest.
    (if (wait-for-readable-fd sock handshake-timeout)
        (match (read sock)
          (('secret-service-server ('version version ...))
           (log "sending files from ~s...~%" secret-root)
           (send-files sock)
           (log "done sending files to ~a~%"
                (socket-address->string address))
           (close-port sock)
           secret-root)
          (x
           (log "invalid handshake ~s~%" x)
           (close-port sock)
           #f))
        (begin                                    ;timeout
         (log "timeout while sending files to ~a~%"
              (socket-address->string address))
         (close-port sock)
         #f))))

(define (delete-file* file)
  "Ensure FILE does not exist."
  (catch 'system-error
    (lambda ()
      (delete-file file))
    (lambda args
      (unless (= ENOENT (system-error-errno args))
        (apply throw args)))))

(define* (secret-service-receive-secrets address
                                         #:key (timeout 60))
  "Listen to ADDRESS, an address returned by 'make-socket-address', and wait
for a secret service client to send secrets.  Write them to the file system.
Return the list of files installed on success, and #f if TIMEOUT seconds
passed without receiving any files or if some other failure occurred."

  (define (wait-for-client address)
    ;; Wait for a connection on ADDRESS.  Note: virtio-serial ports are safer
    ;; than TCP connections but they are (presumably) unsupported on GNU/Hurd.
    (let ((sock (socket AF_INET
                        (logior SOCK_CLOEXEC SOCK_NONBLOCK SOCK_STREAM)
                        0)))
      (bind sock address)
      (listen sock 1)
      (log "waiting for secrets on ~a...~%"
           (socket-address->string address))

      (if (wait-for-readable-fd sock timeout)
          (match (accept sock (logior SOCK_CLOEXEC SOCK_NONBLOCK))
            ((client . address)
             (log "client connection from ~a~%"
                  (inet-ntop (sockaddr:fam address)
                             (sockaddr:addr address)))

             ;; Send a "hello" message.  This allows the client running on the
             ;; host to know that it's now actually connected to server running
             ;; in the guest.
             (write '(secret-service-server (version 0)) client)
             (force-output client)
             (close-port sock)
             client))
          (begin
            (log "did not receive any secrets; time out~%")
            (close-port sock)
            #f))))

  (define (read-secrets port)
    ;; Read secret files from PORT and install them.
    (match (false-if-exception (read port))
      (('secrets ('version 0)
                 ('files ((files sizes modes) ...)))
       (for-each (lambda (file size mode)
                   (log "installing file '~a' (~a bytes)...~%"
                        file size)
                   (mkdir-p (dirname file))

                   ;; It could be that FILE already exists, for instance
                   ;; because it has been created by a service's activation
                   ;; snippet (e.g., SSH host keys).  Delete it.
                   (delete-file* file)

                   (call-with-output-file file
                     (lambda (output)
                       (dump-port port output size)
                       (chmod file mode))))
                 files sizes modes)
       (log "received ~a secret files~%" (length files))
       files)
      (_
       (log "invalid secrets received~%")
       #f)))

  (let* ((port   (wait-for-client address))
         (result (and=> port read-secrets)))
    (when port
      (close-port port))
    result))

;;; Local Variables:
;;; eval: (put 'with-modules 'scheme-indent-function 1)
;;; End:

;;; secret-service.scm ends here
