1{
2 config,
3 lib,
4 pkgs,
5 utils,
6 ...
7}:
8let
9 inherit (lib)
10 mkEnableOption
11 mkPackageOption
12 mkOption
13 literalExpression
14 mkIf
15 mkDefault
16 types
17 optionals
18 getExe
19 ;
20 inherit (utils) escapeSystemdExecArgs;
21 cfg = config.services.sunshine;
22
23 # ports used are offset from a single base port, see https://docs.lizardbyte.dev/projects/sunshine/en/latest/about/advanced_usage.html#port
24 generatePorts = port: offsets: map (offset: port + offset) offsets;
25 defaultPort = 47989;
26
27 appsFormat = pkgs.formats.json { };
28 settingsFormat = pkgs.formats.keyValue { };
29
30 appsFile = appsFormat.generate "apps.json" cfg.applications;
31 configFile = settingsFormat.generate "sunshine.conf" cfg.settings;
32in
33{
34 options.services.sunshine = with types; {
35 enable = mkEnableOption "Sunshine, a self-hosted game stream host for Moonlight";
36 package = mkPackageOption pkgs "sunshine" { };
37 openFirewall = mkOption {
38 type = bool;
39 default = false;
40 description = ''
41 Whether to automatically open ports in the firewall.
42 '';
43 };
44 capSysAdmin = mkOption {
45 type = bool;
46 default = false;
47 description = ''
48 Whether to give the Sunshine binary CAP_SYS_ADMIN, required for DRM/KMS screen capture.
49 '';
50 };
51 autoStart = mkOption {
52 type = bool;
53 default = true;
54 description = ''
55 Whether sunshine should be started automatically.
56 '';
57 };
58 settings = mkOption {
59 default = { };
60 description = ''
61 Settings to be rendered into the configuration file. If this is set, no configuration is possible from the web UI.
62
63 See https://docs.lizardbyte.dev/projects/sunshine/en/latest/about/advanced_usage.html#configuration for syntax.
64 '';
65 example = literalExpression ''
66 {
67 sunshine_name = "nixos";
68 }
69 '';
70 type = submodule (settings: {
71 freeformType = settingsFormat.type;
72 options.port = mkOption {
73 type = port;
74 default = defaultPort;
75 description = ''
76 Base port -- others used are offset from this one, see https://docs.lizardbyte.dev/projects/sunshine/en/latest/about/advanced_usage.html#port for details.
77 '';
78 };
79 });
80 };
81 applications = mkOption {
82 default = { };
83 description = ''
84 Configuration for applications to be exposed to Moonlight. If this is set, no configuration is possible from the web UI, and must be by the `settings` option.
85 '';
86 example = literalExpression ''
87 {
88 env = {
89 PATH = "$(PATH):$(HOME)/.local/bin";
90 };
91 apps = [
92 {
93 name = "1440p Desktop";
94 prep-cmd = [
95 {
96 do = "''${pkgs.kdePackages.libkscreen}/bin/kscreen-doctor output.DP-4.mode.2560x1440@144";
97 undo = "''${pkgs.kdePackages.libkscreen}/bin/kscreen-doctor output.DP-4.mode.3440x1440@144";
98 }
99 ];
100 exclude-global-prep-cmd = "false";
101 auto-detach = "true";
102 }
103 ];
104 }
105 '';
106 type = submodule {
107 options = {
108 env = mkOption {
109 default = { };
110 description = ''
111 Environment variables to be set for the applications.
112 '';
113 type = attrsOf str;
114 };
115 apps = mkOption {
116 default = [ ];
117 description = ''
118 Applications to be exposed to Moonlight.
119 '';
120 type = listOf attrs;
121 };
122 };
123 };
124 };
125 };
126
127 config = mkIf cfg.enable {
128 services.sunshine.settings.file_apps = mkIf (cfg.applications.apps != [ ]) "${appsFile}";
129
130 environment.systemPackages = [
131 cfg.package
132 ];
133
134 networking.firewall = mkIf cfg.openFirewall {
135 allowedTCPPorts = generatePorts cfg.settings.port [
136 (-5)
137 0
138 1
139 21
140 ];
141 allowedUDPPorts = generatePorts cfg.settings.port [
142 9
143 10
144 11
145 13
146 21
147 ];
148 };
149
150 boot.kernelModules = [ "uinput" ];
151
152 services.udev.packages = [ cfg.package ];
153
154 services.avahi = {
155 enable = mkDefault true;
156 publish = {
157 enable = mkDefault true;
158 userServices = mkDefault true;
159 };
160 };
161
162 security.wrappers.sunshine = mkIf cfg.capSysAdmin {
163 owner = "root";
164 group = "root";
165 capabilities = "cap_sys_admin+p";
166 source = getExe cfg.package;
167 };
168
169 systemd.user.services.sunshine = {
170 description = "Self-hosted game stream host for Moonlight";
171
172 wantedBy = mkIf cfg.autoStart [ "graphical-session.target" ];
173 partOf = [ "graphical-session.target" ];
174 wants = [ "graphical-session.target" ];
175 after = [ "graphical-session.target" ];
176
177 startLimitIntervalSec = 500;
178 startLimitBurst = 5;
179
180 environment.PATH = lib.mkForce null; # don't use default PATH, needed for tray icon menu links to work
181
182 serviceConfig = {
183 # only add configFile if an application or a setting other than the default port is set to allow configuration from web UI
184 ExecStart = escapeSystemdExecArgs (
185 [
186 (if cfg.capSysAdmin then "${config.security.wrapperDir}/sunshine" else "${getExe cfg.package}")
187 ]
188 ++ optionals (
189 cfg.applications.apps != [ ]
190 || (builtins.length (builtins.attrNames cfg.settings) > 1 || cfg.settings.port != defaultPort)
191 ) [ "${configFile}" ]
192 );
193 Restart = "on-failure";
194 RestartSec = "5s";
195 };
196 };
197 };
198}