consist 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -2,41 +2,656 @@
2
2
 
3
3
  [![Gem Version](https://img.shields.io/gem/v/consist)](https://rubygems.org/gems/consist)
4
4
  [![Gem Downloads](https://img.shields.io/gem/dt/consist)](https://www.ruby-toolbox.com/projects/consist)
5
- [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/johnmcdowall/consist/ci.yml)](https://github.com/johnmcdowall/consist/actions/workflows/ci.yml)
6
- [![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/johnmcdowall/consist)](https://codeclimate.com/github/johnmcdowall/consist)
5
+ [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/consist-sh/consist/ci.yml)](https://github.com/consist-sh/consist/actions/workflows/ci.yml)
6
+ [![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/consist-sh/consist)](https://codeclimate.com/github/consist-sh/consist)
7
7
 
8
- TODO: Description of this gem goes here.
8
+ **THIS IS BETA SOFTWARE UNDER ACTIVE DEVELOPMENT. APIs AND FEATURES WILL CHANGE.**
9
+
10
+ > consist - (noun): a set of railroad vehicles forming a complete train.
11
+
12
+ `consist` is the one person framework server scaffolder. It is stone age tech.
13
+
14
+ You can use it to quickly baseline a raw server using a given recipe provided
15
+ by Consist. I use it to baseline new Droplets to be ready to run Kamal in
16
+ single server setup for a Rails monolith. While Kamal will setup Docker
17
+ for you, it does not do anything else related to configuring the underlying
18
+ server, such as firewalls, general hardening, enabling swapfile etc.
19
+
20
+ ## Project Principles
21
+
22
+ - Minimal tool specific language / knowledge required to use Consist
23
+ - Procedural declaration execution - no converging, orchestration or
24
+ event driven operation
25
+ - If you can shell script it, you can `consist` it directly
9
26
 
10
27
  ---
11
28
 
12
29
  - [Quick start](#quick-start)
13
30
  - [Support](#support)
31
+ - [Rationale](#rationale)
32
+ - [Key Concepts](#key-concepts)
33
+ - [Is It Good?](#is-it-good%3F)
14
34
  - [License](#license)
15
35
  - [Code of conduct](#code-of-conduct)
16
36
  - [Contribution guide](#contribution-guide)
17
37
 
18
38
  ## Quick start
19
39
 
20
- ```
40
+ ```sh
21
41
  gem install consist
22
42
  ```
23
43
 
44
+ You must be already auth'd with the server you want to scaffold. `consist` will use
45
+ your SSH id to perform actions.
46
+
47
+ Then, you have two ways of interacting with Consist. First is the `scaffold` command:
48
+
49
+ ```sh
50
+ consist scaffold <recipe_name> root <ip_address>
51
+ ```
52
+
53
+ Will kick off the scaffolding of that given server with the given
54
+ recipe, using the `root` user.
55
+
56
+ The other way of using `consist` is to go with a `Consistfile` in
57
+ your project root that describes the recipe and steps. Then you can say:
58
+
59
+ ```sh
60
+ consist up <ip_address>
61
+ ```
62
+
63
+ And `consist` will do it's thing with that given IP address.
64
+
65
+ ## Features
66
+
67
+ - Simple Ruby based DSL
68
+ - ERB interpolation of config on shell commands and file contents
69
+ - Small API surface area - quick to learn
70
+
71
+ ## Rationale
72
+
73
+ I wanted a super-simple tool, that was baked in Ruby, for setting up
74
+ random servers to specific configurations. This is the result.
75
+
76
+ On a scale of 1 to 10, with 10 being Terraform, this tool is basically
77
+ as low-rent you can get to hand running scripts yourself, so about a 3
78
+ on the scale.
79
+
80
+ If you know how to shell script what you want, you can stick it in a step,
81
+ and add it to a recipe.
82
+
83
+ The more I work in this industry, the less I see using other people's code
84
+ and tools as a benefit, and more of a liability. I appreciate the paradox I'm
85
+ creating here for you 😅
86
+
87
+ ### Why not use Terraform / Ansible / Salt etc?
88
+
89
+ I think they are bad tools for my needs. I wanted something simple
90
+ I could hack on, grow only when needed, and will work specifically
91
+ without ambiguity. For example, Ansible has a lot of nonsense with case sensitivity,
92
+ Terraform does [weird unexpected things](https://github.com/hashicorp/terraform/issues/16330).
93
+
94
+ I didn't want to keep maintaining specific knowledge of these infrastructure
95
+ as code tools in my brain anymore, along with all of their peculiarities and oddities.
96
+
97
+ **If you prefer those tools, go ahead and use them.**
98
+
99
+ Ain't nobody stopping you.
100
+
101
+ ## Key Concepts
102
+
103
+ Consist leans on three primary ideas: recipes, steps and files. Recipes contain
104
+ one or more steps. Steps tend to be atomic and idempotent.
105
+
106
+ ### Recipes
107
+
108
+ Example of a recipe:
109
+
24
110
  ```ruby
25
- require "consist"
111
+ name "Kamal Single Server"
112
+ description "Sets up a single server to run Kamal"
113
+ user :root
114
+
115
+ steps do
116
+ step :update_apt_packages
117
+ step :install_apt_packages
118
+ end
119
+ ```
120
+
121
+ ### Steps
122
+
123
+ Example of a step:
124
+
125
+ ```ruby
126
+ name "Install APT packages"
127
+ required_user :root
128
+
129
+ shell "Installing essential packages" do
130
+ <<~EOS
131
+ apt-get -y remove systemd-timesyncd
132
+ timedatectl set-ntp no
133
+ apt-get -y install build-essential curl fail2ban git ntp vim
134
+ apt-get autoremove
135
+ apt-get autoclean
136
+ EOS
137
+ end
138
+
139
+ shell "Start NTP and Fail2Ban" do
140
+ <<~EOS
141
+ service ntp restart
142
+ service fail2ban restart
143
+ EOS
144
+ end
26
145
  ```
27
146
 
147
+ ### Files
148
+
149
+ Example of a file:
150
+
151
+ ```ruby
152
+ file :hostname do
153
+ <<~EOS
154
+ <%= hostname %>
155
+ EOS
156
+ end
157
+ ```
158
+
159
+ ### Consistfile
160
+
161
+ A `Consistfile` is a portable giant file of a recipe and all its
162
+ steps. Something like (this is a _full_ example, in practice you
163
+ would reference some of Consist's built in steps):
164
+
165
+ ```ruby
166
+ consist do
167
+ config :hostname, "testexample.com"
168
+ config :site_fqdn, "textexample.com"
169
+ config :admin_email, "j@jmd.fm"
170
+ config :swap_size, "2G"
171
+ config :swap_swappiness, "60"
172
+ config :timezone, "UTC"
173
+
174
+ file :apt_auto_upgrade do
175
+ <<~EOS
176
+ APT::Periodic::AutocleanInterval "7";
177
+ APT::Periodic::Update-Package-Lists "1";
178
+ APT::Periodic::Unattended-Upgrade "1";
179
+ EOS
180
+ end
181
+
182
+ file :hostname do
183
+ <<~EOS
184
+ <%= hostname %>
185
+ EOS
186
+ end
187
+
188
+ file :timezone do
189
+ <<~EOS
190
+ <%= timezone %>
191
+ EOS
192
+ end
193
+
194
+ file :fail2ban_config do
195
+ <<~EOS
196
+ # Fail2Ban configuration file.
197
+ #
198
+
199
+ # to view current bans, run one of the following:
200
+ # fail2ban-client status ssh
201
+ # iptables --list -n | fgrep DROP
202
+
203
+ # The DEFAULT allows a global definition of the options. They can be overridden
204
+ # in each jail afterwards.
205
+
206
+ [DEFAULT]
207
+
208
+ ignoreip = 127.0.0.1
209
+ bantime = 600
210
+ maxretry = 3
211
+ backend = auto
212
+ usedns = warn
213
+ destemail = <%= admin_email %>
214
+
215
+ #
216
+ # ACTIONS
217
+ #
218
+
219
+ banaction = iptables-multiport
220
+ mta = sendmail
221
+ protocol = tcp
222
+ chain = INPUT
223
+
224
+ #
225
+ # Action shortcuts. To be used to define action parameter
226
+
227
+ # The simplest action to take: ban only
228
+ action_ = %(banaction)s[name=%(__name__)s, port="%(port)s", protocol="%(protocol)s", chain="%(chain)s"]
229
+
230
+ # ban & send an e-mail with whois report to the destemail.
231
+ action_mw = %(banaction)s[name=%(__name__)s, port="%(port)s", protocol="%(protocol)s", chain="%(chain)s"]
232
+ %(mta)s-whois[name=%(__name__)s, dest="%(destemail)s", protocol="%(protocol)s", chain="%(chain)s"]
233
+
234
+ # ban & send an e-mail with whois report and relevant log lines
235
+ # to the destemail.
236
+ action_mwl = %(banaction)s[name=%(__name__)s, port="%(port)s", protocol="%(protocol)s", chain="%(chain)s"]
237
+ %(mta)s-whois-lines[name=%(__name__)s, dest="%(destemail)s", logpath=%(logpath)s, chain="%(chain)s"]
238
+
239
+ # default action
240
+ action = %(action_mw)s
241
+
242
+ [ssh]
243
+
244
+ enabled = true
245
+ port = 987
246
+ filter = sshd
247
+ logpath = /var/log/auth.log
248
+ maxretry = 6
249
+
250
+ [ssh-ddos]
251
+
252
+ enabled = true
253
+ port = 987
254
+ filter = sshd-ddos
255
+ logpath = /var/log/auth.log
256
+ maxretry = 6
257
+ EOS
258
+ end
259
+
260
+ file :logwatch_config do
261
+ <<~EOS
262
+ Output = mail
263
+ MailTo = <%= admin_email %>
264
+ MailFrom = logwatch@host1.mydomain.org
265
+ Detail = Low
266
+ Service = All
267
+ EOS
268
+ end
269
+
270
+ file :sysctl_config do
271
+ <<~EOS
272
+ # Do not accept ICMP redirects (prevent MITM attacks)
273
+ net.ipv4.conf.all.accept_redirects = 0
274
+ net.ipv6.conf.all.accept_redirects = 0
275
+ # Do not send ICMP redirects (we are not a router)
276
+ net.ipv4.conf.all.send_redirects = 0
277
+ # Log Martian Packets
278
+ net.ipv4.conf.all.log_martians = 1
279
+ # Controls IP packet forwarding
280
+ net.ipv4.ip_forward = 0
281
+ # Controls source route verification
282
+ net.ipv4.conf.default.rp_filter = 1
283
+ # Do not accept source routing
284
+ net.ipv4.conf.default.accept_source_route = 0
285
+ # Controls the System Request debugging functionality of the kernel
286
+ kernel.sysrq = 0
287
+ # Controls whether core dumps will append the PID to the core filename
288
+ # Useful for debugging multi-threaded applications
289
+ kernel.core_uses_pid = 1
290
+ # Controls the use of TCP syncookies
291
+ net.ipv4.tcp_synack_retries = 2
292
+ ######## IPv4 networking start ###########
293
+ # Send redirects, if router, but this is just server
294
+ net.ipv4.conf.all.send_redirects = 0
295
+ net.ipv4.conf.default.send_redirects = 0
296
+ # Accept packets with SRR option? No
297
+ net.ipv4.conf.all.accept_source_route = 0
298
+ # Accept Redirects? No, this is not router
299
+ net.ipv4.conf.all.accept_redirects = 0
300
+ net.ipv4.conf.all.secure_redirects = 0
301
+ # Log packets with impossible addresses to kernel log? Yes
302
+ net.ipv4.conf.all.log_martians = 1
303
+ net.ipv4.conf.default.accept_source_route = 0
304
+ net.ipv4.conf.default.accept_redirects = 0
305
+ net.ipv4.conf.default.secure_redirects = 0
306
+ # Ignore all ICMP ECHO and TIMESTAMP requests sent to it via broadcast/multicast
307
+ net.ipv4.icmp_echo_ignore_broadcasts = 1
308
+ # Prevent against the common 'syn flood attack'
309
+ net.ipv4.tcp_syncookies = 1
310
+ # Enable source validation by reversed path, as specified in RFC1812
311
+ net.ipv4.conf.all.rp_filter = 1
312
+ net.ipv4.conf.default.rp_filter = 1
313
+ ######## IPv6 networking start ###########
314
+ # Number of Router Solicitations to send until assuming no routers are present.
315
+ # This is host and not router
316
+ net.ipv6.conf.default.router_solicitations = 0
317
+ # Accept Router Preference in RA?
318
+ net.ipv6.conf.default.accept_ra_rtr_pref = 0
319
+ # Learn Prefix Information in Router Advertisement
320
+ net.ipv6.conf.default.accept_ra_pinfo = 0
321
+ # Setting controls whether the system will accept Hop Limit settings from a router advertisement
322
+ net.ipv6.conf.default.accept_ra_defrtr = 0
323
+ #router advertisements can cause the system to assign a global unicast address to an interface
324
+ net.ipv6.conf.default.autoconf = 0
325
+ #how many neighbor solicitations to send out per address?
326
+ net.ipv6.conf.default.dad_transmits = 0
327
+ # How many global unicast IPv6 addresses can be assigned to each interface?
328
+ net.ipv6.conf.default.max_addresses = 1
329
+ ######## IPv6 networking ends ###########
330
+ # Disabled, not used anymore
331
+ #Enable ExecShield protection |
332
+ #kernel.exec-shield = 1
333
+ #kernel.randomize_va_space = 1
334
+ # TCP and memory optimization
335
+ # increase TCP max buffer size setable using setsockopt()
336
+ #net.ipv4.tcp_rmem = 4096 87380 8388608
337
+ #net.ipv4.tcp_wmem = 4096 87380 8388608
338
+ # increase Linux auto tuning TCP buffer limits
339
+ #net.core.rmem_max = 8388608
340
+ #net.core.wmem_max = 8388608
341
+ #net.core.netdev_max_backlog = 5000
342
+ #net.ipv4.tcp_window_scaling = 1
343
+ # increase system file descriptor limit
344
+ fs.file-max = 65535
345
+ #Allow for more PIDs
346
+ kernel.pid_max = 65536
347
+ #Increase system IP port limits
348
+ net.ipv4.ip_local_port_range = 2000 65000
349
+ # Disable IPv6 autoconf
350
+ #net.ipv6.conf.all.autoconf = 0
351
+ #net.ipv6.conf.default.autoconf = 0
352
+ #net.ipv6.conf.eth0.autoconf = 0
353
+ #net.ipv6.conf.all.accept_ra = 0
354
+ #net.ipv6.conf.default.accept_ra = 0
355
+ #net.ipv6.conf.eth0.accept_ra = 0
356
+ EOS
357
+ end
358
+
359
+ recipe :kamal_single_server do
360
+ name "Kamal Single Server Scaffold"
361
+
362
+ steps do
363
+ step :set_hostname do
364
+ upload_file message: "Setting hostname",
365
+ local_file: :hostname,
366
+ remote_path: "/etc/hostname"
367
+
368
+ shell do
369
+ <<~EOS
370
+ hostname <%= Consist.config[:hostname] %>
371
+ EOS
372
+ end
373
+
374
+ mutate_file mode: :replace, target_file: "/etc/hosts", match: "^127.0.0.1 localhost$",
375
+ target_string: "127.0.0.1 localhost <%= hostname %>"
376
+ end
377
+
378
+ step :setup_timezone do
379
+ shell do
380
+ <<~EOS
381
+ rm /etc/localtime
382
+ EOS
383
+ end
384
+
385
+ upload_file message: "Setting Timezone",
386
+ local_file: :timezone,
387
+ remote_path: "/etc/timezone"
388
+
389
+ shell do
390
+ <<~EOS
391
+ chmod 0644 /etc/timezone
392
+ ln -s /usr/share/zoneinfo/<%= timezone %> /etc/localtime
393
+ chmod 0644 /etc/localtime
394
+ DEBIAN_FRONTEND=noninteractive dpkg-reconfigure -f noninteractive tzdata
395
+ EOS
396
+ end
397
+ end
398
+
399
+ step :update_apt_packages do
400
+ name "Updating APT packages"
401
+ required_user :root
402
+
403
+ upload_file message: "Uploading APT config...",
404
+ local_file: :apt_auto_upgrade,
405
+ remote_path: "/etc/apt/apt.conf.d/20auto-upgrades"
406
+
407
+ shell do
408
+ <<~EOS
409
+ apt-get update && apt-get upgrade -y
410
+ apt-get autoremove
411
+ apt-get autoclean
412
+ EOS
413
+ end
414
+ end
415
+
416
+ step :install_apt_packages do
417
+ name "Installing essential APT packages"
418
+ required_user :root
419
+
420
+ shell "Installing essential packages" do
421
+ <<~EOS
422
+ apt-get -y install build-essential curl git vim
423
+ EOS
424
+ end
425
+ end
426
+
427
+ step :setup_ntp do
428
+ name "Installing NTP daemon"
429
+ required_user :root
430
+
431
+ shell "Configuring NTP daemon", params: {raise_on_non_zero_exit: false} do
432
+ <<~EOS
433
+ apt-get -y remove systemd-timesyncd
434
+ timedatectl set-ntp no 2>1
435
+ apt-get -y install ntp
436
+ EOS
437
+ end
438
+
439
+ shell "Start NTP and Fail2Ban" do
440
+ <<~EOS
441
+ service ntp restart
442
+ EOS
443
+ end
444
+ end
445
+
446
+ step :install_fail2ban do
447
+ name "Installing fail2ban"
448
+ required_user :root
449
+
450
+ shell "Installing essential packages" do
451
+ <<~EOS
452
+ apt-get -y install fail2ban
453
+ EOS
454
+ end
455
+
456
+ upload_file message: "Uploading fail2ban confing", local_file: :fail2ban_config,
457
+ remote_path: "/etc/fail2ban/jail.local"
458
+
459
+ shell "Start Fail2Ban" do
460
+ <<~EOS
461
+ service fail2ban restart
462
+ systemctl enable fail2ban.service
463
+ EOS
464
+ end
465
+ end
466
+
467
+ step :setup_swap do
468
+ name "Configure and enable the swapfile"
469
+ required_user :root
470
+
471
+ check status: :nonexistant, file: "/swapfile" do
472
+ shell do
473
+ <<~EOS
474
+ fallocate -l <%= swap_size %> /swapfile
475
+ chmod 600 /swapfile
476
+ mkswap /swapfile
477
+ swapon /swapfile
478
+ echo "\n/swapfile swap swap defaults 0 0\n" >> /etc/fstab
479
+ sysctl vm.swappiness=<%=swap_swappiness%>
480
+ echo "\nvm.swappiness=<%=swap_swappiness%>\n" >> /etc/sysctl.conf
481
+ EOS
482
+ end
483
+ end
484
+ end
485
+
486
+ step :harden_ssh do
487
+ name "Harden the SSH config"
488
+
489
+ mutate_file mode: :replace, target_file: "/etc/ssh/sshd_config", match: "^#PasswordAuthentication yes$",
490
+ target_string: "PasswordAuthentication no"
491
+ mutate_file mode: :replace, target_file: "/etc/ssh/sshd_config", match: "^#PubkeyAuthentication yes$",
492
+ target_string: "PubkeyAuthentication yes"
493
+
494
+ shell do
495
+ <<~EOS
496
+ service ssh restart
497
+ EOS
498
+ end
499
+ end
500
+
501
+ step :harden_system do
502
+ name "Harden the SYSCTL settings"
503
+
504
+ upload_file message: "Uploading sysctl config...",
505
+ local_file: :sysctl_config,
506
+ remote_path: "/tmp/sysctl_config"
507
+
508
+ shell do
509
+ <<~EOS
510
+ cat /etc/sysctl.conf /tmp/sysctl_config > /etc/sysctl.conf
511
+ rm /tmp/sysctl_config
512
+ sysctl -p
513
+ EOS
514
+ end
515
+ end
516
+
517
+ step :setup_ufw do
518
+ name "Setup UFW"
519
+
520
+ shell do
521
+ <<~EOS
522
+ ufw logging on
523
+ ufw default deny incoming
524
+ ufw default allow outgoing
525
+ ufw allow 22
526
+ ufw allow 80
527
+ ufw allow 443
528
+ ufw --force enable
529
+ service ufw restart
530
+ EOS
531
+ end
532
+ end
533
+
534
+ step :setup_postfix do
535
+ name "Install Postfix for admin emails"
536
+
537
+ shell do
538
+ <<~EOS
539
+ echo "postfix postfix/mailname string <%= site_fqdn %>" | debconf-set-selections
540
+ EOS
541
+ end
542
+
543
+ shell do
544
+ <<~EOS
545
+ echo "postfix postfix/main_mailer_type string 'Internet Site'" | debconf-set-selections
546
+ EOS
547
+ end
548
+
549
+ shell do
550
+ <<~EOS
551
+ DEBIAN_FRONTEND=noninteractive apt-get install --assume-yes postfix
552
+ EOS
553
+ end
554
+ end
555
+
556
+ step :setup_logwatch do
557
+ name "Setup Logwatch to automate log reporting"
558
+
559
+ shell do
560
+ <<~EOS
561
+ DEBIAN_FRONTEND=noninteractive apt-get install --assume-yes logwatch
562
+ EOS
563
+ end
564
+
565
+ mutate_file mode: :replace, target_file: "/etc/cron.daily/00logwatch", match: "^/usr/sbin/logwatch --output mail$",
566
+ target_string: "/usr/sbin/logwatch --output mail --mailto <%= admin_email %> --detail high", delim: "#"
567
+
568
+ upload_file message: "Uploading Logwatch confing", local_file: :logwatch_config,
569
+ remote_path: "/etc/logwatch/conf"
570
+ end
571
+
572
+ step :setup_docker do
573
+ name "Setup Docker"
574
+
575
+ shell do
576
+ <<~EOS
577
+ # Add Docker's official GPG key:
578
+ apt-get update
579
+ apt-get install ca-certificates curl gnupg -y
580
+ install -m 0755 -d /etc/apt/keyrings
581
+ rm /etc/apt/keyrings/docker.gpg
582
+ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --batch --no-tty --dearmor -o /etc/apt/keyrings/docker.gpg
583
+ chmod a+r /etc/apt/keyrings/docker.gpg
584
+
585
+ # Add the repository to Apt sources:
586
+ echo \
587
+ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
588
+ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
589
+ tee /etc/apt/sources.list.d/docker.list > /dev/null
590
+ apt-get update
591
+
592
+ # Install Docker
593
+ apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
594
+
595
+ # Make Docker start on boot
596
+ sudo systemctl enable docker.service
597
+ sudo systemctl enable containerd.service
598
+ EOS
599
+ end
600
+
601
+ shell "Create docker group", params: {raise_on_non_zero_exit: false} do
602
+ <<~EOS
603
+ # Create group
604
+ sudo groupadd docker
605
+ sudo usermod -aG docker $USER
606
+ EOS
607
+ end
608
+
609
+ shell "Create default private network", params: {raise_on_non_zero_exit: false} do
610
+ <<~EOS
611
+ # Create default private network
612
+ docker network create private
613
+ EOS
614
+ end
615
+ end
616
+ end
617
+ end
618
+ end
619
+
620
+ # vim: filetype=ruby
621
+ ```
622
+
623
+ Given a `Consistfile` you could then say `consist up <ip_address>` and
624
+ it would just work.
625
+
626
+ ## Is it good?
627
+
628
+ I think so. But I don't know, use your own brain or something. Don't listen to
629
+ me.
630
+
28
631
  ## Support
29
632
 
30
- If you want to report a bug, or have ideas, feedback or questions about the gem, [let me know via GitHub issues](https://github.com/johnmcdowall/consist/issues/new) and I will do my best to provide a helpful answer. Happy hacking!
633
+ If you want to report a bug, or have ideas, feedback or questions about the gem,
634
+ [let me know via GitHub issues](https://github.com/johnmcdowall/consist/issues/new)
635
+ and I will do my best to provide a helpful answer.
31
636
 
32
637
  ## License
33
638
 
34
- The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
639
+ The gem is available as open source under the terms of the [LGPLv3 License](LICENSE.txt).
35
640
 
36
641
  ## Code of conduct
37
642
 
38
- Everyone interacting in this project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
643
+ Everyone interacting in this project’s codebases, issue trackers, chat
644
+ rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
39
645
 
40
646
  ## Contribution guide
41
647
 
42
- Pull requests are welcome!
648
+ Pull requests are welcome, but I want you to open an Issue first to discuss your
649
+ ideas. Thanks.
650
+
651
+ ## Development
652
+
653
+ 1. Clone the repo
654
+ 2. Run `bundle install`
655
+ 3. Run `bin/dev` to execute consist locally without having to build and install.
656
+
657
+ Make sure any PRs have been formatted with `standard`.