Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fusion: init at 0.8.9 #353616

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions nixos/modules/module-list.nix
Original file line number Diff line number Diff line change
Expand Up @@ -1422,6 +1422,7 @@
./services/web-apps/flarum.nix
./services/web-apps/fluidd.nix
./services/web-apps/freshrss.nix
./services/web-apps/fusion.nix
./services/web-apps/galene.nix
./services/web-apps/gancio.nix
./services/web-apps/gerrit.nix
Expand Down
144 changes: 144 additions & 0 deletions nixos/modules/services/web-apps/fusion.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.fusion;
in
{
options.services.fusion = {
enable = lib.mkEnableOption "Fusion, a lightweight, self-hosted friendly RSS aggregator and reader";

host = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
example = "[::1]";
description = "The address that Fusion should listen on.";
};

port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "The port that Fusion should listen on.";
};

passwordFile = lib.mkOption {
type = lib.types.path;
example = "/run/keys/fusion-password";
description = ''
A file containing the password for the Fusion web interface.

This should be a path pointing to a file within a secure directory and NEVER
in the Nix store (which is world-readable)!
SuperSandro2000 marked this conversation as resolved.
Show resolved Hide resolved
'';
};

tls = lib.mkOption {
type = lib.types.nullOr (
lib.types.submodule {
options = {
cert = lib.mkOption {
type = lib.types.path;
description = "Path to TLS certificate";
};
key = lib.mkOption {
type = lib.types.path;
description = "Path to TLS key";
};
};
}
);
default = null;
description = ''
The paths to the TLS certificate and key files for Fusion.

If these options are set, then Fusion can only be accessed through a secure
TLS connection. If you are using a reverse proxy like Nginx to handle HTTPS,
please leave these unset.
'';
};
Comment on lines +38 to +61
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if we just want to use nginx here all the time or wire this into acme directly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not using Nginx? I'm just saying that if you are using a reverse proxy, for example with Nginx, then you need to leave these unset following upstream instructions

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this may have more been of a recommendation to use Nginx over this, as most modules will do that over exposing TLS cert options like this and accessing services directly

I think it could be a good addition here as well, but not a blocker by any means

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not exactly familiar with Nginx 🤦🏼‍♀️ Is there an example I can look at?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pixelfed uses this well. A minimal example would be

{
  options.myService = {
    # ...
    domain = lib.mkOption {
      type = lib.types.str;
      description = "FQDN for myService";
      example = "my-service.example.org";
    };

    nginx = lib.mkOption {
      type = lib.types.nullOr (
        lib.types.submodule (import ../web-servers/nginx/vhost-options.nix { inherit config lib; })
      );
      default = { };
      example = lib.literalExpression ''
        {
          enableACME = true;
          forceSSL = true;
        }
      '';
    };
  };

  config = lib.mkIf cfg.enable {
    # ...
    services.nginx = lib.mkIf (cfg.nginx != null) {
      enable = lib.mkDefault true;

      virtualHosts.${cfg.domain} = lib.mkMerge [
        {
          some = "nice";
          defaults = [
            "if"
            "any"
          ];
        }
        cfg.nginx
      ];
    };
  };
}

};

config = lib.mkIf cfg.enable {
systemd.services.fusion = {
description = "Fusion, a lightweight, self-hosted friendly RSS aggregator and reader";

wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];

environment = {
HOST = cfg.host;
PORT = toString cfg.port;
DB = "/var/lib/fusion/sqlite.db";

TLS_CERT = lib.mkIf (cfg.tls != null) cfg.tls.cert;
TLS_KEY = lib.mkIf (cfg.tls != null) cfg.tls.key;
};

script = ''
export PASSWORD=$(cat $CREDENTIALS_DIRECTORY/fusion)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this exposing the password in ps?

Copy link
Contributor Author

@pluiedev pluiedev Nov 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so? This is run in a run script that's only run by the systemd user, and $CREDENTIALS_DIRECTORY isn't visible anywhere else (see systemd docs.

It's not optimal, but you can argue that a program that's designed to only source its password from an unencrypted environmental variable is already unsafe anyway if you have security concerns 🤷🏼‍♀️

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is used as an example in the upstream docs:

In order to reference the path a credential may be read from within a ExecStart= command line use "${CREDENTIALS_DIRECTORY}/mycred", e.g. "ExecStart=cat ${CREDENTIALS_DIRECTORY}/mycred"

But next they also give an example of

In order to reference the path a credential may be read from within a Environment= line use "%d/mycred", e.g. "Environment=MYCREDPATH=%d/mycred".

That may be better to apply here rather than doing it in the script -- assuming it works

Copy link
Contributor Author

@pluiedev pluiedev Dec 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing is that fusion doesn't expect a path to the credential, but the whole plain password itself. This is why I mentioned that honestly wanting any amount of actual security on this thing is somewhat futile since it only works with plain passwords 100% visible and readable as an environment variable

See 0x2E/fusion#32

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is why I mentioned that honestly wanting any amount of actual security on this thing is somewhat futile since it only works with plain passwords 100% visible and readable as an environment variable

I wouldn't say it's that bad, as this environment variable is at least scoped to the service script (compared to ps info being available globally on many machines)

I don't think this would actually make the secrets contents appear in ps though, only the directory -- which doesn't matter as it should have restricted perms. If we really want to avoid cat though, we could use this instead

PASSWORD="$(< "$CREDENTIALS_DIRECTORY"/fusion)"
export PASSWORD

${lib.getExe pkgs.fusion}
'';

serviceConfig = {
DynamicUser = true;
User = "fusion";
LoadCredential = "fusion:${cfg.passwordFile}";
Restart = "on-failure";
TimeoutStopSec = 300;

# Hardening
WorkingDirectory = "/var/lib/fusion";
StateDirectory = "fusion";
RuntimeDirectory = "fusion";
RootDirectory = "/run/fusion";
RootDirectoryStartOnly = true;

BindReadOnlyPaths =
[ builtins.storeDir ]
++ lib.optionals (cfg.tls != null) [
cfg.tls.cert
cfg.tls.key
];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for packaging this, @pluiedev!

I tested this on my system, and the systemd hardening seems to prevent fusion from making outbound HTTP connections to RSS feeds.

I was able to fix it by adding this:

      BindReadOnlyPaths = [
        builtins.storeDir
        "/etc/ssl/certs"
        "/etc/resolv.conf"
        "/etc/nsswitch.conf"
        "/etc/hosts"
        "${pkgs.fusion}"
        "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
      ] ++ lib.optionals (cfg.tls != null) [
            cfg.tls.cert
            cfg.tls.key
          ];
      Environment = [
        "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
        "NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
      ];


ProtectClock = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
ProtectKernelModules = true;
ProtectHostname = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectHome = true;
ProcSubset = "pid";

PrivateTmp = true;
PrivateNetwork = false;
PrivateUsers = cfg.port >= 1024;
PrivateDevices = true;

RestrictRealtime = true;
RestrictNamespaces = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];

MemoryDenyWriteExecute = true;
LockPersonality = true;
AmbientCapabilities = lib.optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE";
CapabilityBoundingSet = "";
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@resources"
"~@privileged"
"setrlimit"
];
UMask = "0066";
};
};
};
}
1 change: 1 addition & 0 deletions nixos/tests/all-tests.nix
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ in {
fsck = handleTest ./fsck.nix {};
fsck-systemd-stage-1 = handleTest ./fsck.nix { systemdStage1 = true; };
ft2-clone = handleTest ./ft2-clone.nix {};
fusion = handleTest ./fusion.nix {};
legit = handleTest ./legit.nix {};
mimir = handleTest ./mimir.nix {};
gancio = handleTest ./gancio.nix {};
Expand Down
33 changes: 33 additions & 0 deletions nixos/tests/fusion.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import ./make-test-python.nix (
let
password = "much-secure-so-password";
in
{ lib, ... }:
{
name = "fusion";
meta.maintainers = with lib.maintainers; [ pluiedev ];

nodes.machine =
{ pkgs, ... }:
{
networking.firewall.enable = false;
networking.useDHCP = false;

services.fusion = {
enable = true;

# WARNING: Never EVER do this in production.
passwordFile = pkgs.writeText "fusion-test-password" password;
};
};

testScript = ''
machine.wait_for_unit("network-online.target")
machine.wait_for_open_port(8080)

# Try logging in
machine.succeed('curl -X POST -H "Content-Type: application/json" --data \'{"password":"${password}"}\' 127.0.0.1:8080/api/sessions')
machine.wait_for_console_text('"status":201')
'';
}
)
15 changes: 15 additions & 0 deletions pkgs/by-name/fu/fusion/frontend-pin-version.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
diff --git a/vite.config.ts b/vite.config.ts
index 880b306..01f7a92 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -6,9 +6,7 @@ export default defineConfig({
plugins: [sveltekit()],
define: {
'import.meta.env.FUSION': JSON.stringify({
- version:
- execSync('git describe --tags --abbrev=0').toString().trimEnd() ||
- execSync('git rev-parse --short HEAD').toString().trimEnd()
+ version: process.env.version
})
},
server: {
75 changes: 75 additions & 0 deletions pkgs/by-name/fu/fusion/package.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{
lib,
stdenv,
fetchFromGitHub,
buildNpmPackage,
buildGoModule,
mockgen,
nixosTests,
nix-update-script,
}:
buildGoModule rec {
pname = "fusion";
version = "0.8.9";

src = fetchFromGitHub {
owner = "0x2E";
repo = "fusion";
rev = "v${version}";
hash = "sha256-nI587lshHlwZMnGGtzkLSaWc8OvY8QfPsdmAR2j+slI=";
};

frontend = buildNpmPackage {
pluiedev marked this conversation as resolved.
Show resolved Hide resolved
pname = "fusion-frontend";
inherit version;
src = "${src}/frontend";

patches = [ ./frontend-pin-version.patch ];

npmDepsHash = "sha256-sOdviGGVB41IjO2tpw655dSxuqxufQGo2CqmsjNCDn0=";

installPhase = ''
runHook preInstall

cp -r build $out

runHook postInstall
'';
};

vendorHash = "sha256-isSoDDJLWIxmihu9txcPMDBJ+l323yrfXqyhqtEAoUI=";

subPackages = [ "cmd/server" ];

overrideModAttrs = prev: {
nativeBuildInputs = prev.nativeBuildInputs ++ [ mockgen ];

preBuild = ''
go generate ./...
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As of 0.8.11, this is no longer needed (see 0x2E/fusion@d6194e3)

'';
};

preBuild = ''
cp -r ${frontend} frontend/build
'';

postInstall = ''
mv $out/bin/server $out/bin/fusion
'';

passthru = {
tests = lib.optionalAttrs stdenv.hostPlatform.isLinux {
inherit (nixosTests) fusion;
};

updateScript = nix-update-script { };
};

meta = {
description = "Lightweight, self-hosted friendly RSS aggregator and reader";
homepage = "https://github.com/0x2E/fusion";
license = with lib.licenses; [ mit ];
maintainers = with lib.maintainers; [ pluiedev ];
mainProgram = "fusion";
};
}
Loading