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