init - WIP

This commit is contained in:
Geno 2020-11-12 11:41:52 +01:00
parent b1fe1482f2
commit 737482329d
12 changed files with 966 additions and 0 deletions

15
defaults/main.yml Normal file
View File

@ -0,0 +1,15 @@
---
osp_git_root: 'https://gitlab.com/Deamos/flask-nginx-rtmp-manager.git'
osp_git_commit: '0.7.9'
osp_worker_start_port: 5000
osp_worker_count: "{{ ansible_processor_nproc }}"
osp_http_path: "/srv/http"
osp_db_location: 'sqlite:///db/database.db'
osp_secret_key: "{{ lookup('password', 'credentials/'+inventory_hostname+'/osp_secret_key length=8 chars=digits') }}"
osp_password_salt: "{{ lookup('password', 'credentials/'+inventory_hostname+'/osp_password_salt length=8 chars=digits') }}"
osp_allow_registration: yes
osp_require_email_registration: yes
osp_ejabberd_domain: "CHANGEME"
osp_ejabberd_password: "{{ lookup('password', 'credentials/'+inventory_hostname+'/osp_ejabberd_password length=8 chars=digits') }}"

21
handlers/main.yml Normal file
View File

@ -0,0 +1,21 @@
---
- name: restart redis
systemd:
name: redis
state: restarted
- name: restart ejabberd
systemd:
name: ejabberd
state: restarted
- name: reload nginx
systemd:
name: nginx
state: reloaded
- name: restart osp
systemd:
name: osp.target
state: restarted
daemon_reload: yes

2
meta/main.yml Normal file
View File

@ -0,0 +1,2 @@
dependencies:
- kewlfft.aur

185
tasks/main.yml Normal file
View File

@ -0,0 +1,185 @@
- name: Workaround ansible switch between users
file:
path: "/tmp/ansible/"
mode: 0777
- name: Install dependencies
package:
name:
- redis
- ejabberd
#- gunicorn
#- uwsgi-plugin-python
- python-pip
- python-virtualenv
- ffmpeg #important v4
#- python-gevent-websocket
- base-devel
- yay
- name: Create AUR User for build
user:
name: aur_builder
- name: Add sudo permission to aur user
lineinfile:
path: /etc/sudoers.d/11-install-aur_builder
line: 'aur_builder ALL=(ALL) NOPASSWD: /usr/bin/pacman'
create: yes
validate: 'visudo -cf %s'
- name: Install nginx with rtmp
become: yes
become_user: aur_builder
aur:
name: nginx-rtmp-sergey-git
- name: Configure redis
notify: restart redis
lineinfile:
path: /etc/redis.conf
regexp: '^appendfsync'
line: "appendfsync no"
- name: Start redis
systemd:
name: redis
state: started
enabled: yes
- name: Configure ejabberd
notify: restart ejabberd
template:
src: ejabberd.yml
dest: /etc/ejabberd/ejabberd.yml
- name: Start ejabberd
systemd:
name: ejabberd
state: started
enabled: yes
- name: Check if ejabberd account exists
become: yes
become_user: jabber
command: ejabberdctl check_account admin localhost
register: jabber_admin
changed_when: False
failed_when: 'jabber_admin.rc >= 2'
- name: Register ejabberd Adminuser
become: yes
become_user: jabber
when: jabber_admin.rc == 1
command: ejabberdctl register admin localhost "{{ osp_ejabberd_password }}"
- name: Set password of ejabber admin
become: yes
become_user: jabber
when: jabber_admin.rc == 0
command: ejabberdctl change_password admin localhost "{{ osp_ejabberd_password }}"
- name: Configure NGINX
notify: reload nginx
template:
src: "{{ item }}"
dest: "/etc/nginx/{{ item }}"
loop:
- nginx.conf
- osp-rtmp.conf
- osp-socketio.conf
- osp-redirects.conf
- name: Create www directory
file:
path: "{{osp_http_path }}/{{item}}"
owner: http
group: http
state: directory
loop:
- .
- live
- videos
- live-rec
- images
- live-adapt
- stream-thumb
- name: Nginx
systemd:
name: nginx
state: started
enabled: yes
- name: Clone OSP repository
git:
repo: "{{ osp_git_root }}"
dest: "/var/lib/osp/"
version: "{{ osp_git_commit }}"
- name: Install python requirements
pip:
requirements: /var/lib/osp/setup/requirements.txt
virtualenv: /opt/osp-venv
- name: Create cache directory
file:
path: /var/cache/osp
owner: http
group: http
state: directory
- name: Create logging directory
file:
path: /var/log/osp
owner: http
group: http
state: directory
- name: Configure osp
notify: restart osp
template:
src: "config.py.dist"
dest: "/etc/osp.conf"
- name: Configure supply
notify: restart osp
file:
src: "/etc/osp.conf"
dest: "/var/lib/osp/conf/config.py"
state: link
- name: Permissions for database
file:
path: "/var/lib/osp/{{ item }}"
owner: http
group: http
recurse: yes
loop:
- db
- migrations
- name: Init Database
become: yes
become_user: http
command: python3 manage.py db init
args:
chdir: /var/lib/osp/
creates: /var/lib/osp/db/database.db
- name: Install services files and workers
notify: restart osp
template:
src: "{{item}}"
dest: "/etc/systemd/system/{{item}}"
loop:
- osp-worker@.service
- osp.target
- name: Start OSP
systemd:
name: osp.target
state: started
enabled: yes

29
templates/config.py.dist Normal file
View File

@ -0,0 +1,29 @@
# Set Database Location and Type
# For MySQL Connections add ?charset=utf8mb4 for full Unicode Support
dbLocation="{{ osp_db_location }}"
# Redis Configuration
redisHost="localhost" # Default localhost
redisPort=6379 # Default 6379
redisPassword='' # Default ''
# Flask Secret Key
secretKey="{{ osp_secret_key }}"
# Password Salt Value
passwordSalt="{{ osp_password_salt }}"
# Allow Users to Register with the OSP Server
allowRegistration={{ osp_allow_registration }}
# Require Users to Confirm their Email Addresses
requireEmailRegistration={{ osp_require_email_registration }}
# Enables Debug Mode
debugMode = False
# EJabberD Configuration
ejabberdAdmin = "admin"
ejabberdPass = "{{ osp_ejabberd_password }}"
ejabberdHost = "localhost"
#ejabberdServer ="127.0.0.1"

268
templates/ejabberd.yml Normal file
View File

@ -0,0 +1,268 @@
###
### ejabberd configuration file
###
### The parameters used in this configuration file are explained at
###
### https://docs.ejabberd.im/admin/configuration
###
### The configuration file is written in YAML.
### *******************************************************
### ******* !!! WARNING !!! *******
### ******* YAML IS INDENTATION SENSITIVE *******
### ******* MAKE SURE YOU INDENT SECTIONS CORRECTLY *******
### *******************************************************
### Refer to http://en.wikipedia.org/wiki/YAML for the brief description.
###
hosts:
- localhost
- {{ osp_ejabberd_domain }}
loglevel: info
## If you already have certificates, list them here
# certfiles:
# - /etc/letsencrypt/live/domain.tld/fullchain.pem
# - /etc/letsencrypt/live/domain.tld/privkey.pem
listen:
-
port: 5222
ip: "::"
module: ejabberd_c2s
max_stanza_size: 262144
shaper: c2s_shaper
access: c2s
starttls_required: true
-
port: 5269
ip: "::"
module: ejabberd_s2s_in
max_stanza_size: 524288
-
port: 5443
ip: "::FFFF:127.0.0.1"
module: ejabberd_http
tls: true
request_handlers:
/admin: ejabberd_web_admin
/api: mod_http_api
/bosh: mod_bosh
/captcha: ejabberd_captcha
/upload: mod_http_upload
/ws: ejabberd_http_ws
-
port: 5280
ip: "::FFFF:127.0.0.1"
module: ejabberd_http
request_handlers:
/admin: ejabberd_web_admin
/api: mod_http_api
/bosh: mod_bosh
/captcha: ejabberd_captcha
/upload: mod_http_upload
/ws: ejabberd_http_ws
/.well-known/acme-challenge: ejabberd_acme
-
port: 3478
transport: udp
module: ejabberd_stun
use_turn: true
-
port: 1883
ip: "::"
module: mod_mqtt
backlog: 1000
-
port: 4560
ip: "::FFFF:127.0.0.1"
module: ejabberd_xmlrpc
access_commands:
admin:
commands: all
options: []
s2s_use_starttls: optional
acl:
local:
user_regexp: ""
loopback:
ip:
- 127.0.0.0/8
- ::1/128
admin:
user:
- "admin@localhost"
access_rules:
local:
allow: local
c2s:
deny: blocked
allow: all
announce:
allow: admin
configure:
allow: admin
muc_create:
allow: local
pubsub_createnode:
allow: local
trusted_network:
allow: loopback
xmlrpc_access:
allow: admin
api_permissions:
"console commands":
from:
- ejabberd_ctl
who: all
what: "*"
"admin access":
who:
access:
allow:
acl: loopback
acl: admin
oauth:
scope: "ejabberd:admin"
access:
allow:
acl: loopback
acl: admin
what:
- "*"
- "!stop"
- "!start"
"public commands":
who:
ip: 127.0.0.1/8
what:
- status
- connected_users_number
shaper:
normal:
rate: 3000
burst_size: 20000
fast: 100000
shaper_rules:
max_user_sessions: 10
max_user_offline_messages:
5000: admin
100: all
c2s_shaper:
none: admin
normal: all
s2s_shaper: fast
auth_use_cache: false
auth_password_format: scram
extauth_program: "/usr/bin/python3 /var/lib/osp/setup/ejabberd/auth_osp.py"
extauth_instances: 3
host_config:
"{{ osp_ejabberd_domain }}":
auth_method:
- external
- anonymous
allow_multiple_connections: true
anonymous_protocol: login_anon
modules:
mod_adhoc: {}
mod_admin_extra: {}
mod_announce:
access: announce
mod_avatar: {}
mod_blocking: {}
mod_bosh: {}
mod_caps: {}
mod_carboncopy: {}
mod_client_state: {}
mod_configure: {}
mod_disco: {}
mod_fail2ban: {}
mod_http_api: {}
#mod_http_upload:
# put_url: https://@HOST@:5443/upload
mod_last: {}
mod_mam:
## Mnesia is limited to 2GB, better to use an SQL backend
## For small servers SQLite is a good fit and is very easy
## to configure. Uncomment this when you have SQL configured:
## db_type: sql
assume_mam_usage: true
default: always
mod_mqtt: {}
mod_muc:
access:
- allow
access_admin:
- allow: admin
access_create: muc_create
access_persistent: muc_create
access_mam:
- allow
default_room_options:
mam: true
persistent: true
max_users: 2500
allow_visitor_nickchange: false
allow_private_messages_from_visitors: nobody
allow_visitor_status: false
members_by_default: false
max_users: 2500
mod_muc_admin: {}
mod_offline:
access_max_user_messages: max_user_offline_messages
mod_ping:
send_pings: true
ping_interval: 60
timeout_action: none
mod_privacy: {}
mod_private: {}
mod_proxy65:
access: local
max_connections: 5
mod_pubsub:
access_createnode: pubsub_createnode
plugins:
- flat
- pep
force_node_config:
## Avoid buggy clients to make their bookmarks public
storage:bookmarks:
access_model: whitelist
mod_push: {}
mod_push_keepalive: {}
mod_register:
## Only accept registration requests from the "trusted"
## network (see access_rules section above).
## Think twice before enabling registration from any
## address. See the Jabber SPAM Manifesto for details:
## https://github.com/ge0rg/jabber-spam-fighting-manifesto
ip_access: trusted_network
mod_roster:
versioning: true
mod_s2s_dialback: {}
mod_shared_roster: {}
mod_stream_mgmt:
resend_on_timeout: if_offline
mod_stun_disco: {}
mod_vcard: {}
mod_vcard_xupdate: {}
mod_version:
show_os: false
allow_contrib_modules: true
### Local Variables:
### mode: yaml
### End:
### vim: set filetype=yaml tabstop=8

145
templates/nginx.conf Normal file
View File

@ -0,0 +1,145 @@
user http;
worker_processes auto;
# pid in nginx.service
# pid /run/nginx.pid;
events {
worker_connections 1024;
multi_accept on;
use epoll;
}
http {
include mime.types;
default_type application/octet-stream;
proxy_cache_path /var/cache/osp levels=1:2 keys_zone=auth_cache:5m max_size=1g inactive=24h;
sendfile on;
tcp_nopush on;
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_proxied any;
gzip_vary on;
gzip_types
application/atom+xml
application/javascript
application/json
application/ld+json
application/manifest+json
application/rss+xml
application/vnd.geo+json
application/vnd.ms-fontobject
application/x-font-ttf
application/x-web-app-manifest+json
application/xhtml+xml
application/xml
font/opentype
image/bmp
image/svg+xml
image/x-icon
image/gif
image/png
video/mp4
video/mpeg
video/x-flv
text/cache-manifest
text/css
text/plain
text/vcard
text/vnd.rim.location.xloc
text/vtt
text/x-component
text/x-cross-domain-policy;
keepalive_timeout 65;
# Load Balancing for Gunicorn
upstream socket_nodes {
# sticky only on commercial nginx
# sticky cookie srv_id expires=8h;
{% for n in range(osp_worker_count) %}
server 127.0.0.1:{{ osp_worker_start_port + n }};
{% endfor %}
}
# OSP Edge Streaming Nodes
include /var/lib/osp/conf/osp-edge.conf;
server {
listen 9000;
allow 127.0.0.1;
deny all;
location /stat {
rtmp_stat all;
rtmp_stat_stylesheet stat.xsl;
}
location /stat.xsl {
root /var/lib/osp/static;
}
}
# NGINX to OSP Gunicorn Processes Reverse Proxy
server {
listen 80;
listen [::]:80;
# set client body size to 16M #
client_max_body_size 16M;
location / {
proxy_pass http://socket_nodes;
proxy_redirect off;
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
include osp-socketio.conf;
include osp-redirects.conf;
# redirect server error pages to the static page /50x.html
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
# Ejabberd Reverse Proxy Config to Allow for ejabberd acme-challenge
# Uncomment and change server_name to match
#server {
# listen 80;
# server_name conference.subdomain.domain.tld;
# location / {
# proxy_pass http://localhost:5280;
# }
#}
# server {
# listen 80;
# server_name proxy.subdomain.domain.tld;
# location / {
# proxy_pass http://localhost:5280;
# }
#}
#server {
# listen 80;
# server_name pubsub.subdomain.domain.tld;
# location / {
# proxy_pass http://localhost:5280;
# }
#}
}
include osp-rtmp.conf;

View File

@ -0,0 +1,127 @@
location /ospAuth {
internal;
set $channelID "";
if ($request_uri ~* /videos/(.+)/(.+)) {
set $channelID $1;
}
if ($request_uri ~* /videos/(.*)/clips/(.*)\.(.+)) {
set $channelID $1;
}
if ($request_uri ~* /stream-thumb/(.*)\.(.+)) {
set $channelID $1;
}
if ($request_uri ~* /live-adapt/(.*)\.m3u8) {
set $channelID $1;
}
if ($request_uri ~* /live-adapt/(.*)_(.*)/(.*)\.(.*)) {
set $channelID $1;
}
if ($request_uri ~* /live/(.+)/(.+)) {
set $channelID $1;
}
if ($request_uri ~* /edge/(.+)/(.+)) {
set $channelID $1;
}
if ($request_uri ~* /edge-adapt/(.*)\.m3u8) {
set $channelID $1;
}
if ($request_uri ~* /edge-adapt/(.*)_(.*)/(.*)\.(.*)) {
set $channelID $1;
}
proxy_pass http://socket_nodes/auth;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Channel-ID $channelID;
proxy_cache auth_cache;
proxy_cache_key "$cookie_ospSession$http_x_auth_token$channelID";
proxy_cache_valid 200 10m;
proxy_ignore_headers Set-Cookie;
}
location /videos {
auth_request /ospAuth;
alias {{ osp_http_path }}/videos;
}
location /videos/temp {
alias {{ osp_http_path }}/videos/temp;
}
location /stream-thumb {
auth_request /ospAuth;
alias {{ osp_http_path }}/stream-thumb;
}
location /live-adapt {
auth_request /ospAuth;
alias {{ osp_http_path }}/live-adapt;
}
location /live {
auth_request /ospAuth;
alias {{osp_http_path}}/live;
}
location /static {
alias /var/lib/osp/static;
}
location ~ /images(.*) {
# Disable cache
add_header Cache-Control no-cache;
# CORS setup
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length';
# allow CORS preflight requests
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
root {{ osp_http_path }};
}
location /edge {
auth_request /ospAuth;
rewrite ^/edge/(.*)$ $scheme://$ospedge_node/live/$1 redirect;
}
location /edge-adapt {
auth_request /ospAuth;
rewrite ^/edge-adapt/(.*)$ $scheme://$ospedge_node/live-adapt/$1 redirect;
}
location /http-bind/ { # BOSH XMPP-HTTP
proxy_pass http://localhost:5280/bosh;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_redirect off;
proxy_buffering off;
proxy_read_timeout 65s;
proxy_send_timeout 65s;
keepalive_timeout 65s;
tcp_nodelay on;
}

131
templates/osp-rtmp.conf Normal file
View File

@ -0,0 +1,131 @@
rtmp_auto_push on;
rtmp_auto_push_reconnect 1s;
rtmp {
server {
listen 1935;
chunk_size 4096;
application stream {
live on;
record off;
allow publish all;
#deny publish all;
allow play 127.0.0.1;
on_publish http://127.0.0.1:5010/auth-key;
on_publish_done http://127.0.0.1:5010/deauth-user;
}
application stream-data {
live on;
allow publish all;
#deny publish all;
allow play 127.0.0.1;
on_publish http://127.0.0.1:5010/auth-user;
push rtmp://127.0.0.1:1935/live/;
push rtmp://127.0.0.1:1935/record/;
hls on;
hls_path {{ osp_http_path }}/live;
hls_fragment 1;
hls_playlist_length 30s;
hls_nested on;
hls_fragment_naming system;
recorder thumbnail {
record video;
record_max_frames 600;
record_path {{ osp_http_path }}/stream-thumb;
record_interval 120s;
exec_record_done ffmpeg -ss 00:00:01 -i $path -vcodec png -vframes 1 -an -f rawvideo -s 384x216 -y {{ osp_http_path }}/stream-thumb/$name.png;
exec_record_done ffmpeg -ss 00:00:00 -t 3 -i $path -filter_complex "[0:v] fps=30,scale=w=384:h=-1,split [a][b];[a] palettegen=stats_mode=single [p];[b][p] paletteuse=new=1" -y {{ osp_http_path }}/stream-thumb/$name.gif;
}
}
application stream-data-adapt {
live on;
allow publish all;
#deny publish all;
allow play 127.0.0.1;
on_publish http://127.0.0.1:5010/auth-user;
push rtmp://127.0.0.1:1935/live/;
push rtmp://127.0.0.1:1935/record/;
exec ffmpeg -i rtmp://127.0.0.1:1935/live/$name
-c:v libx264 -c:a aac -b:a 128k -vf "scale=-2:720" -vsync 1 -copyts -start_at_zero -sws_flags lanczos -r 30 -g 30 -keyint_min 30 -force_key_frames "expr:gte(t,n_forced*1)" -tune zerolatency -preset ultrafast -crf 28 -maxrate 2096k -bufsize 4192k -threads 16 -f flv rtmp://localhost:1935/show/$name_720
-c:v libx264 -c:a aac -b:a 96k -vf "scale=-2:480" -vsync 1 -copyts -start_at_zero -sws_flags lanczos -r 30 -g 30 -keyint_min 30 -force_key_frames "expr:gte(t,n_forced*1)" -tune zerolatency -preset ultrafast -crf 28 -maxrate 1200k -bufsize 2400k -threads 16 -f flv rtmp://localhost:1935/show/$name_480
-c copy -f flv rtmp://localhost:1935/show/$name_src;
recorder thumbnail {
record video;
record_max_frames 600;
record_path {{ osp_http_path }}/stream-thumb;
record_interval 120s;
exec_record_done ffmpeg -ss 00:00:01 -i $path -vcodec png -vframes 1 -an -f rawvideo -s 384x216 -y {{ osp_http_path }}/stream-thumb/$name.png;
exec_record_done ffmpeg -ss 00:00:00 -t 3 -i $path -filter_complex "[0:v] fps=30,scale=w=384:h=-1,split [a][b];[a] palettegen=stats_mode=single [p];[b][p] paletteuse=new=1" -y {{ osp_http_path }}/stream-thumb/$name.gif;
}
}
application show {
live on;
allow publish 127.0.0.1;
allow play 127.0.0.1;
hls on;
hls_path {{ osp_http_path }}/live-adapt;
hls_nested on;
hls_fragment 1;
hls_playlist_length 30s;
hls_fragment_naming system;
record off;
# Instruct clients to adjust resolution according to bandwidth
hls_variant _480 BANDWIDTH=1200000; # Medium bitrate, SD resolution
hls_variant _720 BANDWIDTH=2048000; # High bitrate, HD 720p resolution
hls_variant _src BANDWIDTH=4096000; # Source bitrate, source resolution
}
application record {
live on;
allow publish 127.0.0.1;
allow play 127.0.0.1;
on_publish http://127.0.0.1:5010/auth-record;
exec_push mkdir -m 764 {{ osp_http_path }}/videos/$name;
recorder all {
record all;
record_path /tmp;
record_unique on;
record_suffix _%Y%m%d_%H%M%S.flv;
exec_record_done bash -c "ffmpeg -y -i $path -codec copy -movflags +faststart {{ osp_http_path }}/videos/$name/$basename.mp4 && rm $path";
exec_record_done mv {{ osp_http_path }}/stream-thumb/$name.png {{ osp_http_path }}/videos/$name/$basename.png;
exec_record_done mv {{ osp_http_path }}/stream-thumb/$name.gif {{ osp_http_path }}/videos/$name/$basename.gif;
on_record_done http://127.0.0.1:5010/deauth-record;
}
}
application live {
live on;
drop_idle_publisher 30s;
allow publish 127.0.0.1;
allow play all;
on_play http://127.0.0.1:5010/playbackAuth;
}
}
}

View File

@ -0,0 +1,22 @@
location /socket.io {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_set_header X-NginX-Proxy true;
# prevents 502 bad gateway error
proxy_buffers 8 32k;
proxy_buffer_size 64k;
proxy_redirect off;
# enables WS support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://socket_nodes/socket.io;
}

View File

@ -0,0 +1,15 @@
[Unit]
Description=Gunicorn instance to serve OSP Workers on port %i
After=network.target
PartOf=osp.target
[Service]
User=http
Group=http
WorkingDirectory=/var/lib/osp
Environment="VIRTUAL_ENV=/opt/osp-venv"
Environment="PATH=/opt/osp-venv/bin:/usr/local/bin:/usr/bin:/bin"
ExecStart=/opt/osp-venv/bin/gunicorn app:app -k geventwebsocket.gunicorn.workers.GeventWebSocketWorker -w 1 --bind 0.0.0.0:%i --reload --access-logfile /var/log/osp/access.log --error-logfile /var/log/osp/error.log
[Install]
WantedBy=multi-user.target

6
templates/osp.target Normal file
View File

@ -0,0 +1,6 @@
[Unit]
Description = OSP Service
Requires = {% for n in range(osp_worker_count) %} osp-worker@{{ osp_worker_start_port + n }}.service{% endfor %}
[Install]
WantedBy = multi-user.target