1 /*
2 * CDDL HEADER START
3 *
4 * The contents of this file are subject to the terms of the
5 * Common Development and Distribution License, Version 1.0 only
6 * (the "License"). You may not use this file except in compliance
7 * with the License.
8 *
9 * You can obtain a copy of the license at http://smartos.org/CDDL
10 *
11 * See the License for the specific language governing permissions
12 * and limitations under the License.
13 *
14 * When distributing Covered Code, include this CDDL HEADER in each
15 * file.
16 *
17 * If applicable, add the following below this CDDL HEADER, with the
18 * fields enclosed by brackets "[]" replaced with your own identifying
19 * information: Portions Copyright [yyyy] [name of copyright owner]
20 *
21 * CDDL HEADER END
22 *
23 * Copyright (c) 2018, Joyent, Inc. All rights reserved.
24 *
25 *
26 * fwadm: firewall rule model
27 */
28
29 'use strict';
30
31 var mod_net = require('net');
32 var mod_uuid = require('uuid');
33 var sprintf = require('extsprintf').sprintf;
34 var util = require('util');
35 var validators = require('./validators');
36 var verror = require('verror');
37
38
39
40 // --- Globals
41
42
43
44 var DIRECTIONS = ['to', 'from'];
45 // Exported fields that can be in the serialized rule:
46 var FIELDS = [
47 'created_by',
48 'description',
49 'enabled',
50 'global',
51 'owner_uuid',
52 'rule',
53 'uuid',
54 'version',
55 'log'
56 ];
57 // Maximum number of targets per side:
58 var MAX_TARGETS_PER_SIDE = 24;
59 // Maximum number of protocol targets:
60 var MAX_PROTOCOL_TARGETS = 24;
61 // Minimum version for using a larger list of protocol targets:
62 var MINVER_LGPROTOTARG = 4;
63 // The old maximum number of protocol targets:
64 var OLD_MAX_PORTS = 8;
65 var STRING_PROPS = ['created_by', 'description'];
66 var TARGET_TYPES = ['wildcard', 'ip', 'subnet', 'tag', 'vm'];
67
68 var icmpr = /^icmp6?$/;
69
70 // --- Internal functions
71
72
73 /**
74 * Safely check if an object has a property
75 */
76 function hasOwnProperty(obj, prop) {
77 return Object.prototype.hasOwnProperty.call(obj, prop);
78 }
79
80
81 /**
82 * Calls callback for all of the firewall target types
83 */
84 function forEachTarget(obj, callback) {
85 DIRECTIONS.forEach(function (dir) {
86 if (!hasOwnProperty(obj, dir)) {
87 return;
88 }
89
90 TARGET_TYPES.forEach(function (type) {
91 var name = type + 's';
92 if (!hasOwnProperty(obj[dir], name)) {
93 return;
94 }
95
96 callback(dir, type, name, obj[dir][name]);
97 });
98 });
99 }
100
101
102 /**
103 * Sorts a list of ICMP types (with optional codes)
104 */
105 function icmpTypeSort(types) {
106 return types.map(function (type) {
107 return type.toString().split(':');
108 }).sort(function (a, b) {
109 var aTot = (Number(a[0]) << 8) + (a.length === 1 ? 0 : Number(a[1]));
110 var bTot = (Number(b[0]) << 8) + (a.length === 1 ? 0 : Number(b[1]));
111 return aTot - bTot;
112 }).map(function (typeArr) {
113 return typeArr.join(':');
114 });
115 }
116
117
118 /**
119 * Adds a tag to an object
120 */
121 function addTag(obj, tag, val) {
122 if (!hasOwnProperty(obj, tag)) {
123 obj[tag] = {};
124 }
125
126 if (val === undefined || val === null) {
127 obj[tag].all = true;
128 return;
129 }
130
131 if (!hasOwnProperty(obj[tag], 'values')) {
132 obj[tag].values = {};
133 }
134
135 obj[tag].values[val] = true;
136 }
137
138
139 /**
140 * Creates a list of tags based on an object populated by addTag() above
141 */
142 function tagList(obj) {
143 var tags = [];
144 Object.keys(obj).sort().forEach(function (tag) {
145 if (hasOwnProperty(obj[tag], 'all')) {
146 tags.push(tag);
147 } else {
148 Object.keys(obj[tag].values).sort().forEach(function (val) {
149 tags.push([tag, val]);
150 });
151 }
152 });
153 return tags;
154 }
155
156
157 /**
158 * The following characters are allowed to come after an escape, and get
159 * escaped when producing rule text.
160 *
161 * Parentheses don't need to be escaped with newer parsers, but will cause
162 * errors with older parsers which expect them to be escaped. We therefore
163 * always escape them when generating rule text, to make sure we don't
164 * cause issues for older parsers.
165 */
166 var escapes = {
167 '"': '"',
168 'b': '\b',
169 'f': '\f',
170 'n': '\n',
171 'r': '\r',
172 't': '\t',
173 '/': '/',
174 '(': '(',
175 ')': ')',
176 '\\': '\\'
177 };
178
179
180 /**
181 * When producing text versions of a rule, we escape Unicode whitespace
182 * characters. These characters don't need to be escaped, but we do so
183 * to reduce the chance that an operator will look at a rule and mistake
184 * any of them for the ASCII space character (\u0020), or not see them
185 * because they're non-printing.
186 */
187 var unescapes = {
188 // Things that need to be escaped for the fwrule parser
189 '"': '"',
190 '(': '(',
191 ')': ')',
192 '\\': '\\',
193
194 // Special ASCII characters we don't want to print
195 '\u0000': 'u0000', // null (NUL)
196 '\u0001': 'u0001', // start of heading (SOH)
197 '\u0002': 'u0002', // start of text (STX)
198 '\u0003': 'u0003', // end of text (ETX)
199 '\u0004': 'u0004', // end of transmission (EOT)
200 '\u0005': 'u0005', // enquiry (ENQ)
201 '\u0006': 'u0006', // acknowledgement (ACK)
202 '\u0007': 'u0007', // bell (BEL)
203 '\u0008': 'b', // backspace (BS)
204 '\u0009': 't', // horizontal tab (HT)
205 '\u000A': 'n', // newline (NL)
206 '\u000B': 'u000B', // vertical tab (VT)
207 '\u000C': 'f', // form feed/next page (NP)
208 '\u000D': 'r', // carriage return (CR)
209 '\u000E': 'u000E', // shift out (SO)
210 '\u000F': 'u000F', // shift in (SI)
211 '\u0010': 'u0010', // data link escape (DLE)
212 '\u0011': 'u0011', // device control 1 (DC1)/XON
213 '\u0012': 'u0012', // device control 2 (DC2)
214 '\u0013': 'u0013', // device control 3 (DC3)/XOFF
215 '\u0014': 'u0014', // device control 4 (DC4)
216 '\u0015': 'u0015', // negative acknowledgement (NAK)
217 '\u0016': 'u0016', // synchronous idle (SYN)
218 '\u0017': 'u0017', // end of transmission block (ETB)
219 '\u0018': 'u0018', // cancel (CAN)
220 '\u0019': 'u0019', // end of medium (EM)
221 '\u001A': 'u001A', // substitute (SUB)
222 '\u001B': 'u001B', // escape (ESC)
223 '\u001C': 'u001C', // file separator (FS)
224 '\u001D': 'u001D', // group separator (GS)
225 '\u001E': 'u001E', // record separator (RS)
226 '\u001F': 'u001F', // unit separator (US)
227 '\u007F': 'u007F', // delete (DEL)
228
229 // Unicode whitespace characters
230 '\u0085': 'u0085', // next line
231 '\u00A0': 'u00A0', // non-breaking space
232 '\u1680': 'u1680', // ogham space mark
233 '\u180E': 'u180E', // mongolian vowel separator
234 '\u2000': 'u2000', // en quad
235 '\u2001': 'u2001', // em quad
236 '\u2002': 'u2002', // en space
237 '\u2003': 'u2003', // em space
238 '\u2004': 'u2004', // three-per-em space
239 '\u2005': 'u2005', // four-per-em space
240 '\u2006': 'u2006', // six-per-em space
241 '\u2007': 'u2007', // figure space
242 '\u2008': 'u2008', // punctuation space
243 '\u2009': 'u2009', // thin space
244 '\u200A': 'u200A', // hair space
245 '\u200B': 'u200B', // zero width space
246 '\u200C': 'u200C', // zero width non-joiner
247 '\u200D': 'u200D', // zero width joiner
248 '\u2028': 'u2028', // line separator
249 '\u2029': 'u2029', // paragraph separator
250 '\u202F': 'u202F', // narrow no-break space
251 '\u205F': 'u205F', // medium mathematical space
252 '\u2060': 'u2060', // word joiner
253 '\u3000': 'u3000', // ideographic space
254 '\uFEFF': 'uFEFF' // zero width no-break space
255 };
256
257
258 /**
259 * Unescape a string that's been escaped so that it can be used
260 * in a firewall rule.
261 */
262 function tagUnescape(ostr) {
263 var nstr = '';
264 var len = ostr.length;
265
266 for (var cur = 0; cur < len; cur += 1) {
267 var val = ostr[cur];
268 if (val === '\\') {
269 var escaped = ostr[cur + 1];
270 if (escaped === 'u') {
271 nstr += String.fromCharCode(
272 parseInt(ostr.substring(cur + 2, cur + 6), 16));
273 cur += 5;
274 } else if (escapes[escaped] !== undefined) {
275 nstr += escapes[escaped];
276 cur += 1;
277 } else {
278 throw new Error('Invalid escape sequence "\\' + escaped + '"!');
279 }
280 } else {
281 nstr += val;
282 }
283 }
284
285 return nstr;
286 }
287
288
289 /**
290 * Escape a string so that it can be placed, quoted, into a
291 * firewall rule.
292 */
293 function tagEscape(ostr) {
294 var nstr = '';
295 var len = ostr.length;
296
297 for (var cur = 0; cur < len; cur += 1) {
298 var val = ostr[cur];
299 if (unescapes[val] !== undefined) {
300 nstr += '\\' + unescapes[val];
301 } else {
302 nstr += val;
303 }
304 }
305
306 return nstr;
307 }
308
309
310 /**
311 * Quotes a string in case it contains non-alphanumeric
312 * characters or keywords for firewall rules.
313 */
314 function quote(str) {
315 return '"' + tagEscape(str) + '"';
316 }
317
318
319
320 // --- Firewall object and methods
321
322
323
324 /**
325 * Firewall rule constructor
326 */
327 function FwRule(data, opts) {
328 var errs = [];
329 var parsed;
330
331 if (!opts) {
332 opts = {};
333 }
334
335 // -- validation --
336
337 if (!data.rule && !data.parsed) {
338 errs.push(new validators.InvalidParamError('rule',
339 'No rule specified'));
340 } else {
341 try {
342 parsed = data.parsed || require('./').parse(data.rule, opts);
343 } catch (err) {
344 errs.push(err);
345 }
346 }
347
348 if (hasOwnProperty(data, 'uuid')) {
349 if (!validators.validateUUID(data.uuid)) {
350 errs.push(new validators.InvalidParamError('uuid',
351 'Invalid rule UUID'));
352 }
353
354 this.uuid = data.uuid;
355 } else {
356 this.uuid = mod_uuid.v4();
357 }
358
359 this.version = data.version || generateVersion();
360
361 if (hasOwnProperty(data, 'owner_uuid')) {
362 if (!validators.validateUUID(data.owner_uuid)) {
363 errs.push(new validators.InvalidParamError('owner_uuid',
364 'Invalid owner UUID'));
365 }
366 this.owner_uuid = data.owner_uuid;
367 } else {
368 // No owner: this rule will affect all VMs
369 this.global = true;
370 }
371
372 if (hasOwnProperty(data, 'enabled')) {
373 if (!validators.bool(data.enabled)) {
374 errs.push(new validators.InvalidParamError('enabled',
375 'enabled must be true or false'));
376 }
377
378 this.enabled = data.enabled;
379 } else {
380 this.enabled = false;
381 }
382
383 if (hasOwnProperty(data, 'log')) {
384 if (!validators.bool(data.log)) {
385 errs.push(new validators.InvalidParamError('log',
386 'log must be true or false'));
387 }
388
389 this.log = data.log;
390 } else {
391 this.log = false;
392 }
393
394 for (var s in STRING_PROPS) {
395 var str = STRING_PROPS[s];
396 if (hasOwnProperty(data, str)) {
397 try {
398 validators.validateString(str, data[str]);
399 this[str] = data[str];
400 } catch (valErr) {
401 errs.push(valErr);
402 }
403 }
404 }
405
406 if (opts.enforceGlobal) {
407 if (hasOwnProperty(data, 'global') && !validators.bool(data.global)) {
408 errs.push(new validators.InvalidParamError('global',
409 'global must be true or false'));
410 }
411
412 if (hasOwnProperty(data, 'global') &&
413 hasOwnProperty(data, 'owner_uuid') && data.global) {
414 errs.push(new validators.InvalidParamError('global',
415 'cannot specify both global and owner_uuid'));
416 }
417
418 if (!hasOwnProperty(data, 'global') &&
419 !hasOwnProperty(data, 'owner_uuid')) {
420 errs.push(new validators.InvalidParamError('owner_uuid',
421 'owner_uuid required'));
422 }
423 }
424
425 if (errs.length !== 0) {
426 if (errs.length === 1) {
427 throw errs[0];
428 }
429
430 throw new verror.MultiError(errs);
431 }
432
433 // -- translate into the internal rule format --
434
435 var d;
436 var dir;
437
438 this.action = parsed.action;
439 this.priority = parsed.priority || 0;
440 this.protocol = parsed.protocol.name;
441
442 switch (this.protocol) {
443 case 'icmp':
444 case 'icmp6':
445 this.types = icmpTypeSort(parsed.protocol.targets);
446 this.protoTargets = this.types;
447 break;
448 case 'ah':
449 case 'esp':
450 this.protoTargets = parsed.protocol.targets;
451 break;
452 case 'tcp':
453 case 'udp':
454 this.ports = parsed.protocol.targets.sort(function (a, b) {
455 var first = hasOwnProperty(a, 'start') ? a.start : a;
456 var second = hasOwnProperty(b, 'start') ? b.start : b;
457 return Number(first) - Number(second);
458 });
459 this.protoTargets = this.ports;
460 break;
461 default:
462 throw new validators.InvalidParamError('rule',
463 'unknown protocol "%s"', this.protocol);
464 }
465
466 if (opts.maxVersion < MINVER_LGPROTOTARG) {
467 if (this.protoTargets.length > OLD_MAX_PORTS) {
468 throw new validators.InvalidParamError('rule',
469 'maximum of %d %s allowed', OLD_MAX_PORTS,
470 icmpr.test(this.protocol) ? 'types' : 'ports');
471 }
472 } else if (this.protoTargets.length > MAX_PROTOCOL_TARGETS) {
473 throw new validators.InvalidParamError('rule',
474 'maximum of %d %s allowed', MAX_PROTOCOL_TARGETS,
475 icmpr.test(this.protocol) ? 'types' : 'ports');
476 }
477
478 this.from = {};
479 this.to = {};
480
481 this.allVMs = false;
482 this.ips = {};
483 this.tags = {};
484 this.vms = {};
485 this.subnets = {};
486 this.wildcards = {};
487
488 var dirs = {
489 'to': {},
490 'from': {}
491 };
492 var numTargets;
493
494 for (d in DIRECTIONS) {
495 dir = DIRECTIONS[d];
496 numTargets = 0;
497 for (var j in parsed[dir]) {
498 var target = parsed[dir][j];
499 var targetName;
500 var name = target[0] + 's';
501
502 numTargets++;
503 if (!hasOwnProperty(dirs[dir], name)) {
504 dirs[dir][name] = {};
505 }
506
507 if (name === 'tags') {
508 var targetVal = null;
509 if (typeof (target[1]) === 'string') {
510 targetName = target[1];
511 } else {
512 targetName = target[1][0];
513 targetVal = target[1][1];
514 }
515
516 addTag(this[name], targetName, targetVal);
517 addTag(dirs[dir][name], targetName, targetVal);
518
519 } else {
520 targetName = target[1];
521 this[name][targetName] = target[1];
522 dirs[dir][name][targetName] = target[1];
523 }
524 }
525
526 if (numTargets > MAX_TARGETS_PER_SIDE) {
527 throw new validators.InvalidParamError('rule',
528 'maximum of %d targets allowed per side',
529 MAX_TARGETS_PER_SIDE);
530 }
531 }
532
533 // Now dedup and sort
534 for (d in DIRECTIONS) {
535 dir = DIRECTIONS[d];
536 for (var t in TARGET_TYPES) {
537 var type = TARGET_TYPES[t] + 's';
538 if (hasOwnProperty(dirs[dir], type)) {
539 if (type === 'tags') {
540 this[dir][type] = tagList(dirs[dir][type]);
541
542 } else {
543 this[dir][type] = Object.keys(dirs[dir][type]).sort();
544 }
545 } else {
546 this[dir][type] = [];
547 }
548 }
549 }
550
551 this.ips = Object.keys(this.ips).sort();
552 this.tags = tagList(this.tags);
553 this.vms = Object.keys(this.vms).sort();
554 this.subnets = Object.keys(this.subnets).sort();
555 this.wildcards = Object.keys(this.wildcards).sort();
556
557 if (this.wildcards.length !== 0 && this.wildcards.indexOf('vmall') !== -1) {
558 this.allVMs = true;
559 }
560
561 // Check for rules that obviously don't make sense
562 if (this.protocol === 'icmp') {
563 this.ips.map(function (ip) {
564 if (!mod_net.isIPv4(ip)) {
565 throw new validators.InvalidParamError('rule',
566 'rule affects ICMPv4 but contains a non-IPv4 address');
567 }
568 });
569 this.subnets.map(function (subnet) {
570 if (!mod_net.isIPv4(subnet.split('/')[0])) {
571 throw new validators.InvalidParamError('rule',
572 'rule affects ICMPv4 but contains a non-IPv4 subnet');
573 }
574 });
575 } else if (this.protocol === 'icmp6') {
576 this.ips.map(function (ip) {
577 if (!mod_net.isIPv6(ip)) {
578 throw new validators.InvalidParamError('rule',
579 'rule affects ICMPv6 but contains a non-IPv6 address');
580 }
581 });
582 this.subnets.map(function (subnet) {
583 if (!mod_net.isIPv6(subnet.split('/')[0])) {
584 throw new validators.InvalidParamError('rule',
585 'rule affects ICMPv6 but contains a non-IPv6 subnet');
586 }
587 });
588 }
589
590 // Final check: does this rule actually contain targets that can actually
591 // affect VMs?
592 if (!this.allVMs && this.tags.length === 0 && this.vms.length === 0) {
593 throw new validators.InvalidParamError('rule',
594 'rule does not affect VMs');
595 }
596 }
597
598
599 /**
600 * Returns the internal representation of the rule
601 */
602 FwRule.prototype.raw = function () {
603 var raw = {
604 action: this.action,
605 enabled: this.enabled,
606 from: this.from,
607 priority: this.priority,
608 protocol: this.protocol,
609 to: this.to,
610 uuid: this.uuid,
611 version: this.version,
612 log: this.log
613 };
614
615 if (this.owner_uuid) {
616 raw.owner_uuid = this.owner_uuid;
617 }
618
619 switch (this.protocol) {
620 case 'icmp':
621 case 'icmp6':
622 raw.types = this.types;
623 break;
624 case 'ah':
625 case 'esp':
626 break;
627 case 'tcp':
628 case 'udp':
629 raw.ports = this.ports;
630 break;
631 default:
632 throw new Error('unknown protocol: ' + this.protocol);
633 }
634
635 for (var s in STRING_PROPS) {
636 var str = STRING_PROPS[s];
637 if (hasOwnProperty(this, str)) {
638 raw[str] = this[str];
639 }
640 }
641
642 return raw;
643 };
644
645
646 /**
647 * Returns the serialized version of the rule, suitable for storing
648 *
649 * @param fields {Array}: fields to return (optional)
650 */
651 FwRule.prototype.serialize = function (fields) {
652 var ser = {};
653 if (!fields) {
654 fields = FIELDS;
655 }
656
657 for (var f in fields) {
658 var field = fields[f];
659 if (field === 'rule') {
660 ser.rule = this.text();
661 } else if (field === 'global') {
662 // Only display the global flag if true
663 if (this.global) {
664 ser.global = true;
665 }
666 } else {
667 if (hasOwnProperty(this, field)) {
668 ser[field] = this[field];
669 }
670 }
671 }
672
673 return ser;
674 };
675
676
677 /**
678 * Returns the text of the rule
679 */
680 FwRule.prototype.text = function () {
681 var containsRange;
682 var ports;
683 var protoTxt;
684 var prioTxt = '';
685 var targets = {
686 from: [],
687 to: []
688 };
689
690 forEachTarget(this, function (dir, type, _, arr) {
691 for (var i in arr) {
692 var txt;
693 if (type === 'tag') {
694 txt = util.format('%s %s', type,
695 typeof (arr[i]) === 'string' ? quote(arr[i])
696 : (quote(arr[i][0]) + ' = ' + quote(arr[i][1])));
697 } else {
698 txt = util.format('%s %s', type, arr[i]);
699 }
700
701 if (type === 'wildcard') {
702 txt = arr[i] === 'vmall' ? 'all vms' : arr[i];
703 }
704 targets[dir].push(txt);
705 }
706 });
707
708 // Protocol-specific text: different for ICMP rather than TCP/UDP
709 switch (this.protocol) {
710 case 'icmp':
711 case 'icmp6':
712 protoTxt = util.format(' %sTYPE %s%s',
713 this.types.length > 1 ? '(' : '',
714 this.types.map(function (type) {
715 return type.toString().split(':');
716 }).map(function (code) {
717 return code[0] + (code.length === 1 ? '' : ' CODE ' + code[1]);
718 }).join(' AND TYPE '),
719 this.types.length > 1 ? ')' : ''
720 );
721 break;
722 case 'ah':
723 case 'esp':
724 protoTxt = '';
725 break;
726 case 'tcp':
727 case 'udp':
728 ports = this.ports.map(function (port) {
729 if (hasOwnProperty(port, 'start') &&
730 hasOwnProperty(port, 'end')) {
731 /*
732 * We only output PORTS when we have a range, since we don't
733 * distinguish PORTS 1, 2 from (PORT 1 AND PORT 2) after
734 * parsing.
735 */
736 containsRange = true;
737 return port.start + ' - ' + port.end;
738 } else {
739 return port;
740 }
741 });
742 if (containsRange) {
743 protoTxt = util.format(' PORTS %s', ports.join(', '));
744 } else {
745 protoTxt = util.format(' %sPORT %s%s',
746 ports.length > 1 ? '(' : '',
747 ports.join(' AND PORT '),
748 ports.length > 1 ? ')' : ''
749 );
750 }
751 break;
752 default:
753 throw new Error('unknown protocol: ' + this.protocol);
754 }
755
756 if (this.priority > 0) {
757 prioTxt += ' PRIORITY ' + this.priority.toString();
758 }
759
760 return util.format('FROM %s%s%s TO %s%s%s %s %s%s%s',
761 targets.from.length > 1 ? '(' : '',
762 targets.from.join(' OR '),
763 targets.from.length > 1 ? ')' : '',
764 targets.to.length > 1 ? '(' : '',
765 targets.to.join(' OR '),
766 targets.to.length > 1 ? ')' : '',
767 this.action.toUpperCase(),
768 this.protocol.toLowerCase(),
769 protoTxt,
770 prioTxt
771 );
772 };
773
774
775 /**
776 * Returns the string representation of the rule
777 */
778 FwRule.prototype.toString = function () {
779 return util.format('[%s,%s%s] %s', this.uuid, this.enabled,
780 (this.owner_uuid ? ',' + this.owner_uuid : ''),
781 this.text());
782 };
783
784
785
786 // --- Exported functions
787
788
789
790 /**
791 * Creates a new firewall rule from the payload
792 */
793 function createRule(payload, opts) {
794 return new FwRule(payload, opts);
795 }
796
797
798 function generateVersion() {
799 return Date.now(0) + '.' + sprintf('%06d', process.pid);
800 }
801
802 module.exports = {
803 create: createRule,
804 generateVersion: generateVersion,
805 tagEscape: tagEscape,
806 tagUnescape: tagUnescape,
807 DIRECTIONS: DIRECTIONS,
808 FIELDS: FIELDS,
809 FwRule: FwRule,
810 TARGET_TYPES: TARGET_TYPES
811 };